Add tests and implement timeline ingestion options with NATS and Redis subscribers

- Introduced `BinaryReachabilityLifterTests` to validate binary lifting functionality.
- Created `PackRunWorkerOptions` for configuring worker paths and execution persistence.
- Added `TimelineIngestionOptions` for configuring NATS and Redis ingestion transports.
- Implemented `NatsTimelineEventSubscriber` for subscribing to NATS events.
- Developed `RedisTimelineEventSubscriber` for reading from Redis Streams.
- Added `TimelineEnvelopeParser` to normalize incoming event envelopes.
- Created unit tests for `TimelineEnvelopeParser` to ensure correct field mapping.
- Implemented `TimelineAuthorizationAuditSink` for logging authorization outcomes.
This commit is contained in:
StellaOps Bot
2025-12-03 09:46:48 +02:00
parent e923880694
commit 35c8f9216f
520 changed files with 4416 additions and 31492 deletions

View File

@@ -1,37 +0,0 @@
# Excititor Storage (Mongo) Charter
## Mission
Provide Mongo-backed persistence for Excititor ingestion, linksets, and observations with deterministic schemas, indexes, and migrations; keep aggregation-only semantics intact.
## Scope
- Working directory: `src/Excititor/__Libraries/StellaOps.Excititor.Storage.Mongo`
- Collections, indexes, migrations, repository abstractions, and data access helpers shared by WebService/Worker/Core.
## Required Reading
- `docs/modules/excititor/architecture.md`
- `docs/modules/excititor/vex_observations.md`
- `docs/ingestion/aggregation-only-contract.md`
- `docs/modules/excititor/implementation_plan.md`
## Roles
- Backend/storage engineer (.NET 10, MongoDB driver ≥3.0).
- QA automation (repository + migration tests).
## Working Agreements
1. Maintain deterministic migrations; record new indexes and shapes in sprint `Execution Log` and module docs if added.
2. Enforce tenant scope in all queries; include partition keys in indexes where applicable.
3. No consensus/weighting logic; store raw facts, provenance, and precedence pointers only.
4. Offline-first; no runtime external calls.
## Testing & Determinism
- Use Mongo test fixtures/in-memory harness with seeded data; assert index presence and sort stability.
- Keep timestamps UTC ISO-8601 and ordering explicit (e.g., vendor, upstreamId, version, createdUtc).
- Avoid nondeterministic ObjectId/GUID usage in tests; seed values.
## Boundaries
- Do not embed Policy Engine or Cartographer schemas; consume published contracts.
- Config via DI/appsettings; no hard-coded connection strings.
## Ready-to-Start Checklist
- Required docs reviewed.
- Test fixture database prepared; migrations scripted and reversible where possible.

View File

@@ -1,173 +0,0 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using MongoDB.Driver;
namespace StellaOps.Excititor.Storage.Mongo;
public interface IAirgapImportStore
{
Task SaveAsync(AirgapImportRecord record, CancellationToken cancellationToken);
Task<AirgapImportRecord?> FindByBundleIdAsync(
string tenantId,
string bundleId,
string? mirrorGeneration,
CancellationToken cancellationToken);
Task<IReadOnlyList<AirgapImportRecord>> ListAsync(
string tenantId,
string? publisherFilter,
DateTimeOffset? importedAfter,
int limit,
int offset,
CancellationToken cancellationToken);
Task<int> CountAsync(
string tenantId,
string? publisherFilter,
DateTimeOffset? importedAfter,
CancellationToken cancellationToken);
}
public sealed class DuplicateAirgapImportException : Exception
{
public string BundleId { get; }
public string MirrorGeneration { get; }
public DuplicateAirgapImportException(string bundleId, string mirrorGeneration, Exception inner)
: base($"Airgap import already exists for bundle '{bundleId}' generation '{mirrorGeneration}'.", inner)
{
BundleId = bundleId;
MirrorGeneration = mirrorGeneration;
}
}
internal sealed class MongoAirgapImportStore : IAirgapImportStore
{
private readonly IMongoCollection<AirgapImportRecord> _collection;
public MongoAirgapImportStore(IMongoDatabase database)
{
ArgumentNullException.ThrowIfNull(database);
VexMongoMappingRegistry.Register();
_collection = database.GetCollection<AirgapImportRecord>(VexMongoCollectionNames.AirgapImports);
// Enforce idempotency on (bundleId, generation) via Id uniqueness and explicit index.
var idIndex = Builders<AirgapImportRecord>.IndexKeys.Ascending(x => x.Id);
var bundleIndex = Builders<AirgapImportRecord>.IndexKeys
.Ascending(x => x.BundleId)
.Ascending(x => x.MirrorGeneration);
_collection.Indexes.CreateMany(new[]
{
new CreateIndexModel<AirgapImportRecord>(idIndex, new CreateIndexOptions { Unique = true, Name = "airgap_import_id_unique" }),
new CreateIndexModel<AirgapImportRecord>(bundleIndex, new CreateIndexOptions { Unique = true, Name = "airgap_bundle_generation_unique" })
});
}
public Task SaveAsync(AirgapImportRecord record, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(record);
try
{
return _collection.InsertOneAsync(record, cancellationToken: cancellationToken);
}
catch (MongoWriteException ex) when (ex.WriteError.Category == ServerErrorCategory.DuplicateKey)
{
throw new DuplicateAirgapImportException(record.BundleId, record.MirrorGeneration, ex);
}
}
public async Task<AirgapImportRecord?> FindByBundleIdAsync(
string tenantId,
string bundleId,
string? mirrorGeneration,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(tenantId);
ArgumentNullException.ThrowIfNull(bundleId);
var filter = Builders<AirgapImportRecord>.Filter.And(
Builders<AirgapImportRecord>.Filter.Eq(x => x.TenantId, tenantId),
Builders<AirgapImportRecord>.Filter.Eq(x => x.BundleId, bundleId));
if (!string.IsNullOrWhiteSpace(mirrorGeneration))
{
filter = Builders<AirgapImportRecord>.Filter.And(
filter,
Builders<AirgapImportRecord>.Filter.Eq(x => x.MirrorGeneration, mirrorGeneration));
}
var sort = Builders<AirgapImportRecord>.Sort.Descending(x => x.MirrorGeneration);
return await _collection
.Find(filter)
.Sort(sort)
.FirstOrDefaultAsync(cancellationToken)
.ConfigureAwait(false);
}
public async Task<IReadOnlyList<AirgapImportRecord>> ListAsync(
string tenantId,
string? publisherFilter,
DateTimeOffset? importedAfter,
int limit,
int offset,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(tenantId);
var filter = BuildListFilter(tenantId, publisherFilter, importedAfter);
var sort = Builders<AirgapImportRecord>.Sort.Descending(x => x.ImportedAt);
return await _collection
.Find(filter)
.Sort(sort)
.Skip(offset)
.Limit(Math.Clamp(limit, 1, 1000))
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
}
public async Task<int> CountAsync(
string tenantId,
string? publisherFilter,
DateTimeOffset? importedAfter,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(tenantId);
var filter = BuildListFilter(tenantId, publisherFilter, importedAfter);
var count = await _collection
.CountDocumentsAsync(filter, cancellationToken: cancellationToken)
.ConfigureAwait(false);
return (int)Math.Min(count, int.MaxValue);
}
private static FilterDefinition<AirgapImportRecord> BuildListFilter(
string tenantId,
string? publisherFilter,
DateTimeOffset? importedAfter)
{
var filters = new List<FilterDefinition<AirgapImportRecord>>
{
Builders<AirgapImportRecord>.Filter.Eq(x => x.TenantId, tenantId)
};
if (!string.IsNullOrWhiteSpace(publisherFilter))
{
filters.Add(Builders<AirgapImportRecord>.Filter.Eq(x => x.Publisher, publisherFilter));
}
if (importedAfter is { } after)
{
filters.Add(Builders<AirgapImportRecord>.Filter.Gte(x => x.ImportedAt, after));
}
return Builders<AirgapImportRecord>.Filter.And(filters);
}
}

View File

@@ -1,36 +0,0 @@
using System;
using MongoDB.Bson.Serialization.Attributes;
namespace StellaOps.Excititor.Storage.Mongo;
[BsonIgnoreExtraElements]
public sealed class AirgapTimelineEntry
{
public string EventType { get; set; } = string.Empty;
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
public string TenantId { get; set; } = "default";
public string BundleId { get; set; } = string.Empty;
public string MirrorGeneration { get; set; } = string.Empty;
public int? StalenessSeconds { get; set; }
= null;
public string? ErrorCode { get; set; }
= null;
public string? Message { get; set; }
= null;
public string? Remediation { get; set; }
= null;
public string? Actor { get; set; }
= null;
public string? Scopes { get; set; }
= null;
}

View File

@@ -1,12 +0,0 @@
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Excititor.Core;
namespace StellaOps.Excititor.Storage.Mongo;
public interface IVexAttestationLinkStore
{
ValueTask UpsertAsync(VexAttestationPayload payload, CancellationToken cancellationToken);
ValueTask<VexAttestationPayload?> FindAsync(string attestationId, CancellationToken cancellationToken);
}

View File

@@ -1,13 +0,0 @@
using System.Threading;
using System.Threading.Tasks;
using MongoDB.Driver;
using StellaOps.Excititor.Core;
namespace StellaOps.Excititor.Storage.Mongo;
public interface IVexExportStore
{
ValueTask<VexExportManifest?> FindAsync(VexQuerySignature signature, VexExportFormat format, CancellationToken cancellationToken, IClientSessionHandle? session = null);
ValueTask SaveAsync(VexExportManifest manifest, CancellationToken cancellationToken, IClientSessionHandle? session = null);
}

View File

@@ -1,13 +0,0 @@
using System.Threading;
using System.Threading.Tasks;
using MongoDB.Driver;
using StellaOps.Excititor.Core;
namespace StellaOps.Excititor.Storage.Mongo;
public interface IVexRawStore : IVexRawDocumentSink
{
ValueTask StoreAsync(VexRawDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null);
ValueTask<VexRawDocument?> FindByDigestAsync(string digest, CancellationToken cancellationToken, IClientSessionHandle? session = null);
}

View File

@@ -1,115 +0,0 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Threading;
using System.Threading.Tasks;
using MongoDB.Driver;
using StellaOps.Excititor.Core;
namespace StellaOps.Excititor.Storage.Mongo;
public interface IVexProviderStore
{
ValueTask<VexProvider?> FindAsync(string id, CancellationToken cancellationToken, IClientSessionHandle? session = null);
ValueTask<IReadOnlyCollection<VexProvider>> ListAsync(CancellationToken cancellationToken, IClientSessionHandle? session = null);
ValueTask SaveAsync(VexProvider provider, CancellationToken cancellationToken, IClientSessionHandle? session = null);
}
public interface IVexConsensusStore
{
ValueTask<VexConsensus?> FindAsync(string vulnerabilityId, string productKey, CancellationToken cancellationToken, IClientSessionHandle? session = null);
ValueTask<IReadOnlyCollection<VexConsensus>> FindByVulnerabilityAsync(string vulnerabilityId, CancellationToken cancellationToken, IClientSessionHandle? session = null);
ValueTask SaveAsync(VexConsensus consensus, CancellationToken cancellationToken, IClientSessionHandle? session = null);
IAsyncEnumerable<VexConsensus> FindCalculatedBeforeAsync(DateTimeOffset cutoff, int batchSize, CancellationToken cancellationToken, IClientSessionHandle? session = null)
=> throw new NotSupportedException();
}
public interface IVexClaimStore
{
ValueTask AppendAsync(IEnumerable<VexClaim> claims, DateTimeOffset observedAt, CancellationToken cancellationToken, IClientSessionHandle? session = null);
ValueTask<IReadOnlyCollection<VexClaim>> FindAsync(string vulnerabilityId, string productKey, DateTimeOffset? since, CancellationToken cancellationToken, IClientSessionHandle? session = null);
/// <summary>
/// Retrieves all claims for a specific vulnerability ID (EXCITITOR-VULN-29-002).
/// </summary>
ValueTask<IReadOnlyCollection<VexClaim>> FindByVulnerabilityAsync(string vulnerabilityId, int limit, CancellationToken cancellationToken, IClientSessionHandle? session = null);
}
public sealed record VexConnectorState(
string ConnectorId,
DateTimeOffset? LastUpdated,
ImmutableArray<string> DocumentDigests,
ImmutableDictionary<string, string> ResumeTokens,
DateTimeOffset? LastSuccessAt,
int FailureCount,
DateTimeOffset? NextEligibleRun,
string? LastFailureReason,
DateTimeOffset? LastHeartbeatAt = null,
string? LastHeartbeatStatus = null,
string? LastArtifactHash = null,
string? LastArtifactKind = null,
string? LastCheckpoint = null)
{
public VexConnectorState(
string connectorId,
DateTimeOffset? lastUpdated,
ImmutableArray<string> documentDigests)
: this(
connectorId,
lastUpdated,
documentDigests,
ImmutableDictionary<string, string>.Empty,
LastSuccessAt: null,
FailureCount: 0,
NextEligibleRun: null,
LastFailureReason: null,
LastHeartbeatAt: null,
LastHeartbeatStatus: null,
LastArtifactHash: null,
LastArtifactKind: null,
LastCheckpoint: null)
{
}
}
public interface IVexConnectorStateRepository
{
ValueTask<VexConnectorState?> GetAsync(string connectorId, CancellationToken cancellationToken, IClientSessionHandle? session = null);
ValueTask SaveAsync(VexConnectorState state, CancellationToken cancellationToken, IClientSessionHandle? session = null);
ValueTask<IReadOnlyCollection<VexConnectorState>> ListAsync(CancellationToken cancellationToken, IClientSessionHandle? session = null);
}
public interface IVexConsensusHoldStore
{
ValueTask<VexConsensusHold?> FindAsync(string vulnerabilityId, string productKey, CancellationToken cancellationToken, IClientSessionHandle? session = null);
ValueTask SaveAsync(VexConsensusHold hold, CancellationToken cancellationToken, IClientSessionHandle? session = null);
ValueTask RemoveAsync(string vulnerabilityId, string productKey, CancellationToken cancellationToken, IClientSessionHandle? session = null);
IAsyncEnumerable<VexConsensusHold> FindEligibleAsync(DateTimeOffset asOf, int batchSize, CancellationToken cancellationToken, IClientSessionHandle? session = null);
}
public interface IVexCacheIndex
{
ValueTask<VexCacheEntry?> FindAsync(VexQuerySignature signature, VexExportFormat format, CancellationToken cancellationToken, IClientSessionHandle? session = null);
ValueTask SaveAsync(VexCacheEntry entry, CancellationToken cancellationToken, IClientSessionHandle? session = null);
ValueTask RemoveAsync(VexQuerySignature signature, VexExportFormat format, CancellationToken cancellationToken, IClientSessionHandle? session = null);
}
public interface IVexCacheMaintenance
{
ValueTask<int> RemoveExpiredAsync(DateTimeOffset asOf, CancellationToken cancellationToken, IClientSessionHandle? session = null);
ValueTask<int> RemoveMissingManifestReferencesAsync(CancellationToken cancellationToken, IClientSessionHandle? session = null);
}

View File

@@ -1,12 +0,0 @@
using System.Threading;
using System.Threading.Tasks;
using MongoDB.Driver;
namespace StellaOps.Excititor.Storage.Mongo.Migrations;
internal interface IVexMongoMigration
{
string Id { get; }
ValueTask ExecuteAsync(IMongoDatabase database, CancellationToken cancellationToken);
}

View File

@@ -1,29 +0,0 @@
using System.Threading;
using System.Threading.Tasks;
using MongoDB.Driver;
namespace StellaOps.Excititor.Storage.Mongo.Migrations;
internal sealed class VexConsensusHoldMigration : IVexMongoMigration
{
public string Id => "20251021-consensus-holds";
public async ValueTask ExecuteAsync(IMongoDatabase database, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(database);
var collection = database.GetCollection<VexConsensusHoldRecord>(VexMongoCollectionNames.ConsensusHolds);
var eligibleIndex = Builders<VexConsensusHoldRecord>.IndexKeys
.Ascending(x => x.EligibleAt);
var keyIndex = Builders<VexConsensusHoldRecord>.IndexKeys
.Ascending(x => x.VulnerabilityId)
.Ascending(x => x.ProductKey);
await Task.WhenAll(
collection.Indexes.CreateOneAsync(new CreateIndexModel<VexConsensusHoldRecord>(eligibleIndex), cancellationToken: cancellationToken),
collection.Indexes.CreateOneAsync(new CreateIndexModel<VexConsensusHoldRecord>(keyIndex), cancellationToken: cancellationToken))
.ConfigureAwait(false);
}
}

View File

@@ -1,52 +0,0 @@
using System.Threading;
using System.Threading.Tasks;
using MongoDB.Driver;
namespace StellaOps.Excititor.Storage.Mongo.Migrations;
internal sealed class VexConsensusSignalsMigration : IVexMongoMigration
{
public string Id => "20251019-consensus-signals-statements";
public async ValueTask ExecuteAsync(IMongoDatabase database, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(database);
await EnsureConsensusIndexesAsync(database, cancellationToken).ConfigureAwait(false);
await EnsureStatementIndexesAsync(database, cancellationToken).ConfigureAwait(false);
}
private static Task EnsureConsensusIndexesAsync(IMongoDatabase database, CancellationToken cancellationToken)
{
var collection = database.GetCollection<VexConsensusRecord>(VexMongoCollectionNames.Consensus);
var revisionGeneratedIndex = Builders<VexConsensusRecord>.IndexKeys
.Ascending(x => x.PolicyRevisionId)
.Descending(x => x.CalculatedAt);
return collection.Indexes.CreateOneAsync(
new CreateIndexModel<VexConsensusRecord>(revisionGeneratedIndex),
cancellationToken: cancellationToken);
}
private static Task EnsureStatementIndexesAsync(IMongoDatabase database, CancellationToken cancellationToken)
{
var collection = database.GetCollection<VexStatementRecord>(VexMongoCollectionNames.Statements);
var vulnProductInsertedIndex = Builders<VexStatementRecord>.IndexKeys
.Ascending(x => x.VulnerabilityId)
.Ascending(x => x.Product.Key)
.Descending(x => x.InsertedAt);
var providerInsertedIndex = Builders<VexStatementRecord>.IndexKeys
.Ascending(x => x.ProviderId)
.Descending(x => x.InsertedAt);
var digestIndex = Builders<VexStatementRecord>.IndexKeys
.Ascending(x => x.Document.Digest);
return Task.WhenAll(
collection.Indexes.CreateOneAsync(new CreateIndexModel<VexStatementRecord>(vulnProductInsertedIndex), cancellationToken: cancellationToken),
collection.Indexes.CreateOneAsync(new CreateIndexModel<VexStatementRecord>(providerInsertedIndex), cancellationToken: cancellationToken),
collection.Indexes.CreateOneAsync(new CreateIndexModel<VexStatementRecord>(digestIndex), cancellationToken: cancellationToken));
}
}

View File

@@ -1,75 +0,0 @@
using System.Threading;
using System.Threading.Tasks;
using MongoDB.Driver;
namespace StellaOps.Excititor.Storage.Mongo.Migrations;
internal sealed class VexInitialIndexMigration : IVexMongoMigration
{
public string Id => "20251016-initial-indexes";
public async ValueTask ExecuteAsync(IMongoDatabase database, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(database);
await EnsureRawIndexesAsync(database, cancellationToken).ConfigureAwait(false);
await EnsureProviderIndexesAsync(database, cancellationToken).ConfigureAwait(false);
await EnsureConsensusIndexesAsync(database, cancellationToken).ConfigureAwait(false);
await EnsureExportIndexesAsync(database, cancellationToken).ConfigureAwait(false);
await EnsureCacheIndexesAsync(database, cancellationToken).ConfigureAwait(false);
}
private static Task EnsureRawIndexesAsync(IMongoDatabase database, CancellationToken cancellationToken)
{
var collection = database.GetCollection<VexRawDocumentRecord>(VexMongoCollectionNames.Raw);
var providerFormatIndex = Builders<VexRawDocumentRecord>.IndexKeys
.Ascending(x => x.ProviderId)
.Ascending(x => x.Format)
.Ascending(x => x.RetrievedAt);
return collection.Indexes.CreateOneAsync(new CreateIndexModel<VexRawDocumentRecord>(providerFormatIndex), cancellationToken: cancellationToken);
}
private static Task EnsureProviderIndexesAsync(IMongoDatabase database, CancellationToken cancellationToken)
{
var collection = database.GetCollection<VexProviderRecord>(VexMongoCollectionNames.Providers);
var kindIndex = Builders<VexProviderRecord>.IndexKeys.Ascending(x => x.Kind);
return collection.Indexes.CreateOneAsync(new CreateIndexModel<VexProviderRecord>(kindIndex), cancellationToken: cancellationToken);
}
private static Task EnsureConsensusIndexesAsync(IMongoDatabase database, CancellationToken cancellationToken)
{
var collection = database.GetCollection<VexConsensusRecord>(VexMongoCollectionNames.Consensus);
var vulnProductIndex = Builders<VexConsensusRecord>.IndexKeys
.Ascending(x => x.VulnerabilityId)
.Ascending(x => x.Product.Key);
var policyIndex = Builders<VexConsensusRecord>.IndexKeys
.Ascending(x => x.PolicyRevisionId)
.Ascending(x => x.PolicyDigest);
return Task.WhenAll(
collection.Indexes.CreateOneAsync(new CreateIndexModel<VexConsensusRecord>(vulnProductIndex, new CreateIndexOptions { Unique = true }), cancellationToken: cancellationToken),
collection.Indexes.CreateOneAsync(new CreateIndexModel<VexConsensusRecord>(policyIndex), cancellationToken: cancellationToken));
}
private static Task EnsureExportIndexesAsync(IMongoDatabase database, CancellationToken cancellationToken)
{
var collection = database.GetCollection<VexExportManifestRecord>(VexMongoCollectionNames.Exports);
var signatureIndex = Builders<VexExportManifestRecord>.IndexKeys
.Ascending(x => x.QuerySignature)
.Ascending(x => x.Format);
return collection.Indexes.CreateOneAsync(new CreateIndexModel<VexExportManifestRecord>(signatureIndex, new CreateIndexOptions { Unique = true }), cancellationToken: cancellationToken);
}
private static Task EnsureCacheIndexesAsync(IMongoDatabase database, CancellationToken cancellationToken)
{
var collection = database.GetCollection<VexCacheEntryRecord>(VexMongoCollectionNames.Cache);
var signatureIndex = Builders<VexCacheEntryRecord>.IndexKeys
.Ascending(x => x.QuerySignature)
.Ascending(x => x.Format);
var expirationIndex = Builders<VexCacheEntryRecord>.IndexKeys.Ascending(x => x.ExpiresAt);
return Task.WhenAll(
collection.Indexes.CreateOneAsync(new CreateIndexModel<VexCacheEntryRecord>(signatureIndex, new CreateIndexOptions { Unique = true }), cancellationToken: cancellationToken),
collection.Indexes.CreateOneAsync(new CreateIndexModel<VexCacheEntryRecord>(expirationIndex, new CreateIndexOptions { ExpireAfter = TimeSpan.FromSeconds(0) }), cancellationToken: cancellationToken));
}
}

View File

@@ -1,18 +0,0 @@
using System;
using MongoDB.Bson.Serialization.Attributes;
namespace StellaOps.Excititor.Storage.Mongo.Migrations;
internal sealed class VexMigrationRecord
{
public VexMigrationRecord(string id, DateTimeOffset executedAt)
{
Id = string.IsNullOrWhiteSpace(id) ? throw new ArgumentException("Migration id must be provided.", nameof(id)) : id.Trim();
ExecutedAt = executedAt;
}
[BsonId]
public string Id { get; }
public DateTimeOffset ExecutedAt { get; }
}

View File

@@ -1,22 +0,0 @@
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Hosting;
namespace StellaOps.Excititor.Storage.Mongo.Migrations;
internal sealed class VexMongoMigrationHostedService : IHostedService
{
private readonly VexMongoMigrationRunner _runner;
public VexMongoMigrationHostedService(VexMongoMigrationRunner runner)
{
_runner = runner ?? throw new ArgumentNullException(nameof(runner));
}
public async Task StartAsync(CancellationToken cancellationToken)
{
await _runner.RunAsync(cancellationToken).ConfigureAwait(false);
}
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}

View File

@@ -1,74 +0,0 @@
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using MongoDB.Driver;
namespace StellaOps.Excititor.Storage.Mongo.Migrations;
internal sealed class VexMongoMigrationRunner
{
private readonly IMongoDatabase _database;
private readonly IReadOnlyList<IVexMongoMigration> _migrations;
private readonly ILogger<VexMongoMigrationRunner> _logger;
public VexMongoMigrationRunner(
IMongoDatabase database,
IEnumerable<IVexMongoMigration> migrations,
ILogger<VexMongoMigrationRunner> logger)
{
_database = database ?? throw new ArgumentNullException(nameof(database));
_migrations = (migrations ?? throw new ArgumentNullException(nameof(migrations)))
.OrderBy(migration => migration.Id, StringComparer.Ordinal)
.ToArray();
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async ValueTask RunAsync(CancellationToken cancellationToken)
{
if (_migrations.Count == 0)
{
return;
}
var migrationsCollection = _database.GetCollection<VexMigrationRecord>(VexMongoCollectionNames.Migrations);
await EnsureMigrationsIndexAsync(migrationsCollection, cancellationToken).ConfigureAwait(false);
var applied = await LoadAppliedMigrationsAsync(migrationsCollection, cancellationToken).ConfigureAwait(false);
foreach (var migration in _migrations)
{
if (applied.Contains(migration.Id))
{
continue;
}
_logger.LogInformation("Applying Excititor Mongo migration {MigrationId}", migration.Id);
await migration.ExecuteAsync(_database, cancellationToken).ConfigureAwait(false);
var record = new VexMigrationRecord(migration.Id, DateTimeOffset.UtcNow);
await migrationsCollection.InsertOneAsync(record, cancellationToken: cancellationToken).ConfigureAwait(false);
_logger.LogInformation("Completed Excititor Mongo migration {MigrationId}", migration.Id);
}
}
private static ValueTask EnsureMigrationsIndexAsync(
IMongoCollection<VexMigrationRecord> collection,
CancellationToken cancellationToken)
{
// default _id index already enforces uniqueness
return ValueTask.CompletedTask;
}
private static async ValueTask<HashSet<string>> LoadAppliedMigrationsAsync(
IMongoCollection<VexMigrationRecord> collection,
CancellationToken cancellationToken)
{
var records = await collection.Find(FilterDefinition<VexMigrationRecord>.Empty)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
return records.Select(static record => record.Id)
.ToHashSet(StringComparer.Ordinal);
}
}

View File

@@ -1,84 +0,0 @@
using System.Threading;
using System.Threading.Tasks;
using MongoDB.Driver;
namespace StellaOps.Excititor.Storage.Mongo.Migrations;
internal sealed class VexObservationCollectionsMigration : IVexMongoMigration
{
public string Id => "20251117-observations-linksets";
public async ValueTask ExecuteAsync(IMongoDatabase database, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(database);
await EnsureObservationsIndexesAsync(database, cancellationToken).ConfigureAwait(false);
await EnsureLinksetIndexesAsync(database, cancellationToken).ConfigureAwait(false);
}
private static Task EnsureObservationsIndexesAsync(IMongoDatabase database, CancellationToken cancellationToken)
{
var collection = database.GetCollection<VexObservationRecord>(VexMongoCollectionNames.Observations);
var tenantObservationIndex = Builders<VexObservationRecord>.IndexKeys
.Ascending(x => x.Tenant)
.Ascending(x => x.ObservationId);
var tenantVulnIndex = Builders<VexObservationRecord>.IndexKeys
.Ascending(x => x.Tenant)
.Ascending(x => x.VulnerabilityId);
var tenantProductIndex = Builders<VexObservationRecord>.IndexKeys
.Ascending(x => x.Tenant)
.Ascending(x => x.ProductKey);
var tenantDigestIndex = Builders<VexObservationRecord>.IndexKeys
.Ascending(x => x.Tenant)
.Ascending("Document.Digest");
var tenantProviderStatusIndex = Builders<VexObservationRecord>.IndexKeys
.Ascending(x => x.Tenant)
.Ascending(x => x.ProviderId)
.Ascending(x => x.Status);
return Task.WhenAll(
collection.Indexes.CreateOneAsync(new CreateIndexModel<VexObservationRecord>(tenantObservationIndex, new CreateIndexOptions { Unique = true }), cancellationToken: cancellationToken),
collection.Indexes.CreateOneAsync(new CreateIndexModel<VexObservationRecord>(tenantVulnIndex), cancellationToken: cancellationToken),
collection.Indexes.CreateOneAsync(new CreateIndexModel<VexObservationRecord>(tenantProductIndex), cancellationToken: cancellationToken),
collection.Indexes.CreateOneAsync(new CreateIndexModel<VexObservationRecord>(tenantDigestIndex), cancellationToken: cancellationToken),
collection.Indexes.CreateOneAsync(new CreateIndexModel<VexObservationRecord>(tenantProviderStatusIndex), cancellationToken: cancellationToken));
}
private static Task EnsureLinksetIndexesAsync(IMongoDatabase database, CancellationToken cancellationToken)
{
var collection = database.GetCollection<VexLinksetRecord>(VexMongoCollectionNames.Linksets);
var tenantLinksetIndex = Builders<VexLinksetRecord>.IndexKeys
.Ascending(x => x.Tenant)
.Ascending(x => x.LinksetId);
var tenantVulnIndex = Builders<VexLinksetRecord>.IndexKeys
.Ascending(x => x.Tenant)
.Ascending(x => x.VulnerabilityId);
var tenantProductIndex = Builders<VexLinksetRecord>.IndexKeys
.Ascending(x => x.Tenant)
.Ascending(x => x.ProductKey);
var tenantProviderIndex = Builders<VexLinksetRecord>.IndexKeys
.Ascending(x => x.Tenant)
.Ascending("ProviderIds");
var tenantDisagreementProviderIndex = Builders<VexLinksetRecord>.IndexKeys
.Ascending(x => x.Tenant)
.Ascending("Disagreements.ProviderId")
.Ascending("Disagreements.Status");
return Task.WhenAll(
collection.Indexes.CreateOneAsync(new CreateIndexModel<VexLinksetRecord>(tenantLinksetIndex, new CreateIndexOptions { Unique = true }), cancellationToken: cancellationToken),
collection.Indexes.CreateOneAsync(new CreateIndexModel<VexLinksetRecord>(tenantVulnIndex), cancellationToken: cancellationToken),
collection.Indexes.CreateOneAsync(new CreateIndexModel<VexLinksetRecord>(tenantProductIndex), cancellationToken: cancellationToken),
collection.Indexes.CreateOneAsync(new CreateIndexModel<VexLinksetRecord>(tenantProviderIndex), cancellationToken: cancellationToken),
collection.Indexes.CreateOneAsync(new CreateIndexModel<VexLinksetRecord>(tenantDisagreementProviderIndex), cancellationToken: cancellationToken));
}
}

View File

@@ -1,137 +0,0 @@
using System.Threading;
using System.Threading.Tasks;
using MongoDB.Bson;
using MongoDB.Driver;
namespace StellaOps.Excititor.Storage.Mongo.Migrations;
/// <summary>
/// Adds idempotency indexes to the vex_raw collection to enforce content-addressed storage.
/// Ensures that:
/// 1. Each document is uniquely identified by its content digest
/// 2. Provider+Source combinations are unique per digest
/// 3. Supports efficient queries for evidence retrieval
/// </summary>
/// <remarks>
/// Rollback: Run db.vex_raw.dropIndex("idx_provider_sourceUri_digest_unique")
/// and db.vex_raw.dropIndex("idx_digest_providerId") to reverse this migration.
/// </remarks>
internal sealed class VexRawIdempotencyIndexMigration : IVexMongoMigration
{
public string Id => "20251127-vex-raw-idempotency-indexes";
public async ValueTask ExecuteAsync(IMongoDatabase database, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(database);
var collection = database.GetCollection<BsonDocument>(VexMongoCollectionNames.Raw);
// Index 1: Unique constraint on providerId + sourceUri + digest
// Ensures the same document from the same provider/source is only stored once
var providerSourceDigestIndex = new BsonDocument
{
{ "providerId", 1 },
{ "sourceUri", 1 },
{ "digest", 1 }
};
var uniqueIndexModel = new CreateIndexModel<BsonDocument>(
providerSourceDigestIndex,
new CreateIndexOptions
{
Unique = true,
Name = "idx_provider_sourceUri_digest_unique",
Background = true
});
// Index 2: Compound index for efficient evidence queries by digest + provider
var digestProviderIndex = new BsonDocument
{
{ "digest", 1 },
{ "providerId", 1 }
};
var queryIndexModel = new CreateIndexModel<BsonDocument>(
digestProviderIndex,
new CreateIndexOptions
{
Name = "idx_digest_providerId",
Background = true
});
// Index 3: TTL index candidate for future cleanup (optional staleness tracking)
var retrievedAtIndex = new BsonDocument
{
{ "retrievedAt", 1 }
};
var retrievedAtIndexModel = new CreateIndexModel<BsonDocument>(
retrievedAtIndex,
new CreateIndexOptions
{
Name = "idx_retrievedAt",
Background = true
});
// Create all indexes
await collection.Indexes.CreateManyAsync(
new[] { uniqueIndexModel, queryIndexModel, retrievedAtIndexModel },
cancellationToken).ConfigureAwait(false);
}
}
/// <summary>
/// Extension methods for idempotency index management.
/// </summary>
public static class VexRawIdempotencyIndexExtensions
{
/// <summary>
/// Drops the idempotency indexes (for rollback).
/// </summary>
public static async Task RollbackIdempotencyIndexesAsync(
this IMongoDatabase database,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(database);
var collection = database.GetCollection<BsonDocument>(VexMongoCollectionNames.Raw);
var indexNames = new[]
{
"idx_provider_sourceUri_digest_unique",
"idx_digest_providerId",
"idx_retrievedAt"
};
foreach (var indexName in indexNames)
{
try
{
await collection.Indexes.DropOneAsync(indexName, cancellationToken).ConfigureAwait(false);
}
catch (MongoCommandException ex) when (ex.CodeName == "IndexNotFound")
{
// Index doesn't exist, skip
}
}
}
/// <summary>
/// Verifies that idempotency indexes exist.
/// </summary>
public static async Task<bool> VerifyIdempotencyIndexesExistAsync(
this IMongoDatabase database,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(database);
var collection = database.GetCollection<BsonDocument>(VexMongoCollectionNames.Raw);
var cursor = await collection.Indexes.ListAsync(cancellationToken).ConfigureAwait(false);
var indexes = await cursor.ToListAsync(cancellationToken).ConfigureAwait(false);
var indexNames = indexes.Select(i => i.GetValue("name", "").AsString).ToHashSet();
return indexNames.Contains("idx_provider_sourceUri_digest_unique") &&
indexNames.Contains("idx_digest_providerId");
}
}

View File

@@ -1,117 +0,0 @@
using System.Threading;
using System.Threading.Tasks;
using MongoDB.Bson;
using MongoDB.Driver;
namespace StellaOps.Excititor.Storage.Mongo.Migrations;
/// <summary>
/// Adds a $jsonSchema validator to the raw VEX collection to enforce aggregation-only
/// shape (immutable content hash + provenance fields).
/// ValidationAction=warn keeps rollout safe while surfacing violations.
/// </summary>
internal sealed class VexRawSchemaMigration : IVexMongoMigration
{
public string Id => "20251125-vex-raw-json-schema";
public async ValueTask ExecuteAsync(IMongoDatabase database, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(database);
var exists = await CollectionExistsAsync(database, VexMongoCollectionNames.Raw, cancellationToken)
.ConfigureAwait(false);
var validator = BuildValidator();
if (!exists)
{
// In MongoDB.Driver 3.x, CreateCollectionOptions doesn't support Validator directly.
// Use the create command instead.
var createCommand = new BsonDocument
{
{ "create", VexMongoCollectionNames.Raw },
{ "validator", validator },
{ "validationAction", "warn" },
{ "validationLevel", "moderate" }
};
await database.RunCommandAsync<BsonDocument>(createCommand, cancellationToken: cancellationToken)
.ConfigureAwait(false);
return;
}
var command = new BsonDocument
{
{ "collMod", VexMongoCollectionNames.Raw },
{ "validator", validator },
{ "validationAction", "warn" },
{ "validationLevel", "moderate" },
};
await database.RunCommandAsync<BsonDocument>(command, cancellationToken: cancellationToken)
.ConfigureAwait(false);
}
private static async Task<bool> CollectionExistsAsync(
IMongoDatabase database,
string name,
CancellationToken cancellationToken)
{
using var cursor = await database.ListCollectionNamesAsync(
new ListCollectionNamesOptions { Filter = new BsonDocument("name", name) },
cancellationToken).ConfigureAwait(false);
return await cursor.AnyAsync(cancellationToken).ConfigureAwait(false);
}
private static BsonDocument BuildValidator()
{
var properties = new BsonDocument
{
{ "_id", new BsonDocument { { "bsonType", "string" }, { "description", "digest" } } },
{ "providerId", new BsonDocument { { "bsonType", "string" }, { "minLength", 1 } } },
{ "format", new BsonDocument
{
{ "bsonType", "string" },
{ "enum", new BsonArray { "csaf", "cyclonedx", "openvex" } }
}
},
{ "sourceUri", new BsonDocument { { "bsonType", "string" }, { "minLength", 1 } } },
{ "retrievedAt", new BsonDocument { { "bsonType", "date" } } },
{ "digest", new BsonDocument { { "bsonType", "string" }, { "minLength", 32 } } },
{ "content", new BsonDocument
{
{ "bsonType", new BsonArray { "binData", "string" } }
}
},
{ "gridFsObjectId", new BsonDocument
{
{ "bsonType", new BsonArray { "objectId", "null", "string" } }
}
},
{ "metadata", new BsonDocument
{
{ "bsonType", "object" },
{ "additionalProperties", true },
{ "patternProperties", new BsonDocument
{
{ ".*", new BsonDocument { { "bsonType", "string" } } }
}
}
}
}
};
return new BsonDocument
{
{
"$jsonSchema",
new BsonDocument
{
{ "bsonType", "object" },
{ "required", new BsonArray { "_id", "providerId", "format", "sourceUri", "retrievedAt", "digest" } },
{ "properties", properties },
{ "additionalProperties", true }
}
}
};
}
}

View File

@@ -1,71 +0,0 @@
using System.Threading;
using System.Threading.Tasks;
using MongoDB.Driver;
namespace StellaOps.Excititor.Storage.Mongo.Migrations;
/// <summary>
/// Migration that creates indexes for the vex.timeline_events collection.
/// </summary>
internal sealed class VexTimelineEventIndexMigration : IVexMongoMigration
{
public string Id => "20251127-timeline-events";
public async ValueTask ExecuteAsync(IMongoDatabase database, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(database);
var collection = database.GetCollection<VexTimelineEventRecord>(VexMongoCollectionNames.TimelineEvents);
// Unique index on tenant + event ID
var tenantEventIdIndex = Builders<VexTimelineEventRecord>.IndexKeys
.Ascending(x => x.Tenant)
.Ascending(x => x.Id);
// Index for querying by time range (descending for recent-first queries)
var tenantTimeIndex = Builders<VexTimelineEventRecord>.IndexKeys
.Ascending(x => x.Tenant)
.Descending(x => x.CreatedAt);
// Index for querying by trace ID
var tenantTraceIndex = Builders<VexTimelineEventRecord>.IndexKeys
.Ascending(x => x.Tenant)
.Ascending(x => x.TraceId)
.Ascending(x => x.CreatedAt);
// Index for querying by provider
var tenantProviderIndex = Builders<VexTimelineEventRecord>.IndexKeys
.Ascending(x => x.Tenant)
.Ascending(x => x.ProviderId)
.Descending(x => x.CreatedAt);
// Index for querying by event type
var tenantEventTypeIndex = Builders<VexTimelineEventRecord>.IndexKeys
.Ascending(x => x.Tenant)
.Ascending(x => x.EventType)
.Descending(x => x.CreatedAt);
// TTL index for automatic cleanup (30 days by default)
// Uncomment if timeline events should expire:
// var ttlIndex = Builders<VexTimelineEventRecord>.IndexKeys.Ascending(x => x.CreatedAt);
// var ttlOptions = new CreateIndexOptions { ExpireAfter = TimeSpan.FromDays(30) };
await Task.WhenAll(
collection.Indexes.CreateOneAsync(
new CreateIndexModel<VexTimelineEventRecord>(tenantEventIdIndex, new CreateIndexOptions { Unique = true }),
cancellationToken: cancellationToken),
collection.Indexes.CreateOneAsync(
new CreateIndexModel<VexTimelineEventRecord>(tenantTimeIndex),
cancellationToken: cancellationToken),
collection.Indexes.CreateOneAsync(
new CreateIndexModel<VexTimelineEventRecord>(tenantTraceIndex),
cancellationToken: cancellationToken),
collection.Indexes.CreateOneAsync(
new CreateIndexModel<VexTimelineEventRecord>(tenantProviderIndex),
cancellationToken: cancellationToken),
collection.Indexes.CreateOneAsync(
new CreateIndexModel<VexTimelineEventRecord>(tenantEventTypeIndex),
cancellationToken: cancellationToken)
).ConfigureAwait(false);
}
}

View File

@@ -1,43 +0,0 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using MongoDB.Driver;
using StellaOps.Excititor.Core;
namespace StellaOps.Excititor.Storage.Mongo;
public sealed class MongoVexAttestationLinkStore : IVexAttestationLinkStore
{
private readonly IMongoCollection<VexAttestationLinkRecord> _collection;
public MongoVexAttestationLinkStore(IMongoDatabase database)
{
ArgumentNullException.ThrowIfNull(database);
VexMongoMappingRegistry.Register();
_collection = database.GetCollection<VexAttestationLinkRecord>(VexMongoCollectionNames.Attestations);
}
public async ValueTask UpsertAsync(VexAttestationPayload payload, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(payload);
var record = VexAttestationLinkRecord.FromDomain(payload);
var filter = Builders<VexAttestationLinkRecord>.Filter.Eq(x => x.AttestationId, record.AttestationId);
var options = new ReplaceOptions { IsUpsert = true };
await _collection.ReplaceOneAsync(filter, record, options, cancellationToken).ConfigureAwait(false);
}
public async ValueTask<VexAttestationPayload?> FindAsync(string attestationId, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(attestationId))
{
throw new ArgumentException("Attestation id must be provided.", nameof(attestationId));
}
var filter = Builders<VexAttestationLinkRecord>.Filter.Eq(x => x.AttestationId, attestationId.Trim());
var record = await _collection.Find(filter).FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
return record?.ToDomain();
}
}

View File

@@ -1,59 +0,0 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using MongoDB.Bson;
using MongoDB.Driver;
using StellaOps.Excititor.Core;
namespace StellaOps.Excititor.Storage.Mongo;
public sealed class MongoVexCacheIndex : IVexCacheIndex
{
private readonly IMongoCollection<VexCacheEntryRecord> _collection;
public MongoVexCacheIndex(IMongoDatabase database)
{
ArgumentNullException.ThrowIfNull(database);
VexMongoMappingRegistry.Register();
_collection = database.GetCollection<VexCacheEntryRecord>(VexMongoCollectionNames.Cache);
}
public async ValueTask<VexCacheEntry?> FindAsync(VexQuerySignature signature, VexExportFormat format, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
ArgumentNullException.ThrowIfNull(signature);
var filter = Builders<VexCacheEntryRecord>.Filter.Eq(x => x.Id, VexCacheEntryRecord.CreateId(signature, format));
var record = session is null
? await _collection.Find(filter).FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false)
: await _collection.Find(session, filter).FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
return record?.ToDomain();
}
public async ValueTask SaveAsync(VexCacheEntry entry, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
ArgumentNullException.ThrowIfNull(entry);
var record = VexCacheEntryRecord.FromDomain(entry);
var filter = Builders<VexCacheEntryRecord>.Filter.Eq(x => x.Id, record.Id);
if (session is null)
{
await _collection.ReplaceOneAsync(filter, record, new ReplaceOptions { IsUpsert = true }, cancellationToken).ConfigureAwait(false);
}
else
{
await _collection.ReplaceOneAsync(session, filter, record, new ReplaceOptions { IsUpsert = true }, cancellationToken).ConfigureAwait(false);
}
}
public async ValueTask RemoveAsync(VexQuerySignature signature, VexExportFormat format, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
ArgumentNullException.ThrowIfNull(signature);
var filter = Builders<VexCacheEntryRecord>.Filter.Eq(x => x.Id, VexCacheEntryRecord.CreateId(signature, format));
if (session is null)
{
await _collection.DeleteOneAsync(filter, cancellationToken).ConfigureAwait(false);
}
else
{
await _collection.DeleteOneAsync(session, filter, options: null, cancellationToken).ConfigureAwait(false);
}
}
}

View File

@@ -1,106 +0,0 @@
using System.Collections.Generic;
using Microsoft.Extensions.Logging;
using MongoDB.Driver;
namespace StellaOps.Excititor.Storage.Mongo;
internal sealed class MongoVexCacheMaintenance : IVexCacheMaintenance
{
private readonly IMongoCollection<VexCacheEntryRecord> _cache;
private readonly IMongoCollection<VexExportManifestRecord> _exports;
private readonly ILogger<MongoVexCacheMaintenance> _logger;
public MongoVexCacheMaintenance(
IMongoDatabase database,
ILogger<MongoVexCacheMaintenance> logger)
{
ArgumentNullException.ThrowIfNull(database);
ArgumentNullException.ThrowIfNull(logger);
VexMongoMappingRegistry.Register();
_cache = database.GetCollection<VexCacheEntryRecord>(VexMongoCollectionNames.Cache);
_exports = database.GetCollection<VexExportManifestRecord>(VexMongoCollectionNames.Exports);
_logger = logger;
}
public async ValueTask<int> RemoveExpiredAsync(DateTimeOffset asOf, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
var cutoff = asOf.UtcDateTime;
var filter = Builders<VexCacheEntryRecord>.Filter.Lt(x => x.ExpiresAt, cutoff);
DeleteResult result;
if (session is null)
{
result = await _cache.DeleteManyAsync(filter, cancellationToken).ConfigureAwait(false);
}
else
{
result = await _cache.DeleteManyAsync(session, filter, options: null, cancellationToken).ConfigureAwait(false);
}
var removed = (int)result.DeletedCount;
if (removed > 0)
{
_logger.LogInformation("Pruned {Count} expired VEX export cache entries (cutoff {Cutoff})", removed, cutoff);
}
return removed;
}
public async ValueTask<int> RemoveMissingManifestReferencesAsync(CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
var filter = Builders<VexCacheEntryRecord>.Filter.Ne(x => x.ManifestId, null);
var cursor = session is null
? await _cache.Find(filter).ToListAsync(cancellationToken).ConfigureAwait(false)
: await _cache.Find(session, filter).ToListAsync(cancellationToken).ConfigureAwait(false);
if (cursor.Count == 0)
{
return 0;
}
var danglingIds = new List<string>(cursor.Count);
foreach (var entry in cursor)
{
if (string.IsNullOrWhiteSpace(entry.ManifestId))
{
continue;
}
var manifestFilter = Builders<VexExportManifestRecord>.Filter.Eq(x => x.Id, entry.ManifestId);
var manifestQuery = session is null
? _exports.Find(manifestFilter)
: _exports.Find(session, manifestFilter);
var manifestExists = await manifestQuery
.Limit(1)
.AnyAsync(cancellationToken)
.ConfigureAwait(false);
if (!manifestExists)
{
danglingIds.Add(entry.Id);
}
}
if (danglingIds.Count == 0)
{
return 0;
}
var danglingFilter = Builders<VexCacheEntryRecord>.Filter.In(x => x.Id, danglingIds);
DeleteResult result;
if (session is null)
{
result = await _cache.DeleteManyAsync(danglingFilter, cancellationToken).ConfigureAwait(false);
}
else
{
result = await _cache.DeleteManyAsync(session, danglingFilter, options: null, cancellationToken).ConfigureAwait(false);
}
var removed = (int)result.DeletedCount;
_logger.LogWarning("Removed {Count} cache entries referencing missing export manifests.", removed);
return removed;
}
}

View File

@@ -1,86 +0,0 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using MongoDB.Driver;
using StellaOps.Excititor.Core;
namespace StellaOps.Excititor.Storage.Mongo;
public sealed class MongoVexClaimStore : IVexClaimStore
{
private readonly IMongoCollection<VexStatementRecord> _collection;
public MongoVexClaimStore(IMongoDatabase database)
{
ArgumentNullException.ThrowIfNull(database);
VexMongoMappingRegistry.Register();
_collection = database.GetCollection<VexStatementRecord>(VexMongoCollectionNames.Statements);
}
public async ValueTask AppendAsync(IEnumerable<VexClaim> claims, DateTimeOffset observedAt, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
ArgumentNullException.ThrowIfNull(claims);
var records = claims
.Select(claim => VexStatementRecord.FromDomain(claim, observedAt))
.ToList();
if (records.Count == 0)
{
return;
}
if (session is null)
{
await _collection.InsertManyAsync(records, new InsertManyOptions { IsOrdered = false }, cancellationToken).ConfigureAwait(false);
}
else
{
await _collection.InsertManyAsync(session, records, new InsertManyOptions { IsOrdered = false }, cancellationToken).ConfigureAwait(false);
}
}
public async ValueTask<IReadOnlyCollection<VexClaim>> FindAsync(string vulnerabilityId, string productKey, DateTimeOffset? since, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
ArgumentException.ThrowIfNullOrWhiteSpace(vulnerabilityId);
ArgumentException.ThrowIfNullOrWhiteSpace(productKey);
var filter = Builders<VexStatementRecord>.Filter.Eq(x => x.VulnerabilityId, vulnerabilityId.Trim()) &
Builders<VexStatementRecord>.Filter.Eq(x => x.Product.Key, productKey.Trim());
if (since is { } sinceValue)
{
filter &= Builders<VexStatementRecord>.Filter.Gte(x => x.InsertedAt, sinceValue.UtcDateTime);
}
var find = session is null
? _collection.Find(filter)
: _collection.Find(session, filter);
var records = await find
.SortByDescending(x => x.InsertedAt)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
return records.ConvertAll(static record => record.ToDomain());
}
public async ValueTask<IReadOnlyCollection<VexClaim>> FindByVulnerabilityAsync(string vulnerabilityId, int limit, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
ArgumentException.ThrowIfNullOrWhiteSpace(vulnerabilityId);
var filter = Builders<VexStatementRecord>.Filter.Eq(x => x.VulnerabilityId, vulnerabilityId.Trim());
var find = session is null
? _collection.Find(filter)
: _collection.Find(session, filter);
var records = await find
.SortByDescending(x => x.InsertedAt)
.Limit(limit)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
return records.ConvertAll(static record => record.ToDomain());
}
}

View File

@@ -1,79 +0,0 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using MongoDB.Driver;
namespace StellaOps.Excititor.Storage.Mongo;
public sealed class MongoVexConnectorStateRepository : IVexConnectorStateRepository
{
private readonly IMongoCollection<VexConnectorStateDocument> _collection;
public MongoVexConnectorStateRepository(IMongoDatabase database)
{
ArgumentNullException.ThrowIfNull(database);
VexMongoMappingRegistry.Register();
_collection = database.GetCollection<VexConnectorStateDocument>(VexMongoCollectionNames.ConnectorState);
}
public async ValueTask<VexConnectorState?> GetAsync(string connectorId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
ArgumentException.ThrowIfNullOrWhiteSpace(connectorId);
var filter = Builders<VexConnectorStateDocument>.Filter.Eq(x => x.ConnectorId, connectorId.Trim());
var document = session is null
? await _collection.Find(filter).FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false)
: await _collection.Find(session, filter).FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
return document?.ToRecord();
}
public async ValueTask SaveAsync(VexConnectorState state, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
ArgumentNullException.ThrowIfNull(state);
var document = VexConnectorStateDocument.FromRecord(state.WithNormalizedDigests());
var filter = Builders<VexConnectorStateDocument>.Filter.Eq(x => x.ConnectorId, document.ConnectorId);
if (session is null)
{
await _collection.ReplaceOneAsync(filter, document, new ReplaceOptions { IsUpsert = true }, cancellationToken).ConfigureAwait(false);
}
else
{
await _collection.ReplaceOneAsync(session, filter, document, new ReplaceOptions { IsUpsert = true }, cancellationToken).ConfigureAwait(false);
}
}
public async ValueTask<IReadOnlyCollection<VexConnectorState>> ListAsync(CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
var find = session is null
? _collection.Find(FilterDefinition<VexConnectorStateDocument>.Empty)
: _collection.Find(session, FilterDefinition<VexConnectorStateDocument>.Empty);
var documents = await find
.SortBy(x => x.ConnectorId)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
return documents.ConvertAll(static document => document.ToRecord());
}
}
internal static class VexConnectorStateExtensions
{
private const int MaxDigestHistory = 200;
public static VexConnectorState WithNormalizedDigests(this VexConnectorState state)
{
var digests = state.DocumentDigests;
if (digests.Length <= MaxDigestHistory)
{
return state;
}
var trimmed = digests.Skip(digests.Length - MaxDigestHistory).ToImmutableArray();
return state with { DocumentDigests = trimmed };
}
}

View File

@@ -1,88 +0,0 @@
using System.Collections.Generic;
using System.Runtime.CompilerServices;
using System.Threading;
using System.Threading.Tasks;
using MongoDB.Driver;
using StellaOps.Excititor.Core;
namespace StellaOps.Excititor.Storage.Mongo;
public sealed class MongoVexConsensusHoldStore : IVexConsensusHoldStore
{
private readonly IMongoCollection<VexConsensusHoldRecord> _collection;
public MongoVexConsensusHoldStore(IMongoDatabase database)
{
ArgumentNullException.ThrowIfNull(database);
VexMongoMappingRegistry.Register();
_collection = database.GetCollection<VexConsensusHoldRecord>(VexMongoCollectionNames.ConsensusHolds);
}
public async ValueTask<VexConsensusHold?> FindAsync(string vulnerabilityId, string productKey, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
ArgumentException.ThrowIfNullOrWhiteSpace(vulnerabilityId);
ArgumentException.ThrowIfNullOrWhiteSpace(productKey);
var id = VexConsensusRecord.CreateId(vulnerabilityId, productKey);
var filter = Builders<VexConsensusHoldRecord>.Filter.Eq(x => x.Id, id);
var record = session is null
? await _collection.Find(filter).FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false)
: await _collection.Find(session, filter).FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
return record?.ToDomain();
}
public async ValueTask SaveAsync(VexConsensusHold hold, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
ArgumentNullException.ThrowIfNull(hold);
var record = VexConsensusHoldRecord.FromDomain(hold);
var filter = Builders<VexConsensusHoldRecord>.Filter.Eq(x => x.Id, record.Id);
if (session is null)
{
await _collection.ReplaceOneAsync(filter, record, new ReplaceOptions { IsUpsert = true }, cancellationToken).ConfigureAwait(false);
}
else
{
await _collection.ReplaceOneAsync(session, filter, record, new ReplaceOptions { IsUpsert = true }, cancellationToken).ConfigureAwait(false);
}
}
public async ValueTask RemoveAsync(string vulnerabilityId, string productKey, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
ArgumentException.ThrowIfNullOrWhiteSpace(vulnerabilityId);
ArgumentException.ThrowIfNullOrWhiteSpace(productKey);
var id = VexConsensusRecord.CreateId(vulnerabilityId, productKey);
var filter = Builders<VexConsensusHoldRecord>.Filter.Eq(x => x.Id, id);
if (session is null)
{
await _collection.DeleteOneAsync(filter, cancellationToken).ConfigureAwait(false);
}
else
{
await _collection.DeleteOneAsync(session, filter, options: null, cancellationToken).ConfigureAwait(false);
}
}
public async IAsyncEnumerable<VexConsensusHold> FindEligibleAsync(DateTimeOffset asOf, int batchSize, [EnumeratorCancellation] CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
var cutoff = asOf.UtcDateTime;
var filter = Builders<VexConsensusHoldRecord>.Filter.Lte(x => x.EligibleAt, cutoff);
var find = session is null
? _collection.Find(filter)
: _collection.Find(session, filter);
find = find.SortBy(x => x.EligibleAt);
if (batchSize > 0)
{
find = find.Limit(batchSize);
}
using var cursor = await find.ToCursorAsync(cancellationToken).ConfigureAwait(false);
while (await cursor.MoveNextAsync(cancellationToken).ConfigureAwait(false))
{
foreach (var record in cursor.Current)
{
yield return record.ToDomain();
}
}
}
}

View File

@@ -1,82 +0,0 @@
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using MongoDB.Driver;
using StellaOps.Excititor.Core;
namespace StellaOps.Excititor.Storage.Mongo;
public sealed class MongoVexConsensusStore : IVexConsensusStore
{
private readonly IMongoCollection<VexConsensusRecord> _collection;
public MongoVexConsensusStore(IMongoDatabase database)
{
ArgumentNullException.ThrowIfNull(database);
VexMongoMappingRegistry.Register();
_collection = database.GetCollection<VexConsensusRecord>(VexMongoCollectionNames.Consensus);
}
public async ValueTask<VexConsensus?> FindAsync(string vulnerabilityId, string productKey, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
ArgumentException.ThrowIfNullOrWhiteSpace(vulnerabilityId);
ArgumentException.ThrowIfNullOrWhiteSpace(productKey);
var id = VexConsensusRecord.CreateId(vulnerabilityId, productKey);
var filter = Builders<VexConsensusRecord>.Filter.Eq(x => x.Id, id);
var record = session is null
? await _collection.Find(filter).FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false)
: await _collection.Find(session, filter).FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
return record?.ToDomain();
}
public async ValueTask<IReadOnlyCollection<VexConsensus>> FindByVulnerabilityAsync(string vulnerabilityId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
ArgumentException.ThrowIfNullOrWhiteSpace(vulnerabilityId);
var filter = Builders<VexConsensusRecord>.Filter.Eq(x => x.VulnerabilityId, vulnerabilityId.Trim());
var find = session is null
? _collection.Find(filter)
: _collection.Find(session, filter);
var records = await find.ToListAsync(cancellationToken).ConfigureAwait(false);
return records.ConvertAll(static record => record.ToDomain());
}
public async ValueTask SaveAsync(VexConsensus consensus, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
ArgumentNullException.ThrowIfNull(consensus);
var record = VexConsensusRecord.FromDomain(consensus);
var filter = Builders<VexConsensusRecord>.Filter.Eq(x => x.Id, record.Id);
if (session is null)
{
await _collection.ReplaceOneAsync(filter, record, new ReplaceOptions { IsUpsert = true }, cancellationToken).ConfigureAwait(false);
}
else
{
await _collection.ReplaceOneAsync(session, filter, record, new ReplaceOptions { IsUpsert = true }, cancellationToken).ConfigureAwait(false);
}
}
public async IAsyncEnumerable<VexConsensus> FindCalculatedBeforeAsync(DateTimeOffset cutoff, int batchSize, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
var filter = Builders<VexConsensusRecord>.Filter.Lt(x => x.CalculatedAt, cutoff.UtcDateTime);
var find = session is null
? _collection.Find(filter)
: _collection.Find(session, filter);
find = find.SortBy(x => x.CalculatedAt);
if (batchSize > 0)
{
find = find.Limit(batchSize);
}
using var cursor = await find.ToCursorAsync(cancellationToken).ConfigureAwait(false);
while (await cursor.MoveNextAsync(cancellationToken).ConfigureAwait(false))
{
foreach (var record in cursor.Current)
{
yield return record.ToDomain();
}
}
}
}

View File

@@ -1,172 +0,0 @@
using System;
using System.ComponentModel.DataAnnotations;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Options;
using MongoDB.Driver;
using MongoDB.Driver.Core.Clusters;
using StellaOps.Excititor.Core;
namespace StellaOps.Excititor.Storage.Mongo;
public sealed class MongoVexExportStore : IVexExportStore
{
private readonly IMongoClient _client;
private readonly IMongoCollection<VexExportManifestRecord> _exports;
private readonly IMongoCollection<VexCacheEntryRecord> _cache;
private readonly VexMongoStorageOptions _options;
private readonly IVexMongoSessionProvider _sessionProvider;
public MongoVexExportStore(
IMongoClient client,
IMongoDatabase database,
IOptions<VexMongoStorageOptions> options,
IVexMongoSessionProvider sessionProvider)
{
_client = client ?? throw new ArgumentNullException(nameof(client));
ArgumentNullException.ThrowIfNull(database);
ArgumentNullException.ThrowIfNull(options);
_sessionProvider = sessionProvider ?? throw new ArgumentNullException(nameof(sessionProvider));
_options = options.Value;
Validator.ValidateObject(_options, new ValidationContext(_options), validateAllProperties: true);
VexMongoMappingRegistry.Register();
_exports = database.GetCollection<VexExportManifestRecord>(VexMongoCollectionNames.Exports);
_cache = database.GetCollection<VexCacheEntryRecord>(VexMongoCollectionNames.Cache);
}
public async ValueTask<VexExportManifest?> FindAsync(VexQuerySignature signature, VexExportFormat format, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
ArgumentNullException.ThrowIfNull(signature);
var cacheId = VexCacheEntryRecord.CreateId(signature, format);
var cacheFilter = Builders<VexCacheEntryRecord>.Filter.Eq(x => x.Id, cacheId);
var cacheRecord = session is null
? await _cache.Find(cacheFilter).FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false)
: await _cache.Find(session, cacheFilter).FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
if (cacheRecord is null)
{
return null;
}
if (cacheRecord.ExpiresAt is DateTime expiresAt && expiresAt <= DateTime.UtcNow)
{
await _cache.DeleteOneAsync(cacheFilter, cancellationToken).ConfigureAwait(false);
return null;
}
var manifestId = VexExportManifestRecord.CreateId(signature, format);
var manifestFilter = Builders<VexExportManifestRecord>.Filter.Eq(x => x.Id, manifestId);
var manifest = session is null
? await _exports.Find(manifestFilter).FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false)
: await _exports.Find(session, manifestFilter).FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
if (manifest is null)
{
if (session is null)
{
await _cache.DeleteOneAsync(cacheFilter, cancellationToken).ConfigureAwait(false);
}
else
{
await _cache.DeleteOneAsync(session, cacheFilter, options: null, cancellationToken).ConfigureAwait(false);
}
return null;
}
if (!string.IsNullOrWhiteSpace(cacheRecord.ManifestId) &&
!string.Equals(cacheRecord.ManifestId, manifest.Id, StringComparison.Ordinal))
{
if (session is null)
{
await _cache.DeleteOneAsync(cacheFilter, cancellationToken).ConfigureAwait(false);
}
else
{
await _cache.DeleteOneAsync(session, cacheFilter, options: null, cancellationToken).ConfigureAwait(false);
}
return null;
}
return manifest.ToDomain();
}
public async ValueTask SaveAsync(VexExportManifest manifest, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
ArgumentNullException.ThrowIfNull(manifest);
var sessionHandle = session ?? await _sessionProvider.StartSessionAsync(cancellationToken).ConfigureAwait(false);
var supportsTransactions = sessionHandle.Client.Cluster.Description.Type != ClusterType.Standalone
&& !sessionHandle.IsInTransaction;
var startedTransaction = false;
if (supportsTransactions)
{
try
{
sessionHandle.StartTransaction();
startedTransaction = true;
}
catch (NotSupportedException)
{
supportsTransactions = false;
}
}
try
{
var manifestRecord = VexExportManifestRecord.FromDomain(manifest);
var manifestFilter = Builders<VexExportManifestRecord>.Filter.Eq(x => x.Id, manifestRecord.Id);
await _exports
.ReplaceOneAsync(
sessionHandle,
manifestFilter,
manifestRecord,
new ReplaceOptions { IsUpsert = true },
cancellationToken)
.ConfigureAwait(false);
var cacheEntry = CreateCacheEntry(manifest);
var cacheRecord = VexCacheEntryRecord.FromDomain(cacheEntry);
var cacheFilter = Builders<VexCacheEntryRecord>.Filter.Eq(x => x.Id, cacheRecord.Id);
await _cache
.ReplaceOneAsync(
sessionHandle,
cacheFilter,
cacheRecord,
new ReplaceOptions { IsUpsert = true },
cancellationToken)
.ConfigureAwait(false);
if (startedTransaction)
{
await sessionHandle.CommitTransactionAsync(cancellationToken).ConfigureAwait(false);
}
}
catch
{
if (startedTransaction && sessionHandle.IsInTransaction)
{
await sessionHandle.AbortTransactionAsync(cancellationToken).ConfigureAwait(false);
}
throw;
}
}
private VexCacheEntry CreateCacheEntry(VexExportManifest manifest)
{
var expiresAt = manifest.CreatedAt + _options.ExportCacheTtl;
return new VexCacheEntry(
manifest.QuerySignature,
manifest.Format,
manifest.Artifact,
manifest.CreatedAt,
manifest.SizeBytes,
manifestId: manifest.ExportId,
gridFsObjectId: null,
expiresAt: expiresAt);
}
}

View File

@@ -1,93 +0,0 @@
using MongoDB.Driver;
using StellaOps.Excititor.Core.Observations;
namespace StellaOps.Excititor.Storage.Mongo;
/// <summary>
/// MongoDB implementation of <see cref="IVexLinksetEventPublisher"/>.
/// Events are persisted to the vex.linkset_events collection for replay and audit.
/// </summary>
internal sealed class MongoVexLinksetEventPublisher : IVexLinksetEventPublisher
{
private readonly IMongoCollection<VexLinksetEventRecord> _collection;
public MongoVexLinksetEventPublisher(IMongoDatabase database)
{
ArgumentNullException.ThrowIfNull(database);
_collection = database.GetCollection<VexLinksetEventRecord>(VexMongoCollectionNames.LinksetEvents);
}
public async Task PublishAsync(VexLinksetUpdatedEvent @event, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(@event);
var record = ToRecord(@event);
await _collection.InsertOneAsync(record, cancellationToken: cancellationToken)
.ConfigureAwait(false);
}
public async Task PublishManyAsync(IEnumerable<VexLinksetUpdatedEvent> events, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(events);
var records = events
.Where(e => e is not null)
.Select(ToRecord)
.ToList();
if (records.Count == 0)
{
return;
}
var options = new InsertManyOptions { IsOrdered = false };
await _collection.InsertManyAsync(records, options, cancellationToken)
.ConfigureAwait(false);
}
private static VexLinksetEventRecord ToRecord(VexLinksetUpdatedEvent @event)
{
var eventId = $"{@event.LinksetId}:{@event.CreatedAtUtc.UtcTicks}";
return new VexLinksetEventRecord
{
Id = eventId,
EventType = @event.EventType,
Tenant = @event.Tenant.ToLowerInvariant(),
LinksetId = @event.LinksetId,
VulnerabilityId = @event.VulnerabilityId,
ProductKey = @event.ProductKey,
Scope = new VexLinksetScopeRecord
{
ProductKey = @event.Scope.ProductKey,
Type = @event.Scope.Type,
Version = @event.Scope.Version,
Purl = @event.Scope.Purl,
Cpe = @event.Scope.Cpe,
Identifiers = @event.Scope.Identifiers.ToList(),
},
Observations = @event.Observations
.Select(o => new VexLinksetEventObservationRecord
{
ObservationId = o.ObservationId,
ProviderId = o.ProviderId,
Status = o.Status,
Confidence = o.Confidence
})
.ToList(),
Disagreements = @event.Disagreements
.Select(d => new VexLinksetDisagreementRecord
{
ProviderId = d.ProviderId,
Status = d.Status,
Justification = d.Justification,
Confidence = d.Confidence
})
.ToList(),
CreatedAtUtc = @event.CreatedAtUtc.UtcDateTime,
PublishedAtUtc = DateTime.UtcNow,
ConflictCount = @event.Disagreements.Length,
ObservationCount = @event.Observations.Length
};
}
}

View File

@@ -1,376 +0,0 @@
using System.Collections.Immutable;
using MongoDB.Driver;
using StellaOps.Excititor.Core.Observations;
namespace StellaOps.Excititor.Storage.Mongo;
internal sealed class MongoVexLinksetStore : IVexLinksetStore
{
private readonly IMongoCollection<VexLinksetRecord> _collection;
public MongoVexLinksetStore(IMongoDatabase database)
{
ArgumentNullException.ThrowIfNull(database);
_collection = database.GetCollection<VexLinksetRecord>(VexMongoCollectionNames.Linksets);
}
public async ValueTask<bool> InsertAsync(
VexLinkset linkset,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(linkset);
var record = ToRecord(linkset);
try
{
await _collection.InsertOneAsync(record, cancellationToken: cancellationToken)
.ConfigureAwait(false);
return true;
}
catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey)
{
return false;
}
}
public async ValueTask<bool> UpsertAsync(
VexLinkset linkset,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(linkset);
var record = ToRecord(linkset);
var normalizedTenant = NormalizeTenant(linkset.Tenant);
var filter = Builders<VexLinksetRecord>.Filter.And(
Builders<VexLinksetRecord>.Filter.Eq(r => r.Tenant, normalizedTenant),
Builders<VexLinksetRecord>.Filter.Eq(r => r.LinksetId, linkset.LinksetId));
var options = new ReplaceOptions { IsUpsert = true };
var result = await _collection
.ReplaceOneAsync(filter, record, options, cancellationToken)
.ConfigureAwait(false);
return result.UpsertedId is not null;
}
public async ValueTask<VexLinkset?> GetByIdAsync(
string tenant,
string linksetId,
CancellationToken cancellationToken)
{
var normalizedTenant = NormalizeTenant(tenant);
var normalizedId = linksetId?.Trim() ?? throw new ArgumentNullException(nameof(linksetId));
var filter = Builders<VexLinksetRecord>.Filter.And(
Builders<VexLinksetRecord>.Filter.Eq(r => r.Tenant, normalizedTenant),
Builders<VexLinksetRecord>.Filter.Eq(r => r.LinksetId, normalizedId));
var record = await _collection
.Find(filter)
.FirstOrDefaultAsync(cancellationToken)
.ConfigureAwait(false);
return record is null ? null : ToModel(record);
}
public async ValueTask<VexLinkset> GetOrCreateAsync(
string tenant,
string vulnerabilityId,
string productKey,
CancellationToken cancellationToken)
{
var normalizedTenant = NormalizeTenant(tenant);
var normalizedVuln = vulnerabilityId?.Trim() ?? throw new ArgumentNullException(nameof(vulnerabilityId));
var normalizedProduct = productKey?.Trim() ?? throw new ArgumentNullException(nameof(productKey));
var linksetId = VexLinkset.CreateLinksetId(normalizedTenant, normalizedVuln, normalizedProduct);
var existing = await GetByIdAsync(normalizedTenant, linksetId, cancellationToken).ConfigureAwait(false);
if (existing is not null)
{
return existing;
}
var newLinkset = new VexLinkset(
linksetId,
normalizedTenant,
normalizedVuln,
normalizedProduct,
scope: VexProductScope.Unknown(normalizedProduct),
observations: Array.Empty<VexLinksetObservationRefModel>(),
disagreements: null,
createdAt: DateTimeOffset.UtcNow,
updatedAt: DateTimeOffset.UtcNow);
try
{
await InsertAsync(newLinkset, cancellationToken).ConfigureAwait(false);
return newLinkset;
}
catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey)
{
// Race condition - another process created it. Fetch and return.
var created = await GetByIdAsync(normalizedTenant, linksetId, cancellationToken).ConfigureAwait(false);
return created ?? newLinkset;
}
}
public async ValueTask<IReadOnlyList<VexLinkset>> FindByVulnerabilityAsync(
string tenant,
string vulnerabilityId,
int limit,
CancellationToken cancellationToken)
{
var normalizedTenant = NormalizeTenant(tenant);
var normalizedVuln = vulnerabilityId?.Trim().ToLowerInvariant()
?? throw new ArgumentNullException(nameof(vulnerabilityId));
var filter = Builders<VexLinksetRecord>.Filter.And(
Builders<VexLinksetRecord>.Filter.Eq(r => r.Tenant, normalizedTenant),
Builders<VexLinksetRecord>.Filter.Eq(r => r.VulnerabilityId, normalizedVuln));
var records = await _collection
.Find(filter)
.Sort(Builders<VexLinksetRecord>.Sort.Descending(r => r.UpdatedAt))
.Limit(Math.Max(1, limit))
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
return records.Select(ToModel).ToList();
}
public async ValueTask<IReadOnlyList<VexLinkset>> FindByProductKeyAsync(
string tenant,
string productKey,
int limit,
CancellationToken cancellationToken)
{
var normalizedTenant = NormalizeTenant(tenant);
var normalizedProduct = productKey?.Trim().ToLowerInvariant()
?? throw new ArgumentNullException(nameof(productKey));
var filter = Builders<VexLinksetRecord>.Filter.And(
Builders<VexLinksetRecord>.Filter.Eq(r => r.Tenant, normalizedTenant),
Builders<VexLinksetRecord>.Filter.Eq(r => r.ProductKey, normalizedProduct));
var records = await _collection
.Find(filter)
.Sort(Builders<VexLinksetRecord>.Sort.Descending(r => r.UpdatedAt))
.Limit(Math.Max(1, limit))
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
return records.Select(ToModel).ToList();
}
public async ValueTask<IReadOnlyList<VexLinkset>> FindWithConflictsAsync(
string tenant,
int limit,
CancellationToken cancellationToken)
{
var normalizedTenant = NormalizeTenant(tenant);
var filter = Builders<VexLinksetRecord>.Filter.And(
Builders<VexLinksetRecord>.Filter.Eq(r => r.Tenant, normalizedTenant),
Builders<VexLinksetRecord>.Filter.SizeGt(r => r.Disagreements, 0));
var records = await _collection
.Find(filter)
.Sort(Builders<VexLinksetRecord>.Sort.Descending(r => r.UpdatedAt))
.Limit(Math.Max(1, limit))
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
return records.Select(ToModel).ToList();
}
public async ValueTask<IReadOnlyList<VexLinkset>> FindByProviderAsync(
string tenant,
string providerId,
int limit,
CancellationToken cancellationToken)
{
var normalizedTenant = NormalizeTenant(tenant);
var normalizedProvider = providerId?.Trim().ToLowerInvariant()
?? throw new ArgumentNullException(nameof(providerId));
var filter = Builders<VexLinksetRecord>.Filter.And(
Builders<VexLinksetRecord>.Filter.Eq(r => r.Tenant, normalizedTenant),
Builders<VexLinksetRecord>.Filter.AnyEq(r => r.ProviderIds, normalizedProvider));
var records = await _collection
.Find(filter)
.Sort(Builders<VexLinksetRecord>.Sort.Descending(r => r.UpdatedAt))
.Limit(Math.Max(1, limit))
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
return records.Select(ToModel).ToList();
}
public async ValueTask<bool> DeleteAsync(
string tenant,
string linksetId,
CancellationToken cancellationToken)
{
var normalizedTenant = NormalizeTenant(tenant);
var normalizedId = linksetId?.Trim() ?? throw new ArgumentNullException(nameof(linksetId));
var filter = Builders<VexLinksetRecord>.Filter.And(
Builders<VexLinksetRecord>.Filter.Eq(r => r.Tenant, normalizedTenant),
Builders<VexLinksetRecord>.Filter.Eq(r => r.LinksetId, normalizedId));
var result = await _collection
.DeleteOneAsync(filter, cancellationToken)
.ConfigureAwait(false);
return result.DeletedCount > 0;
}
public async ValueTask<long> CountAsync(
string tenant,
CancellationToken cancellationToken)
{
var normalizedTenant = NormalizeTenant(tenant);
var filter = Builders<VexLinksetRecord>.Filter.Eq(r => r.Tenant, normalizedTenant);
return await _collection
.CountDocumentsAsync(filter, cancellationToken: cancellationToken)
.ConfigureAwait(false);
}
public async ValueTask<long> CountWithConflictsAsync(
string tenant,
CancellationToken cancellationToken)
{
var normalizedTenant = NormalizeTenant(tenant);
var filter = Builders<VexLinksetRecord>.Filter.And(
Builders<VexLinksetRecord>.Filter.Eq(r => r.Tenant, normalizedTenant),
Builders<VexLinksetRecord>.Filter.SizeGt(r => r.Disagreements, 0));
return await _collection
.CountDocumentsAsync(filter, cancellationToken: cancellationToken)
.ConfigureAwait(false);
}
private static string NormalizeTenant(string tenant)
{
if (string.IsNullOrWhiteSpace(tenant))
{
throw new ArgumentException("tenant is required", nameof(tenant));
}
return tenant.Trim().ToLowerInvariant();
}
private static VexLinksetRecord ToRecord(VexLinkset linkset)
{
return new VexLinksetRecord
{
Id = linkset.LinksetId,
Tenant = linkset.Tenant.ToLowerInvariant(),
LinksetId = linkset.LinksetId,
VulnerabilityId = linkset.VulnerabilityId.ToLowerInvariant(),
ProductKey = linkset.ProductKey.ToLowerInvariant(),
Scope = ToScopeRecord(linkset.Scope),
ProviderIds = linkset.ProviderIds.ToList(),
Statuses = linkset.Statuses.ToList(),
CreatedAt = linkset.CreatedAt.UtcDateTime,
UpdatedAt = linkset.UpdatedAt.UtcDateTime,
Observations = linkset.Observations.Select(ToObservationRecord).ToList(),
Disagreements = linkset.Disagreements.Select(ToDisagreementRecord).ToList()
};
}
private static VexObservationLinksetObservationRecord ToObservationRecord(VexLinksetObservationRefModel obs)
{
return new VexObservationLinksetObservationRecord
{
ObservationId = obs.ObservationId,
ProviderId = obs.ProviderId,
Status = obs.Status,
Confidence = obs.Confidence
};
}
private static VexLinksetDisagreementRecord ToDisagreementRecord(VexObservationDisagreement disagreement)
{
return new VexLinksetDisagreementRecord
{
ProviderId = disagreement.ProviderId,
Status = disagreement.Status,
Justification = disagreement.Justification,
Confidence = disagreement.Confidence
};
}
private static VexLinkset ToModel(VexLinksetRecord record)
{
var observations = record.Observations?
.Where(o => o is not null)
.Select(o => new VexLinksetObservationRefModel(
o.ObservationId,
o.ProviderId,
o.Status,
o.Confidence))
.ToImmutableArray() ?? ImmutableArray<VexLinksetObservationRefModel>.Empty;
var disagreements = record.Disagreements?
.Where(d => d is not null)
.Select(d => new VexObservationDisagreement(
d.ProviderId,
d.Status,
d.Justification,
d.Confidence))
.ToImmutableArray() ?? ImmutableArray<VexObservationDisagreement>.Empty;
var scope = record.Scope is not null
? ToScope(record.Scope)
: VexProductScope.Unknown(record.ProductKey);
return new VexLinkset(
linksetId: record.LinksetId,
tenant: record.Tenant,
vulnerabilityId: record.VulnerabilityId,
productKey: record.ProductKey,
scope: scope,
observations: observations,
disagreements: disagreements,
createdAt: new DateTimeOffset(record.CreatedAt, TimeSpan.Zero),
updatedAt: new DateTimeOffset(record.UpdatedAt, TimeSpan.Zero));
}
private static VexLinksetScopeRecord ToScopeRecord(VexProductScope scope)
{
return new VexLinksetScopeRecord
{
ProductKey = scope.ProductKey,
Type = scope.Type,
Version = scope.Version,
Purl = scope.Purl,
Cpe = scope.Cpe,
Identifiers = scope.Identifiers.ToList()
};
}
private static VexProductScope ToScope(VexLinksetScopeRecord record)
{
var identifiers = record.Identifiers?
.Where(id => !string.IsNullOrWhiteSpace(id))
.Select(id => id.Trim())
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToImmutableArray() ?? ImmutableArray<string>.Empty;
return new VexProductScope(
ProductKey: record.ProductKey ?? string.Empty,
Type: record.Type ?? "unknown",
Version: record.Version,
Purl: record.Purl,
Cpe: record.Cpe,
Identifiers: identifiers);
}
}

View File

@@ -1,222 +0,0 @@
using System.Collections.Immutable;
using System.Text.Json.Nodes;
using MongoDB.Driver;
using MongoDB.Bson;
using StellaOps.Excititor.Core;
using StellaOps.Excititor.Core.Observations;
namespace StellaOps.Excititor.Storage.Mongo;
internal sealed class MongoVexObservationLookup : IVexObservationLookup
{
private readonly IMongoCollection<VexObservationRecord> _collection;
public MongoVexObservationLookup(IMongoDatabase database)
{
ArgumentNullException.ThrowIfNull(database);
_collection = database.GetCollection<VexObservationRecord>(VexMongoCollectionNames.Observations);
}
public async ValueTask<IReadOnlyList<VexObservation>> ListByTenantAsync(
string tenant,
CancellationToken cancellationToken)
{
var normalizedTenant = NormalizeTenant(tenant);
var filter = Builders<VexObservationRecord>.Filter.Eq(record => record.Tenant, normalizedTenant);
var records = await _collection
.Find(filter)
.Sort(Builders<VexObservationRecord>.Sort.Descending(r => r.CreatedAt).Descending(r => r.ObservationId))
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
return records.Select(Map).ToList();
}
public async ValueTask<IReadOnlyList<VexObservation>> FindByFiltersAsync(
string tenant,
IReadOnlyCollection<string> observationIds,
IReadOnlyCollection<string> vulnerabilityIds,
IReadOnlyCollection<string> productKeys,
IReadOnlyCollection<string> purls,
IReadOnlyCollection<string> cpes,
IReadOnlyCollection<string> providerIds,
IReadOnlyCollection<VexClaimStatus> statuses,
VexObservationCursor? cursor,
int limit,
CancellationToken cancellationToken)
{
var normalizedTenant = NormalizeTenant(tenant);
var filters = new List<FilterDefinition<VexObservationRecord>>(capacity: 8)
{
Builders<VexObservationRecord>.Filter.Eq(r => r.Tenant, normalizedTenant)
};
AddInFilter(filters, r => r.ObservationId, observationIds);
AddInFilter(filters, r => r.VulnerabilityId, vulnerabilityIds.Select(v => v.ToLowerInvariant()));
AddInFilter(filters, r => r.ProductKey, productKeys.Select(p => p.ToLowerInvariant()));
AddInFilter(filters, r => r.ProviderId, providerIds.Select(p => p.ToLowerInvariant()));
if (statuses.Count > 0)
{
var statusStrings = statuses.Select(status => status.ToString().ToLowerInvariant()).ToArray();
filters.Add(Builders<VexObservationRecord>.Filter.In(r => r.Status, statusStrings));
}
if (cursor is not null)
{
var cursorFilter = Builders<VexObservationRecord>.Filter.Or(
Builders<VexObservationRecord>.Filter.Lt(r => r.CreatedAt, cursor.CreatedAt.UtcDateTime),
Builders<VexObservationRecord>.Filter.And(
Builders<VexObservationRecord>.Filter.Eq(r => r.CreatedAt, cursor.CreatedAt.UtcDateTime),
Builders<VexObservationRecord>.Filter.Lt(r => r.ObservationId, cursor.ObservationId)));
filters.Add(cursorFilter);
}
var combinedFilter = filters.Count == 1
? filters[0]
: Builders<VexObservationRecord>.Filter.And(filters);
var records = await _collection
.Find(combinedFilter)
.Sort(Builders<VexObservationRecord>.Sort.Descending(r => r.CreatedAt).Descending(r => r.ObservationId))
.Limit(limit)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
return records.Select(Map).ToList();
}
private static void AddInFilter(
ICollection<FilterDefinition<VexObservationRecord>> filters,
System.Linq.Expressions.Expression<Func<VexObservationRecord, string>> field,
IEnumerable<string> values)
{
var normalized = values
.Where(value => !string.IsNullOrWhiteSpace(value))
.Select(value => value.Trim())
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
if (normalized.Length > 0)
{
filters.Add(Builders<VexObservationRecord>.Filter.In(field, normalized));
}
}
private static VexObservation Map(VexObservationRecord record)
{
var statements = record.Statements.Select(MapStatement).ToImmutableArray();
var linkset = MapLinkset(record.Linkset);
var upstreamSignature = record.Upstream?.Signature is null
? new VexObservationSignature(false, null, null, null)
: new VexObservationSignature(
record.Upstream.Signature.Present,
record.Upstream.Signature.Subject,
record.Upstream.Signature.Issuer,
signature: null);
var upstream = record.Upstream is null
? new VexObservationUpstream(
upstreamId: record.ObservationId,
documentVersion: null,
fetchedAt: record.CreatedAt,
receivedAt: record.CreatedAt,
contentHash: record.Document.Digest,
signature: upstreamSignature)
: new VexObservationUpstream(
record.Upstream.UpstreamId,
record.Upstream.DocumentVersion,
record.Upstream.FetchedAt,
record.Upstream.ReceivedAt,
record.Upstream.ContentHash,
upstreamSignature);
var documentSignature = record.Document.Signature is null
? null
: new VexObservationSignature(
record.Document.Signature.Present,
record.Document.Signature.Subject,
record.Document.Signature.Issuer,
signature: null);
var content = record.Content is null
? new VexObservationContent("unknown", null, new JsonObject())
: new VexObservationContent(
record.Content.Format ?? "unknown",
record.Content.SpecVersion,
JsonNode.Parse(record.Content.Raw.ToJson()) ?? new JsonObject(),
metadata: ImmutableDictionary<string, string>.Empty);
var observation = new VexObservation(
observationId: record.ObservationId,
tenant: record.Tenant,
providerId: record.ProviderId,
streamId: string.IsNullOrWhiteSpace(record.StreamId) ? record.ProviderId : record.StreamId,
upstream: upstream,
statements: statements,
content: content,
linkset: linkset,
createdAt: new DateTimeOffset(record.CreatedAt, TimeSpan.Zero),
supersedes: ImmutableArray<string>.Empty,
attributes: ImmutableDictionary<string, string>.Empty);
return observation;
}
private static VexObservationStatement MapStatement(VexObservationStatementRecord record)
{
var justification = string.IsNullOrWhiteSpace(record.Justification)
? (VexJustification?)null
: Enum.Parse<VexJustification>(record.Justification, ignoreCase: true);
return new VexObservationStatement(
record.VulnerabilityId,
record.ProductKey,
Enum.Parse<VexClaimStatus>(record.Status, ignoreCase: true),
record.LastObserved,
locator: record.Locator,
justification: justification,
introducedVersion: record.IntroducedVersion,
fixedVersion: record.FixedVersion,
purl: null,
cpe: null,
evidence: null,
metadata: ImmutableDictionary<string, string>.Empty);
}
private static VexObservationDisagreement MapDisagreement(VexLinksetDisagreementRecord record)
=> new(record.ProviderId, record.Status, record.Justification, record.Confidence);
private static VexObservationLinkset MapLinkset(VexObservationLinksetRecord record)
{
var aliases = record?.Aliases?.Where(NotNullOrWhiteSpace).Select(a => a.Trim()).ToImmutableArray() ?? ImmutableArray<string>.Empty;
var purls = record?.Purls?.Where(NotNullOrWhiteSpace).Select(p => p.Trim()).ToImmutableArray() ?? ImmutableArray<string>.Empty;
var cpes = record?.Cpes?.Where(NotNullOrWhiteSpace).Select(c => c.Trim()).ToImmutableArray() ?? ImmutableArray<string>.Empty;
var references = record?.References?.Select(r => new VexObservationReference(r.Type, r.Url)).ToImmutableArray() ?? ImmutableArray<VexObservationReference>.Empty;
var reconciledFrom = record?.ReconciledFrom?.Where(NotNullOrWhiteSpace).Select(r => r.Trim()).ToImmutableArray() ?? ImmutableArray<string>.Empty;
var disagreements = record?.Disagreements?.Select(MapDisagreement).ToImmutableArray() ?? ImmutableArray<VexObservationDisagreement>.Empty;
var observationRefs = record?.Observations?.Select(o => new VexLinksetObservationRefModel(
o.ObservationId,
o.ProviderId,
o.Status,
o.Confidence)).ToImmutableArray() ?? ImmutableArray<VexLinksetObservationRefModel>.Empty;
return new VexObservationLinkset(aliases, purls, cpes, references, reconciledFrom, disagreements, observationRefs);
}
private static bool NotNullOrWhiteSpace(string? value) => !string.IsNullOrWhiteSpace(value);
private static string NormalizeTenant(string tenant)
{
if (string.IsNullOrWhiteSpace(tenant))
{
throw new ArgumentException("tenant is required", nameof(tenant));
}
return tenant.Trim().ToLowerInvariant();
}
}

View File

@@ -1,398 +0,0 @@
using System.Collections.Immutable;
using System.Text.Json.Nodes;
using MongoDB.Bson;
using MongoDB.Driver;
using StellaOps.Excititor.Core;
using StellaOps.Excititor.Core.Observations;
namespace StellaOps.Excititor.Storage.Mongo;
internal sealed class MongoVexObservationStore : IVexObservationStore
{
private readonly IMongoCollection<VexObservationRecord> _collection;
public MongoVexObservationStore(IMongoDatabase database)
{
ArgumentNullException.ThrowIfNull(database);
_collection = database.GetCollection<VexObservationRecord>(VexMongoCollectionNames.Observations);
}
public async ValueTask<bool> InsertAsync(
VexObservation observation,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(observation);
var record = ToRecord(observation);
try
{
await _collection.InsertOneAsync(record, cancellationToken: cancellationToken)
.ConfigureAwait(false);
return true;
}
catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey)
{
return false;
}
}
public async ValueTask<bool> UpsertAsync(
VexObservation observation,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(observation);
var record = ToRecord(observation);
var normalizedTenant = NormalizeTenant(observation.Tenant);
var filter = Builders<VexObservationRecord>.Filter.And(
Builders<VexObservationRecord>.Filter.Eq(r => r.Tenant, normalizedTenant),
Builders<VexObservationRecord>.Filter.Eq(r => r.ObservationId, observation.ObservationId));
var options = new ReplaceOptions { IsUpsert = true };
var result = await _collection
.ReplaceOneAsync(filter, record, options, cancellationToken)
.ConfigureAwait(false);
return result.UpsertedId is not null;
}
public async ValueTask<int> InsertManyAsync(
string tenant,
IEnumerable<VexObservation> observations,
CancellationToken cancellationToken)
{
var normalizedTenant = NormalizeTenant(tenant);
var records = observations
.Where(o => o is not null && string.Equals(NormalizeTenant(o.Tenant), normalizedTenant, StringComparison.Ordinal))
.Select(ToRecord)
.ToList();
if (records.Count == 0)
{
return 0;
}
var options = new InsertManyOptions { IsOrdered = false };
try
{
await _collection.InsertManyAsync(records, options, cancellationToken)
.ConfigureAwait(false);
return records.Count;
}
catch (MongoBulkWriteException<VexObservationRecord> ex)
{
// Return the count of successful inserts
var duplicates = ex.WriteErrors?.Count(e => e.Category == ServerErrorCategory.DuplicateKey) ?? 0;
return records.Count - duplicates;
}
}
public async ValueTask<VexObservation?> GetByIdAsync(
string tenant,
string observationId,
CancellationToken cancellationToken)
{
var normalizedTenant = NormalizeTenant(tenant);
var normalizedId = observationId?.Trim() ?? throw new ArgumentNullException(nameof(observationId));
var filter = Builders<VexObservationRecord>.Filter.And(
Builders<VexObservationRecord>.Filter.Eq(r => r.Tenant, normalizedTenant),
Builders<VexObservationRecord>.Filter.Eq(r => r.ObservationId, normalizedId));
var record = await _collection
.Find(filter)
.FirstOrDefaultAsync(cancellationToken)
.ConfigureAwait(false);
return record is null ? null : ToModel(record);
}
public async ValueTask<IReadOnlyList<VexObservation>> FindByVulnerabilityAndProductAsync(
string tenant,
string vulnerabilityId,
string productKey,
CancellationToken cancellationToken)
{
var normalizedTenant = NormalizeTenant(tenant);
var normalizedVuln = vulnerabilityId?.Trim().ToLowerInvariant()
?? throw new ArgumentNullException(nameof(vulnerabilityId));
var normalizedProduct = productKey?.Trim().ToLowerInvariant()
?? throw new ArgumentNullException(nameof(productKey));
var filter = Builders<VexObservationRecord>.Filter.And(
Builders<VexObservationRecord>.Filter.Eq(r => r.Tenant, normalizedTenant),
Builders<VexObservationRecord>.Filter.Eq(r => r.VulnerabilityId, normalizedVuln),
Builders<VexObservationRecord>.Filter.Eq(r => r.ProductKey, normalizedProduct));
var records = await _collection
.Find(filter)
.Sort(Builders<VexObservationRecord>.Sort.Descending(r => r.CreatedAt))
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
return records.Select(ToModel).ToList();
}
public async ValueTask<IReadOnlyList<VexObservation>> FindByProviderAsync(
string tenant,
string providerId,
int limit,
CancellationToken cancellationToken)
{
var normalizedTenant = NormalizeTenant(tenant);
var normalizedProvider = providerId?.Trim().ToLowerInvariant()
?? throw new ArgumentNullException(nameof(providerId));
var filter = Builders<VexObservationRecord>.Filter.And(
Builders<VexObservationRecord>.Filter.Eq(r => r.Tenant, normalizedTenant),
Builders<VexObservationRecord>.Filter.Eq(r => r.ProviderId, normalizedProvider));
var records = await _collection
.Find(filter)
.Sort(Builders<VexObservationRecord>.Sort.Descending(r => r.CreatedAt))
.Limit(Math.Max(1, limit))
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
return records.Select(ToModel).ToList();
}
public async ValueTask<bool> DeleteAsync(
string tenant,
string observationId,
CancellationToken cancellationToken)
{
var normalizedTenant = NormalizeTenant(tenant);
var normalizedId = observationId?.Trim() ?? throw new ArgumentNullException(nameof(observationId));
var filter = Builders<VexObservationRecord>.Filter.And(
Builders<VexObservationRecord>.Filter.Eq(r => r.Tenant, normalizedTenant),
Builders<VexObservationRecord>.Filter.Eq(r => r.ObservationId, normalizedId));
var result = await _collection
.DeleteOneAsync(filter, cancellationToken)
.ConfigureAwait(false);
return result.DeletedCount > 0;
}
public async ValueTask<long> CountAsync(
string tenant,
CancellationToken cancellationToken)
{
var normalizedTenant = NormalizeTenant(tenant);
var filter = Builders<VexObservationRecord>.Filter.Eq(r => r.Tenant, normalizedTenant);
return await _collection
.CountDocumentsAsync(filter, cancellationToken: cancellationToken)
.ConfigureAwait(false);
}
private static string NormalizeTenant(string tenant)
{
if (string.IsNullOrWhiteSpace(tenant))
{
throw new ArgumentException("tenant is required", nameof(tenant));
}
return tenant.Trim().ToLowerInvariant();
}
private static VexObservationRecord ToRecord(VexObservation observation)
{
var firstStatement = observation.Statements.FirstOrDefault();
return new VexObservationRecord
{
Id = observation.ObservationId,
Tenant = observation.Tenant,
ObservationId = observation.ObservationId,
VulnerabilityId = firstStatement?.VulnerabilityId?.ToLowerInvariant() ?? string.Empty,
ProductKey = firstStatement?.ProductKey?.ToLowerInvariant() ?? string.Empty,
ProviderId = observation.ProviderId,
StreamId = observation.StreamId,
Status = firstStatement?.Status.ToString().ToLowerInvariant() ?? "unknown",
Document = new VexObservationDocumentRecord
{
Digest = observation.Upstream.ContentHash,
SourceUri = null,
Format = observation.Content.Format,
Revision = observation.Upstream.DocumentVersion,
Signature = new VexObservationSignatureRecord
{
Present = observation.Upstream.Signature.Present,
Subject = observation.Upstream.Signature.Format,
Issuer = observation.Upstream.Signature.KeyId,
VerifiedAt = null
}
},
Upstream = new VexObservationUpstreamRecord
{
UpstreamId = observation.Upstream.UpstreamId,
DocumentVersion = observation.Upstream.DocumentVersion,
FetchedAt = observation.Upstream.FetchedAt,
ReceivedAt = observation.Upstream.ReceivedAt,
ContentHash = observation.Upstream.ContentHash,
Signature = new VexObservationSignatureRecord
{
Present = observation.Upstream.Signature.Present,
Subject = observation.Upstream.Signature.Format,
Issuer = observation.Upstream.Signature.KeyId,
VerifiedAt = null
}
},
Content = new VexObservationContentRecord
{
Format = observation.Content.Format,
SpecVersion = observation.Content.SpecVersion,
Raw = BsonDocument.Parse(observation.Content.Raw.ToJsonString())
},
Statements = observation.Statements.Select(ToStatementRecord).ToList(),
Linkset = ToLinksetRecord(observation.Linkset),
CreatedAt = observation.CreatedAt.UtcDateTime
};
}
private static VexObservationStatementRecord ToStatementRecord(VexObservationStatement statement)
{
return new VexObservationStatementRecord
{
VulnerabilityId = statement.VulnerabilityId,
ProductKey = statement.ProductKey,
Status = statement.Status.ToString().ToLowerInvariant(),
LastObserved = statement.LastObserved,
Locator = statement.Locator,
Justification = statement.Justification?.ToString().ToLowerInvariant(),
IntroducedVersion = statement.IntroducedVersion,
FixedVersion = statement.FixedVersion,
Detail = null,
ScopeScore = null,
Epss = null,
Kev = null
};
}
private static VexObservationLinksetRecord ToLinksetRecord(VexObservationLinkset linkset)
{
return new VexObservationLinksetRecord
{
Aliases = linkset.Aliases.ToList(),
Purls = linkset.Purls.ToList(),
Cpes = linkset.Cpes.ToList(),
References = linkset.References.Select(r => new VexObservationReferenceRecord
{
Type = r.Type,
Url = r.Url
}).ToList(),
ReconciledFrom = linkset.ReconciledFrom.ToList(),
Disagreements = linkset.Disagreements.Select(d => new VexLinksetDisagreementRecord
{
ProviderId = d.ProviderId,
Status = d.Status,
Justification = d.Justification,
Confidence = d.Confidence
}).ToList(),
Observations = linkset.Observations.Select(o => new VexObservationLinksetObservationRecord
{
ObservationId = o.ObservationId,
ProviderId = o.ProviderId,
Status = o.Status,
Confidence = o.Confidence
}).ToList()
};
}
private static VexObservation ToModel(VexObservationRecord record)
{
var statements = record.Statements.Select(MapStatement).ToImmutableArray();
var linkset = MapLinkset(record.Linkset);
var upstreamSignature = record.Upstream?.Signature is null
? new VexObservationSignature(false, null, null, null)
: new VexObservationSignature(
record.Upstream.Signature.Present,
record.Upstream.Signature.Subject,
record.Upstream.Signature.Issuer,
signature: null);
var upstream = record.Upstream is null
? new VexObservationUpstream(
upstreamId: record.ObservationId,
documentVersion: null,
fetchedAt: record.CreatedAt,
receivedAt: record.CreatedAt,
contentHash: record.Document.Digest,
signature: upstreamSignature)
: new VexObservationUpstream(
record.Upstream.UpstreamId,
record.Upstream.DocumentVersion,
record.Upstream.FetchedAt,
record.Upstream.ReceivedAt,
record.Upstream.ContentHash,
upstreamSignature);
var content = record.Content is null
? new VexObservationContent("unknown", null, new JsonObject())
: new VexObservationContent(
record.Content.Format ?? "unknown",
record.Content.SpecVersion,
JsonNode.Parse(record.Content.Raw.ToJson()) ?? new JsonObject(),
metadata: ImmutableDictionary<string, string>.Empty);
return new VexObservation(
observationId: record.ObservationId,
tenant: record.Tenant,
providerId: record.ProviderId,
streamId: string.IsNullOrWhiteSpace(record.StreamId) ? record.ProviderId : record.StreamId,
upstream: upstream,
statements: statements,
content: content,
linkset: linkset,
createdAt: new DateTimeOffset(record.CreatedAt, TimeSpan.Zero),
supersedes: ImmutableArray<string>.Empty,
attributes: ImmutableDictionary<string, string>.Empty);
}
private static VexObservationStatement MapStatement(VexObservationStatementRecord record)
{
var justification = string.IsNullOrWhiteSpace(record.Justification)
? (VexJustification?)null
: Enum.Parse<VexJustification>(record.Justification, ignoreCase: true);
return new VexObservationStatement(
record.VulnerabilityId,
record.ProductKey,
Enum.Parse<VexClaimStatus>(record.Status, ignoreCase: true),
record.LastObserved,
locator: record.Locator,
justification: justification,
introducedVersion: record.IntroducedVersion,
fixedVersion: record.FixedVersion,
purl: null,
cpe: null,
evidence: null,
metadata: ImmutableDictionary<string, string>.Empty);
}
private static VexObservationLinkset MapLinkset(VexObservationLinksetRecord record)
{
var aliases = record?.Aliases?.Where(NotNullOrWhiteSpace).Select(a => a.Trim()).ToImmutableArray() ?? ImmutableArray<string>.Empty;
var purls = record?.Purls?.Where(NotNullOrWhiteSpace).Select(p => p.Trim()).ToImmutableArray() ?? ImmutableArray<string>.Empty;
var cpes = record?.Cpes?.Where(NotNullOrWhiteSpace).Select(c => c.Trim()).ToImmutableArray() ?? ImmutableArray<string>.Empty;
var references = record?.References?.Select(r => new VexObservationReference(r.Type, r.Url)).ToImmutableArray() ?? ImmutableArray<VexObservationReference>.Empty;
var reconciledFrom = record?.ReconciledFrom?.Where(NotNullOrWhiteSpace).Select(r => r.Trim()).ToImmutableArray() ?? ImmutableArray<string>.Empty;
var disagreements = record?.Disagreements?.Select(d => new VexObservationDisagreement(d.ProviderId, d.Status, d.Justification, d.Confidence)).ToImmutableArray() ?? ImmutableArray<VexObservationDisagreement>.Empty;
var observationRefs = record?.Observations?.Select(o => new VexLinksetObservationRefModel(
o.ObservationId,
o.ProviderId,
o.Status,
o.Confidence)).ToImmutableArray() ?? ImmutableArray<VexLinksetObservationRefModel>.Empty;
return new VexObservationLinkset(aliases, purls, cpes, references, reconciledFrom, disagreements, observationRefs);
}
private static bool NotNullOrWhiteSpace(string? value) => !string.IsNullOrWhiteSpace(value);
}

View File

@@ -1,57 +0,0 @@
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using MongoDB.Driver;
using StellaOps.Excititor.Core;
namespace StellaOps.Excititor.Storage.Mongo;
public sealed class MongoVexProviderStore : IVexProviderStore
{
private readonly IMongoCollection<VexProviderRecord> _collection;
public MongoVexProviderStore(IMongoDatabase database)
{
ArgumentNullException.ThrowIfNull(database);
VexMongoMappingRegistry.Register();
_collection = database.GetCollection<VexProviderRecord>(VexMongoCollectionNames.Providers);
}
public async ValueTask<VexProvider?> FindAsync(string id, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
ArgumentException.ThrowIfNullOrWhiteSpace(id);
var filter = Builders<VexProviderRecord>.Filter.Eq(x => x.Id, id.Trim());
var record = session is null
? await _collection.Find(filter).FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false)
: await _collection.Find(session, filter).FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
return record?.ToDomain();
}
public async ValueTask<IReadOnlyCollection<VexProvider>> ListAsync(CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
var find = session is null
? _collection.Find(FilterDefinition<VexProviderRecord>.Empty)
: _collection.Find(session, FilterDefinition<VexProviderRecord>.Empty);
var records = await find
.Sort(Builders<VexProviderRecord>.Sort.Ascending(x => x.Id))
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
return records.ConvertAll(static record => record.ToDomain());
}
public async ValueTask SaveAsync(VexProvider provider, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
ArgumentNullException.ThrowIfNull(provider);
var record = VexProviderRecord.FromDomain(provider);
var filter = Builders<VexProviderRecord>.Filter.Eq(x => x.Id, record.Id);
if (session is null)
{
await _collection.ReplaceOneAsync(filter, record, new ReplaceOptions { IsUpsert = true }, cancellationToken).ConfigureAwait(false);
}
else
{
await _collection.ReplaceOneAsync(session, filter, record, new ReplaceOptions { IsUpsert = true }, cancellationToken).ConfigureAwait(false);
}
}
}

View File

@@ -1,345 +0,0 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.ComponentModel.DataAnnotations;
using System.Diagnostics;
using System.IO;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using MongoDB.Bson;
using MongoDB.Driver;
using MongoDB.Driver.Core.Clusters;
using MongoDB.Driver.GridFS;
using StellaOps.Excititor.Core;
using StellaOps.Excititor.Core.Aoc;
using StellaOps.Ingestion.Telemetry;
namespace StellaOps.Excititor.Storage.Mongo;
public sealed class MongoVexRawStore : IVexRawStore
{
private readonly IMongoClient _client;
private readonly IMongoCollection<VexRawDocumentRecord> _collection;
private readonly GridFSBucket _bucket;
private readonly VexMongoStorageOptions _options;
private readonly IVexMongoSessionProvider _sessionProvider;
private readonly IVexRawWriteGuard _guard;
private readonly ILogger<MongoVexRawStore> _logger;
private readonly string _connectorVersion;
public MongoVexRawStore(
IMongoClient client,
IMongoDatabase database,
IOptions<VexMongoStorageOptions> options,
IVexMongoSessionProvider sessionProvider,
IVexRawWriteGuard guard,
ILogger<MongoVexRawStore>? logger = null)
{
_client = client ?? throw new ArgumentNullException(nameof(client));
ArgumentNullException.ThrowIfNull(database);
ArgumentNullException.ThrowIfNull(options);
_sessionProvider = sessionProvider ?? throw new ArgumentNullException(nameof(sessionProvider));
_guard = guard ?? throw new ArgumentNullException(nameof(guard));
_logger = logger ?? NullLogger<MongoVexRawStore>.Instance;
_options = options.Value;
Validator.ValidateObject(_options, new ValidationContext(_options), validateAllProperties: true);
_connectorVersion = typeof(MongoVexRawStore).Assembly.GetName().Version?.ToString() ?? "0.0.0";
VexMongoMappingRegistry.Register();
_collection = database.GetCollection<VexRawDocumentRecord>(VexMongoCollectionNames.Raw);
_bucket = new GridFSBucket(database, new GridFSBucketOptions
{
BucketName = _options.RawBucketName,
ReadConcern = database.Settings.ReadConcern,
ReadPreference = database.Settings.ReadPreference,
WriteConcern = database.Settings.WriteConcern,
});
}
public async ValueTask StoreAsync(VexRawDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
ArgumentNullException.ThrowIfNull(document);
var guardPayload = VexRawDocumentMapper.ToRawModel(document, _options.DefaultTenant);
var tenant = guardPayload.Tenant;
var sourceVendor = guardPayload.Source.Vendor;
var upstreamId = guardPayload.Upstream.UpstreamId;
var contentHash = guardPayload.Upstream.ContentHash;
using var logScope = _logger.BeginScope(new Dictionary<string, object?>(StringComparer.Ordinal)
{
["tenant"] = tenant,
["source.vendor"] = sourceVendor,
["upstream.upstreamId"] = upstreamId,
["contentHash"] = contentHash,
["providerId"] = document.ProviderId,
["digest"] = document.Digest,
});
var transformWatch = Stopwatch.StartNew();
using var transformActivity = IngestionTelemetry.StartTransformActivity(
tenant,
sourceVendor,
upstreamId,
contentHash,
document.Format.ToString(),
document.Content.Length);
try
{
_guard.EnsureValid(guardPayload);
transformActivity?.SetStatus(ActivityStatusCode.Ok);
}
catch (ExcititorAocGuardException ex)
{
transformActivity?.SetTag("violationCount", ex.Violations.IsDefaultOrEmpty ? 0 : ex.Violations.Length);
transformActivity?.SetTag("code", ex.PrimaryErrorCode);
transformActivity?.SetStatus(ActivityStatusCode.Error, ex.PrimaryErrorCode);
IngestionTelemetry.RecordViolation(tenant, sourceVendor, ex.PrimaryErrorCode);
IngestionTelemetry.RecordWriteAttempt(tenant, sourceVendor, IngestionTelemetry.ResultReject);
_logger.LogWarning(ex, "AOC guard rejected VEX document digest={Digest} provider={ProviderId}", document.Digest, document.ProviderId);
throw;
}
finally
{
if (transformWatch.IsRunning)
{
transformWatch.Stop();
}
IngestionTelemetry.RecordLatency(tenant, sourceVendor, IngestionTelemetry.PhaseTransform, transformWatch.Elapsed);
}
var threshold = _options.GridFsInlineThresholdBytes;
var useInline = threshold == 0 || document.Content.Length <= threshold;
string? newGridId = null;
string? oldGridIdToDelete = null;
var sessionHandle = session ?? await _sessionProvider.StartSessionAsync(cancellationToken).ConfigureAwait(false);
var supportsTransactions = sessionHandle.Client.Cluster.Description.Type != ClusterType.Standalone
&& !sessionHandle.IsInTransaction;
var startedTransaction = false;
if (supportsTransactions)
{
try
{
sessionHandle.StartTransaction();
startedTransaction = true;
}
catch (NotSupportedException)
{
supportsTransactions = false;
}
}
var fetchWatch = Stopwatch.StartNew();
using var fetchActivity = IngestionTelemetry.StartFetchActivity(
tenant,
sourceVendor,
upstreamId,
contentHash,
document.SourceUri.ToString());
fetchActivity?.SetTag("providerId", document.ProviderId);
fetchActivity?.SetTag("format", document.Format.ToString().ToLowerInvariant());
VexRawDocumentRecord? existing;
try
{
var filter = Builders<VexRawDocumentRecord>.Filter.Eq(x => x.Id, document.Digest);
existing = await _collection
.Find(sessionHandle, filter)
.FirstOrDefaultAsync(cancellationToken)
.ConfigureAwait(false);
fetchActivity?.SetTag("result", existing is null ? "miss" : "hit");
fetchActivity?.SetStatus(ActivityStatusCode.Ok);
}
catch
{
fetchActivity?.SetStatus(ActivityStatusCode.Error, "lookup-failed");
throw;
}
finally
{
if (fetchWatch.IsRunning)
{
fetchWatch.Stop();
}
IngestionTelemetry.RecordLatency(tenant, sourceVendor, IngestionTelemetry.PhaseFetch, fetchWatch.Elapsed);
}
// Append-only: if the digest already exists, skip write
if (existing is not null)
{
IngestionTelemetry.RecordWriteAttempt(tenant, sourceVendor, IngestionTelemetry.ResultNoop);
return;
}
if (!useInline)
{
newGridId = await UploadToGridFsAsync(document, sessionHandle, cancellationToken).ConfigureAwait(false);
}
var record = VexRawDocumentRecord.FromDomain(document, includeContent: useInline);
record.GridFsObjectId = useInline ? null : newGridId;
var writeWatch = Stopwatch.StartNew();
using var writeActivity = IngestionTelemetry.StartWriteActivity(
tenant,
sourceVendor,
upstreamId,
contentHash,
VexMongoCollectionNames.Raw);
string? writeResult = null;
try
{
await _collection
.ReplaceOneAsync(
sessionHandle,
Builders<VexRawDocumentRecord>.Filter.Eq(x => x.Id, document.Digest),
record,
new ReplaceOptions { IsUpsert = true },
cancellationToken)
.ConfigureAwait(false);
writeResult = existing is null ? IngestionTelemetry.ResultOk : IngestionTelemetry.ResultNoop;
writeActivity?.SetTag("result", writeResult);
if (existing?.GridFsObjectId is string oldGridId && !string.IsNullOrWhiteSpace(oldGridId))
{
if (useInline || !string.Equals(newGridId, oldGridId, StringComparison.Ordinal))
{
oldGridIdToDelete = oldGridId;
}
}
if (startedTransaction)
{
await sessionHandle.CommitTransactionAsync(cancellationToken).ConfigureAwait(false);
}
writeActivity?.SetStatus(ActivityStatusCode.Ok);
}
catch
{
if (startedTransaction && sessionHandle.IsInTransaction)
{
await sessionHandle.AbortTransactionAsync(cancellationToken).ConfigureAwait(false);
}
if (!useInline && !string.IsNullOrWhiteSpace(newGridId))
{
await DeleteFromGridFsAsync(newGridId, sessionHandle, cancellationToken).ConfigureAwait(false);
}
writeActivity?.SetStatus(ActivityStatusCode.Error, "write-failed");
throw;
}
finally
{
if (writeWatch.IsRunning)
{
writeWatch.Stop();
}
IngestionTelemetry.RecordLatency(tenant, sourceVendor, IngestionTelemetry.PhaseWrite, writeWatch.Elapsed);
if (!string.IsNullOrEmpty(writeResult))
{
IngestionTelemetry.RecordWriteAttempt(tenant, sourceVendor, writeResult);
}
}
if (!string.IsNullOrWhiteSpace(oldGridIdToDelete))
{
await DeleteFromGridFsAsync(oldGridIdToDelete!, sessionHandle, cancellationToken).ConfigureAwait(false);
}
}
public async ValueTask<VexRawDocument?> FindByDigestAsync(string digest, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
if (string.IsNullOrWhiteSpace(digest))
{
throw new ArgumentException("Digest must be provided.", nameof(digest));
}
var trimmed = digest.Trim();
var filter = Builders<VexRawDocumentRecord>.Filter.Eq(x => x.Id, trimmed);
var record = session is null
? await _collection.Find(filter).FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false)
: await _collection.Find(session, filter).FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
if (record is null)
{
return null;
}
if (!string.IsNullOrWhiteSpace(record.GridFsObjectId))
{
var handle = session ?? await _sessionProvider.StartSessionAsync(cancellationToken).ConfigureAwait(false);
var bytes = await DownloadFromGridFsAsync(record.GridFsObjectId, handle, cancellationToken).ConfigureAwait(false);
return record.ToDomain(new ReadOnlyMemory<byte>(bytes));
}
return record.ToDomain();
}
private async Task<string?> UploadToGridFsAsync(VexRawDocument document, IClientSessionHandle? session, CancellationToken cancellationToken)
{
using var stream = new MemoryStream(document.Content.ToArray(), writable: false);
var metadata = new BsonDocument
{
{ "providerId", document.ProviderId },
{ "format", document.Format.ToString().ToLowerInvariant() },
{ "sourceUri", document.SourceUri.ToString() },
{ "retrievedAt", document.RetrievedAt.UtcDateTime },
};
var options = new GridFSUploadOptions { Metadata = metadata };
var objectId = await _bucket
.UploadFromStreamAsync(document.Digest, stream, options, cancellationToken)
.ConfigureAwait(false);
return objectId.ToString();
}
private async Task DeleteFromGridFsAsync(string gridFsObjectId, IClientSessionHandle? session, CancellationToken cancellationToken)
{
if (!ObjectId.TryParse(gridFsObjectId, out var objectId))
{
return;
}
try
{
await _bucket.DeleteAsync(objectId, cancellationToken).ConfigureAwait(false);
}
catch (GridFSFileNotFoundException)
{
// file already removed by TTL or manual cleanup
}
}
private async Task<byte[]> DownloadFromGridFsAsync(string gridFsObjectId, IClientSessionHandle? session, CancellationToken cancellationToken)
{
if (!ObjectId.TryParse(gridFsObjectId, out var objectId))
{
return Array.Empty<byte>();
}
return await _bucket.DownloadAsBytesAsync(objectId, null, cancellationToken).ConfigureAwait(false);
}
async ValueTask IVexRawDocumentSink.StoreAsync(VexRawDocument document, CancellationToken cancellationToken)
=> await StoreAsync(document, cancellationToken, session: null).ConfigureAwait(false);
}

View File

@@ -1,316 +0,0 @@
using System.Collections.Immutable;
using MongoDB.Bson.Serialization.Attributes;
using MongoDB.Driver;
using StellaOps.Excititor.Core.Observations;
namespace StellaOps.Excititor.Storage.Mongo;
/// <summary>
/// MongoDB record for timeline events.
/// </summary>
[BsonIgnoreExtraElements]
internal sealed class VexTimelineEventRecord
{
[BsonId]
public string Id { get; set; } = default!;
public string Tenant { get; set; } = default!;
public string ProviderId { get; set; } = default!;
public string StreamId { get; set; } = default!;
public string EventType { get; set; } = default!;
public string TraceId { get; set; } = default!;
public string JustificationSummary { get; set; } = string.Empty;
public string? EvidenceHash { get; set; }
public string? PayloadHash { get; set; }
public DateTime CreatedAt { get; set; }
= DateTime.SpecifyKind(DateTime.UtcNow, DateTimeKind.Utc);
public Dictionary<string, string> Attributes { get; set; } = new(StringComparer.Ordinal);
}
/// <summary>
/// MongoDB implementation of the timeline event store.
/// </summary>
internal sealed class MongoVexTimelineEventStore : IVexTimelineEventStore
{
private readonly IMongoCollection<VexTimelineEventRecord> _collection;
public MongoVexTimelineEventStore(IMongoDatabase database)
{
ArgumentNullException.ThrowIfNull(database);
_collection = database.GetCollection<VexTimelineEventRecord>(VexMongoCollectionNames.TimelineEvents);
}
public async ValueTask<string> InsertAsync(
TimelineEvent evt,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(evt);
var record = ToRecord(evt);
try
{
await _collection.InsertOneAsync(record, cancellationToken: cancellationToken)
.ConfigureAwait(false);
return record.Id;
}
catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey)
{
// Event already exists, return the ID anyway
return record.Id;
}
}
public async ValueTask<int> InsertManyAsync(
string tenant,
IEnumerable<TimelineEvent> events,
CancellationToken cancellationToken)
{
var normalizedTenant = NormalizeTenant(tenant);
var records = events
.Where(e => e is not null && string.Equals(NormalizeTenant(e.Tenant), normalizedTenant, StringComparison.Ordinal))
.Select(ToRecord)
.ToList();
if (records.Count == 0)
{
return 0;
}
var options = new InsertManyOptions { IsOrdered = false };
try
{
await _collection.InsertManyAsync(records, options, cancellationToken)
.ConfigureAwait(false);
return records.Count;
}
catch (MongoBulkWriteException<VexTimelineEventRecord> ex)
{
var duplicates = ex.WriteErrors?.Count(e => e.Category == ServerErrorCategory.DuplicateKey) ?? 0;
return records.Count - duplicates;
}
}
public async ValueTask<IReadOnlyList<TimelineEvent>> FindByTimeRangeAsync(
string tenant,
DateTimeOffset from,
DateTimeOffset to,
int limit,
CancellationToken cancellationToken)
{
var normalizedTenant = NormalizeTenant(tenant);
var fromUtc = from.UtcDateTime;
var toUtc = to.UtcDateTime;
var filter = Builders<VexTimelineEventRecord>.Filter.And(
Builders<VexTimelineEventRecord>.Filter.Eq(r => r.Tenant, normalizedTenant),
Builders<VexTimelineEventRecord>.Filter.Gte(r => r.CreatedAt, fromUtc),
Builders<VexTimelineEventRecord>.Filter.Lte(r => r.CreatedAt, toUtc));
var records = await _collection
.Find(filter)
.Sort(Builders<VexTimelineEventRecord>.Sort.Ascending(r => r.CreatedAt))
.Limit(Math.Max(1, limit))
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
return records.Select(ToModel).ToList();
}
public async ValueTask<IReadOnlyList<TimelineEvent>> FindByTraceIdAsync(
string tenant,
string traceId,
CancellationToken cancellationToken)
{
var normalizedTenant = NormalizeTenant(tenant);
var normalizedTraceId = traceId?.Trim() ?? throw new ArgumentNullException(nameof(traceId));
var filter = Builders<VexTimelineEventRecord>.Filter.And(
Builders<VexTimelineEventRecord>.Filter.Eq(r => r.Tenant, normalizedTenant),
Builders<VexTimelineEventRecord>.Filter.Eq(r => r.TraceId, normalizedTraceId));
var records = await _collection
.Find(filter)
.Sort(Builders<VexTimelineEventRecord>.Sort.Ascending(r => r.CreatedAt))
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
return records.Select(ToModel).ToList();
}
public async ValueTask<IReadOnlyList<TimelineEvent>> FindByProviderAsync(
string tenant,
string providerId,
int limit,
CancellationToken cancellationToken)
{
var normalizedTenant = NormalizeTenant(tenant);
var normalizedProvider = providerId?.Trim().ToLowerInvariant()
?? throw new ArgumentNullException(nameof(providerId));
var filter = Builders<VexTimelineEventRecord>.Filter.And(
Builders<VexTimelineEventRecord>.Filter.Eq(r => r.Tenant, normalizedTenant),
Builders<VexTimelineEventRecord>.Filter.Eq(r => r.ProviderId, normalizedProvider));
var records = await _collection
.Find(filter)
.Sort(Builders<VexTimelineEventRecord>.Sort.Descending(r => r.CreatedAt))
.Limit(Math.Max(1, limit))
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
return records.Select(ToModel).ToList();
}
public async ValueTask<IReadOnlyList<TimelineEvent>> FindByEventTypeAsync(
string tenant,
string eventType,
int limit,
CancellationToken cancellationToken)
{
var normalizedTenant = NormalizeTenant(tenant);
var normalizedType = eventType?.Trim().ToLowerInvariant()
?? throw new ArgumentNullException(nameof(eventType));
var filter = Builders<VexTimelineEventRecord>.Filter.And(
Builders<VexTimelineEventRecord>.Filter.Eq(r => r.Tenant, normalizedTenant),
Builders<VexTimelineEventRecord>.Filter.Eq(r => r.EventType, normalizedType));
var records = await _collection
.Find(filter)
.Sort(Builders<VexTimelineEventRecord>.Sort.Descending(r => r.CreatedAt))
.Limit(Math.Max(1, limit))
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
return records.Select(ToModel).ToList();
}
public async ValueTask<IReadOnlyList<TimelineEvent>> GetRecentAsync(
string tenant,
int limit,
CancellationToken cancellationToken)
{
var normalizedTenant = NormalizeTenant(tenant);
var filter = Builders<VexTimelineEventRecord>.Filter.Eq(r => r.Tenant, normalizedTenant);
var records = await _collection
.Find(filter)
.Sort(Builders<VexTimelineEventRecord>.Sort.Descending(r => r.CreatedAt))
.Limit(Math.Max(1, limit))
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
return records.Select(ToModel).ToList();
}
public async ValueTask<TimelineEvent?> GetByIdAsync(
string tenant,
string eventId,
CancellationToken cancellationToken)
{
var normalizedTenant = NormalizeTenant(tenant);
var normalizedId = eventId?.Trim() ?? throw new ArgumentNullException(nameof(eventId));
var filter = Builders<VexTimelineEventRecord>.Filter.And(
Builders<VexTimelineEventRecord>.Filter.Eq(r => r.Tenant, normalizedTenant),
Builders<VexTimelineEventRecord>.Filter.Eq(r => r.Id, normalizedId));
var record = await _collection
.Find(filter)
.FirstOrDefaultAsync(cancellationToken)
.ConfigureAwait(false);
return record is null ? null : ToModel(record);
}
public async ValueTask<long> CountAsync(
string tenant,
CancellationToken cancellationToken)
{
var normalizedTenant = NormalizeTenant(tenant);
var filter = Builders<VexTimelineEventRecord>.Filter.Eq(r => r.Tenant, normalizedTenant);
return await _collection
.CountDocumentsAsync(filter, cancellationToken: cancellationToken)
.ConfigureAwait(false);
}
public async ValueTask<long> CountInRangeAsync(
string tenant,
DateTimeOffset from,
DateTimeOffset to,
CancellationToken cancellationToken)
{
var normalizedTenant = NormalizeTenant(tenant);
var fromUtc = from.UtcDateTime;
var toUtc = to.UtcDateTime;
var filter = Builders<VexTimelineEventRecord>.Filter.And(
Builders<VexTimelineEventRecord>.Filter.Eq(r => r.Tenant, normalizedTenant),
Builders<VexTimelineEventRecord>.Filter.Gte(r => r.CreatedAt, fromUtc),
Builders<VexTimelineEventRecord>.Filter.Lte(r => r.CreatedAt, toUtc));
return await _collection
.CountDocumentsAsync(filter, cancellationToken: cancellationToken)
.ConfigureAwait(false);
}
private static string NormalizeTenant(string tenant)
{
if (string.IsNullOrWhiteSpace(tenant))
{
throw new ArgumentException("tenant is required", nameof(tenant));
}
return tenant.Trim().ToLowerInvariant();
}
private static VexTimelineEventRecord ToRecord(TimelineEvent evt)
{
return new VexTimelineEventRecord
{
Id = evt.EventId,
Tenant = evt.Tenant,
ProviderId = evt.ProviderId.ToLowerInvariant(),
StreamId = evt.StreamId.ToLowerInvariant(),
EventType = evt.EventType.ToLowerInvariant(),
TraceId = evt.TraceId,
JustificationSummary = evt.JustificationSummary,
EvidenceHash = evt.EvidenceHash,
PayloadHash = evt.PayloadHash,
CreatedAt = evt.CreatedAt.UtcDateTime,
Attributes = evt.Attributes.ToDictionary(kvp => kvp.Key, kvp => kvp.Value, StringComparer.Ordinal)
};
}
private static TimelineEvent ToModel(VexTimelineEventRecord record)
{
var attributes = record.Attributes?.ToImmutableDictionary(StringComparer.Ordinal)
?? ImmutableDictionary<string, string>.Empty;
return new TimelineEvent(
eventId: record.Id,
tenant: record.Tenant,
providerId: record.ProviderId,
streamId: record.StreamId,
eventType: record.EventType,
traceId: record.TraceId,
justificationSummary: record.JustificationSummary,
createdAt: new DateTimeOffset(DateTime.SpecifyKind(record.CreatedAt, DateTimeKind.Utc)),
evidenceHash: record.EvidenceHash,
payloadHash: record.PayloadHash,
attributes: attributes);
}
}

View File

@@ -1,3 +0,0 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("StellaOps.Excititor.Storage.Mongo.Tests")]

View File

@@ -1,80 +0,0 @@
using System.ComponentModel.DataAnnotations;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Options;
using MongoDB.Driver;
using StellaOps.Excititor.Core;
using StellaOps.Excititor.Storage.Mongo.Migrations;
using StellaOps.Excititor.Core.Observations;
namespace StellaOps.Excititor.Storage.Mongo;
public static class VexMongoServiceCollectionExtensions
{
public static IServiceCollection AddExcititorMongoStorage(this IServiceCollection services)
{
services.AddOptions<VexMongoStorageOptions>();
services.TryAddSingleton<IMongoClient>(static provider =>
{
var options = provider.GetRequiredService<IOptions<VexMongoStorageOptions>>().Value;
Validator.ValidateObject(options, new ValidationContext(options), validateAllProperties: true);
var mongoUrl = MongoUrl.Create(options.ConnectionString);
var settings = MongoClientSettings.FromUrl(mongoUrl);
settings.ReadConcern = ReadConcern.Majority;
settings.ReadPreference = ReadPreference.Primary;
settings.WriteConcern = WriteConcern.WMajority.With(wTimeout: options.CommandTimeout);
settings.RetryReads = true;
settings.RetryWrites = true;
return new MongoClient(settings);
});
services.TryAddScoped(static provider =>
{
var options = provider.GetRequiredService<IOptions<VexMongoStorageOptions>>().Value;
var client = provider.GetRequiredService<IMongoClient>();
var settings = new MongoDatabaseSettings
{
ReadConcern = ReadConcern.Majority,
ReadPreference = ReadPreference.PrimaryPreferred,
WriteConcern = WriteConcern.WMajority.With(wTimeout: options.CommandTimeout),
};
return client.GetDatabase(options.GetDatabaseName(), settings);
});
services.AddScoped<IVexMongoSessionProvider, VexMongoSessionProvider>();
services.AddScoped<IVexRawStore, MongoVexRawStore>();
services.AddScoped<IVexExportStore, MongoVexExportStore>();
services.AddScoped<IVexProviderStore, MongoVexProviderStore>();
services.AddScoped<IVexNormalizerRouter, StorageBackedVexNormalizerRouter>();
services.AddScoped<IVexConsensusStore, MongoVexConsensusStore>();
services.AddScoped<IVexConsensusHoldStore, MongoVexConsensusHoldStore>();
services.AddScoped<IVexClaimStore, MongoVexClaimStore>();
services.AddScoped<IVexCacheIndex, MongoVexCacheIndex>();
services.AddScoped<IVexCacheMaintenance, MongoVexCacheMaintenance>();
services.AddScoped<IVexConnectorStateRepository, MongoVexConnectorStateRepository>();
services.AddScoped<IAirgapImportStore, MongoAirgapImportStore>();
services.AddScoped<VexStatementBackfillService>();
services.AddScoped<IVexObservationLookup, MongoVexObservationLookup>();
services.AddScoped<IVexObservationStore, MongoVexObservationStore>();
services.AddScoped<IVexLinksetStore, MongoVexLinksetStore>();
services.AddScoped<IVexLinksetEventPublisher, MongoVexLinksetEventPublisher>();
services.AddScoped<VexLinksetDisagreementService>();
services.AddScoped<IVexTimelineEventStore, MongoVexTimelineEventStore>();
services.AddScoped<IVexTimelineEventEmitter, VexTimelineEventEmitter>();
services.AddSingleton<IVexMongoMigration, VexInitialIndexMigration>();
services.AddSingleton<IVexMongoMigration, VexTimelineEventIndexMigration>();
services.AddSingleton<IVexMongoMigration, VexRawSchemaMigration>();
services.AddSingleton<IVexMongoMigration, VexConsensusSignalsMigration>();
services.AddSingleton<IVexMongoMigration, VexConsensusHoldMigration>();
services.AddSingleton<IVexMongoMigration, VexObservationCollectionsMigration>();
services.AddSingleton<IVexMongoMigration, VexRawIdempotencyIndexMigration>();
services.AddSingleton<VexMongoMigrationRunner>();
services.AddHostedService<VexMongoMigrationHostedService>();
return services;
}
}

View File

@@ -1,19 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0-rc.2.25502.107" />
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0-rc.2.25502.107" />
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.0-rc.2.25502.107" />
<PackageReference Include="MongoDB.Driver" Version="3.5.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.Excititor.Core\StellaOps.Excititor.Core.csproj" />
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Ingestion.Telemetry\StellaOps.Ingestion.Telemetry.csproj" />
</ItemGroup>
</Project>

View File

@@ -1,114 +0,0 @@
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Diagnostics;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using StellaOps.Excititor.Core;
namespace StellaOps.Excititor.Storage.Mongo;
/// <summary>
/// Normalizer router that resolves providers from Mongo storage before invoking the format-specific normalizer.
/// Records telemetry for normalization operations (EXCITITOR-VULN-29-004).
/// </summary>
public sealed class StorageBackedVexNormalizerRouter : IVexNormalizerRouter
{
private readonly VexNormalizerRegistry _registry;
private readonly IVexProviderStore _providerStore;
private readonly IVexMongoSessionProvider _sessionProvider;
private readonly ILogger<StorageBackedVexNormalizerRouter> _logger;
private readonly IVexNormalizationTelemetryRecorder? _telemetryRecorder;
public StorageBackedVexNormalizerRouter(
IEnumerable<IVexNormalizer> normalizers,
IVexProviderStore providerStore,
IVexMongoSessionProvider sessionProvider,
ILogger<StorageBackedVexNormalizerRouter> logger,
IVexNormalizationTelemetryRecorder? telemetryRecorder = null)
{
ArgumentNullException.ThrowIfNull(normalizers);
_providerStore = providerStore ?? throw new ArgumentNullException(nameof(providerStore));
_sessionProvider = sessionProvider ?? throw new ArgumentNullException(nameof(sessionProvider));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_telemetryRecorder = telemetryRecorder;
_registry = new VexNormalizerRegistry(normalizers.ToImmutableArray());
}
public async ValueTask<VexClaimBatch> NormalizeAsync(VexRawDocument document, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(document);
var stopwatch = Stopwatch.StartNew();
var normalizer = _registry.Resolve(document);
if (normalizer is null)
{
stopwatch.Stop();
_logger.LogWarning(
"No normalizer registered for VEX document format {Format}. Skipping normalization for {Digest} from provider {ProviderId}.",
document.Format,
document.Digest,
document.ProviderId);
_telemetryRecorder?.RecordNormalizationError(
tenant: null,
document.ProviderId,
"unsupported_format",
$"No normalizer for format {document.Format}");
return new VexClaimBatch(
document,
ImmutableArray<VexClaim>.Empty,
ImmutableDictionary<string, string>.Empty);
}
var session = await _sessionProvider.StartSessionAsync(cancellationToken).ConfigureAwait(false);
var provider = await _providerStore.FindAsync(document.ProviderId, cancellationToken, session).ConfigureAwait(false)
?? new VexProvider(document.ProviderId, document.ProviderId, VexProviderKind.Vendor);
try
{
var batch = await normalizer.NormalizeAsync(document, provider, cancellationToken).ConfigureAwait(false);
stopwatch.Stop();
if (batch.Claims.IsDefaultOrEmpty || batch.Claims.Length == 0)
{
_logger.LogDebug(
"Normalization produced no claims for document {Digest} from provider {ProviderId}.",
document.Digest,
document.ProviderId);
}
else
{
_logger.LogDebug(
"Normalization produced {ClaimCount} claims for document {Digest} from provider {ProviderId} in {Duration}ms.",
batch.Claims.Length,
document.Digest,
document.ProviderId,
stopwatch.Elapsed.TotalMilliseconds);
}
return batch;
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
stopwatch.Stop();
_logger.LogError(
ex,
"Normalization failed for document {Digest} from provider {ProviderId} after {Duration}ms: {Message}",
document.Digest,
document.ProviderId,
stopwatch.Elapsed.TotalMilliseconds,
ex.Message);
_telemetryRecorder?.RecordNormalizationError(
tenant: null,
document.ProviderId,
"normalization_exception",
ex.Message);
throw;
}
}
}

View File

@@ -1,299 +0,0 @@
using System.Collections.Immutable;
using System.Text.RegularExpressions;
using MongoDB.Bson;
namespace StellaOps.Excititor.Storage.Mongo.Validation;
/// <summary>
/// Validates VEX raw documents against the schema defined in <see cref="Migrations.VexRawSchemaMigration"/>.
/// Provides programmatic validation for operators to prove Excititor stores only immutable evidence.
/// </summary>
public static class VexRawSchemaValidator
{
private static readonly ImmutableHashSet<string> ValidFormats = ImmutableHashSet.Create(
StringComparer.OrdinalIgnoreCase,
"csaf", "cyclonedx", "openvex");
private static readonly ImmutableHashSet<BsonType> ValidContentTypes = ImmutableHashSet.Create(
BsonType.Binary, BsonType.String);
private static readonly ImmutableHashSet<BsonType> ValidGridFsTypes = ImmutableHashSet.Create(
BsonType.ObjectId, BsonType.Null, BsonType.String);
/// <summary>
/// Validates a VEX raw document against the schema requirements.
/// </summary>
/// <param name="document">The document to validate.</param>
/// <returns>Validation result with any violations found.</returns>
public static VexRawValidationResult Validate(BsonDocument document)
{
ArgumentNullException.ThrowIfNull(document);
var violations = new List<VexRawSchemaViolation>();
// Required fields
ValidateRequired(document, "_id", violations);
ValidateRequired(document, "providerId", violations);
ValidateRequired(document, "format", violations);
ValidateRequired(document, "sourceUri", violations);
ValidateRequired(document, "retrievedAt", violations);
ValidateRequired(document, "digest", violations);
// Field types and constraints
ValidateStringField(document, "_id", minLength: 1, violations);
ValidateStringField(document, "providerId", minLength: 1, violations);
ValidateFormatEnum(document, violations);
ValidateStringField(document, "sourceUri", minLength: 1, violations);
ValidateDateField(document, "retrievedAt", violations);
ValidateStringField(document, "digest", minLength: 32, violations);
// Optional fields with type constraints
if (document.Contains("content"))
{
ValidateContentField(document, violations);
}
if (document.Contains("gridFsObjectId"))
{
ValidateGridFsObjectIdField(document, violations);
}
if (document.Contains("metadata"))
{
ValidateMetadataField(document, violations);
}
return new VexRawValidationResult(
document.GetValue("_id", BsonNull.Value).ToString() ?? "<unknown>",
violations.Count == 0,
violations.ToImmutableArray());
}
/// <summary>
/// Validates multiple documents and returns aggregated results.
/// </summary>
public static VexRawBatchValidationResult ValidateBatch(IEnumerable<BsonDocument> documents)
{
ArgumentNullException.ThrowIfNull(documents);
var results = new List<VexRawValidationResult>();
foreach (var doc in documents)
{
results.Add(Validate(doc));
}
var valid = results.Count(r => r.IsValid);
var invalid = results.Count(r => !r.IsValid);
return new VexRawBatchValidationResult(
results.Count,
valid,
invalid,
results.Where(r => !r.IsValid).ToImmutableArray());
}
/// <summary>
/// Gets the MongoDB JSON Schema document for offline validation.
/// </summary>
public static BsonDocument GetJsonSchema()
{
var properties = new BsonDocument
{
{ "_id", new BsonDocument { { "bsonType", "string" }, { "description", "Content digest serving as immutable key" } } },
{ "providerId", new BsonDocument { { "bsonType", "string" }, { "minLength", 1 }, { "description", "VEX provider identifier" } } },
{ "format", new BsonDocument
{
{ "bsonType", "string" },
{ "enum", new BsonArray { "csaf", "cyclonedx", "openvex" } },
{ "description", "VEX document format" }
}
},
{ "sourceUri", new BsonDocument { { "bsonType", "string" }, { "minLength", 1 }, { "description", "Original source URI" } } },
{ "retrievedAt", new BsonDocument { { "bsonType", "date" }, { "description", "Timestamp when document was fetched" } } },
{ "digest", new BsonDocument { { "bsonType", "string" }, { "minLength", 32 }, { "description", "Content hash (SHA-256 hex)" } } },
{ "content", new BsonDocument
{
{ "bsonType", new BsonArray { "binData", "string" } },
{ "description", "Raw document content (binary or base64 string)" }
}
},
{ "gridFsObjectId", new BsonDocument
{
{ "bsonType", new BsonArray { "objectId", "null", "string" } },
{ "description", "GridFS reference for large documents" }
}
},
{ "metadata", new BsonDocument
{
{ "bsonType", "object" },
{ "additionalProperties", true },
{ "description", "Provider-specific metadata (string values only)" }
}
}
};
return new BsonDocument
{
{
"$jsonSchema",
new BsonDocument
{
{ "bsonType", "object" },
{ "title", "VEX Raw Document Schema" },
{ "description", "Schema for immutable VEX evidence storage. Documents are content-addressed and must not be modified after insertion." },
{ "required", new BsonArray { "_id", "providerId", "format", "sourceUri", "retrievedAt", "digest" } },
{ "properties", properties },
{ "additionalProperties", true }
}
}
};
}
/// <summary>
/// Gets the schema as a JSON string for operator documentation.
/// </summary>
public static string GetJsonSchemaAsJson()
{
return GetJsonSchema().ToJson(new MongoDB.Bson.IO.JsonWriterSettings { Indent = true });
}
private static void ValidateRequired(BsonDocument doc, string field, List<VexRawSchemaViolation> violations)
{
if (!doc.Contains(field) || doc[field].IsBsonNull)
{
violations.Add(new VexRawSchemaViolation(field, $"Required field '{field}' is missing or null"));
}
}
private static void ValidateStringField(BsonDocument doc, string field, int minLength, List<VexRawSchemaViolation> violations)
{
if (!doc.Contains(field))
{
return;
}
var value = doc[field];
if (value.IsBsonNull)
{
return;
}
if (!value.IsString)
{
violations.Add(new VexRawSchemaViolation(field, $"Field '{field}' must be a string, got {value.BsonType}"));
return;
}
if (value.AsString.Length < minLength)
{
violations.Add(new VexRawSchemaViolation(field, $"Field '{field}' must have minimum length {minLength}, got {value.AsString.Length}"));
}
}
private static void ValidateFormatEnum(BsonDocument doc, List<VexRawSchemaViolation> violations)
{
if (!doc.Contains("format"))
{
return;
}
var value = doc["format"];
if (value.IsBsonNull || !value.IsString)
{
return;
}
if (!ValidFormats.Contains(value.AsString))
{
violations.Add(new VexRawSchemaViolation("format", $"Field 'format' must be one of [{string.Join(", ", ValidFormats)}], got '{value.AsString}'"));
}
}
private static void ValidateDateField(BsonDocument doc, string field, List<VexRawSchemaViolation> violations)
{
if (!doc.Contains(field))
{
return;
}
var value = doc[field];
if (value.IsBsonNull)
{
return;
}
if (value.BsonType != BsonType.DateTime)
{
violations.Add(new VexRawSchemaViolation(field, $"Field '{field}' must be a date, got {value.BsonType}"));
}
}
private static void ValidateContentField(BsonDocument doc, List<VexRawSchemaViolation> violations)
{
var value = doc["content"];
if (value.IsBsonNull)
{
return;
}
if (!ValidContentTypes.Contains(value.BsonType))
{
violations.Add(new VexRawSchemaViolation("content", $"Field 'content' must be binary or string, got {value.BsonType}"));
}
}
private static void ValidateGridFsObjectIdField(BsonDocument doc, List<VexRawSchemaViolation> violations)
{
var value = doc["gridFsObjectId"];
if (!ValidGridFsTypes.Contains(value.BsonType))
{
violations.Add(new VexRawSchemaViolation("gridFsObjectId", $"Field 'gridFsObjectId' must be objectId, null, or string, got {value.BsonType}"));
}
}
private static void ValidateMetadataField(BsonDocument doc, List<VexRawSchemaViolation> violations)
{
var value = doc["metadata"];
if (value.IsBsonNull)
{
return;
}
if (value.BsonType != BsonType.Document)
{
violations.Add(new VexRawSchemaViolation("metadata", $"Field 'metadata' must be an object, got {value.BsonType}"));
return;
}
var metadata = value.AsBsonDocument;
foreach (var element in metadata)
{
if (!element.Value.IsString && !element.Value.IsBsonNull)
{
violations.Add(new VexRawSchemaViolation($"metadata.{element.Name}", $"Metadata field '{element.Name}' must be a string, got {element.Value.BsonType}"));
}
}
}
}
/// <summary>
/// Represents a schema violation found during validation.
/// </summary>
public sealed record VexRawSchemaViolation(string Field, string Message);
/// <summary>
/// Result of validating a single VEX raw document.
/// </summary>
public sealed record VexRawValidationResult(
string DocumentId,
bool IsValid,
ImmutableArray<VexRawSchemaViolation> Violations);
/// <summary>
/// Result of validating a batch of VEX raw documents.
/// </summary>
public sealed record VexRawBatchValidationResult(
int TotalCount,
int ValidCount,
int InvalidCount,
ImmutableArray<VexRawValidationResult> InvalidDocuments);

View File

@@ -1,63 +0,0 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using MongoDB.Bson.Serialization.Attributes;
using StellaOps.Excititor.Core;
namespace StellaOps.Excititor.Storage.Mongo;
[BsonIgnoreExtraElements]
internal sealed class VexAttestationLinkRecord
{
[BsonId]
public string AttestationId { get; set; } = default!;
public string SupplierId { get; set; } = default!;
public string ObservationId { get; set; } = default!;
public string LinksetId { get; set; } = default!;
public string VulnerabilityId { get; set; } = default!;
public string ProductKey { get; set; } = default!;
public string? JustificationSummary { get; set; }
= null;
public DateTime IssuedAt { get; set; }
= DateTime.SpecifyKind(DateTime.UtcNow, DateTimeKind.Utc);
public Dictionary<string, string> Metadata { get; set; } = new(StringComparer.Ordinal);
public static VexAttestationLinkRecord FromDomain(VexAttestationPayload payload)
=> new()
{
AttestationId = payload.AttestationId,
SupplierId = payload.SupplierId,
ObservationId = payload.ObservationId,
LinksetId = payload.LinksetId,
VulnerabilityId = payload.VulnerabilityId,
ProductKey = payload.ProductKey,
JustificationSummary = payload.JustificationSummary,
IssuedAt = payload.IssuedAt.UtcDateTime,
Metadata = payload.Metadata.ToDictionary(kv => kv.Key, kv => kv.Value, StringComparer.Ordinal),
};
public VexAttestationPayload ToDomain()
{
var metadata = (Metadata ?? new Dictionary<string, string>(StringComparer.Ordinal))
.ToImmutableDictionary(StringComparer.Ordinal);
return new VexAttestationPayload(
AttestationId,
SupplierId,
ObservationId,
LinksetId,
VulnerabilityId,
ProductKey,
JustificationSummary,
new DateTimeOffset(DateTime.SpecifyKind(IssuedAt, DateTimeKind.Utc)),
metadata);
}
}

View File

@@ -1,87 +0,0 @@
using System.Threading;
using MongoDB.Bson.Serialization;
using MongoDB.Bson.Serialization.Serializers;
namespace StellaOps.Excititor.Storage.Mongo;
public static class VexMongoMappingRegistry
{
private static int _initialized;
public static void Register()
{
if (Interlocked.Exchange(ref _initialized, 1) == 1)
{
return;
}
try
{
BsonSerializer.RegisterSerializer(typeof(byte[]), new ByteArraySerializer());
}
catch
{
// serializer already registered safe to ignore
}
RegisterClassMaps();
}
private static void RegisterClassMaps()
{
RegisterClassMap<VexProviderRecord>();
RegisterClassMap<VexProviderDiscoveryDocument>();
RegisterClassMap<VexProviderTrustDocument>();
RegisterClassMap<VexCosignTrustDocument>();
RegisterClassMap<VexConsensusRecord>();
RegisterClassMap<VexProductDocument>();
RegisterClassMap<VexConsensusSourceDocument>();
RegisterClassMap<VexConsensusConflictDocument>();
RegisterClassMap<VexConfidenceDocument>();
RegisterClassMap<VexSignalDocument>();
RegisterClassMap<VexSeveritySignalDocument>();
RegisterClassMap<VexClaimDocumentRecord>();
RegisterClassMap<VexSignatureMetadataDocument>();
RegisterClassMap<VexStatementRecord>();
RegisterClassMap<VexCacheEntryRecord>();
RegisterClassMap<VexConnectorStateDocument>();
RegisterClassMap<VexConsensusHoldRecord>();
RegisterClassMap<AirgapImportRecord>();
RegisterClassMap<VexTimelineEventRecord>();
}
private static void RegisterClassMap<TDocument>()
where TDocument : class
{
if (BsonClassMap.IsClassMapRegistered(typeof(TDocument)))
{
return;
}
BsonClassMap.RegisterClassMap<TDocument>(classMap =>
{
classMap.AutoMap();
classMap.SetIgnoreExtraElements(true);
});
}
}
public static class VexMongoCollectionNames
{
public const string Migrations = "vex.migrations";
public const string Providers = "vex.providers";
public const string Raw = "vex.raw";
public const string Statements = "vex.statements";
public const string Claims = Statements;
public const string Consensus = "vex.consensus";
public const string Exports = "vex.exports";
public const string Cache = "vex.cache";
public const string ConnectorState = "vex.connector_state";
public const string ConsensusHolds = "vex.consensus_holds";
public const string Attestations = "vex.attestations";
public const string Observations = "vex.observations";
public const string Linksets = "vex.linksets";
public const string LinksetEvents = "vex.linkset_events";
public const string AirgapImports = "vex.airgap_imports";
public const string TimelineEvents = "vex.timeline_events";
}

View File

@@ -1,120 +0,0 @@
using Microsoft.Extensions.Options;
using MongoDB.Driver;
namespace StellaOps.Excititor.Storage.Mongo;
public interface IVexMongoSessionProvider : IAsyncDisposable
{
ValueTask<IClientSessionHandle> StartSessionAsync(CancellationToken cancellationToken = default);
}
internal sealed class VexMongoSessionProvider : IVexMongoSessionProvider
{
private readonly IMongoClient _client;
private readonly VexMongoStorageOptions _options;
private readonly object _gate = new();
private Task<IClientSessionHandle>? _sessionTask;
private IClientSessionHandle? _session;
private bool _disposed;
public VexMongoSessionProvider(IMongoClient client, IOptions<VexMongoStorageOptions> options)
{
_client = client ?? throw new ArgumentNullException(nameof(client));
if (options is null)
{
throw new ArgumentNullException(nameof(options));
}
_options = options.Value;
}
public async ValueTask<IClientSessionHandle> StartSessionAsync(CancellationToken cancellationToken = default)
{
ObjectDisposedException.ThrowIf(_disposed, this);
var existing = Volatile.Read(ref _session);
if (existing is not null)
{
return existing;
}
Task<IClientSessionHandle> startTask;
lock (_gate)
{
if (_session is { } current)
{
return current;
}
_sessionTask ??= StartSessionInternalAsync(cancellationToken);
startTask = _sessionTask;
}
try
{
var handle = await startTask.WaitAsync(cancellationToken).ConfigureAwait(false);
if (_session is null)
{
lock (_gate)
{
if (_session is null)
{
_session = handle;
_sessionTask = Task.FromResult(handle);
}
}
}
return handle;
}
catch
{
lock (_gate)
{
if (ReferenceEquals(_sessionTask, startTask))
{
_sessionTask = null;
}
}
throw;
}
}
private Task<IClientSessionHandle> StartSessionInternalAsync(CancellationToken cancellationToken)
{
var sessionOptions = new ClientSessionOptions
{
CausalConsistency = true,
DefaultTransactionOptions = new TransactionOptions(
readPreference: ReadPreference.Primary,
readConcern: ReadConcern.Majority,
writeConcern: WriteConcern.WMajority.With(wTimeout: _options.CommandTimeout))
};
return _client.StartSessionAsync(sessionOptions, cancellationToken);
}
public ValueTask DisposeAsync()
{
if (_disposed)
{
return ValueTask.CompletedTask;
}
_disposed = true;
IClientSessionHandle? handle;
lock (_gate)
{
handle = _session;
_session = null;
_sessionTask = null;
}
handle?.Dispose();
GC.SuppressFinalize(this);
return ValueTask.CompletedTask;
}
}

View File

@@ -1,108 +0,0 @@
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using MongoDB.Driver;
namespace StellaOps.Excititor.Storage.Mongo;
/// <summary>
/// Configuration controlling Mongo-backed storage for Excititor repositories.
/// </summary>
public sealed class VexMongoStorageOptions : IValidatableObject
{
private const int DefaultInlineThreshold = 256 * 1024;
private static readonly TimeSpan DefaultCacheTtl = TimeSpan.FromHours(12);
private static readonly TimeSpan DefaultCommandTimeout = TimeSpan.FromSeconds(30);
/// <summary>
/// MongoDB connection string for Excititor storage.
/// </summary>
public string ConnectionString { get; set; } = "mongodb://localhost:27017";
/// <summary>
/// Overrides the database name extracted from <see cref="ConnectionString"/>.
/// </summary>
public string? DatabaseName { get; set; }
/// <summary>
/// Timeout applied to write operations to ensure majority acknowledgement completes promptly.
/// </summary>
public TimeSpan CommandTimeout { get; set; } = DefaultCommandTimeout;
/// <summary>
/// Name of the GridFS bucket used for raw VEX payloads that exceed <see cref="GridFsInlineThresholdBytes"/>.
/// </summary>
public string RawBucketName { get; set; } = "vex.raw";
/// <summary>
/// Inline raw document payloads smaller than this threshold; larger payloads are stored in GridFS.
/// </summary>
public int GridFsInlineThresholdBytes { get; set; } = DefaultInlineThreshold;
/// <summary>
/// Default TTL applied to export cache entries (absolute expiration).
/// </summary>
public TimeSpan ExportCacheTtl { get; set; } = DefaultCacheTtl;
/// <summary>
/// Tenant identifier associated with raw VEX documents persisted by this storage instance.
/// </summary>
public string DefaultTenant { get; set; } = "tenant-default";
/// <summary>
/// Resolve the Mongo database name using the explicit override or connection string.
/// </summary>
public string GetDatabaseName()
{
if (!string.IsNullOrWhiteSpace(DatabaseName))
{
return DatabaseName.Trim();
}
if (!string.IsNullOrWhiteSpace(ConnectionString))
{
var url = MongoUrl.Create(ConnectionString);
if (!string.IsNullOrWhiteSpace(url.DatabaseName))
{
return url.DatabaseName;
}
}
return "excititor";
}
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
{
if (string.IsNullOrWhiteSpace(ConnectionString))
{
yield return new ValidationResult("Mongo connection string must be provided.", new[] { nameof(ConnectionString) });
}
if (CommandTimeout <= TimeSpan.Zero)
{
yield return new ValidationResult("Command timeout must be greater than zero.", new[] { nameof(CommandTimeout) });
}
if (string.IsNullOrWhiteSpace(RawBucketName))
{
yield return new ValidationResult("Raw bucket name must be provided.", new[] { nameof(RawBucketName) });
}
if (GridFsInlineThresholdBytes < 0)
{
yield return new ValidationResult("GridFS inline threshold must be non-negative.", new[] { nameof(GridFsInlineThresholdBytes) });
}
if (ExportCacheTtl <= TimeSpan.Zero)
{
yield return new ValidationResult("Export cache TTL must be greater than zero.", new[] { nameof(ExportCacheTtl) });
}
if (string.IsNullOrWhiteSpace(DefaultTenant))
{
yield return new ValidationResult("Default tenant must be provided.", new[] { nameof(DefaultTenant) });
}
_ = GetDatabaseName();
}
}

View File

@@ -1,194 +0,0 @@
using System;
using System.Collections.Immutable;
using System.Text.Json;
using StellaOps.Excititor.Core;
using RawVexDocumentModel = StellaOps.Concelier.RawModels.VexRawDocument;
using RawSourceMetadata = StellaOps.Concelier.RawModels.RawSourceMetadata;
using RawUpstreamMetadata = StellaOps.Concelier.RawModels.RawUpstreamMetadata;
using RawContentMetadata = StellaOps.Concelier.RawModels.RawContent;
using RawSignatureMetadata = StellaOps.Concelier.RawModels.RawSignatureMetadata;
using RawLinkset = StellaOps.Concelier.RawModels.RawLinkset;
using RawReference = StellaOps.Concelier.RawModels.RawReference;
using VexStatementSummaryModel = StellaOps.Concelier.RawModels.VexStatementSummary;
namespace StellaOps.Excititor.Storage.Mongo;
/// <summary>
/// Converts Excititor domain VEX documents into Aggregation-Only Contract raw payloads.
/// </summary>
public static class VexRawDocumentMapper
{
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web);
public static RawVexDocumentModel ToRawModel(VexRawDocument document, string defaultTenant)
{
ArgumentNullException.ThrowIfNull(document);
var metadata = document.Metadata ?? ImmutableDictionary<string, string>.Empty;
var tenant = ResolveTenant(metadata, defaultTenant);
var source = CreateSourceMetadata(document, metadata);
var upstream = CreateUpstreamMetadata(document, metadata);
var content = CreateContent(document, metadata);
var linkset = CreateLinkset();
ImmutableArray<VexStatementSummaryModel>? statements = null;
return new RawVexDocumentModel(tenant, source, upstream, content, linkset, statements);
}
private static string ResolveTenant(ImmutableDictionary<string, string> metadata, string fallback)
{
var tenant = TryMetadata(metadata, "tenant", "aoc.tenant");
if (string.IsNullOrWhiteSpace(tenant))
{
return (fallback ?? "tenant-default").Trim().ToLowerInvariant();
}
return tenant.Trim().ToLowerInvariant();
}
private static RawSourceMetadata CreateSourceMetadata(VexRawDocument document, ImmutableDictionary<string, string> metadata)
{
var vendor = TryMetadata(metadata, "source.vendor", "connector.vendor") ?? ExtractVendor(document.ProviderId);
var connector = TryMetadata(metadata, "source.connector") ?? document.ProviderId;
var version = TryMetadata(metadata, "source.connector_version", "connector.version") ?? GetAssemblyVersion();
var stream = TryMetadata(metadata, "source.stream", "connector.stream") ?? document.Format.ToString().ToLowerInvariant();
return new RawSourceMetadata(vendor, connector, version, stream);
}
private static string GetAssemblyVersion()
=> typeof(VexRawDocumentMapper).Assembly.GetName().Version?.ToString() ?? "0.0.0";
private static RawUpstreamMetadata CreateUpstreamMetadata(VexRawDocument document, ImmutableDictionary<string, string> metadata)
{
var upstreamId = TryMetadata(
metadata,
"upstream.id",
"aoc.upstream_id",
"vulnerability.id",
"advisory.id",
"msrc.vulnerabilityId",
"msrc.advisoryId",
"oracle.csaf.entryId",
"ubuntu.advisoryId",
"cisco.csaf.documentId",
"rancher.vex.id") ?? document.SourceUri.ToString();
var documentVersion = TryMetadata(
metadata,
"upstream.version",
"aoc.document_version",
"msrc.lastModified",
"msrc.releaseDate",
"oracle.csaf.revision",
"ubuntu.version",
"ubuntu.lastModified",
"cisco.csaf.revision") ?? document.RetrievedAt.ToString("O");
var signature = CreateSignatureMetadata(metadata);
return new RawUpstreamMetadata(
upstreamId,
documentVersion,
document.RetrievedAt,
document.Digest,
signature,
metadata);
}
private static RawSignatureMetadata CreateSignatureMetadata(ImmutableDictionary<string, string> metadata)
{
if (!TryBool(metadata, out var present, "signature.present", "aoc.signature.present"))
{
return new RawSignatureMetadata(false);
}
if (!present)
{
return new RawSignatureMetadata(false);
}
var format = TryMetadata(metadata, "signature.format", "aoc.signature.format");
var keyId = TryMetadata(metadata, "signature.key_id", "signature.keyId", "aoc.signature.key_id");
var signature = TryMetadata(metadata, "signature.sig", "signature.signature", "aoc.signature.sig");
var digest = TryMetadata(metadata, "signature.digest", "aoc.signature.digest");
var certificate = TryMetadata(metadata, "signature.certificate", "aoc.signature.certificate");
return new RawSignatureMetadata(true, format, keyId, signature, certificate, digest);
}
private static RawContentMetadata CreateContent(VexRawDocument document, ImmutableDictionary<string, string> metadata)
{
if (document.Content.IsEmpty)
{
throw new InvalidOperationException("Raw VEX document content cannot be empty when enforcing AOC guard.");
}
try
{
using var payload = JsonDocument.Parse(document.Content.ToArray());
var raw = payload.RootElement.Clone();
var specVersion = TryMetadata(metadata, "content.spec_version", "csaf.version", "openvex.version");
var encoding = TryMetadata(metadata, "content.encoding");
return new RawContentMetadata(
document.Format.ToString(),
specVersion,
raw,
encoding);
}
catch (JsonException ex)
{
throw new InvalidOperationException("Raw VEX document payload must be valid JSON for AOC guard enforcement.", ex);
}
}
private static RawLinkset CreateLinkset()
=> new()
{
Aliases = ImmutableArray<string>.Empty,
PackageUrls = ImmutableArray<string>.Empty,
Cpes = ImmutableArray<string>.Empty,
References = ImmutableArray<RawReference>.Empty,
ReconciledFrom = ImmutableArray<string>.Empty,
Notes = ImmutableDictionary<string, string>.Empty,
};
private static string? TryMetadata(ImmutableDictionary<string, string> metadata, params string[] keys)
{
foreach (var key in keys)
{
if (metadata.TryGetValue(key, out var value) && !string.IsNullOrWhiteSpace(value))
{
return value;
}
}
return null;
}
private static bool TryBool(ImmutableDictionary<string, string> metadata, out bool value, params string[] keys)
{
foreach (var key in keys)
{
if (metadata.TryGetValue(key, out var text) && bool.TryParse(text, out value))
{
return true;
}
}
value = default;
return false;
}
private static string ExtractVendor(string providerId)
{
if (string.IsNullOrWhiteSpace(providerId))
{
return "unknown";
}
var trimmed = providerId.Trim();
var separatorIndex = trimmed.LastIndexOfAny(new[] { ':', '.' });
if (separatorIndex >= 0 && separatorIndex < trimmed.Length - 1)
{
return trimmed[(separatorIndex + 1)..];
}
return trimmed;
}
}

View File

@@ -1,170 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using MongoDB.Driver;
using StellaOps.Excititor.Core;
namespace StellaOps.Excititor.Storage.Mongo;
public sealed record VexStatementBackfillRequest(
DateTimeOffset? RetrievedSince = null,
bool Force = false,
int BatchSize = 100,
int? MaxDocuments = null);
public sealed record VexStatementBackfillResult(
int DocumentsEvaluated,
int DocumentsBackfilled,
int ClaimsWritten,
int SkippedExisting,
int NormalizationFailures);
public sealed class VexStatementBackfillService
{
private readonly IVexRawStore _rawStore;
private readonly IVexNormalizerRouter _normalizerRouter;
private readonly IVexClaimStore _claimStore;
private readonly IVexMongoSessionProvider _sessionProvider;
private readonly IMongoCollection<VexRawDocumentRecord> _rawCollection;
private readonly IMongoCollection<VexStatementRecord> _statementCollection;
private readonly ILogger<VexStatementBackfillService> _logger;
private readonly TimeProvider _timeProvider;
public VexStatementBackfillService(
IMongoDatabase database,
IVexRawStore rawStore,
IVexNormalizerRouter normalizerRouter,
IVexClaimStore claimStore,
IVexMongoSessionProvider sessionProvider,
TimeProvider? timeProvider,
ILogger<VexStatementBackfillService> logger)
{
ArgumentNullException.ThrowIfNull(database);
_rawStore = rawStore ?? throw new ArgumentNullException(nameof(rawStore));
_normalizerRouter = normalizerRouter ?? throw new ArgumentNullException(nameof(normalizerRouter));
_claimStore = claimStore ?? throw new ArgumentNullException(nameof(claimStore));
_sessionProvider = sessionProvider ?? throw new ArgumentNullException(nameof(sessionProvider));
_timeProvider = timeProvider ?? TimeProvider.System;
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
VexMongoMappingRegistry.Register();
_rawCollection = database.GetCollection<VexRawDocumentRecord>(VexMongoCollectionNames.Raw);
_statementCollection = database.GetCollection<VexStatementRecord>(VexMongoCollectionNames.Statements);
}
public async Task<VexStatementBackfillResult> RunAsync(VexStatementBackfillRequest request, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);
if (request.BatchSize < 1)
{
throw new ArgumentOutOfRangeException(nameof(request.BatchSize), "Batch size must be at least 1.");
}
if (request.MaxDocuments is { } max && max <= 0)
{
throw new ArgumentOutOfRangeException(nameof(request.MaxDocuments), "Max documents must be positive when specified.");
}
var evaluated = 0;
var backfilled = 0;
var claimsWritten = 0;
var skipped = 0;
var failures = 0;
var filter = request.RetrievedSince is { } since
? Builders<VexRawDocumentRecord>.Filter.Gte(x => x.RetrievedAt, since.UtcDateTime)
: FilterDefinition<VexRawDocumentRecord>.Empty;
var findOptions = new FindOptions<VexRawDocumentRecord>
{
Sort = Builders<VexRawDocumentRecord>.Sort.Ascending(x => x.RetrievedAt),
BatchSize = request.BatchSize,
};
if (request.MaxDocuments is { } limit)
{
findOptions.Limit = limit;
}
using var cursor = await _rawCollection.FindAsync(filter, findOptions, cancellationToken).ConfigureAwait(false);
var session = await _sessionProvider.StartSessionAsync(cancellationToken).ConfigureAwait(false);
while (await cursor.MoveNextAsync(cancellationToken).ConfigureAwait(false))
{
foreach (var record in cursor.Current)
{
cancellationToken.ThrowIfCancellationRequested();
evaluated++;
if (!request.Force && await StatementExistsAsync(record.Digest, session, cancellationToken).ConfigureAwait(false))
{
skipped++;
continue;
}
var rawDocument = await _rawStore.FindByDigestAsync(record.Digest, cancellationToken, session).ConfigureAwait(false);
if (rawDocument is null)
{
failures++;
_logger.LogWarning("Backfill skipped missing raw document {Digest}.", record.Digest);
continue;
}
VexClaimBatch batch;
try
{
batch = await _normalizerRouter.NormalizeAsync(rawDocument, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
failures++;
_logger.LogError(ex, "Failed to normalize raw document {Digest} during statement backfill.", record.Digest);
continue;
}
if (batch.Claims.IsDefaultOrEmpty || batch.Claims.Length == 0)
{
failures++;
_logger.LogWarning("Backfill produced no claims for {Digest}; skipping.", record.Digest);
continue;
}
var claims = batch.Claims.AsEnumerable();
var observedAt = rawDocument.RetrievedAt == default
? _timeProvider.GetUtcNow()
: rawDocument.RetrievedAt;
await _claimStore.AppendAsync(claims, observedAt, cancellationToken, session).ConfigureAwait(false);
backfilled++;
claimsWritten += batch.Claims.Length;
}
}
var result = new VexStatementBackfillResult(evaluated, backfilled, claimsWritten, skipped, failures);
_logger.LogInformation(
"Statement backfill completed: evaluated {Evaluated} documents, backfilled {Backfilled}, wrote {Claims} claims, skipped {Skipped}, failures {Failures}.",
result.DocumentsEvaluated,
result.DocumentsBackfilled,
result.ClaimsWritten,
result.SkippedExisting,
result.NormalizationFailures);
return result;
}
private Task<bool> StatementExistsAsync(string digest, IClientSessionHandle session, CancellationToken cancellationToken)
{
var filter = Builders<VexStatementRecord>.Filter.Eq(x => x.Document.Digest, digest);
var find = session is null
? _statementCollection.Find(filter)
: _statementCollection.Find(session, filter);
return find.Limit(1).AnyAsync(cancellationToken);
}
}

View File

@@ -1,169 +0,0 @@
using System.Collections.Immutable;
using System.Security.Cryptography;
using System.Text;
using Microsoft.Extensions.Logging;
using StellaOps.Excititor.Core.Observations;
namespace StellaOps.Excititor.Storage.Mongo;
/// <summary>
/// Default implementation of <see cref="IVexTimelineEventEmitter"/> that persists events to MongoDB.
/// </summary>
internal sealed class VexTimelineEventEmitter : IVexTimelineEventEmitter
{
private readonly IVexTimelineEventStore _store;
private readonly ILogger<VexTimelineEventEmitter> _logger;
private readonly TimeProvider _timeProvider;
public VexTimelineEventEmitter(
IVexTimelineEventStore store,
ILogger<VexTimelineEventEmitter> logger,
TimeProvider? timeProvider = null)
{
_store = store ?? throw new ArgumentNullException(nameof(store));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_timeProvider = timeProvider ?? TimeProvider.System;
}
public async ValueTask EmitObservationIngestAsync(
string tenant,
string providerId,
string streamId,
string traceId,
string observationId,
string evidenceHash,
string justificationSummary,
ImmutableDictionary<string, string>? attributes = null,
CancellationToken cancellationToken = default)
{
var eventAttributes = (attributes ?? ImmutableDictionary<string, string>.Empty)
.SetItem(VexTimelineEventAttributes.ObservationId, observationId);
var evt = new TimelineEvent(
eventId: GenerateEventId(tenant, providerId, VexTimelineEventTypes.ObservationIngested),
tenant: tenant,
providerId: providerId,
streamId: streamId,
eventType: VexTimelineEventTypes.ObservationIngested,
traceId: traceId,
justificationSummary: justificationSummary,
createdAt: _timeProvider.GetUtcNow(),
evidenceHash: evidenceHash,
payloadHash: null,
attributes: eventAttributes);
await EmitAsync(evt, cancellationToken).ConfigureAwait(false);
}
public async ValueTask EmitLinksetUpdateAsync(
string tenant,
string providerId,
string streamId,
string traceId,
string linksetId,
string vulnerabilityId,
string productKey,
string payloadHash,
string justificationSummary,
ImmutableDictionary<string, string>? attributes = null,
CancellationToken cancellationToken = default)
{
var eventAttributes = (attributes ?? ImmutableDictionary<string, string>.Empty)
.SetItem(VexTimelineEventAttributes.LinksetId, linksetId)
.SetItem(VexTimelineEventAttributes.VulnerabilityId, vulnerabilityId)
.SetItem(VexTimelineEventAttributes.ProductKey, productKey);
var evt = new TimelineEvent(
eventId: GenerateEventId(tenant, providerId, VexTimelineEventTypes.LinksetUpdated),
tenant: tenant,
providerId: providerId,
streamId: streamId,
eventType: VexTimelineEventTypes.LinksetUpdated,
traceId: traceId,
justificationSummary: justificationSummary,
createdAt: _timeProvider.GetUtcNow(),
evidenceHash: null,
payloadHash: payloadHash,
attributes: eventAttributes);
await EmitAsync(evt, cancellationToken).ConfigureAwait(false);
}
public async ValueTask EmitAsync(
TimelineEvent evt,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(evt);
try
{
var eventId = await _store.InsertAsync(evt, cancellationToken).ConfigureAwait(false);
_logger.LogDebug(
"Timeline event emitted: {EventType} for tenant {Tenant}, provider {ProviderId}, trace {TraceId}",
evt.EventType,
evt.Tenant,
evt.ProviderId,
evt.TraceId);
}
catch (Exception ex)
{
_logger.LogWarning(
ex,
"Failed to emit timeline event {EventType} for tenant {Tenant}, provider {ProviderId}: {Message}",
evt.EventType,
evt.Tenant,
evt.ProviderId,
ex.Message);
// Don't throw - timeline events are non-critical and shouldn't block main operations
}
}
public async ValueTask EmitBatchAsync(
string tenant,
IEnumerable<TimelineEvent> events,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(events);
var eventList = events.ToList();
if (eventList.Count == 0)
{
return;
}
try
{
var insertedCount = await _store.InsertManyAsync(tenant, eventList, cancellationToken)
.ConfigureAwait(false);
_logger.LogDebug(
"Batch timeline events emitted: {InsertedCount}/{TotalCount} for tenant {Tenant}",
insertedCount,
eventList.Count,
tenant);
}
catch (Exception ex)
{
_logger.LogWarning(
ex,
"Failed to emit batch timeline events for tenant {Tenant}: {Message}",
tenant,
ex.Message);
// Don't throw - timeline events are non-critical
}
}
/// <summary>
/// Generates a deterministic event ID based on tenant, provider, event type, and timestamp.
/// </summary>
private string GenerateEventId(string tenant, string providerId, string eventType)
{
var timestamp = _timeProvider.GetUtcNow().ToUnixTimeMilliseconds();
var input = $"{tenant}|{providerId}|{eventType}|{timestamp}|{Guid.NewGuid():N}";
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(input));
return $"evt:{Convert.ToHexString(hash).ToLowerInvariant()[..32]}";
}
}