This commit is contained in:
@@ -0,0 +1,73 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
|
||||
namespace StellaOps.Excititor.Core.Observations;
|
||||
|
||||
/// <summary>
|
||||
/// Builds deterministic linkset update events from raw VEX observations
|
||||
/// without introducing consensus or derived semantics (AOC-19-002).
|
||||
/// </summary>
|
||||
public sealed class VexLinksetExtractionService
|
||||
{
|
||||
/// <summary>
|
||||
/// Groups observations by (vulnerabilityId, productKey) and emits a linkset update event
|
||||
/// for each group. Ordering is stable and case-insensitive on identifiers.
|
||||
/// </summary>
|
||||
public ImmutableArray<VexLinksetUpdatedEvent> Extract(
|
||||
string tenant,
|
||||
IEnumerable<VexObservation> observations,
|
||||
IEnumerable<VexObservationDisagreement>? disagreements = null)
|
||||
{
|
||||
if (observations is null)
|
||||
{
|
||||
return ImmutableArray<VexLinksetUpdatedEvent>.Empty;
|
||||
}
|
||||
|
||||
var observationList = observations
|
||||
.Where(o => o is not null)
|
||||
.ToList();
|
||||
|
||||
if (observationList.Count == 0)
|
||||
{
|
||||
return ImmutableArray<VexLinksetUpdatedEvent>.Empty;
|
||||
}
|
||||
|
||||
var groups = observationList
|
||||
.SelectMany(obs => obs.Statements.Select(stmt => (obs, stmt)))
|
||||
.GroupBy(x => new
|
||||
{
|
||||
VulnerabilityId = Normalize(x.stmt.VulnerabilityId),
|
||||
ProductKey = Normalize(x.stmt.ProductKey)
|
||||
})
|
||||
.OrderBy(g => g.Key.VulnerabilityId, StringComparer.OrdinalIgnoreCase)
|
||||
.ThenBy(g => g.Key.ProductKey, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var now = observationList.Max(o => o.CreatedAt);
|
||||
|
||||
var events = new List<VexLinksetUpdatedEvent>();
|
||||
foreach (var group in groups)
|
||||
{
|
||||
var linksetId = BuildLinksetId(group.Key.VulnerabilityId, group.Key.ProductKey);
|
||||
var obsForGroup = group.Select(x => x.obs);
|
||||
|
||||
var evt = VexLinksetUpdatedEventFactory.Create(
|
||||
tenant,
|
||||
linksetId,
|
||||
group.Key.VulnerabilityId,
|
||||
group.Key.ProductKey,
|
||||
obsForGroup,
|
||||
disagreements ?? Enumerable.Empty<VexObservationDisagreement>(),
|
||||
now);
|
||||
|
||||
events.Add(evt);
|
||||
}
|
||||
|
||||
return events.ToImmutableArray();
|
||||
}
|
||||
|
||||
private static string BuildLinksetId(string vulnerabilityId, string productKey)
|
||||
=> $"vex:{vulnerabilityId}:{productKey}".ToLowerInvariant();
|
||||
|
||||
private static string Normalize(string value) => VexObservation.EnsureNotNullOrWhiteSpace(value, nameof(value));
|
||||
}
|
||||
@@ -10,6 +10,19 @@ public interface IAirgapImportStore
|
||||
Task SaveAsync(AirgapImportRecord record, 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;
|
||||
@@ -19,11 +32,30 @@ internal sealed class MongoAirgapImportStore : IAirgapImportStore
|
||||
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);
|
||||
return _collection.InsertOneAsync(record, cancellationToken: cancellationToken);
|
||||
try
|
||||
{
|
||||
return _collection.InsertOneAsync(record, cancellationToken: cancellationToken);
|
||||
}
|
||||
catch (MongoWriteException ex) when (ex.WriteError.Category == ServerErrorCategory.DuplicateKey)
|
||||
{
|
||||
throw new DuplicateAirgapImportException(record.BundleId, record.MirrorGeneration, ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,11 +124,6 @@ public sealed class MongoVexRawStore : IVexRawStore
|
||||
|
||||
var sessionHandle = session ?? await _sessionProvider.StartSessionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!useInline)
|
||||
{
|
||||
newGridId = await UploadToGridFsAsync(document, sessionHandle, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
var supportsTransactions = sessionHandle.Client.Cluster.Description.Type != ClusterType.Standalone
|
||||
&& !sessionHandle.IsInTransaction;
|
||||
|
||||
@@ -183,6 +178,18 @@ public sealed class MongoVexRawStore : IVexRawStore
|
||||
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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user