feat: Implement Wine CSP HTTP provider for GOST cryptographic operations
Some checks failed
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
Scanner Analyzers / Discover Analyzers (push) Has been cancelled
Scanner Analyzers / Build Analyzers (push) Has been cancelled
Scanner Analyzers / Test Language Analyzers (push) Has been cancelled
Scanner Analyzers / Validate Test Fixtures (push) Has been cancelled
Scanner Analyzers / Verify Deterministic Output (push) Has been cancelled
Signals CI & Image / signals-ci (push) Has been cancelled
Some checks failed
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
Scanner Analyzers / Discover Analyzers (push) Has been cancelled
Scanner Analyzers / Build Analyzers (push) Has been cancelled
Scanner Analyzers / Test Language Analyzers (push) Has been cancelled
Scanner Analyzers / Validate Test Fixtures (push) Has been cancelled
Scanner Analyzers / Verify Deterministic Output (push) Has been cancelled
Signals CI & Image / signals-ci (push) Has been cancelled
- Added WineCspHttpProvider class to interface with Wine-hosted CryptoPro CSP. - Implemented ICryptoProvider, ICryptoProviderDiagnostics, and IDisposable interfaces. - Introduced WineCspHttpSigner and WineCspHttpHasher for signing and hashing operations. - Created WineCspProviderOptions for configuration settings including service URL and key options. - Developed CryptoProGostSigningService to handle GOST signing operations and key management. - Implemented HTTP service for the Wine CSP with endpoints for signing, verification, and hashing. - Added Swagger documentation for API endpoints. - Included health checks and error handling for service availability. - Established DTOs for request and response models in the service.
This commit is contained in:
@@ -3,6 +3,12 @@ namespace StellaOps.Excititor.Core;
|
||||
/// <summary>
|
||||
/// Baseline consensus policy applying tier-based weights and enforcing justification gates.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// DEPRECATED: Consensus logic is being removed per AOC-19 contract.
|
||||
/// Use append-only linksets with <see cref="StellaOps.Excititor.Core.Observations.IAppendOnlyLinksetStore"/>
|
||||
/// and let downstream policy engines make verdicts.
|
||||
/// </remarks>
|
||||
[Obsolete("Consensus logic is deprecated per AOC-19. Use append-only linksets instead.", DiagnosticId = "EXCITITOR001")]
|
||||
public sealed class BaselineVexConsensusPolicy : IVexConsensusPolicy
|
||||
{
|
||||
private readonly VexConsensusPolicyOptions _options;
|
||||
|
||||
@@ -3,6 +3,12 @@ namespace StellaOps.Excititor.Core;
|
||||
/// <summary>
|
||||
/// Policy abstraction supplying trust weights and gating logic for consensus decisions.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// DEPRECATED: Consensus logic is being removed per AOC-19 contract.
|
||||
/// Use append-only linksets with <see cref="StellaOps.Excititor.Core.Observations.IAppendOnlyLinksetStore"/>
|
||||
/// and let downstream policy engines make verdicts.
|
||||
/// </remarks>
|
||||
[Obsolete("Consensus logic is deprecated per AOC-19. Use append-only linksets instead.", DiagnosticId = "EXCITITOR001")]
|
||||
public interface IVexConsensusPolicy
|
||||
{
|
||||
/// <summary>
|
||||
|
||||
@@ -0,0 +1,340 @@
|
||||
using System.Collections.Immutable;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Excititor.Core.Canonicalization;
|
||||
|
||||
namespace StellaOps.Excititor.Core.Observations;
|
||||
|
||||
/// <summary>
|
||||
/// Extracts linkset updates from VEX observations using append-only semantics (AOC-19-013).
|
||||
/// Replaces consensus-based extraction with deterministic append-only operations.
|
||||
/// </summary>
|
||||
public sealed class AppendOnlyLinksetExtractionService
|
||||
{
|
||||
private readonly IAppendOnlyLinksetStore _store;
|
||||
private readonly IVexLinksetEventPublisher? _eventPublisher;
|
||||
private readonly ILogger<AppendOnlyLinksetExtractionService> _logger;
|
||||
|
||||
public AppendOnlyLinksetExtractionService(
|
||||
IAppendOnlyLinksetStore store,
|
||||
ILogger<AppendOnlyLinksetExtractionService> logger,
|
||||
IVexLinksetEventPublisher? eventPublisher = null)
|
||||
{
|
||||
_store = store ?? throw new ArgumentNullException(nameof(store));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_eventPublisher = eventPublisher;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Processes observations and appends them to linksets.
|
||||
/// Returns linkset update events for downstream consumers.
|
||||
/// </summary>
|
||||
public async Task<ImmutableArray<LinksetAppendResult>> ProcessObservationsAsync(
|
||||
string tenant,
|
||||
IEnumerable<VexObservation> observations,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tenant))
|
||||
{
|
||||
throw new ArgumentException("Tenant must be provided.", nameof(tenant));
|
||||
}
|
||||
|
||||
if (observations is null)
|
||||
{
|
||||
return ImmutableArray<LinksetAppendResult>.Empty;
|
||||
}
|
||||
|
||||
var normalizedTenant = tenant.Trim().ToLowerInvariant();
|
||||
var observationList = observations.Where(o => o is not null).ToList();
|
||||
|
||||
if (observationList.Count == 0)
|
||||
{
|
||||
return ImmutableArray<LinksetAppendResult>.Empty;
|
||||
}
|
||||
|
||||
// Group by (vulnerabilityId, productKey) deterministically
|
||||
var groups = observationList
|
||||
.SelectMany(obs => obs.Statements.Select(stmt => (obs, stmt)))
|
||||
.GroupBy(x => new LinksetKey(
|
||||
VulnerabilityId: Normalize(x.stmt.VulnerabilityId),
|
||||
ProductKey: Normalize(x.stmt.ProductKey)))
|
||||
.OrderBy(g => g.Key.VulnerabilityId, StringComparer.OrdinalIgnoreCase)
|
||||
.ThenBy(g => g.Key.ProductKey, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
var results = new List<LinksetAppendResult>(groups.Count);
|
||||
|
||||
foreach (var group in groups)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await ProcessGroupAsync(
|
||||
normalizedTenant,
|
||||
group.Key,
|
||||
group.Select(x => x.obs).Distinct(),
|
||||
cancellationToken);
|
||||
|
||||
results.Add(result);
|
||||
|
||||
if (result.HadChanges && _eventPublisher is not null)
|
||||
{
|
||||
await _eventPublisher.PublishLinksetUpdatedAsync(
|
||||
normalizedTenant,
|
||||
result.Linkset,
|
||||
cancellationToken);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(
|
||||
ex,
|
||||
"Failed to process linkset for tenant {Tenant}, vulnerability {VulnerabilityId}, product {ProductKey}",
|
||||
normalizedTenant,
|
||||
group.Key.VulnerabilityId,
|
||||
group.Key.ProductKey);
|
||||
|
||||
results.Add(LinksetAppendResult.Failed(
|
||||
normalizedTenant,
|
||||
group.Key.VulnerabilityId,
|
||||
group.Key.ProductKey,
|
||||
ex.Message));
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Processed {ObservationCount} observations into {LinksetCount} linksets for tenant {Tenant}",
|
||||
observationList.Count,
|
||||
results.Count(r => r.Success),
|
||||
normalizedTenant);
|
||||
|
||||
return results.ToImmutableArray();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Appends a disagreement to a linkset.
|
||||
/// </summary>
|
||||
public async Task<LinksetAppendResult> AppendDisagreementAsync(
|
||||
string tenant,
|
||||
string vulnerabilityId,
|
||||
string productKey,
|
||||
VexObservationDisagreement disagreement,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tenant))
|
||||
{
|
||||
throw new ArgumentException("Tenant must be provided.", nameof(tenant));
|
||||
}
|
||||
|
||||
if (disagreement is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(disagreement));
|
||||
}
|
||||
|
||||
var normalizedTenant = tenant.Trim().ToLowerInvariant();
|
||||
var normalizedVuln = Normalize(vulnerabilityId);
|
||||
var normalizedProduct = Normalize(productKey);
|
||||
|
||||
try
|
||||
{
|
||||
var storeResult = await _store.AppendDisagreementAsync(
|
||||
normalizedTenant,
|
||||
normalizedVuln,
|
||||
normalizedProduct,
|
||||
disagreement,
|
||||
cancellationToken);
|
||||
|
||||
if (storeResult.HadChanges && _eventPublisher is not null)
|
||||
{
|
||||
await _eventPublisher.PublishLinksetUpdatedAsync(
|
||||
normalizedTenant,
|
||||
storeResult.Linkset,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
return LinksetAppendResult.Succeeded(
|
||||
normalizedTenant,
|
||||
normalizedVuln,
|
||||
normalizedProduct,
|
||||
storeResult.Linkset,
|
||||
storeResult.WasCreated,
|
||||
storeResult.ObservationsAdded,
|
||||
storeResult.DisagreementsAdded,
|
||||
storeResult.SequenceNumber);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(
|
||||
ex,
|
||||
"Failed to append disagreement for tenant {Tenant}, vulnerability {VulnerabilityId}, product {ProductKey}",
|
||||
normalizedTenant,
|
||||
normalizedVuln,
|
||||
normalizedProduct);
|
||||
|
||||
return LinksetAppendResult.Failed(
|
||||
normalizedTenant,
|
||||
normalizedVuln,
|
||||
normalizedProduct,
|
||||
ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<LinksetAppendResult> ProcessGroupAsync(
|
||||
string tenant,
|
||||
LinksetKey key,
|
||||
IEnumerable<VexObservation> observations,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var scope = BuildScope(key.ProductKey);
|
||||
var observationRefs = observations
|
||||
.SelectMany(obs => obs.Statements
|
||||
.Where(stmt => string.Equals(Normalize(stmt.VulnerabilityId), key.VulnerabilityId, StringComparison.OrdinalIgnoreCase)
|
||||
&& string.Equals(Normalize(stmt.ProductKey), key.ProductKey, StringComparison.OrdinalIgnoreCase))
|
||||
.Select(stmt => new VexLinksetObservationRefModel(
|
||||
ObservationId: obs.ObservationId,
|
||||
ProviderId: obs.ProviderId,
|
||||
Status: stmt.Status.ToString().ToLowerInvariant(),
|
||||
Confidence: null)))
|
||||
.Distinct(VexLinksetObservationRefComparer.Instance)
|
||||
.ToList();
|
||||
|
||||
if (observationRefs.Count == 0)
|
||||
{
|
||||
return LinksetAppendResult.NoChange(tenant, key.VulnerabilityId, key.ProductKey);
|
||||
}
|
||||
|
||||
var storeResult = await _store.AppendObservationsBatchAsync(
|
||||
tenant,
|
||||
key.VulnerabilityId,
|
||||
key.ProductKey,
|
||||
observationRefs,
|
||||
scope,
|
||||
cancellationToken);
|
||||
|
||||
return LinksetAppendResult.Succeeded(
|
||||
tenant,
|
||||
key.VulnerabilityId,
|
||||
key.ProductKey,
|
||||
storeResult.Linkset,
|
||||
storeResult.WasCreated,
|
||||
storeResult.ObservationsAdded,
|
||||
storeResult.DisagreementsAdded,
|
||||
storeResult.SequenceNumber);
|
||||
}
|
||||
|
||||
private static VexProductScope BuildScope(string productKey)
|
||||
{
|
||||
var canonicalizer = new VexProductKeyCanonicalizer();
|
||||
try
|
||||
{
|
||||
var canonical = canonicalizer.Canonicalize(productKey);
|
||||
var identifiers = canonical.Links
|
||||
.Where(link => link is not null && !string.IsNullOrWhiteSpace(link.Identifier))
|
||||
.Select(link => link.Identifier.Trim())
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToImmutableArray();
|
||||
|
||||
var purl = canonical.Links.FirstOrDefault(link =>
|
||||
string.Equals(link.Type, "purl", StringComparison.OrdinalIgnoreCase))?.Identifier;
|
||||
var cpe = canonical.Links.FirstOrDefault(link =>
|
||||
string.Equals(link.Type, "cpe", StringComparison.OrdinalIgnoreCase))?.Identifier;
|
||||
var version = ExtractVersion(purl ?? canonical.ProductKey);
|
||||
|
||||
return new VexProductScope(
|
||||
ProductKey: canonical.ProductKey,
|
||||
Type: canonical.Scope.ToString().ToLowerInvariant(),
|
||||
Version: version,
|
||||
Purl: purl,
|
||||
Cpe: cpe,
|
||||
Identifiers: identifiers);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return VexProductScope.Unknown(productKey);
|
||||
}
|
||||
}
|
||||
|
||||
private static string? ExtractVersion(string? key)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(key))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var at = key.LastIndexOf('@');
|
||||
return at >= 0 && at < key.Length - 1 ? key[(at + 1)..] : null;
|
||||
}
|
||||
|
||||
private static string Normalize(string value) =>
|
||||
VexObservation.EnsureNotNullOrWhiteSpace(value, nameof(value));
|
||||
|
||||
private sealed record LinksetKey(string VulnerabilityId, string ProductKey);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of a linkset append operation.
|
||||
/// </summary>
|
||||
public sealed record LinksetAppendResult
|
||||
{
|
||||
private LinksetAppendResult(
|
||||
string tenant,
|
||||
string vulnerabilityId,
|
||||
string productKey,
|
||||
VexLinkset? linkset,
|
||||
bool success,
|
||||
bool wasCreated,
|
||||
int observationsAdded,
|
||||
int disagreementsAdded,
|
||||
long sequenceNumber,
|
||||
string? errorMessage)
|
||||
{
|
||||
Tenant = tenant;
|
||||
VulnerabilityId = vulnerabilityId;
|
||||
ProductKey = productKey;
|
||||
Linkset = linkset;
|
||||
Success = success;
|
||||
WasCreated = wasCreated;
|
||||
ObservationsAdded = observationsAdded;
|
||||
DisagreementsAdded = disagreementsAdded;
|
||||
SequenceNumber = sequenceNumber;
|
||||
ErrorMessage = errorMessage;
|
||||
}
|
||||
|
||||
public string Tenant { get; }
|
||||
public string VulnerabilityId { get; }
|
||||
public string ProductKey { get; }
|
||||
public VexLinkset? Linkset { get; }
|
||||
public bool Success { get; }
|
||||
public bool WasCreated { get; }
|
||||
public int ObservationsAdded { get; }
|
||||
public int DisagreementsAdded { get; }
|
||||
public long SequenceNumber { get; }
|
||||
public string? ErrorMessage { get; }
|
||||
|
||||
public bool HadChanges => Success && (WasCreated || ObservationsAdded > 0 || DisagreementsAdded > 0);
|
||||
|
||||
public static LinksetAppendResult Succeeded(
|
||||
string tenant,
|
||||
string vulnerabilityId,
|
||||
string productKey,
|
||||
VexLinkset linkset,
|
||||
bool wasCreated,
|
||||
int observationsAdded,
|
||||
int disagreementsAdded,
|
||||
long sequenceNumber)
|
||||
=> new(tenant, vulnerabilityId, productKey, linkset, success: true,
|
||||
wasCreated, observationsAdded, disagreementsAdded, sequenceNumber, errorMessage: null);
|
||||
|
||||
public static LinksetAppendResult NoChange(
|
||||
string tenant,
|
||||
string vulnerabilityId,
|
||||
string productKey)
|
||||
=> new(tenant, vulnerabilityId, productKey, linkset: null, success: true,
|
||||
wasCreated: false, observationsAdded: 0, disagreementsAdded: 0, sequenceNumber: 0, errorMessage: null);
|
||||
|
||||
public static LinksetAppendResult Failed(
|
||||
string tenant,
|
||||
string vulnerabilityId,
|
||||
string productKey,
|
||||
string errorMessage)
|
||||
=> new(tenant, vulnerabilityId, productKey, linkset: null, success: false,
|
||||
wasCreated: false, observationsAdded: 0, disagreementsAdded: 0, sequenceNumber: 0, errorMessage);
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
namespace StellaOps.Excititor.Core.Observations;
|
||||
|
||||
/// <summary>
|
||||
/// Append-only linkset store interface enforcing AOC-19 contract.
|
||||
/// Linksets can only be appended (new observations added), never modified or deleted.
|
||||
/// This guarantees deterministic replay and audit trails.
|
||||
/// </summary>
|
||||
public interface IAppendOnlyLinksetStore
|
||||
{
|
||||
/// <summary>
|
||||
/// Appends a new observation to an existing linkset or creates a new linkset.
|
||||
/// Returns the updated linkset with the new observation appended.
|
||||
/// Thread-safe and idempotent (duplicate observations are deduplicated).
|
||||
/// </summary>
|
||||
/// <param name="tenant">Tenant identifier.</param>
|
||||
/// <param name="vulnerabilityId">Vulnerability identifier (CVE, GHSA, etc.).</param>
|
||||
/// <param name="productKey">Product key (PURL, CPE, etc.).</param>
|
||||
/// <param name="observation">The observation reference to append.</param>
|
||||
/// <param name="scope">Product scope metadata.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The updated linkset with the appended observation.</returns>
|
||||
ValueTask<AppendLinksetResult> AppendObservationAsync(
|
||||
string tenant,
|
||||
string vulnerabilityId,
|
||||
string productKey,
|
||||
VexLinksetObservationRefModel observation,
|
||||
VexProductScope scope,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Appends multiple observations to a linkset in a single atomic operation.
|
||||
/// </summary>
|
||||
ValueTask<AppendLinksetResult> AppendObservationsBatchAsync(
|
||||
string tenant,
|
||||
string vulnerabilityId,
|
||||
string productKey,
|
||||
IEnumerable<VexLinksetObservationRefModel> observations,
|
||||
VexProductScope scope,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Appends a disagreement annotation to an existing linkset.
|
||||
/// Disagreements are append-only and never removed.
|
||||
/// </summary>
|
||||
ValueTask<AppendLinksetResult> AppendDisagreementAsync(
|
||||
string tenant,
|
||||
string vulnerabilityId,
|
||||
string productKey,
|
||||
VexObservationDisagreement disagreement,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves a linkset by tenant and linkset ID (read-only).
|
||||
/// </summary>
|
||||
ValueTask<VexLinkset?> GetByIdAsync(
|
||||
string tenant,
|
||||
string linksetId,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves a linkset by vulnerability and product key (read-only).
|
||||
/// </summary>
|
||||
ValueTask<VexLinkset?> GetByKeyAsync(
|
||||
string tenant,
|
||||
string vulnerabilityId,
|
||||
string productKey,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Finds linksets by vulnerability ID (read-only).
|
||||
/// </summary>
|
||||
ValueTask<IReadOnlyList<VexLinkset>> FindByVulnerabilityAsync(
|
||||
string tenant,
|
||||
string vulnerabilityId,
|
||||
int limit,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Finds linksets by product key (read-only).
|
||||
/// </summary>
|
||||
ValueTask<IReadOnlyList<VexLinkset>> FindByProductKeyAsync(
|
||||
string tenant,
|
||||
string productKey,
|
||||
int limit,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Finds linksets with conflicts/disagreements (read-only).
|
||||
/// </summary>
|
||||
ValueTask<IReadOnlyList<VexLinkset>> FindWithConflictsAsync(
|
||||
string tenant,
|
||||
int limit,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Returns the count of linksets for the specified tenant.
|
||||
/// </summary>
|
||||
ValueTask<long> CountAsync(
|
||||
string tenant,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Returns the count of linksets with conflicts for the specified tenant.
|
||||
/// </summary>
|
||||
ValueTask<long> CountWithConflictsAsync(
|
||||
string tenant,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the append-only event log for a specific linkset.
|
||||
/// Returns all mutations in chronological order for audit/replay.
|
||||
/// </summary>
|
||||
ValueTask<IReadOnlyList<LinksetMutationEvent>> GetMutationLogAsync(
|
||||
string tenant,
|
||||
string linksetId,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of an append operation on a linkset.
|
||||
/// </summary>
|
||||
public sealed record AppendLinksetResult
|
||||
{
|
||||
private AppendLinksetResult(
|
||||
VexLinkset linkset,
|
||||
bool wasCreated,
|
||||
int observationsAdded,
|
||||
int disagreementsAdded,
|
||||
long sequenceNumber)
|
||||
{
|
||||
Linkset = linkset ?? throw new ArgumentNullException(nameof(linkset));
|
||||
WasCreated = wasCreated;
|
||||
ObservationsAdded = observationsAdded;
|
||||
DisagreementsAdded = disagreementsAdded;
|
||||
SequenceNumber = sequenceNumber;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The updated linkset.
|
||||
/// </summary>
|
||||
public VexLinkset Linkset { get; }
|
||||
|
||||
/// <summary>
|
||||
/// True if the linkset was newly created by this operation.
|
||||
/// </summary>
|
||||
public bool WasCreated { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of new observations added (0 if deduplicated).
|
||||
/// </summary>
|
||||
public int ObservationsAdded { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of new disagreements added (0 if deduplicated).
|
||||
/// </summary>
|
||||
public int DisagreementsAdded { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Monotonic sequence number for this mutation (for ordering/replay).
|
||||
/// </summary>
|
||||
public long SequenceNumber { get; }
|
||||
|
||||
/// <summary>
|
||||
/// True if any data was actually appended.
|
||||
/// </summary>
|
||||
public bool HadChanges => WasCreated || ObservationsAdded > 0 || DisagreementsAdded > 0;
|
||||
|
||||
public static AppendLinksetResult Created(VexLinkset linkset, int observationsAdded, long sequenceNumber)
|
||||
=> new(linkset, wasCreated: true, observationsAdded, disagreementsAdded: 0, sequenceNumber);
|
||||
|
||||
public static AppendLinksetResult Updated(VexLinkset linkset, int observationsAdded, int disagreementsAdded, long sequenceNumber)
|
||||
=> new(linkset, wasCreated: false, observationsAdded, disagreementsAdded, sequenceNumber);
|
||||
|
||||
public static AppendLinksetResult NoChange(VexLinkset linkset, long sequenceNumber)
|
||||
=> new(linkset, wasCreated: false, observationsAdded: 0, disagreementsAdded: 0, sequenceNumber);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a mutation event in the append-only linkset log.
|
||||
/// Used for audit trails and deterministic replay.
|
||||
/// </summary>
|
||||
public sealed record LinksetMutationEvent
|
||||
{
|
||||
public LinksetMutationEvent(
|
||||
long sequenceNumber,
|
||||
string mutationType,
|
||||
DateTimeOffset timestamp,
|
||||
string? observationId,
|
||||
string? providerId,
|
||||
string? status,
|
||||
double? confidence,
|
||||
string? justification)
|
||||
{
|
||||
SequenceNumber = sequenceNumber;
|
||||
MutationType = mutationType ?? throw new ArgumentNullException(nameof(mutationType));
|
||||
Timestamp = timestamp.ToUniversalTime();
|
||||
ObservationId = observationId;
|
||||
ProviderId = providerId;
|
||||
Status = status;
|
||||
Confidence = confidence;
|
||||
Justification = justification;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Monotonic sequence number for ordering.
|
||||
/// </summary>
|
||||
public long SequenceNumber { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Type of mutation: "observation_added", "disagreement_added", "linkset_created".
|
||||
/// </summary>
|
||||
public string MutationType { get; }
|
||||
|
||||
/// <summary>
|
||||
/// When this mutation occurred.
|
||||
/// </summary>
|
||||
public DateTimeOffset Timestamp { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Observation ID (for observation mutations).
|
||||
/// </summary>
|
||||
public string? ObservationId { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Provider ID.
|
||||
/// </summary>
|
||||
public string? ProviderId { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Status value.
|
||||
/// </summary>
|
||||
public string? Status { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Confidence value.
|
||||
/// </summary>
|
||||
public double? Confidence { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Justification (for disagreement mutations).
|
||||
/// </summary>
|
||||
public string? Justification { get; }
|
||||
|
||||
public static class MutationTypes
|
||||
{
|
||||
public const string LinksetCreated = "linkset_created";
|
||||
public const string ObservationAdded = "observation_added";
|
||||
public const string DisagreementAdded = "disagreement_added";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.Excititor.Core.Testing;
|
||||
|
||||
/// <summary>
|
||||
/// Utility for seeding Authority tenants in test scenarios (AOC-19-004).
|
||||
/// Provides deterministic tenant fixtures with configurable settings.
|
||||
/// </summary>
|
||||
public sealed class AuthorityTenantSeeder
|
||||
{
|
||||
private readonly List<TestTenant> _tenants = new();
|
||||
private readonly HashSet<string> _usedSlugs = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// Default test tenant for single-tenant scenarios.
|
||||
/// </summary>
|
||||
public static TestTenant DefaultTenant { get; } = new TestTenant(
|
||||
Id: Guid.Parse("00000000-0000-0000-0000-000000000001"),
|
||||
Slug: "test",
|
||||
Name: "Test Tenant",
|
||||
Description: "Default test tenant for unit tests",
|
||||
Enabled: true,
|
||||
Settings: TestTenantSettings.Default,
|
||||
Metadata: TestTenantMetadata.Default);
|
||||
|
||||
/// <summary>
|
||||
/// Multi-tenant test fixtures (Acme, Beta, Gamma).
|
||||
/// </summary>
|
||||
public static ImmutableArray<TestTenant> MultiTenantFixtures { get; } = ImmutableArray.Create(
|
||||
new TestTenant(
|
||||
Id: Guid.Parse("00000000-0000-0000-0000-000000000010"),
|
||||
Slug: "acme",
|
||||
Name: "Acme Corp",
|
||||
Description: "Primary test tenant",
|
||||
Enabled: true,
|
||||
Settings: TestTenantSettings.Default,
|
||||
Metadata: new TestTenantMetadata(
|
||||
Environment: "test",
|
||||
Region: "us-east-1",
|
||||
Tier: "enterprise",
|
||||
Features: ImmutableArray.Create("vex-ingestion", "policy-engine", "graph-explorer"))),
|
||||
new TestTenant(
|
||||
Id: Guid.Parse("00000000-0000-0000-0000-000000000020"),
|
||||
Slug: "beta",
|
||||
Name: "Beta Inc",
|
||||
Description: "Secondary test tenant",
|
||||
Enabled: true,
|
||||
Settings: TestTenantSettings.Default with { MaxProviders = 5 },
|
||||
Metadata: new TestTenantMetadata(
|
||||
Environment: "test",
|
||||
Region: "eu-west-1",
|
||||
Tier: "professional",
|
||||
Features: ImmutableArray.Create("vex-ingestion"))),
|
||||
new TestTenant(
|
||||
Id: Guid.Parse("00000000-0000-0000-0000-000000000030"),
|
||||
Slug: "gamma",
|
||||
Name: "Gamma Ltd",
|
||||
Description: "Disabled test tenant",
|
||||
Enabled: false,
|
||||
Settings: TestTenantSettings.Default,
|
||||
Metadata: TestTenantMetadata.Default));
|
||||
|
||||
/// <summary>
|
||||
/// Airgap test tenant with restricted settings.
|
||||
/// </summary>
|
||||
public static TestTenant AirgapTenant { get; } = new TestTenant(
|
||||
Id: Guid.Parse("00000000-0000-0000-0000-000000000099"),
|
||||
Slug: "airgap-test",
|
||||
Name: "Airgap Test Tenant",
|
||||
Description: "Tenant for airgap/offline testing",
|
||||
Enabled: true,
|
||||
Settings: TestTenantSettings.Airgap,
|
||||
Metadata: new TestTenantMetadata(
|
||||
Environment: "airgap",
|
||||
Region: "isolated",
|
||||
Tier: "enterprise",
|
||||
Features: ImmutableArray.Create("vex-ingestion", "offline-mode", "mirror-import")));
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new seeder instance.
|
||||
/// </summary>
|
||||
public AuthorityTenantSeeder()
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds the default test tenant to the seed set.
|
||||
/// </summary>
|
||||
public AuthorityTenantSeeder WithDefaultTenant()
|
||||
{
|
||||
AddTenant(DefaultTenant);
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds multi-tenant fixtures to the seed set.
|
||||
/// </summary>
|
||||
public AuthorityTenantSeeder WithMultiTenantFixtures()
|
||||
{
|
||||
foreach (var tenant in MultiTenantFixtures)
|
||||
{
|
||||
AddTenant(tenant);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds the airgap test tenant to the seed set.
|
||||
/// </summary>
|
||||
public AuthorityTenantSeeder WithAirgapTenant()
|
||||
{
|
||||
AddTenant(AirgapTenant);
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a custom tenant to the seed set.
|
||||
/// </summary>
|
||||
public AuthorityTenantSeeder WithTenant(TestTenant tenant)
|
||||
{
|
||||
AddTenant(tenant);
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a custom tenant with minimal configuration.
|
||||
/// </summary>
|
||||
public AuthorityTenantSeeder WithTenant(string slug, string name, bool enabled = true)
|
||||
{
|
||||
var tenant = new TestTenant(
|
||||
Id: Guid.NewGuid(),
|
||||
Slug: slug,
|
||||
Name: name,
|
||||
Description: null,
|
||||
Enabled: enabled,
|
||||
Settings: TestTenantSettings.Default,
|
||||
Metadata: TestTenantMetadata.Default);
|
||||
AddTenant(tenant);
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all tenants in the seed set.
|
||||
/// </summary>
|
||||
public IReadOnlyList<TestTenant> GetTenants() => _tenants.ToList();
|
||||
|
||||
/// <summary>
|
||||
/// Gets tenant slugs for use in test data generation.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> GetSlugs() => _tenants.Select(t => t.Slug).ToList();
|
||||
|
||||
/// <summary>
|
||||
/// Generates SQL INSERT statements for seeding tenants.
|
||||
/// </summary>
|
||||
public string GenerateSql()
|
||||
{
|
||||
if (_tenants.Count == 0)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var sb = new System.Text.StringBuilder();
|
||||
sb.AppendLine("-- Authority tenant seed data (AOC-19-004)");
|
||||
sb.AppendLine("INSERT INTO auth.tenants (id, slug, name, description, contact_email, enabled, settings, metadata, created_at, updated_at, created_by)");
|
||||
sb.AppendLine("VALUES");
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var lines = new List<string>();
|
||||
foreach (var tenant in _tenants)
|
||||
{
|
||||
var settingsJson = JsonSerializer.Serialize(tenant.Settings, JsonOptions);
|
||||
var metadataJson = JsonSerializer.Serialize(tenant.Metadata, JsonOptions);
|
||||
lines.Add($" ('{tenant.Id}', '{EscapeSql(tenant.Slug)}', '{EscapeSql(tenant.Name)}', {NullableString(tenant.Description)}, NULL, {(tenant.Enabled ? "TRUE" : "FALSE")}, '{EscapeSql(settingsJson)}', '{EscapeSql(metadataJson)}', '{now:O}', '{now:O}', 'test-seeder')");
|
||||
}
|
||||
|
||||
sb.AppendLine(string.Join(",\n", lines));
|
||||
sb.AppendLine("ON CONFLICT (slug) DO NOTHING;");
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private void AddTenant(TestTenant tenant)
|
||||
{
|
||||
if (_usedSlugs.Contains(tenant.Slug))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_usedSlugs.Add(tenant.Slug);
|
||||
_tenants.Add(tenant);
|
||||
}
|
||||
|
||||
private static string EscapeSql(string value) => value.Replace("'", "''");
|
||||
|
||||
private static string NullableString(string? value) =>
|
||||
value is null ? "NULL" : $"'{EscapeSql(value)}'";
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
WriteIndented = false
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test tenant fixture record.
|
||||
/// </summary>
|
||||
public sealed record TestTenant(
|
||||
Guid Id,
|
||||
string Slug,
|
||||
string Name,
|
||||
string? Description,
|
||||
bool Enabled,
|
||||
TestTenantSettings Settings,
|
||||
TestTenantMetadata Metadata);
|
||||
|
||||
/// <summary>
|
||||
/// Test tenant settings.
|
||||
/// </summary>
|
||||
public sealed record TestTenantSettings(
|
||||
int MaxProviders,
|
||||
int MaxObservationsPerLinkset,
|
||||
bool AllowExternalConnectors,
|
||||
bool AllowAirgapMode,
|
||||
int RetentionDays)
|
||||
{
|
||||
/// <summary>
|
||||
/// Default test tenant settings.
|
||||
/// </summary>
|
||||
public static TestTenantSettings Default { get; } = new TestTenantSettings(
|
||||
MaxProviders: 50,
|
||||
MaxObservationsPerLinkset: 1000,
|
||||
AllowExternalConnectors: true,
|
||||
AllowAirgapMode: false,
|
||||
RetentionDays: 365);
|
||||
|
||||
/// <summary>
|
||||
/// Airgap-mode tenant settings.
|
||||
/// </summary>
|
||||
public static TestTenantSettings Airgap { get; } = new TestTenantSettings(
|
||||
MaxProviders: 20,
|
||||
MaxObservationsPerLinkset: 500,
|
||||
AllowExternalConnectors: false,
|
||||
AllowAirgapMode: true,
|
||||
RetentionDays: 730);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test tenant metadata.
|
||||
/// </summary>
|
||||
public sealed record TestTenantMetadata(
|
||||
string Environment,
|
||||
string Region,
|
||||
string Tier,
|
||||
ImmutableArray<string> Features)
|
||||
{
|
||||
/// <summary>
|
||||
/// Default test tenant metadata.
|
||||
/// </summary>
|
||||
public static TestTenantMetadata Default { get; } = new TestTenantMetadata(
|
||||
Environment: "test",
|
||||
Region: "local",
|
||||
Tier: "free",
|
||||
Features: ImmutableArray<string>.Empty);
|
||||
}
|
||||
@@ -3,6 +3,15 @@ using System.Runtime.Serialization;
|
||||
|
||||
namespace StellaOps.Excititor.Core;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a VEX consensus result from weighted voting.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// DEPRECATED: Consensus logic is being removed per AOC-19 contract.
|
||||
/// Use append-only linksets with <see cref="StellaOps.Excititor.Core.Observations.IAppendOnlyLinksetStore"/>
|
||||
/// and let downstream policy engines make verdicts.
|
||||
/// </remarks>
|
||||
[Obsolete("Consensus logic is deprecated per AOC-19. Use append-only linksets instead.", DiagnosticId = "EXCITITOR001")]
|
||||
public sealed record VexConsensus
|
||||
{
|
||||
public VexConsensus(
|
||||
|
||||
@@ -2,6 +2,15 @@ using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Excititor.Core;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for consensus policy weights.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// DEPRECATED: Consensus logic is being removed per AOC-19 contract.
|
||||
/// Use append-only linksets with <see cref="StellaOps.Excititor.Core.Observations.IAppendOnlyLinksetStore"/>
|
||||
/// and let downstream policy engines make verdicts.
|
||||
/// </remarks>
|
||||
[Obsolete("Consensus logic is deprecated per AOC-19. Use append-only linksets instead.", DiagnosticId = "EXCITITOR001")]
|
||||
public sealed record VexConsensusPolicyOptions
|
||||
{
|
||||
public const string BaselineVersion = "baseline/v1";
|
||||
|
||||
@@ -3,6 +3,15 @@ using System.Globalization;
|
||||
|
||||
namespace StellaOps.Excititor.Core;
|
||||
|
||||
/// <summary>
|
||||
/// Resolves VEX consensus from multiple claims using weighted voting.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// DEPRECATED: Consensus logic is being removed per AOC-19 contract.
|
||||
/// Use append-only linksets with <see cref="StellaOps.Excititor.Core.Observations.IAppendOnlyLinksetStore"/>
|
||||
/// and let downstream policy engines make verdicts.
|
||||
/// </remarks>
|
||||
[Obsolete("Consensus logic is deprecated per AOC-19. Use append-only linksets instead.", DiagnosticId = "EXCITITOR001")]
|
||||
public sealed class VexConsensusResolver
|
||||
{
|
||||
private readonly IVexConsensusPolicy _policy;
|
||||
@@ -273,6 +282,14 @@ public sealed class VexConsensusResolver
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request model for consensus resolution.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// DEPRECATED: Consensus logic is being removed per AOC-19 contract.
|
||||
/// </remarks>
|
||||
#pragma warning disable EXCITITOR001 // Using obsolete VexConsensusPolicyOptions
|
||||
[Obsolete("Consensus logic is deprecated per AOC-19. Use append-only linksets instead.", DiagnosticId = "EXCITITOR001")]
|
||||
public sealed record VexConsensusRequest(
|
||||
string VulnerabilityId,
|
||||
VexProduct Product,
|
||||
@@ -283,11 +300,26 @@ public sealed record VexConsensusRequest(
|
||||
VexSignalSnapshot? Signals = null,
|
||||
string? PolicyRevisionId = null,
|
||||
string? PolicyDigest = null);
|
||||
#pragma warning restore EXCITITOR001
|
||||
|
||||
/// <summary>
|
||||
/// Result of consensus resolution including decision log.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// DEPRECATED: Consensus logic is being removed per AOC-19 contract.
|
||||
/// </remarks>
|
||||
[Obsolete("Consensus logic is deprecated per AOC-19. Use append-only linksets instead.", DiagnosticId = "EXCITITOR001")]
|
||||
public sealed record VexConsensusResolution(
|
||||
VexConsensus Consensus,
|
||||
ImmutableArray<VexConsensusDecisionTelemetry> DecisionLog);
|
||||
|
||||
/// <summary>
|
||||
/// Telemetry record for consensus decision auditing.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// DEPRECATED: Consensus logic is being removed per AOC-19 contract.
|
||||
/// </remarks>
|
||||
[Obsolete("Consensus logic is deprecated per AOC-19. Use append-only linksets instead.", DiagnosticId = "EXCITITOR001")]
|
||||
public sealed record VexConsensusDecisionTelemetry(
|
||||
string ProviderId,
|
||||
string DocumentDigest,
|
||||
|
||||
@@ -0,0 +1,393 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
using System.Text.Json.Nodes;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Excititor.Core.Observations;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Excititor.Core.UnitTests.Observations;
|
||||
|
||||
public class AppendOnlyLinksetExtractionServiceTests
|
||||
{
|
||||
private readonly InMemoryAppendOnlyLinksetStore _store;
|
||||
private readonly AppendOnlyLinksetExtractionService _service;
|
||||
|
||||
public AppendOnlyLinksetExtractionServiceTests()
|
||||
{
|
||||
_store = new InMemoryAppendOnlyLinksetStore();
|
||||
_service = new AppendOnlyLinksetExtractionService(
|
||||
_store,
|
||||
NullLogger<AppendOnlyLinksetExtractionService>.Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessObservationsAsync_AppendsToStore_WithDeterministicOrdering()
|
||||
{
|
||||
var obs1 = BuildObservation(
|
||||
id: "obs-1",
|
||||
provider: "provider-a",
|
||||
vuln: "CVE-2025-0001",
|
||||
product: "pkg:npm/leftpad",
|
||||
createdAt: DateTimeOffset.Parse("2025-11-20T10:00:00Z"));
|
||||
|
||||
var obs2 = BuildObservation(
|
||||
id: "obs-2",
|
||||
provider: "provider-b",
|
||||
vuln: "CVE-2025-0001",
|
||||
product: "pkg:npm/leftpad",
|
||||
createdAt: DateTimeOffset.Parse("2025-11-20T11:00:00Z"));
|
||||
|
||||
var results = await _service.ProcessObservationsAsync("tenant-a", new[] { obs2, obs1 }, CancellationToken.None);
|
||||
|
||||
Assert.Single(results);
|
||||
var result = results[0];
|
||||
Assert.True(result.Success);
|
||||
Assert.True(result.WasCreated);
|
||||
Assert.Equal(2, result.ObservationsAdded);
|
||||
Assert.NotNull(result.Linkset);
|
||||
Assert.Equal("CVE-2025-0001", result.Linkset.VulnerabilityId);
|
||||
Assert.Equal("pkg:npm/leftpad", result.Linkset.ProductKey);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessObservationsAsync_DeduplicatesObservations()
|
||||
{
|
||||
var obs = BuildObservation(
|
||||
id: "obs-1",
|
||||
provider: "provider-a",
|
||||
vuln: "CVE-2025-0001",
|
||||
product: "pkg:npm/leftpad",
|
||||
createdAt: DateTimeOffset.UtcNow);
|
||||
|
||||
// Process the same observation twice
|
||||
await _service.ProcessObservationsAsync("tenant-a", new[] { obs }, CancellationToken.None);
|
||||
var results = await _service.ProcessObservationsAsync("tenant-a", new[] { obs }, CancellationToken.None);
|
||||
|
||||
Assert.Single(results);
|
||||
var result = results[0];
|
||||
Assert.True(result.Success);
|
||||
Assert.False(result.WasCreated); // Already exists
|
||||
Assert.Equal(0, result.ObservationsAdded); // Deduplicated
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessObservationsAsync_GroupsByVulnerabilityAndProduct()
|
||||
{
|
||||
var obs1 = BuildObservation("obs-1", "provider-a", "CVE-2025-0001", "pkg:npm/foo", DateTimeOffset.UtcNow);
|
||||
var obs2 = BuildObservation("obs-2", "provider-b", "CVE-2025-0001", "pkg:npm/bar", DateTimeOffset.UtcNow);
|
||||
var obs3 = BuildObservation("obs-3", "provider-c", "CVE-2025-0002", "pkg:npm/foo", DateTimeOffset.UtcNow);
|
||||
|
||||
var results = await _service.ProcessObservationsAsync("tenant-a", new[] { obs1, obs2, obs3 }, CancellationToken.None);
|
||||
|
||||
Assert.Equal(3, results.Length);
|
||||
Assert.True(results.All(r => r.Success));
|
||||
Assert.True(results.All(r => r.WasCreated));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessObservationsAsync_EnforcesTenantIsolation()
|
||||
{
|
||||
var obs = BuildObservation("obs-1", "provider-a", "CVE-2025-0001", "pkg:npm/leftpad", DateTimeOffset.UtcNow);
|
||||
|
||||
await _service.ProcessObservationsAsync("tenant-a", new[] { obs }, CancellationToken.None);
|
||||
var linkset = await _store.GetByKeyAsync("tenant-b", "CVE-2025-0001", "pkg:npm/leftpad", CancellationToken.None);
|
||||
|
||||
Assert.Null(linkset); // Different tenant should not see it
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessObservationsAsync_ReturnsEmptyForNullOrEmpty()
|
||||
{
|
||||
var results1 = await _service.ProcessObservationsAsync("tenant-a", null!, CancellationToken.None);
|
||||
var results2 = await _service.ProcessObservationsAsync("tenant-a", Array.Empty<VexObservation>(), CancellationToken.None);
|
||||
|
||||
Assert.Empty(results1);
|
||||
Assert.Empty(results2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AppendDisagreementAsync_AppendsToExistingLinkset()
|
||||
{
|
||||
var obs = BuildObservation("obs-1", "provider-a", "CVE-2025-0001", "pkg:npm/leftpad", DateTimeOffset.UtcNow);
|
||||
await _service.ProcessObservationsAsync("tenant-a", new[] { obs }, CancellationToken.None);
|
||||
|
||||
var disagreement = new VexObservationDisagreement("provider-b", "not_affected", "inline_mitigations_already_exist", 0.9);
|
||||
var result = await _service.AppendDisagreementAsync(
|
||||
"tenant-a",
|
||||
"CVE-2025-0001",
|
||||
"pkg:npm/leftpad",
|
||||
disagreement,
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.Equal(1, result.DisagreementsAdded);
|
||||
Assert.NotNull(result.Linkset);
|
||||
Assert.True(result.Linkset.HasConflicts);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AppendDisagreementAsync_CreatesLinksetIfNotExists()
|
||||
{
|
||||
var disagreement = new VexObservationDisagreement("provider-a", "affected", null, null);
|
||||
var result = await _service.AppendDisagreementAsync(
|
||||
"tenant-a",
|
||||
"CVE-2025-9999",
|
||||
"pkg:npm/new-package",
|
||||
disagreement,
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.True(result.WasCreated);
|
||||
Assert.Equal(1, result.DisagreementsAdded);
|
||||
}
|
||||
|
||||
private static VexObservation BuildObservation(string id, string provider, string vuln, string product, DateTimeOffset createdAt)
|
||||
{
|
||||
var statement = new VexObservationStatement(
|
||||
vulnerabilityId: vuln,
|
||||
productKey: product,
|
||||
status: VexClaimStatus.Affected,
|
||||
lastObserved: null,
|
||||
locator: null,
|
||||
justification: null,
|
||||
introducedVersion: null,
|
||||
fixedVersion: null,
|
||||
purl: product,
|
||||
cpe: null,
|
||||
evidence: null,
|
||||
metadata: null);
|
||||
|
||||
var upstream = new VexObservationUpstream(
|
||||
upstreamId: $"upstream-{id}",
|
||||
documentVersion: "1",
|
||||
fetchedAt: createdAt,
|
||||
receivedAt: createdAt,
|
||||
contentHash: "sha256:deadbeef",
|
||||
signature: new VexObservationSignature(false, null, null, null));
|
||||
|
||||
var content = new VexObservationContent(
|
||||
format: "openvex",
|
||||
specVersion: "1.0.0",
|
||||
raw: JsonNode.Parse("{}")!,
|
||||
metadata: null);
|
||||
|
||||
var linkset = new VexObservationLinkset(
|
||||
aliases: new[] { vuln },
|
||||
purls: new[] { product },
|
||||
cpes: Array.Empty<string>(),
|
||||
references: Array.Empty<VexObservationReference>());
|
||||
|
||||
return new VexObservation(
|
||||
observationId: id,
|
||||
tenant: "tenant-a",
|
||||
providerId: provider,
|
||||
streamId: "ingest",
|
||||
upstream: upstream,
|
||||
statements: ImmutableArray.Create(statement),
|
||||
content: content,
|
||||
linkset: linkset,
|
||||
createdAt: createdAt);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// In-memory implementation of IAppendOnlyLinksetStore for testing.
|
||||
/// </summary>
|
||||
internal class InMemoryAppendOnlyLinksetStore : IAppendOnlyLinksetStore
|
||||
{
|
||||
private readonly Dictionary<string, VexLinkset> _linksets = new();
|
||||
private readonly List<LinksetMutationEvent> _mutations = new();
|
||||
private long _sequenceNumber = 0;
|
||||
private readonly object _lock = new();
|
||||
|
||||
public ValueTask<AppendLinksetResult> AppendObservationAsync(
|
||||
string tenant,
|
||||
string vulnerabilityId,
|
||||
string productKey,
|
||||
VexLinksetObservationRefModel observation,
|
||||
VexProductScope scope,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return AppendObservationsBatchAsync(tenant, vulnerabilityId, productKey, new[] { observation }, scope, cancellationToken);
|
||||
}
|
||||
|
||||
public ValueTask<AppendLinksetResult> AppendObservationsBatchAsync(
|
||||
string tenant,
|
||||
string vulnerabilityId,
|
||||
string productKey,
|
||||
IEnumerable<VexLinksetObservationRefModel> observations,
|
||||
VexProductScope scope,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
var linksetId = VexLinkset.CreateLinksetId(tenant, vulnerabilityId, productKey);
|
||||
var key = $"{tenant}|{linksetId}";
|
||||
var wasCreated = false;
|
||||
var observationsAdded = 0;
|
||||
|
||||
if (!_linksets.TryGetValue(key, out var linkset))
|
||||
{
|
||||
wasCreated = true;
|
||||
linkset = new VexLinkset(
|
||||
linksetId, tenant, vulnerabilityId, productKey, scope,
|
||||
Enumerable.Empty<VexLinksetObservationRefModel>(),
|
||||
null, DateTimeOffset.UtcNow, DateTimeOffset.UtcNow);
|
||||
_linksets[key] = linkset;
|
||||
|
||||
_mutations.Add(new LinksetMutationEvent(
|
||||
++_sequenceNumber, LinksetMutationEvent.MutationTypes.LinksetCreated,
|
||||
DateTimeOffset.UtcNow, null, null, null, null, null));
|
||||
}
|
||||
|
||||
var existingObsIds = new HashSet<string>(
|
||||
linkset.Observations.Select(o => o.ObservationId),
|
||||
StringComparer.Ordinal);
|
||||
|
||||
var newObservations = observations
|
||||
.Where(o => !existingObsIds.Contains(o.ObservationId))
|
||||
.ToList();
|
||||
|
||||
if (newObservations.Count > 0)
|
||||
{
|
||||
var allObservations = linkset.Observations.Concat(newObservations);
|
||||
linkset = linkset.WithObservations(allObservations, linkset.Disagreements);
|
||||
_linksets[key] = linkset;
|
||||
observationsAdded = newObservations.Count;
|
||||
|
||||
foreach (var obs in newObservations)
|
||||
{
|
||||
_mutations.Add(new LinksetMutationEvent(
|
||||
++_sequenceNumber, LinksetMutationEvent.MutationTypes.ObservationAdded,
|
||||
DateTimeOffset.UtcNow, obs.ObservationId, obs.ProviderId, obs.Status, obs.Confidence, null));
|
||||
}
|
||||
}
|
||||
|
||||
return ValueTask.FromResult(wasCreated
|
||||
? AppendLinksetResult.Created(linkset, observationsAdded, _sequenceNumber)
|
||||
: (observationsAdded > 0
|
||||
? AppendLinksetResult.Updated(linkset, observationsAdded, 0, _sequenceNumber)
|
||||
: AppendLinksetResult.NoChange(linkset, _sequenceNumber)));
|
||||
}
|
||||
}
|
||||
|
||||
public ValueTask<AppendLinksetResult> AppendDisagreementAsync(
|
||||
string tenant,
|
||||
string vulnerabilityId,
|
||||
string productKey,
|
||||
VexObservationDisagreement disagreement,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
var linksetId = VexLinkset.CreateLinksetId(tenant, vulnerabilityId, productKey);
|
||||
var key = $"{tenant}|{linksetId}";
|
||||
var wasCreated = false;
|
||||
|
||||
if (!_linksets.TryGetValue(key, out var linkset))
|
||||
{
|
||||
wasCreated = true;
|
||||
var scope = VexProductScope.Unknown(productKey);
|
||||
linkset = new VexLinkset(
|
||||
linksetId, tenant, vulnerabilityId, productKey, scope,
|
||||
Enumerable.Empty<VexLinksetObservationRefModel>(),
|
||||
null, DateTimeOffset.UtcNow, DateTimeOffset.UtcNow);
|
||||
}
|
||||
|
||||
var allDisagreements = linkset.Disagreements.Append(disagreement);
|
||||
linkset = linkset.WithObservations(linkset.Observations, allDisagreements);
|
||||
_linksets[key] = linkset;
|
||||
|
||||
_mutations.Add(new LinksetMutationEvent(
|
||||
++_sequenceNumber, LinksetMutationEvent.MutationTypes.DisagreementAdded,
|
||||
DateTimeOffset.UtcNow, null, disagreement.ProviderId, disagreement.Status,
|
||||
disagreement.Confidence, disagreement.Justification));
|
||||
|
||||
return ValueTask.FromResult(wasCreated
|
||||
? AppendLinksetResult.Created(linkset, 0, _sequenceNumber)
|
||||
: AppendLinksetResult.Updated(linkset, 0, 1, _sequenceNumber));
|
||||
}
|
||||
}
|
||||
|
||||
public ValueTask<VexLinkset?> GetByIdAsync(string tenant, string linksetId, CancellationToken cancellationToken)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
var key = $"{tenant}|{linksetId}";
|
||||
_linksets.TryGetValue(key, out var linkset);
|
||||
return ValueTask.FromResult(linkset);
|
||||
}
|
||||
}
|
||||
|
||||
public ValueTask<VexLinkset?> GetByKeyAsync(string tenant, string vulnerabilityId, string productKey, CancellationToken cancellationToken)
|
||||
{
|
||||
var linksetId = VexLinkset.CreateLinksetId(tenant, vulnerabilityId, productKey);
|
||||
return GetByIdAsync(tenant, linksetId, cancellationToken);
|
||||
}
|
||||
|
||||
public ValueTask<IReadOnlyList<VexLinkset>> FindByVulnerabilityAsync(string tenant, string vulnerabilityId, int limit, CancellationToken cancellationToken)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
var results = _linksets.Values
|
||||
.Where(l => l.Tenant == tenant && string.Equals(l.VulnerabilityId, vulnerabilityId, StringComparison.OrdinalIgnoreCase))
|
||||
.Take(limit)
|
||||
.ToList();
|
||||
return ValueTask.FromResult<IReadOnlyList<VexLinkset>>(results);
|
||||
}
|
||||
}
|
||||
|
||||
public ValueTask<IReadOnlyList<VexLinkset>> FindByProductKeyAsync(string tenant, string productKey, int limit, CancellationToken cancellationToken)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
var results = _linksets.Values
|
||||
.Where(l => l.Tenant == tenant && string.Equals(l.ProductKey, productKey, StringComparison.OrdinalIgnoreCase))
|
||||
.Take(limit)
|
||||
.ToList();
|
||||
return ValueTask.FromResult<IReadOnlyList<VexLinkset>>(results);
|
||||
}
|
||||
}
|
||||
|
||||
public ValueTask<IReadOnlyList<VexLinkset>> FindWithConflictsAsync(string tenant, int limit, CancellationToken cancellationToken)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
var results = _linksets.Values
|
||||
.Where(l => l.Tenant == tenant && l.HasConflicts)
|
||||
.Take(limit)
|
||||
.ToList();
|
||||
return ValueTask.FromResult<IReadOnlyList<VexLinkset>>(results);
|
||||
}
|
||||
}
|
||||
|
||||
public ValueTask<long> CountAsync(string tenant, CancellationToken cancellationToken)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
var count = _linksets.Values.Count(l => l.Tenant == tenant);
|
||||
return ValueTask.FromResult((long)count);
|
||||
}
|
||||
}
|
||||
|
||||
public ValueTask<long> CountWithConflictsAsync(string tenant, CancellationToken cancellationToken)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
var count = _linksets.Values.Count(l => l.Tenant == tenant && l.HasConflicts);
|
||||
return ValueTask.FromResult((long)count);
|
||||
}
|
||||
}
|
||||
|
||||
public ValueTask<IReadOnlyList<LinksetMutationEvent>> GetMutationLogAsync(string tenant, string linksetId, CancellationToken cancellationToken)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
return ValueTask.FromResult<IReadOnlyList<LinksetMutationEvent>>(_mutations.ToList());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
using System;
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
using StellaOps.Excititor.Core.Testing;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Excititor.Core.UnitTests.Testing;
|
||||
|
||||
public class AuthorityTenantSeederTests
|
||||
{
|
||||
[Fact]
|
||||
public void DefaultTenant_HasExpectedValues()
|
||||
{
|
||||
var tenant = AuthorityTenantSeeder.DefaultTenant;
|
||||
|
||||
Assert.NotEqual(Guid.Empty, tenant.Id);
|
||||
Assert.Equal("test", tenant.Slug);
|
||||
Assert.Equal("Test Tenant", tenant.Name);
|
||||
Assert.True(tenant.Enabled);
|
||||
Assert.NotNull(tenant.Settings);
|
||||
Assert.NotNull(tenant.Metadata);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MultiTenantFixtures_ContainsThreeTenants()
|
||||
{
|
||||
var fixtures = AuthorityTenantSeeder.MultiTenantFixtures;
|
||||
|
||||
Assert.Equal(3, fixtures.Length);
|
||||
Assert.Contains(fixtures, t => t.Slug == "acme");
|
||||
Assert.Contains(fixtures, t => t.Slug == "beta");
|
||||
Assert.Contains(fixtures, t => t.Slug == "gamma");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MultiTenantFixtures_GammaIsDisabled()
|
||||
{
|
||||
var gamma = AuthorityTenantSeeder.MultiTenantFixtures.Single(t => t.Slug == "gamma");
|
||||
|
||||
Assert.False(gamma.Enabled);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AirgapTenant_HasRestrictedSettings()
|
||||
{
|
||||
var tenant = AuthorityTenantSeeder.AirgapTenant;
|
||||
|
||||
Assert.Equal("airgap-test", tenant.Slug);
|
||||
Assert.False(tenant.Settings.AllowExternalConnectors);
|
||||
Assert.True(tenant.Settings.AllowAirgapMode);
|
||||
Assert.Equal("airgap", tenant.Metadata.Environment);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WithDefaultTenant_AddsTenantToSeedSet()
|
||||
{
|
||||
var seeder = new AuthorityTenantSeeder()
|
||||
.WithDefaultTenant();
|
||||
|
||||
var tenants = seeder.GetTenants();
|
||||
|
||||
Assert.Single(tenants);
|
||||
Assert.Equal("test", tenants[0].Slug);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WithMultiTenantFixtures_AddsAllFixtures()
|
||||
{
|
||||
var seeder = new AuthorityTenantSeeder()
|
||||
.WithMultiTenantFixtures();
|
||||
|
||||
var tenants = seeder.GetTenants();
|
||||
var slugs = seeder.GetSlugs();
|
||||
|
||||
Assert.Equal(3, tenants.Count);
|
||||
Assert.Contains("acme", slugs);
|
||||
Assert.Contains("beta", slugs);
|
||||
Assert.Contains("gamma", slugs);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WithTenant_AddsDuplicateSlugOnce()
|
||||
{
|
||||
var seeder = new AuthorityTenantSeeder()
|
||||
.WithDefaultTenant()
|
||||
.WithDefaultTenant(); // Duplicate
|
||||
|
||||
var tenants = seeder.GetTenants();
|
||||
|
||||
Assert.Single(tenants);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WithCustomTenant_AddsToSeedSet()
|
||||
{
|
||||
var customTenant = new TestTenant(
|
||||
Id: Guid.NewGuid(),
|
||||
Slug: "custom",
|
||||
Name: "Custom Tenant",
|
||||
Description: "A custom test tenant",
|
||||
Enabled: true,
|
||||
Settings: TestTenantSettings.Default,
|
||||
Metadata: new TestTenantMetadata("test", "local", "free", ImmutableArray<string>.Empty));
|
||||
|
||||
var seeder = new AuthorityTenantSeeder()
|
||||
.WithTenant(customTenant);
|
||||
|
||||
var tenants = seeder.GetTenants();
|
||||
|
||||
Assert.Single(tenants);
|
||||
Assert.Equal("custom", tenants[0].Slug);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WithTenant_SimpleOverload_CreatesMinimalTenant()
|
||||
{
|
||||
var seeder = new AuthorityTenantSeeder()
|
||||
.WithTenant("simple", "Simple Tenant", enabled: false);
|
||||
|
||||
var tenants = seeder.GetTenants();
|
||||
|
||||
Assert.Single(tenants);
|
||||
Assert.Equal("simple", tenants[0].Slug);
|
||||
Assert.Equal("Simple Tenant", tenants[0].Name);
|
||||
Assert.False(tenants[0].Enabled);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateSql_ProducesValidInsertStatements()
|
||||
{
|
||||
var seeder = new AuthorityTenantSeeder()
|
||||
.WithDefaultTenant();
|
||||
|
||||
var sql = seeder.GenerateSql();
|
||||
|
||||
Assert.Contains("INSERT INTO auth.tenants", sql);
|
||||
Assert.Contains("'test'", sql);
|
||||
Assert.Contains("'Test Tenant'", sql);
|
||||
Assert.Contains("ON CONFLICT (slug) DO NOTHING", sql);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateSql_ReturnsEmptyForNoTenants()
|
||||
{
|
||||
var seeder = new AuthorityTenantSeeder();
|
||||
|
||||
var sql = seeder.GenerateSql();
|
||||
|
||||
Assert.Equal(string.Empty, sql);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateSql_EscapesSingleQuotes()
|
||||
{
|
||||
var tenant = new TestTenant(
|
||||
Id: Guid.NewGuid(),
|
||||
Slug: "test-escape",
|
||||
Name: "O'Reilly's Tenant",
|
||||
Description: "Contains 'quotes'",
|
||||
Enabled: true,
|
||||
Settings: TestTenantSettings.Default,
|
||||
Metadata: TestTenantMetadata.Default);
|
||||
|
||||
var seeder = new AuthorityTenantSeeder()
|
||||
.WithTenant(tenant);
|
||||
|
||||
var sql = seeder.GenerateSql();
|
||||
|
||||
Assert.Contains("O''Reilly''s Tenant", sql);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ChainedBuilderPattern_WorksCorrectly()
|
||||
{
|
||||
var seeder = new AuthorityTenantSeeder()
|
||||
.WithDefaultTenant()
|
||||
.WithMultiTenantFixtures()
|
||||
.WithAirgapTenant()
|
||||
.WithTenant("custom", "Custom");
|
||||
|
||||
var tenants = seeder.GetTenants();
|
||||
|
||||
Assert.Equal(5, tenants.Count); // 1 + 3 + 1 (custom)
|
||||
// Note: airgap tenant is separate
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TestTenantSettings_Default_HasExpectedValues()
|
||||
{
|
||||
var settings = TestTenantSettings.Default;
|
||||
|
||||
Assert.Equal(50, settings.MaxProviders);
|
||||
Assert.Equal(1000, settings.MaxObservationsPerLinkset);
|
||||
Assert.True(settings.AllowExternalConnectors);
|
||||
Assert.False(settings.AllowAirgapMode);
|
||||
Assert.Equal(365, settings.RetentionDays);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TestTenantSettings_Airgap_HasRestrictedValues()
|
||||
{
|
||||
var settings = TestTenantSettings.Airgap;
|
||||
|
||||
Assert.Equal(20, settings.MaxProviders);
|
||||
Assert.Equal(500, settings.MaxObservationsPerLinkset);
|
||||
Assert.False(settings.AllowExternalConnectors);
|
||||
Assert.True(settings.AllowAirgapMode);
|
||||
Assert.Equal(730, settings.RetentionDays);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TestTenantMetadata_Default_HasExpectedValues()
|
||||
{
|
||||
var metadata = TestTenantMetadata.Default;
|
||||
|
||||
Assert.Equal("test", metadata.Environment);
|
||||
Assert.Equal("local", metadata.Region);
|
||||
Assert.Equal("free", metadata.Tier);
|
||||
Assert.Empty(metadata.Features);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MultiTenantFixtures_AcmeHasFeatures()
|
||||
{
|
||||
var acme = AuthorityTenantSeeder.MultiTenantFixtures.Single(t => t.Slug == "acme");
|
||||
|
||||
Assert.Contains("vex-ingestion", acme.Metadata.Features);
|
||||
Assert.Contains("policy-engine", acme.Metadata.Features);
|
||||
Assert.Contains("graph-explorer", acme.Metadata.Features);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user