save progress

This commit is contained in:
StellaOps Bot
2026-01-04 14:54:52 +02:00
parent c49b03a254
commit 3098e84de4
132 changed files with 19783 additions and 31 deletions

View File

@@ -1,5 +1,8 @@
using Microsoft.AspNetCore.Mvc;
using StellaOps.VexLens.Api;
using StellaOps.VexLens.Delta;
using StellaOps.VexLens.NoiseGate;
using StellaOps.VexLens.Storage;
namespace StellaOps.VexLens.WebService.Extensions;
@@ -73,6 +76,32 @@ public static class VexLensEndpointExtensions
.WithDescription("Get projections with conflicts")
.Produces<QueryProjectionsResponse>(StatusCodes.Status200OK);
// Delta/Noise-Gating endpoints
var deltaGroup = app.MapGroup("/api/v1/vexlens/deltas")
.WithTags("VexLens Delta")
.WithOpenApi();
deltaGroup.MapPost("/compute", ComputeDeltaAsync)
.WithName("ComputeDelta")
.WithDescription("Compute delta report between two snapshots")
.Produces<DeltaReportResponse>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status400BadRequest);
var gatingGroup = app.MapGroup("/api/v1/vexlens/gating")
.WithTags("VexLens Gating")
.WithOpenApi();
gatingGroup.MapGet("/statistics", GetGatingStatisticsAsync)
.WithName("GetGatingStatistics")
.WithDescription("Get aggregated noise-gating statistics")
.Produces<AggregatedGatingStatisticsResponse>(StatusCodes.Status200OK);
gatingGroup.MapPost("/snapshots/{snapshotId}/gate", GateSnapshotAsync)
.WithName("GateSnapshot")
.WithDescription("Apply noise-gating to a snapshot")
.Produces<GatedSnapshotResponse>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound);
// Issuer endpoints
var issuerGroup = app.MapGroup("/api/v1/vexlens/issuers")
.WithTags("VexLens Issuers")
@@ -265,6 +294,91 @@ public static class VexLensEndpointExtensions
return Results.Ok(conflictsOnly);
}
// Delta/Noise-Gating handlers
private static async Task<IResult> ComputeDeltaAsync(
[FromBody] ComputeDeltaRequest request,
[FromServices] INoiseGate noiseGate,
[FromServices] ISnapshotStore snapshotStore,
HttpContext context,
CancellationToken cancellationToken)
{
var tenantId = GetTenantId(context) ?? request.TenantId;
// Get snapshots
var fromSnapshot = await snapshotStore.GetAsync(request.FromSnapshotId, tenantId, cancellationToken);
var toSnapshot = await snapshotStore.GetAsync(request.ToSnapshotId, tenantId, cancellationToken);
if (fromSnapshot is null || toSnapshot is null)
{
return Results.BadRequest("One or both snapshot IDs not found");
}
// Compute delta
var options = NoiseGatingApiMapper.MapOptions(request.Options);
var delta = await noiseGate.DiffAsync(fromSnapshot, toSnapshot, options, cancellationToken);
return Results.Ok(NoiseGatingApiMapper.MapToResponse(delta));
}
private static async Task<IResult> GetGatingStatisticsAsync(
[FromQuery] string? tenantId,
[FromQuery] DateTimeOffset? fromDate,
[FromQuery] DateTimeOffset? toDate,
[FromServices] IGatingStatisticsStore statsStore,
HttpContext context,
CancellationToken cancellationToken)
{
var tenant = GetTenantId(context) ?? tenantId;
var stats = await statsStore.GetAggregatedAsync(tenant, fromDate, toDate, cancellationToken);
return Results.Ok(new AggregatedGatingStatisticsResponse(
TotalSnapshots: stats.TotalSnapshots,
TotalEdgesProcessed: stats.TotalEdgesProcessed,
TotalEdgesAfterDedup: stats.TotalEdgesAfterDedup,
AverageEdgeReductionPercent: stats.AverageEdgeReductionPercent,
TotalVerdicts: stats.TotalVerdicts,
TotalSurfaced: stats.TotalSurfaced,
TotalDamped: stats.TotalDamped,
AverageDampingPercent: stats.AverageDampingPercent,
ComputedAt: DateTimeOffset.UtcNow));
}
private static async Task<IResult> GateSnapshotAsync(
string snapshotId,
[FromBody] GateSnapshotRequest request,
[FromServices] INoiseGate noiseGate,
[FromServices] ISnapshotStore snapshotStore,
HttpContext context,
CancellationToken cancellationToken)
{
var tenantId = GetTenantId(context) ?? request.TenantId;
// Get the raw snapshot
var snapshot = await snapshotStore.GetRawAsync(snapshotId, tenantId, cancellationToken);
if (snapshot is null)
{
return Results.NotFound();
}
// Apply noise-gating
var gateRequest = new NoiseGateRequest
{
Graph = snapshot.Graph,
SnapshotId = snapshotId,
Verdicts = snapshot.Verdicts
};
var gatedSnapshot = await noiseGate.GateAsync(gateRequest, cancellationToken);
return Results.Ok(new GatedSnapshotResponse(
SnapshotId: gatedSnapshot.SnapshotId,
Digest: gatedSnapshot.Digest,
CreatedAt: gatedSnapshot.CreatedAt,
EdgeCount: gatedSnapshot.Edges.Count,
VerdictCount: gatedSnapshot.Verdicts.Count,
Statistics: NoiseGatingApiMapper.MapStatistics(gatedSnapshot.Statistics)));
}
// Issuer handlers
private static async Task<IResult> ListIssuersAsync(
[FromQuery] string? category,

View File

@@ -7,6 +7,12 @@ Deliver the VEX Consensus Lens service that normalizes VEX evidence, computes de
- Service code under `src/VexLens/StellaOps.VexLens` (normalizer, mapping, trust weighting, consensus projection, APIs, simulation hooks).
- Batch workers consuming Excitor, Conseiller, SBOM, and policy events; projection storage and caching; telemetry.
- Coordination with Policy Engine, Vuln Explorer, Findings Ledger, Console, CLI, and Docs.
- **NoiseGate** (Sprint NG-001): Unified noise-gating for vulnerability graphs:
- **INoiseGate**: Central interface for noise-gating operations
- **EdgeDeduplicator**: Collapses semantically equivalent edges (uses StellaOps.ReachGraph)
- **StabilityDampingGate**: Hysteresis-based damping (uses StellaOps.Policy.Engine.Gates)
- **DeltaReport**: Typed sections (New, Resolved, ConfidenceUp/Down, PolicyImpact, Damped, EvidenceChanged)
- **DeltaReportBuilder**: Fluent builder for change reports with deterministic output
## Principles
1. **Evidence preserving** never edit or merge raw VEX docs; link via evidence IDs and maintain provenance.

View File

@@ -0,0 +1,205 @@
// Licensed to StellaOps under the AGPL-3.0-or-later license.
using StellaOps.VexLens.Delta;
using StellaOps.VexLens.Models;
using StellaOps.VexLens.NoiseGate;
namespace StellaOps.VexLens.Api;
/// <summary>
/// Request to compute delta between two snapshots.
/// </summary>
public sealed record ComputeDeltaRequest(
string FromSnapshotId,
string ToSnapshotId,
string? TenantId,
DeltaReportOptionsRequest? Options);
/// <summary>
/// Options for delta report computation.
/// </summary>
public sealed record DeltaReportOptionsRequest(
double? ConfidenceChangeThreshold,
bool? IncludeDamped,
bool? IncludeEvidenceChanges);
/// <summary>
/// Response from delta computation.
/// </summary>
public sealed record DeltaReportResponse(
string ReportId,
string FromSnapshotDigest,
string ToSnapshotDigest,
DateTimeOffset GeneratedAt,
IReadOnlyList<DeltaEntryResponse> Entries,
DeltaSummaryResponse Summary,
bool HasActionableChanges);
/// <summary>
/// Summary counts for delta report.
/// </summary>
public sealed record DeltaSummaryResponse(
int TotalCount,
int NewCount,
int ResolvedCount,
int ConfidenceUpCount,
int ConfidenceDownCount,
int PolicyImpactCount,
int DampedCount,
int EvidenceChangedCount);
/// <summary>
/// Single delta entry in API format.
/// </summary>
public sealed record DeltaEntryResponse(
string Section,
string VulnerabilityId,
string ProductKey,
string? FromStatus,
string? ToStatus,
double? FromConfidence,
double? ToConfidence,
string? Justification,
string? FromRationaleClass,
string? ToRationaleClass,
string? Summary,
IReadOnlyList<string>? ContributingSources,
DateTimeOffset CreatedAt);
/// <summary>
/// Request to gate a graph snapshot.
/// </summary>
public sealed record GateSnapshotRequest(
string SnapshotId,
string? TenantId,
NoiseGateOptionsRequest? Options);
/// <summary>
/// Options for noise-gating.
/// </summary>
public sealed record NoiseGateOptionsRequest(
bool? EdgeDeduplicationEnabled,
bool? StabilityDampingEnabled,
double? MinConfidenceThreshold,
double? ConfidenceChangeThreshold);
/// <summary>
/// Response from gating a snapshot.
/// </summary>
public sealed record GatedSnapshotResponse(
string SnapshotId,
string Digest,
DateTimeOffset CreatedAt,
int EdgeCount,
int VerdictCount,
GatingStatisticsResponse Statistics);
/// <summary>
/// Gating statistics for API response.
/// </summary>
public sealed record GatingStatisticsResponse(
int OriginalEdgeCount,
int DeduplicatedEdgeCount,
double EdgeReductionPercent,
int TotalVerdictCount,
int SurfacedVerdictCount,
int DampedVerdictCount,
string Duration);
/// <summary>
/// Request to get aggregated gating statistics.
/// </summary>
public sealed record GatingStatisticsQueryRequest(
string? TenantId,
DateTimeOffset? FromDate,
DateTimeOffset? ToDate);
/// <summary>
/// Aggregated gating statistics response.
/// </summary>
public sealed record AggregatedGatingStatisticsResponse(
int TotalSnapshots,
int TotalEdgesProcessed,
int TotalEdgesAfterDedup,
double AverageEdgeReductionPercent,
int TotalVerdicts,
int TotalSurfaced,
int TotalDamped,
double AverageDampingPercent,
DateTimeOffset ComputedAt);
/// <summary>
/// Maps internal delta models to API responses.
/// </summary>
internal static class NoiseGatingApiMapper
{
public static DeltaReportResponse MapToResponse(DeltaReport report)
{
return new DeltaReportResponse(
ReportId: report.ReportId,
FromSnapshotDigest: report.FromSnapshotDigest,
ToSnapshotDigest: report.ToSnapshotDigest,
GeneratedAt: report.GeneratedAt,
Entries: report.Entries.Select(MapEntry).ToList(),
Summary: MapSummary(report.Summary),
HasActionableChanges: report.HasActionableChanges);
}
public static DeltaSummaryResponse MapSummary(DeltaSummary summary)
{
return new DeltaSummaryResponse(
TotalCount: summary.TotalCount,
NewCount: summary.NewCount,
ResolvedCount: summary.ResolvedCount,
ConfidenceUpCount: summary.ConfidenceUpCount,
ConfidenceDownCount: summary.ConfidenceDownCount,
PolicyImpactCount: summary.PolicyImpactCount,
DampedCount: summary.DampedCount,
EvidenceChangedCount: summary.EvidenceChangedCount);
}
public static DeltaEntryResponse MapEntry(DeltaEntry entry)
{
return new DeltaEntryResponse(
Section: entry.Section.ToString().ToLowerInvariant(),
VulnerabilityId: entry.VulnerabilityId,
ProductKey: entry.ProductKey,
FromStatus: entry.FromStatus?.ToString(),
ToStatus: entry.ToStatus?.ToString(),
FromConfidence: entry.FromConfidence,
ToConfidence: entry.ToConfidence,
Justification: entry.Justification?.ToString(),
FromRationaleClass: entry.FromRationaleClass,
ToRationaleClass: entry.ToRationaleClass,
Summary: entry.Summary,
ContributingSources: entry.ContributingSources?.ToList(),
CreatedAt: entry.Timestamp);
}
public static GatingStatisticsResponse MapStatistics(GatingStatistics stats)
{
return new GatingStatisticsResponse(
OriginalEdgeCount: stats.OriginalEdgeCount,
DeduplicatedEdgeCount: stats.DeduplicatedEdgeCount,
EdgeReductionPercent: stats.EdgeReductionPercent,
TotalVerdictCount: stats.TotalVerdictCount,
SurfacedVerdictCount: stats.SurfacedVerdictCount,
DampedVerdictCount: stats.DampedVerdictCount,
Duration: stats.Duration.ToString("c"));
}
public static DeltaReportOptions MapOptions(DeltaReportOptionsRequest? request)
{
if (request is null)
{
return new DeltaReportOptions();
}
return new DeltaReportOptions
{
ConfidenceChangeThreshold = request.ConfidenceChangeThreshold ?? 0.15,
IncludeDamped = request.IncludeDamped ?? true,
IncludeEvidenceChanges = request.IncludeEvidenceChanges ?? true
};
}
}

View File

@@ -0,0 +1,88 @@
// Licensed to StellaOps under the AGPL-3.0-or-later license.
using System.Collections.Immutable;
using StellaOps.VexLens.Models;
namespace StellaOps.VexLens.Delta;
/// <summary>
/// A single entry in a delta report representing a change between snapshots.
/// </summary>
public sealed record DeltaEntry
{
/// <summary>
/// Gets the unique identifier for this delta entry.
/// </summary>
public required string DeltaId { get; init; }
/// <summary>
/// Gets the vulnerability identifier.
/// </summary>
public required string VulnerabilityId { get; init; }
/// <summary>
/// Gets the product/component key (typically PURL).
/// </summary>
public required string ProductKey { get; init; }
/// <summary>
/// Gets the section this delta belongs to.
/// </summary>
public required DeltaSection Section { get; init; }
/// <summary>
/// Gets the previous VEX status, if any.
/// </summary>
public VexStatus? FromStatus { get; init; }
/// <summary>
/// Gets the current VEX status.
/// </summary>
public required VexStatus ToStatus { get; init; }
/// <summary>
/// Gets the previous confidence score, if any.
/// </summary>
public double? FromConfidence { get; init; }
/// <summary>
/// Gets the current confidence score.
/// </summary>
public required double ToConfidence { get; init; }
/// <summary>
/// Gets the previous rationale class, if any.
/// </summary>
public string? FromRationaleClass { get; init; }
/// <summary>
/// Gets the current rationale class.
/// </summary>
public string? ToRationaleClass { get; init; }
/// <summary>
/// Gets the justification for the current status.
/// </summary>
public VexJustification? Justification { get; init; }
/// <summary>
/// Gets a human-readable summary of the change.
/// </summary>
public required string Summary { get; init; }
/// <summary>
/// Gets the timestamp of this delta.
/// </summary>
public required DateTimeOffset Timestamp { get; init; }
/// <summary>
/// Gets the sources that contributed to this change.
/// </summary>
public ImmutableArray<string> ContributingSources { get; init; } = [];
/// <summary>
/// Gets additional metadata.
/// </summary>
public ImmutableDictionary<string, string> Metadata { get; init; } =
ImmutableDictionary<string, string>.Empty;
}

View File

@@ -0,0 +1,183 @@
// Licensed to StellaOps under the AGPL-3.0-or-later license.
using System.Collections.Immutable;
using System.Globalization;
namespace StellaOps.VexLens.Delta;
/// <summary>
/// A report summarizing changes between two vulnerability graph snapshots.
/// </summary>
/// <remarks>
/// DeltaReport groups changes by section for efficient triage:
/// - Users can focus on New findings first
/// - Resolved items can be quickly acknowledged
/// - Confidence changes help reprioritize existing findings
/// - Policy impacts highlight workflow-affecting changes
/// </remarks>
public sealed record DeltaReport
{
/// <summary>
/// Gets the unique identifier for this report.
/// </summary>
public required string ReportId { get; init; }
/// <summary>
/// Gets the digest of the previous snapshot.
/// </summary>
public required string FromSnapshotDigest { get; init; }
/// <summary>
/// Gets the digest of the current snapshot.
/// </summary>
public required string ToSnapshotDigest { get; init; }
/// <summary>
/// Gets the timestamp when the report was generated.
/// </summary>
public required DateTimeOffset GeneratedAt { get; init; }
/// <summary>
/// Gets all delta entries in this report.
/// </summary>
public required ImmutableArray<DeltaEntry> Entries { get; init; }
/// <summary>
/// Gets the summary counts by section.
/// </summary>
public required DeltaSummary Summary { get; init; }
/// <summary>
/// Gets entries grouped by section for UI consumption.
/// </summary>
public ImmutableDictionary<DeltaSection, ImmutableArray<DeltaEntry>> BySection =>
Entries
.GroupBy(e => e.Section)
.ToImmutableDictionary(
g => g.Key,
g => g.ToImmutableArray());
/// <summary>
/// Gets entries for a specific section.
/// </summary>
public ImmutableArray<DeltaEntry> GetSection(DeltaSection section) =>
BySection.TryGetValue(section, out var entries)
? entries
: [];
/// <summary>
/// Gets whether this report has any actionable changes.
/// </summary>
public bool HasActionableChanges =>
Summary.NewCount > 0 ||
Summary.PolicyImpactCount > 0 ||
Summary.ConfidenceUpCount > 0;
/// <summary>
/// Gets a one-line summary suitable for notifications.
/// </summary>
public string ToNotificationSummary()
{
var parts = new List<string>();
if (Summary.NewCount > 0)
{
parts.Add(string.Create(CultureInfo.InvariantCulture,
$"{Summary.NewCount} new"));
}
if (Summary.ResolvedCount > 0)
{
parts.Add(string.Create(CultureInfo.InvariantCulture,
$"{Summary.ResolvedCount} resolved"));
}
if (Summary.PolicyImpactCount > 0)
{
parts.Add(string.Create(CultureInfo.InvariantCulture,
$"{Summary.PolicyImpactCount} policy impact"));
}
if (Summary.ConfidenceUpCount > 0)
{
parts.Add(string.Create(CultureInfo.InvariantCulture,
$"{Summary.ConfidenceUpCount} confidence up"));
}
if (Summary.ConfidenceDownCount > 0)
{
parts.Add(string.Create(CultureInfo.InvariantCulture,
$"{Summary.ConfidenceDownCount} confidence down"));
}
return parts.Count == 0
? "No significant changes"
: string.Join(", ", parts);
}
}
/// <summary>
/// Summary counts for a delta report.
/// </summary>
public sealed record DeltaSummary
{
/// <summary>
/// Gets the total number of entries.
/// </summary>
public required int TotalCount { get; init; }
/// <summary>
/// Gets the count of new findings.
/// </summary>
public required int NewCount { get; init; }
/// <summary>
/// Gets the count of resolved findings.
/// </summary>
public required int ResolvedCount { get; init; }
/// <summary>
/// Gets the count of confidence increases.
/// </summary>
public required int ConfidenceUpCount { get; init; }
/// <summary>
/// Gets the count of confidence decreases.
/// </summary>
public required int ConfidenceDownCount { get; init; }
/// <summary>
/// Gets the count of policy impact changes.
/// </summary>
public required int PolicyImpactCount { get; init; }
/// <summary>
/// Gets the count of damped (suppressed) changes.
/// </summary>
public int DampedCount { get; init; }
/// <summary>
/// Gets the count of evidence-only changes.
/// </summary>
public int EvidenceChangedCount { get; init; }
/// <summary>
/// Creates a summary from a list of entries.
/// </summary>
public static DeltaSummary FromEntries(IEnumerable<DeltaEntry> entries)
{
var list = entries.ToList();
return new DeltaSummary
{
TotalCount = list.Count,
NewCount = list.Count(e => e.Section == DeltaSection.New),
ResolvedCount = list.Count(e => e.Section == DeltaSection.Resolved),
ConfidenceUpCount = list.Count(e => e.Section == DeltaSection.ConfidenceUp),
ConfidenceDownCount = list.Count(e => e.Section == DeltaSection.ConfidenceDown),
PolicyImpactCount = list.Count(e => e.Section == DeltaSection.PolicyImpact),
DampedCount = list.Count(e => e.Section == DeltaSection.Damped),
EvidenceChangedCount = list.Count(e => e.Section == DeltaSection.EvidenceChanged)
};
}
}

View File

@@ -0,0 +1,347 @@
// Licensed to StellaOps under the AGPL-3.0-or-later license.
using System.Collections.Immutable;
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
using StellaOps.VexLens.Models;
namespace StellaOps.VexLens.Delta;
/// <summary>
/// Options for delta report generation.
/// </summary>
public sealed record DeltaReportOptions
{
/// <summary>
/// Gets or sets the confidence change threshold for triggering ConfidenceUp/Down sections.
/// </summary>
public double ConfidenceChangeThreshold { get; init; } = 0.15;
/// <summary>
/// Gets or sets whether to include damped entries in the report.
/// </summary>
public bool IncludeDamped { get; init; } = false;
/// <summary>
/// Gets or sets whether to include evidence-only changes.
/// </summary>
public bool IncludeEvidenceChanges { get; init; } = true;
}
/// <summary>
/// Builder for creating <see cref="DeltaReport"/> instances.
/// </summary>
public sealed class DeltaReportBuilder
{
private readonly List<DeltaEntry> _entries = new();
private readonly TimeProvider _timeProvider;
private string _fromDigest = string.Empty;
private string _toDigest = string.Empty;
private DeltaReportOptions _options = new();
/// <summary>
/// Initializes a new delta report builder.
/// </summary>
public DeltaReportBuilder(TimeProvider? timeProvider = null)
{
_timeProvider = timeProvider ?? TimeProvider.System;
}
/// <summary>
/// Sets the snapshot digests.
/// </summary>
public DeltaReportBuilder WithSnapshots(string fromDigest, string toDigest)
{
_fromDigest = fromDigest;
_toDigest = toDigest;
return this;
}
/// <summary>
/// Sets the report options.
/// </summary>
public DeltaReportBuilder WithOptions(DeltaReportOptions options)
{
_options = options;
return this;
}
/// <summary>
/// Adds a new finding entry.
/// </summary>
public DeltaReportBuilder AddNew(
string vulnerabilityId,
string productKey,
VexStatus status,
double confidence,
string? rationaleClass = null,
VexJustification? justification = null,
IEnumerable<string>? sources = null)
{
var entry = CreateEntry(
vulnerabilityId,
productKey,
DeltaSection.New,
null,
status,
null,
confidence,
null,
rationaleClass,
justification,
$"New {status} finding",
sources);
_entries.Add(entry);
return this;
}
/// <summary>
/// Adds a resolved finding entry.
/// </summary>
public DeltaReportBuilder AddResolved(
string vulnerabilityId,
string productKey,
VexStatus fromStatus,
VexStatus toStatus,
double fromConfidence,
double toConfidence,
VexJustification? justification = null,
IEnumerable<string>? sources = null)
{
var entry = CreateEntry(
vulnerabilityId,
productKey,
DeltaSection.Resolved,
fromStatus,
toStatus,
fromConfidence,
toConfidence,
null,
null,
justification,
$"Resolved: {fromStatus} -> {toStatus}",
sources);
_entries.Add(entry);
return this;
}
/// <summary>
/// Adds a confidence change entry.
/// </summary>
public DeltaReportBuilder AddConfidenceChange(
string vulnerabilityId,
string productKey,
VexStatus status,
double fromConfidence,
double toConfidence,
IEnumerable<string>? sources = null)
{
var delta = toConfidence - fromConfidence;
var section = delta > 0 ? DeltaSection.ConfidenceUp : DeltaSection.ConfidenceDown;
if (Math.Abs(delta) < _options.ConfidenceChangeThreshold)
{
return this; // Below threshold, don't add
}
var entry = CreateEntry(
vulnerabilityId,
productKey,
section,
status,
status,
fromConfidence,
toConfidence,
null,
null,
null,
string.Create(CultureInfo.InvariantCulture,
$"Confidence {(delta > 0 ? "increased" : "decreased")}: {fromConfidence:P0} -> {toConfidence:P0}"),
sources);
_entries.Add(entry);
return this;
}
/// <summary>
/// Adds a policy impact entry.
/// </summary>
public DeltaReportBuilder AddPolicyImpact(
string vulnerabilityId,
string productKey,
VexStatus status,
double confidence,
string impactDescription,
IEnumerable<string>? sources = null)
{
var entry = CreateEntry(
vulnerabilityId,
productKey,
DeltaSection.PolicyImpact,
status,
status,
confidence,
confidence,
null,
null,
null,
$"Policy impact: {impactDescription}",
sources);
_entries.Add(entry);
return this;
}
/// <summary>
/// Adds a damped (suppressed) entry.
/// </summary>
public DeltaReportBuilder AddDamped(
string vulnerabilityId,
string productKey,
VexStatus fromStatus,
VexStatus toStatus,
double fromConfidence,
double toConfidence,
string dampReason)
{
if (!_options.IncludeDamped)
{
return this;
}
var entry = CreateEntry(
vulnerabilityId,
productKey,
DeltaSection.Damped,
fromStatus,
toStatus,
fromConfidence,
toConfidence,
null,
null,
null,
$"Damped: {dampReason}",
null);
_entries.Add(entry);
return this;
}
/// <summary>
/// Adds an evidence change entry.
/// </summary>
public DeltaReportBuilder AddEvidenceChange(
string vulnerabilityId,
string productKey,
VexStatus status,
double confidence,
string fromRationaleClass,
string toRationaleClass,
IEnumerable<string>? sources = null)
{
if (!_options.IncludeEvidenceChanges)
{
return this;
}
var entry = CreateEntry(
vulnerabilityId,
productKey,
DeltaSection.EvidenceChanged,
status,
status,
confidence,
confidence,
fromRationaleClass,
toRationaleClass,
null,
$"Evidence changed: {fromRationaleClass} -> {toRationaleClass}",
sources);
_entries.Add(entry);
return this;
}
/// <summary>
/// Builds the delta report.
/// </summary>
public DeltaReport Build()
{
var now = _timeProvider.GetUtcNow();
var reportId = GenerateReportId(now);
// Sort entries for deterministic output
var sortedEntries = _entries
.OrderBy(e => (int)e.Section)
.ThenBy(e => e.VulnerabilityId, StringComparer.Ordinal)
.ThenBy(e => e.ProductKey, StringComparer.Ordinal)
.ToImmutableArray();
return new DeltaReport
{
ReportId = reportId,
FromSnapshotDigest = _fromDigest,
ToSnapshotDigest = _toDigest,
GeneratedAt = now,
Entries = sortedEntries,
Summary = DeltaSummary.FromEntries(sortedEntries)
};
}
private DeltaEntry CreateEntry(
string vulnerabilityId,
string productKey,
DeltaSection section,
VexStatus? fromStatus,
VexStatus toStatus,
double? fromConfidence,
double toConfidence,
string? fromRationaleClass,
string? toRationaleClass,
VexJustification? justification,
string summary,
IEnumerable<string>? sources)
{
var now = _timeProvider.GetUtcNow();
var deltaId = ComputeDeltaId(vulnerabilityId, productKey, section, now);
return new DeltaEntry
{
DeltaId = deltaId,
VulnerabilityId = vulnerabilityId,
ProductKey = productKey,
Section = section,
FromStatus = fromStatus,
ToStatus = toStatus,
FromConfidence = fromConfidence,
ToConfidence = toConfidence,
FromRationaleClass = fromRationaleClass,
ToRationaleClass = toRationaleClass,
Justification = justification,
Summary = summary,
Timestamp = now,
ContributingSources = sources?.ToImmutableArray() ?? []
};
}
private static string ComputeDeltaId(
string vulnerabilityId,
string productKey,
DeltaSection section,
DateTimeOffset timestamp)
{
var input = $"{vulnerabilityId}|{productKey}|{section}|{timestamp:O}";
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(input));
return Convert.ToHexStringLower(hash)[..16];
}
private string GenerateReportId(DateTimeOffset timestamp)
{
var input = $"{_fromDigest}|{_toDigest}|{timestamp:O}";
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(input));
return $"delta-{Convert.ToHexStringLower(hash)[..12]}";
}
}

View File

@@ -0,0 +1,86 @@
// Licensed to StellaOps under the AGPL-3.0-or-later license.
using System.Text.Json.Serialization;
namespace StellaOps.VexLens.Delta;
/// <summary>
/// Categorizes a delta entry for UI presentation.
/// </summary>
/// <remarks>
/// Delta sections enable the UI to present changes in a structured way:
/// - New: First-time findings that require attention
/// - Resolved: Issues that are now fixed or determined not to affect
/// - ConfidenceUp/Down: Changes in certainty that may affect prioritization
/// - PolicyImpact: Changes that affect gate decisions or workflow
/// </remarks>
[JsonConverter(typeof(JsonStringEnumConverter<DeltaSection>))]
public enum DeltaSection
{
/// <summary>
/// A new finding that was not present in the previous snapshot.
/// </summary>
/// <remarks>
/// Use when: Status was not present or was under_investigation
/// and is now affected.
/// </remarks>
[JsonPropertyName("new")]
New,
/// <summary>
/// A finding that has been resolved (no longer actionable).
/// </summary>
/// <remarks>
/// Use when: Status changed from affected to not_affected or fixed.
/// </remarks>
[JsonPropertyName("resolved")]
Resolved,
/// <summary>
/// Confidence in an existing finding has increased significantly.
/// </summary>
/// <remarks>
/// Use when: Same status but confidence increased by threshold amount.
/// </remarks>
[JsonPropertyName("confidence_up")]
ConfidenceUp,
/// <summary>
/// Confidence in an existing finding has decreased significantly.
/// </summary>
/// <remarks>
/// Use when: Same status but confidence decreased by threshold amount.
/// </remarks>
[JsonPropertyName("confidence_down")]
ConfidenceDown,
/// <summary>
/// A change that affects policy gate decisions.
/// </summary>
/// <remarks>
/// Use when: Gate decision changed (pass -> fail, warn -> block, etc.)
/// even if underlying status/confidence didn't change significantly.
/// </remarks>
[JsonPropertyName("policy_impact")]
PolicyImpact,
/// <summary>
/// A finding that was damped and not surfaced.
/// </summary>
/// <remarks>
/// Use when: Change would normally surface but was suppressed by
/// stability damping. Only included when verbose mode is enabled.
/// </remarks>
[JsonPropertyName("damped")]
Damped,
/// <summary>
/// A finding where the rationale class changed.
/// </summary>
/// <remarks>
/// Use when: Evidence authority changed (e.g., heuristic -> authoritative)
/// even if status didn't change.
/// </remarks>
[JsonPropertyName("evidence_changed")]
EvidenceChanged
}

View File

@@ -4,16 +4,19 @@ using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Npgsql;
using StellaOps.Policy.Engine.Gates;
using StellaOps.ReachGraph.Deduplication;
using StellaOps.VexLens.Api;
using StellaOps.VexLens.Caching;
using StellaOps.VexLens.Consensus;
using StellaOps.VexLens.Export;
using StellaOps.VexLens.Integration;
using StellaOps.VexLens.Orchestration;
using StellaOps.VexLens.Mapping;
using StellaOps.VexLens.NoiseGate;
using StellaOps.VexLens.Normalization;
using StellaOps.VexLens.Observability;
using StellaOps.VexLens.Options;
using StellaOps.VexLens.Orchestration;
using StellaOps.VexLens.Storage;
using StellaOps.VexLens.Trust;
using StellaOps.VexLens.Trust.SourceTrust;
@@ -110,6 +113,9 @@ public static class VexLensServiceCollectionExtensions
// Consensus engine
services.TryAddSingleton<IVexConsensusEngine, VexConsensusEngine>();
// Noise-gating services (Sprint: NG-001)
RegisterNoiseGating(services, options);
// Storage
RegisterStorage(services, options.Storage);
@@ -292,4 +298,70 @@ public static class VexLensServiceCollectionExtensions
dualWriteLogger);
});
}
/// <summary>
/// Registers noise-gating services.
/// Sprint: SPRINT_20260104_001_BE_adaptive_noise_gating (NG-001)
/// </summary>
private static void RegisterNoiseGating(
IServiceCollection services,
VexLensOptions options)
{
// Configure NoiseGateOptions
services.TryAddSingleton(sp =>
{
var noiseGateOptions = new NoiseGateOptions();
return Microsoft.Extensions.Options.Options.Create(noiseGateOptions);
});
// TimeProvider for deterministic time handling
services.TryAddSingleton(TimeProvider.System);
// Edge deduplication
services.TryAddSingleton<IEdgeDeduplicator, EdgeDeduplicator>();
// Stability damping gate
services.TryAddSingleton<IOptionsMonitor<StabilityDampingOptions>>(sp =>
{
var dampingOptions = new StabilityDampingOptions();
return new OptionsMonitorAdapter<StabilityDampingOptions>(dampingOptions);
});
services.TryAddSingleton<IStabilityDampingGate, StabilityDampingGate>();
// Noise gate service
services.TryAddSingleton<IOptionsMonitor<NoiseGateOptions>>(sp =>
{
var opts = sp.GetRequiredService<IOptions<NoiseGateOptions>>();
return new OptionsMonitorAdapter<NoiseGateOptions>(opts.Value);
});
services.TryAddSingleton<INoiseGate, NoiseGateService>();
// Snapshot and statistics storage (Sprint: NG-FE-001)
services.TryAddSingleton<ISnapshotStore, InMemorySnapshotStore>();
services.TryAddSingleton<IGatingStatisticsStore>(sp =>
{
var timeProvider = sp.GetRequiredService<TimeProvider>();
return new InMemoryGatingStatisticsStore(timeProvider);
});
}
}
/// <summary>
/// Simple adapter to convert IOptions to IOptionsMonitor for singleton services.
/// </summary>
internal sealed class OptionsMonitorAdapter<T> : IOptionsMonitor<T>
where T : class
{
private readonly T _value;
public OptionsMonitorAdapter(T value)
{
_value = value ?? throw new ArgumentNullException(nameof(value));
}
public T CurrentValue => _value;
public T Get(string? name) => _value;
public IDisposable? OnChange(Action<T, string?> listener) => null;
}

View File

@@ -0,0 +1,310 @@
// Licensed to StellaOps under the AGPL-3.0-or-later license.
using System.Collections.Immutable;
using StellaOps.ReachGraph.Deduplication;
using StellaOps.ReachGraph.Schema;
using StellaOps.VexLens.Delta;
using StellaOps.VexLens.Models;
namespace StellaOps.VexLens.NoiseGate;
/// <summary>
/// Central interface for noise-gating operations on vulnerability graphs.
/// </summary>
/// <remarks>
/// The noise gate provides three core capabilities:
/// <list type="bullet">
/// <item>Edge deduplication: Collapses semantically equivalent edges from multiple sources</item>
/// <item>Verdict resolution: Applies stability damping to prevent flip-flopping</item>
/// <item>Delta reporting: Computes meaningful changes between snapshots</item>
/// </list>
/// </remarks>
public interface INoiseGate
{
/// <summary>
/// Deduplicates edges based on semantic equivalence.
/// </summary>
/// <param name="edges">The edges to deduplicate.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Deduplicated edges with merged provenance.</returns>
Task<IReadOnlyList<DeduplicatedEdge>> DedupeEdgesAsync(
IReadOnlyList<ReachGraphEdge> edges,
CancellationToken cancellationToken = default);
/// <summary>
/// Resolves a verdict by applying stability damping.
/// </summary>
/// <param name="request">The verdict resolution request.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The resolved verdict with damping decision.</returns>
Task<ResolvedVerdict> ResolveVerdictAsync(
VerdictResolutionRequest request,
CancellationToken cancellationToken = default);
/// <summary>
/// Applies noise-gating to a graph snapshot.
/// </summary>
/// <param name="request">The gating request.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The gated graph snapshot.</returns>
Task<GatedGraphSnapshot> GateAsync(
NoiseGateRequest request,
CancellationToken cancellationToken = default);
/// <summary>
/// Computes a delta report between two snapshots.
/// </summary>
/// <param name="fromSnapshot">The previous snapshot.</param>
/// <param name="toSnapshot">The current snapshot.</param>
/// <param name="options">Optional report options.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The delta report.</returns>
Task<DeltaReport> DiffAsync(
GatedGraphSnapshot fromSnapshot,
GatedGraphSnapshot toSnapshot,
DeltaReportOptions? options = null,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Request to resolve a verdict with stability damping.
/// </summary>
public sealed record VerdictResolutionRequest
{
/// <summary>
/// Gets the unique key for this verdict (e.g., "artifact:cve").
/// </summary>
public required string Key { get; init; }
/// <summary>
/// Gets the vulnerability ID.
/// </summary>
public required string VulnerabilityId { get; init; }
/// <summary>
/// Gets the product key (PURL or other identifier).
/// </summary>
public required string ProductKey { get; init; }
/// <summary>
/// Gets the proposed VEX status.
/// </summary>
public required VexStatus ProposedStatus { get; init; }
/// <summary>
/// Gets the proposed confidence score.
/// </summary>
public required double ProposedConfidence { get; init; }
/// <summary>
/// Gets the rationale class for the verdict.
/// </summary>
public string? RationaleClass { get; init; }
/// <summary>
/// Gets the justification for the verdict.
/// </summary>
public VexJustification? Justification { get; init; }
/// <summary>
/// Gets the contributing sources.
/// </summary>
public IReadOnlyList<string>? ContributingSources { get; init; }
/// <summary>
/// Gets the tenant ID for multi-tenant deployments.
/// </summary>
public string? TenantId { get; init; }
}
/// <summary>
/// Result of verdict resolution with damping decision.
/// </summary>
public sealed record ResolvedVerdict
{
/// <summary>
/// Gets the vulnerability ID.
/// </summary>
public required string VulnerabilityId { get; init; }
/// <summary>
/// Gets the product key.
/// </summary>
public required string ProductKey { get; init; }
/// <summary>
/// Gets the final VEX status.
/// </summary>
public required VexStatus Status { get; init; }
/// <summary>
/// Gets the final confidence score.
/// </summary>
public required double Confidence { get; init; }
/// <summary>
/// Gets the rationale class.
/// </summary>
public string? RationaleClass { get; init; }
/// <summary>
/// Gets the justification.
/// </summary>
public VexJustification? Justification { get; init; }
/// <summary>
/// Gets whether the verdict was surfaced (not damped).
/// </summary>
public required bool WasSurfaced { get; init; }
/// <summary>
/// Gets the damping reason if applicable.
/// </summary>
public string? DampingReason { get; init; }
/// <summary>
/// Gets the previous status if available.
/// </summary>
public VexStatus? PreviousStatus { get; init; }
/// <summary>
/// Gets the previous confidence if available.
/// </summary>
public double? PreviousConfidence { get; init; }
/// <summary>
/// Gets the contributing sources.
/// </summary>
public ImmutableArray<string> ContributingSources { get; init; } = [];
/// <summary>
/// Gets the timestamp of resolution.
/// </summary>
public required DateTimeOffset ResolvedAt { get; init; }
}
/// <summary>
/// Request to apply noise-gating to a graph.
/// </summary>
public sealed record NoiseGateRequest
{
/// <summary>
/// Gets the reachability graph to gate.
/// </summary>
public required ReachGraphMinimal Graph { get; init; }
/// <summary>
/// Gets the verdicts to include.
/// </summary>
public required IReadOnlyList<VerdictResolutionRequest> Verdicts { get; init; }
/// <summary>
/// Gets the snapshot ID.
/// </summary>
public required string SnapshotId { get; init; }
/// <summary>
/// Gets the tenant ID.
/// </summary>
public string? TenantId { get; init; }
/// <summary>
/// Gets whether to compute a previous snapshot diff.
/// </summary>
public bool ComputeDiff { get; init; } = true;
/// <summary>
/// Gets the previous snapshot ID for diff computation.
/// </summary>
public string? PreviousSnapshotId { get; init; }
}
/// <summary>
/// A gated graph snapshot with deduplicated edges and resolved verdicts.
/// </summary>
public sealed record GatedGraphSnapshot
{
/// <summary>
/// Gets the unique snapshot identifier.
/// </summary>
public required string SnapshotId { get; init; }
/// <summary>
/// Gets the snapshot digest for integrity verification.
/// </summary>
public required string Digest { get; init; }
/// <summary>
/// Gets the artifact this snapshot describes.
/// </summary>
public required ReachGraphArtifact Artifact { get; init; }
/// <summary>
/// Gets the deduplicated edges.
/// </summary>
public required ImmutableArray<DeduplicatedEdge> Edges { get; init; }
/// <summary>
/// Gets the resolved verdicts.
/// </summary>
public required ImmutableArray<ResolvedVerdict> Verdicts { get; init; }
/// <summary>
/// Gets the verdicts that were damped (not surfaced).
/// </summary>
public ImmutableArray<ResolvedVerdict> DampedVerdicts { get; init; } = [];
/// <summary>
/// Gets the timestamp when this snapshot was created.
/// </summary>
public required DateTimeOffset CreatedAt { get; init; }
/// <summary>
/// Gets the gating statistics.
/// </summary>
public required GatingStatistics Statistics { get; init; }
}
/// <summary>
/// Statistics from a noise-gating operation.
/// </summary>
public sealed record GatingStatistics
{
/// <summary>
/// Gets the original edge count before deduplication.
/// </summary>
public required int OriginalEdgeCount { get; init; }
/// <summary>
/// Gets the edge count after deduplication.
/// </summary>
public required int DeduplicatedEdgeCount { get; init; }
/// <summary>
/// Gets the edge reduction percentage.
/// </summary>
public double EdgeReductionPercent =>
OriginalEdgeCount > 0
? (1.0 - (double)DeduplicatedEdgeCount / OriginalEdgeCount) * 100.0
: 0.0;
/// <summary>
/// Gets the total verdict count.
/// </summary>
public required int TotalVerdictCount { get; init; }
/// <summary>
/// Gets the surfaced verdict count.
/// </summary>
public required int SurfacedVerdictCount { get; init; }
/// <summary>
/// Gets the damped verdict count.
/// </summary>
public required int DampedVerdictCount { get; init; }
/// <summary>
/// Gets the duration of the gating operation.
/// </summary>
public required TimeSpan Duration { get; init; }
}

View File

@@ -0,0 +1,122 @@
// Licensed to StellaOps under the AGPL-3.0-or-later license.
namespace StellaOps.VexLens.NoiseGate;
/// <summary>
/// Configuration options for noise-gating in VexLens.
/// </summary>
public sealed class NoiseGateOptions
{
/// <summary>
/// Configuration section name.
/// </summary>
public const string SectionName = "VexLens:NoiseGate";
/// <summary>
/// Gets or sets whether noise-gating is enabled.
/// </summary>
public bool Enabled { get; set; } = true;
/// <summary>
/// Gets or sets whether edge deduplication is enabled.
/// </summary>
public bool EdgeDeduplicationEnabled { get; set; } = true;
/// <summary>
/// Gets or sets whether stability damping is enabled.
/// </summary>
public bool StabilityDampingEnabled { get; set; } = true;
/// <summary>
/// Gets or sets whether delta reports should include damped entries.
/// </summary>
public bool IncludeDampedInDelta { get; set; } = false;
/// <summary>
/// Gets or sets the minimum confidence threshold for including verdicts.
/// Verdicts below this threshold are excluded from output.
/// </summary>
public double MinConfidenceThreshold { get; set; } = 0.0;
/// <summary>
/// Gets or sets the confidence change threshold for triggering delta sections.
/// </summary>
public double ConfidenceChangeThreshold { get; set; } = 0.15;
/// <summary>
/// Gets or sets whether to collapse semantically equivalent edges.
/// </summary>
public bool CollapseEquivalentEdges { get; set; } = true;
/// <summary>
/// Gets or sets the maximum number of edges to process per operation.
/// </summary>
public int MaxEdgesPerOperation { get; set; } = 100_000;
/// <summary>
/// Gets or sets the maximum number of verdicts to process per operation.
/// </summary>
public int MaxVerdictsPerOperation { get; set; } = 50_000;
/// <summary>
/// Gets or sets whether to log noise-gating decisions.
/// </summary>
public bool LogDecisions { get; set; } = false;
/// <summary>
/// Gets or sets the snapshot retention for delta computation.
/// </summary>
public TimeSpan SnapshotRetention { get; set; } = TimeSpan.FromDays(30);
/// <summary>
/// Gets or sets options specific to edge deduplication.
/// </summary>
public EdgeDeduplicationOptions EdgeDeduplication { get; set; } = new();
/// <summary>
/// Gets or sets options specific to stability damping.
/// </summary>
public StabilityDampingSettings StabilityDamping { get; set; } = new();
}
/// <summary>
/// Options for edge deduplication.
/// </summary>
public sealed class EdgeDeduplicationOptions
{
/// <summary>
/// Gets or sets the minimum provenance count to consider an edge reliable.
/// </summary>
public int MinProvenanceCount { get; set; } = 1;
/// <summary>
/// Gets or sets whether to merge provenance from deduplicated edges.
/// </summary>
public bool MergeProvenance { get; set; } = true;
/// <summary>
/// Gets or sets whether to use the highest confidence from merged edges.
/// </summary>
public bool UseHighestConfidence { get; set; } = true;
}
/// <summary>
/// Settings for stability damping within noise-gating.
/// </summary>
public sealed class StabilityDampingSettings
{
/// <summary>
/// Gets or sets the minimum duration before a state change is surfaced.
/// </summary>
public TimeSpan MinDurationBeforeChange { get; set; } = TimeSpan.FromHours(4);
/// <summary>
/// Gets or sets the minimum confidence delta for immediate surfacing.
/// </summary>
public double MinConfidenceDeltaPercent { get; set; } = 0.15;
/// <summary>
/// Gets or sets whether to only damp downgrades (not upgrades).
/// </summary>
public bool OnlyDampDowngrades { get; set; } = true;
}

View File

@@ -0,0 +1,471 @@
// Licensed to StellaOps under the AGPL-3.0-or-later license.
using System.Collections.Immutable;
using System.Diagnostics;
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Policy.Engine.Gates;
using StellaOps.ReachGraph.Deduplication;
using StellaOps.ReachGraph.Schema;
using StellaOps.VexLens.Delta;
using StellaOps.VexLens.Models;
namespace StellaOps.VexLens.NoiseGate;
/// <summary>
/// Implementation of <see cref="INoiseGate"/> that integrates edge deduplication,
/// stability damping, and delta report generation.
/// </summary>
public sealed class NoiseGateService : INoiseGate
{
private readonly IEdgeDeduplicator _edgeDeduplicator;
private readonly IStabilityDampingGate _stabilityDampingGate;
private readonly IOptionsMonitor<NoiseGateOptions> _options;
private readonly TimeProvider _timeProvider;
private readonly ILogger<NoiseGateService> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="NoiseGateService"/> class.
/// </summary>
public NoiseGateService(
IEdgeDeduplicator edgeDeduplicator,
IStabilityDampingGate stabilityDampingGate,
IOptionsMonitor<NoiseGateOptions> options,
TimeProvider timeProvider,
ILogger<NoiseGateService> logger)
{
_edgeDeduplicator = edgeDeduplicator ?? throw new ArgumentNullException(nameof(edgeDeduplicator));
_stabilityDampingGate = stabilityDampingGate ?? throw new ArgumentNullException(nameof(stabilityDampingGate));
_options = options ?? throw new ArgumentNullException(nameof(options));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <inheritdoc/>
public Task<IReadOnlyList<DeduplicatedEdge>> DedupeEdgesAsync(
IReadOnlyList<ReachGraphEdge> edges,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(edges);
var opts = _options.CurrentValue;
if (!opts.EdgeDeduplicationEnabled || !opts.Enabled)
{
// Return edges without deduplication - create minimal deduplicated wrappers
var passthrough = edges.Select(e => new DeduplicatedEdgeBuilder(
e.From, e.To, e.Why.Type, e.Why.Loc)
.WithConfidence(e.Why.Confidence)
.Build())
.ToList();
return Task.FromResult<IReadOnlyList<DeduplicatedEdge>>(passthrough);
}
var result = _edgeDeduplicator.Deduplicate(edges);
return Task.FromResult(result);
}
/// <inheritdoc/>
public async Task<ResolvedVerdict> ResolveVerdictAsync(
VerdictResolutionRequest request,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
var opts = _options.CurrentValue;
var now = _timeProvider.GetUtcNow();
// If stability damping is disabled, pass through
if (!opts.StabilityDampingEnabled || !opts.Enabled)
{
return new ResolvedVerdict
{
VulnerabilityId = request.VulnerabilityId,
ProductKey = request.ProductKey,
Status = request.ProposedStatus,
Confidence = request.ProposedConfidence,
RationaleClass = request.RationaleClass,
Justification = request.Justification,
WasSurfaced = true,
DampingReason = null,
ContributingSources = request.ContributingSources?.ToImmutableArray() ?? [],
ResolvedAt = now
};
}
// Evaluate with stability damping gate
var dampingRequest = new StabilityDampingRequest
{
Key = request.Key,
TenantId = request.TenantId,
ProposedState = new VerdictState
{
Status = VexStatusToString(request.ProposedStatus),
Confidence = request.ProposedConfidence,
Timestamp = now,
RationaleClass = request.RationaleClass,
SourceId = request.ContributingSources?.FirstOrDefault()
}
};
var decision = await _stabilityDampingGate.EvaluateAsync(dampingRequest, cancellationToken)
.ConfigureAwait(false);
// Record the new state if surfaced
if (decision.ShouldSurface)
{
await _stabilityDampingGate.RecordStateAsync(
request.Key,
dampingRequest.ProposedState,
cancellationToken).ConfigureAwait(false);
}
if (opts.LogDecisions)
{
_logger.LogDebug(
"Verdict resolution for {Key}: surfaced={Surfaced}, reason={Reason}",
request.Key,
decision.ShouldSurface,
decision.Reason);
}
return new ResolvedVerdict
{
VulnerabilityId = request.VulnerabilityId,
ProductKey = request.ProductKey,
Status = request.ProposedStatus,
Confidence = request.ProposedConfidence,
RationaleClass = request.RationaleClass,
Justification = request.Justification,
WasSurfaced = decision.ShouldSurface,
DampingReason = decision.ShouldSurface ? null : decision.Reason,
PreviousStatus = decision.PreviousState != null
? ParseVexStatus(decision.PreviousState.Status)
: null,
PreviousConfidence = decision.PreviousState?.Confidence,
ContributingSources = request.ContributingSources?.ToImmutableArray() ?? [],
ResolvedAt = now
};
}
/// <inheritdoc/>
public async Task<GatedGraphSnapshot> GateAsync(
NoiseGateRequest request,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
var opts = _options.CurrentValue;
var now = _timeProvider.GetUtcNow();
var stopwatch = Stopwatch.StartNew();
// Validate limits
if (request.Graph.Edges.Length > opts.MaxEdgesPerOperation)
{
throw new InvalidOperationException(string.Create(CultureInfo.InvariantCulture,
$"Edge count {request.Graph.Edges.Length} exceeds maximum {opts.MaxEdgesPerOperation}"));
}
if (request.Verdicts.Count > opts.MaxVerdictsPerOperation)
{
throw new InvalidOperationException(string.Create(CultureInfo.InvariantCulture,
$"Verdict count {request.Verdicts.Count} exceeds maximum {opts.MaxVerdictsPerOperation}"));
}
// Deduplicate edges
var edges = request.Graph.Edges.ToList();
var deduplicatedEdges = await DedupeEdgesAsync(edges, cancellationToken).ConfigureAwait(false);
// Resolve verdicts
var surfacedVerdicts = new List<ResolvedVerdict>();
var dampedVerdicts = new List<ResolvedVerdict>();
foreach (var verdictRequest in request.Verdicts)
{
var resolved = await ResolveVerdictAsync(verdictRequest, cancellationToken).ConfigureAwait(false);
if (resolved.WasSurfaced)
{
// Apply confidence threshold
if (resolved.Confidence >= opts.MinConfidenceThreshold)
{
surfacedVerdicts.Add(resolved);
}
else if (opts.IncludeDampedInDelta)
{
dampedVerdicts.Add(resolved with
{
WasSurfaced = false,
DampingReason = string.Create(CultureInfo.InvariantCulture,
$"Confidence {resolved.Confidence:P1} below threshold {opts.MinConfidenceThreshold:P1}")
});
}
}
else
{
dampedVerdicts.Add(resolved);
}
}
stopwatch.Stop();
// Compute snapshot digest
var digest = ComputeSnapshotDigest(
request.SnapshotId,
deduplicatedEdges,
surfacedVerdicts,
now);
var statistics = new GatingStatistics
{
OriginalEdgeCount = edges.Count,
DeduplicatedEdgeCount = deduplicatedEdges.Count,
TotalVerdictCount = request.Verdicts.Count,
SurfacedVerdictCount = surfacedVerdicts.Count,
DampedVerdictCount = dampedVerdicts.Count,
Duration = stopwatch.Elapsed
};
_logger.LogInformation(
"Gated snapshot {SnapshotId}: edges {OriginalEdges}->{DeduplicatedEdges} ({Reduction:F1}% reduction), " +
"verdicts {Surfaced}/{Total} surfaced, {Damped} damped in {Duration}ms",
request.SnapshotId,
statistics.OriginalEdgeCount,
statistics.DeduplicatedEdgeCount,
statistics.EdgeReductionPercent,
statistics.SurfacedVerdictCount,
statistics.TotalVerdictCount,
statistics.DampedVerdictCount,
statistics.Duration.TotalMilliseconds);
return new GatedGraphSnapshot
{
SnapshotId = request.SnapshotId,
Digest = digest,
Artifact = request.Graph.Artifact,
Edges = deduplicatedEdges.ToImmutableArray(),
Verdicts = surfacedVerdicts.ToImmutableArray(),
DampedVerdicts = opts.IncludeDampedInDelta
? dampedVerdicts.ToImmutableArray()
: [],
CreatedAt = now,
Statistics = statistics
};
}
/// <inheritdoc/>
public Task<DeltaReport> DiffAsync(
GatedGraphSnapshot fromSnapshot,
GatedGraphSnapshot toSnapshot,
DeltaReportOptions? options = null,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(fromSnapshot);
ArgumentNullException.ThrowIfNull(toSnapshot);
var opts = _options.CurrentValue;
var reportOptions = options ?? new DeltaReportOptions
{
ConfidenceChangeThreshold = opts.ConfidenceChangeThreshold,
IncludeDamped = opts.IncludeDampedInDelta,
IncludeEvidenceChanges = true
};
var builder = new DeltaReportBuilder(_timeProvider)
.WithSnapshots(fromSnapshot.Digest, toSnapshot.Digest)
.WithOptions(reportOptions);
// Index previous verdicts by key
var previousVerdicts = fromSnapshot.Verdicts
.ToDictionary(
v => $"{v.VulnerabilityId}|{v.ProductKey}",
v => v,
StringComparer.Ordinal);
// Process current verdicts
foreach (var current in toSnapshot.Verdicts)
{
var key = $"{current.VulnerabilityId}|{current.ProductKey}";
if (!previousVerdicts.TryGetValue(key, out var previous))
{
// New finding
builder.AddNew(
current.VulnerabilityId,
current.ProductKey,
current.Status,
current.Confidence,
current.RationaleClass,
current.Justification,
current.ContributingSources);
}
else
{
// Existing finding - check for changes
previousVerdicts.Remove(key); // Mark as processed
var statusChanged = current.Status != previous.Status;
var confidenceChanged = Math.Abs(current.Confidence - previous.Confidence) >= reportOptions.ConfidenceChangeThreshold;
var rationaleChanged = !string.Equals(current.RationaleClass, previous.RationaleClass, StringComparison.Ordinal);
if (statusChanged)
{
// Check if resolved
if (IsResolved(current.Status) && !IsResolved(previous.Status))
{
builder.AddResolved(
current.VulnerabilityId,
current.ProductKey,
previous.Status,
current.Status,
previous.Confidence,
current.Confidence,
current.Justification,
current.ContributingSources);
}
else
{
// Status change but not resolved - treat as policy impact
builder.AddPolicyImpact(
current.VulnerabilityId,
current.ProductKey,
current.Status,
current.Confidence,
string.Create(CultureInfo.InvariantCulture,
$"Status changed: {previous.Status} -> {current.Status}"),
current.ContributingSources);
}
}
else if (confidenceChanged)
{
builder.AddConfidenceChange(
current.VulnerabilityId,
current.ProductKey,
current.Status,
previous.Confidence,
current.Confidence,
current.ContributingSources);
}
else if (rationaleChanged && !string.IsNullOrEmpty(current.RationaleClass))
{
builder.AddEvidenceChange(
current.VulnerabilityId,
current.ProductKey,
current.Status,
current.Confidence,
previous.RationaleClass ?? "unknown",
current.RationaleClass,
current.ContributingSources);
}
}
}
// Remaining previous verdicts are no longer present - they resolved
foreach (var (key, previous) in previousVerdicts)
{
// Treat as resolved (no longer affected)
builder.AddResolved(
previous.VulnerabilityId,
previous.ProductKey,
previous.Status,
VexStatus.NotAffected, // Assumed resolved
previous.Confidence,
1.0, // High confidence in removal
VexJustification.VulnerableCodeNotPresent,
null);
}
// Add damped entries if configured
if (reportOptions.IncludeDamped)
{
foreach (var damped in toSnapshot.DampedVerdicts)
{
if (damped.PreviousStatus.HasValue)
{
builder.AddDamped(
damped.VulnerabilityId,
damped.ProductKey,
damped.PreviousStatus.Value,
damped.Status,
damped.PreviousConfidence ?? 0.0,
damped.Confidence,
damped.DampingReason ?? "Unknown");
}
}
}
return Task.FromResult(builder.Build());
}
private static bool IsResolved(VexStatus status) =>
status == VexStatus.NotAffected || status == VexStatus.Fixed;
private static string VexStatusToString(VexStatus status) => status switch
{
VexStatus.Affected => "affected",
VexStatus.NotAffected => "not_affected",
VexStatus.Fixed => "fixed",
VexStatus.UnderInvestigation => "under_investigation",
_ => "unknown"
};
private static VexStatus? ParseVexStatus(string status) =>
status?.ToLowerInvariant() switch
{
"affected" => VexStatus.Affected,
"not_affected" => VexStatus.NotAffected,
"fixed" => VexStatus.Fixed,
"under_investigation" => VexStatus.UnderInvestigation,
_ => null
};
private static string ComputeSnapshotDigest(
string snapshotId,
IReadOnlyList<DeduplicatedEdge> edges,
IReadOnlyList<ResolvedVerdict> verdicts,
DateTimeOffset timestamp)
{
// Build deterministic input for digest
var sb = new StringBuilder();
sb.Append(snapshotId);
sb.Append('|');
sb.Append(timestamp.ToUniversalTime().ToString("O", CultureInfo.InvariantCulture));
// Add sorted edges
var sortedEdges = edges
.OrderBy(e => e.SemanticKey, StringComparer.Ordinal)
.ToList();
foreach (var edge in sortedEdges)
{
sb.Append('|');
sb.Append(edge.SemanticKey);
}
// Add sorted verdicts
var sortedVerdicts = verdicts
.OrderBy(v => v.VulnerabilityId, StringComparer.Ordinal)
.ThenBy(v => v.ProductKey, StringComparer.Ordinal)
.ToList();
foreach (var verdict in sortedVerdicts)
{
sb.Append('|');
sb.Append(verdict.VulnerabilityId);
sb.Append(':');
sb.Append(verdict.ProductKey);
sb.Append(':');
sb.Append(VexStatusToString(verdict.Status));
sb.Append(':');
sb.Append(verdict.Confidence.ToString("F4", CultureInfo.InvariantCulture));
}
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(sb.ToString()));
return $"sha256:{Convert.ToHexStringLower(hash)}";
}
}

View File

@@ -27,6 +27,9 @@
<!-- VEX delta repository and models from Excititor -->
<ProjectReference Include="..\..\Excititor\__Libraries\StellaOps.Excititor.Core\StellaOps.Excititor.Core.csproj" />
<ProjectReference Include="..\..\Excititor\__Libraries\StellaOps.Excititor.Persistence\StellaOps.Excititor.Persistence.csproj" />
<!-- NG-001: Noise-gating dependencies -->
<ProjectReference Include="..\..\__Libraries\StellaOps.ReachGraph\StellaOps.ReachGraph.csproj" />
<ProjectReference Include="..\..\Policy\StellaOps.Policy.Engine\StellaOps.Policy.Engine.csproj" />
</ItemGroup>
<!-- Exclude legacy folders with external dependencies -->

View File

@@ -0,0 +1,99 @@
// Licensed to StellaOps under the AGPL-3.0-or-later license.
using StellaOps.VexLens.NoiseGate;
namespace StellaOps.VexLens.Storage;
/// <summary>
/// Store for aggregated gating statistics.
/// </summary>
public interface IGatingStatisticsStore
{
/// <summary>
/// Records statistics from a gating operation.
/// </summary>
/// <param name="snapshotId">The snapshot ID.</param>
/// <param name="statistics">The gating statistics.</param>
/// <param name="tenantId">Optional tenant ID.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task RecordAsync(
string snapshotId,
GatingStatistics statistics,
string? tenantId = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets aggregated statistics for a time range.
/// </summary>
/// <param name="tenantId">Optional tenant ID.</param>
/// <param name="fromDate">Start date filter.</param>
/// <param name="toDate">End date filter.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Aggregated statistics.</returns>
Task<AggregatedGatingStatistics> GetAggregatedAsync(
string? tenantId = null,
DateTimeOffset? fromDate = null,
DateTimeOffset? toDate = null,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Aggregated gating statistics across multiple snapshots.
/// </summary>
public sealed record AggregatedGatingStatistics
{
/// <summary>
/// Gets the total number of snapshots processed.
/// </summary>
public required int TotalSnapshots { get; init; }
/// <summary>
/// Gets the total edges processed.
/// </summary>
public required int TotalEdgesProcessed { get; init; }
/// <summary>
/// Gets the total edges after deduplication.
/// </summary>
public required int TotalEdgesAfterDedup { get; init; }
/// <summary>
/// Gets the average edge reduction percentage.
/// </summary>
public required double AverageEdgeReductionPercent { get; init; }
/// <summary>
/// Gets the total verdicts processed.
/// </summary>
public required int TotalVerdicts { get; init; }
/// <summary>
/// Gets the total surfaced verdicts.
/// </summary>
public required int TotalSurfaced { get; init; }
/// <summary>
/// Gets the total damped verdicts.
/// </summary>
public required int TotalDamped { get; init; }
/// <summary>
/// Gets the average damping percentage.
/// </summary>
public required double AverageDampingPercent { get; init; }
/// <summary>
/// Empty statistics for when no data exists.
/// </summary>
public static AggregatedGatingStatistics Empty => new()
{
TotalSnapshots = 0,
TotalEdgesProcessed = 0,
TotalEdgesAfterDedup = 0,
AverageEdgeReductionPercent = 0,
TotalVerdicts = 0,
TotalSurfaced = 0,
TotalDamped = 0,
AverageDampingPercent = 0
};
}

View File

@@ -0,0 +1,96 @@
// Licensed to StellaOps under the AGPL-3.0-or-later license.
using StellaOps.ReachGraph.Schema;
using StellaOps.VexLens.NoiseGate;
namespace StellaOps.VexLens.Storage;
/// <summary>
/// Store for managing graph snapshots (both raw and gated).
/// </summary>
public interface ISnapshotStore
{
/// <summary>
/// Gets a gated snapshot by ID.
/// </summary>
/// <param name="snapshotId">The snapshot ID.</param>
/// <param name="tenantId">Optional tenant ID.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The gated snapshot, or null if not found.</returns>
Task<GatedGraphSnapshot?> GetAsync(
string snapshotId,
string? tenantId = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets a raw (ungated) snapshot by ID.
/// </summary>
/// <param name="snapshotId">The snapshot ID.</param>
/// <param name="tenantId">Optional tenant ID.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The raw snapshot, or null if not found.</returns>
Task<RawGraphSnapshot?> GetRawAsync(
string snapshotId,
string? tenantId = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Stores a gated snapshot.
/// </summary>
/// <param name="snapshot">The snapshot to store.</param>
/// <param name="tenantId">Optional tenant ID.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task StoreAsync(
GatedGraphSnapshot snapshot,
string? tenantId = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Stores a raw snapshot.
/// </summary>
/// <param name="snapshot">The raw snapshot to store.</param>
/// <param name="tenantId">Optional tenant ID.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task StoreRawAsync(
RawGraphSnapshot snapshot,
string? tenantId = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Lists snapshot IDs for a tenant.
/// </summary>
/// <param name="tenantId">Optional tenant ID.</param>
/// <param name="limit">Maximum number of results.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>List of snapshot IDs.</returns>
Task<IReadOnlyList<string>> ListAsync(
string? tenantId = null,
int limit = 100,
CancellationToken cancellationToken = default);
}
/// <summary>
/// A raw (ungated) graph snapshot.
/// </summary>
public sealed record RawGraphSnapshot
{
/// <summary>
/// Gets the snapshot ID.
/// </summary>
public required string SnapshotId { get; init; }
/// <summary>
/// Gets the reachability graph.
/// </summary>
public required ReachGraphMinimal Graph { get; init; }
/// <summary>
/// Gets the verdict requests.
/// </summary>
public required IReadOnlyList<VerdictResolutionRequest> Verdicts { get; init; }
/// <summary>
/// Gets the timestamp when this snapshot was created.
/// </summary>
public required DateTimeOffset CreatedAt { get; init; }
}

View File

@@ -0,0 +1,89 @@
// Licensed to StellaOps under the AGPL-3.0-or-later license.
using System.Collections.Concurrent;
using System.Globalization;
using StellaOps.VexLens.NoiseGate;
namespace StellaOps.VexLens.Storage;
/// <summary>
/// In-memory implementation of <see cref="IGatingStatisticsStore"/> for development and testing.
/// </summary>
public sealed class InMemoryGatingStatisticsStore : IGatingStatisticsStore
{
private readonly ConcurrentDictionary<string, StatisticsEntry> _entries = new();
private readonly TimeProvider _timeProvider;
/// <summary>
/// Initializes a new instance of in-memory statistics store.
/// </summary>
public InMemoryGatingStatisticsStore(TimeProvider? timeProvider = null)
{
_timeProvider = timeProvider ?? TimeProvider.System;
}
/// <inheritdoc />
public Task RecordAsync(
string snapshotId,
GatingStatistics statistics,
string? tenantId = null,
CancellationToken cancellationToken = default)
{
var key = MakeKey(snapshotId, tenantId);
_entries[key] = new StatisticsEntry(statistics, _timeProvider.GetUtcNow());
return Task.CompletedTask;
}
/// <inheritdoc />
public Task<AggregatedGatingStatistics> GetAggregatedAsync(
string? tenantId = null,
DateTimeOffset? fromDate = null,
DateTimeOffset? toDate = null,
CancellationToken cancellationToken = default)
{
var prefix = tenantId is null ? string.Empty : $"{tenantId}:";
var entries = _entries
.Where(kvp => string.IsNullOrEmpty(prefix) || kvp.Key.StartsWith(prefix, StringComparison.Ordinal))
.Where(kvp => fromDate is null || kvp.Value.RecordedAt >= fromDate)
.Where(kvp => toDate is null || kvp.Value.RecordedAt <= toDate)
.Select(kvp => kvp.Value.Statistics)
.ToList();
if (entries.Count == 0)
{
return Task.FromResult(AggregatedGatingStatistics.Empty);
}
var totalEdgesProcessed = entries.Sum(s => s.OriginalEdgeCount);
var totalEdgesAfterDedup = entries.Sum(s => s.DeduplicatedEdgeCount);
var totalVerdicts = entries.Sum(s => s.TotalVerdictCount);
var totalSurfaced = entries.Sum(s => s.SurfacedVerdictCount);
var totalDamped = entries.Sum(s => s.DampedVerdictCount);
var avgEdgeReduction = totalEdgesProcessed > 0
? (1.0 - (double)totalEdgesAfterDedup / totalEdgesProcessed) * 100.0
: 0.0;
var avgDampingPercent = totalVerdicts > 0
? (double)totalDamped / totalVerdicts * 100.0
: 0.0;
return Task.FromResult(new AggregatedGatingStatistics
{
TotalSnapshots = entries.Count,
TotalEdgesProcessed = totalEdgesProcessed,
TotalEdgesAfterDedup = totalEdgesAfterDedup,
AverageEdgeReductionPercent = avgEdgeReduction,
TotalVerdicts = totalVerdicts,
TotalSurfaced = totalSurfaced,
TotalDamped = totalDamped,
AverageDampingPercent = avgDampingPercent
});
}
private static string MakeKey(string snapshotId, string? tenantId) =>
tenantId is null ? snapshotId : $"{tenantId}:{snapshotId}";
private sealed record StatisticsEntry(GatingStatistics Statistics, DateTimeOffset RecordedAt);
}

View File

@@ -0,0 +1,83 @@
// Licensed to StellaOps under the AGPL-3.0-or-later license.
using System.Collections.Concurrent;
using StellaOps.VexLens.NoiseGate;
namespace StellaOps.VexLens.Storage;
/// <summary>
/// In-memory implementation of <see cref="ISnapshotStore"/> for development and testing.
/// </summary>
public sealed class InMemorySnapshotStore : ISnapshotStore
{
private readonly ConcurrentDictionary<string, GatedGraphSnapshot> _gated = new();
private readonly ConcurrentDictionary<string, RawGraphSnapshot> _raw = new();
private readonly ConcurrentDictionary<string, DateTimeOffset> _timestamps = new();
/// <inheritdoc />
public Task<GatedGraphSnapshot?> GetAsync(
string snapshotId,
string? tenantId = null,
CancellationToken cancellationToken = default)
{
var key = MakeKey(snapshotId, tenantId);
_gated.TryGetValue(key, out var snapshot);
return Task.FromResult(snapshot);
}
/// <inheritdoc />
public Task<RawGraphSnapshot?> GetRawAsync(
string snapshotId,
string? tenantId = null,
CancellationToken cancellationToken = default)
{
var key = MakeKey(snapshotId, tenantId);
_raw.TryGetValue(key, out var snapshot);
return Task.FromResult(snapshot);
}
/// <inheritdoc />
public Task StoreAsync(
GatedGraphSnapshot snapshot,
string? tenantId = null,
CancellationToken cancellationToken = default)
{
var key = MakeKey(snapshot.SnapshotId, tenantId);
_gated[key] = snapshot;
_timestamps[key] = snapshot.CreatedAt;
return Task.CompletedTask;
}
/// <inheritdoc />
public Task StoreRawAsync(
RawGraphSnapshot snapshot,
string? tenantId = null,
CancellationToken cancellationToken = default)
{
var key = MakeKey(snapshot.SnapshotId, tenantId);
_raw[key] = snapshot;
_timestamps[key] = snapshot.CreatedAt;
return Task.CompletedTask;
}
/// <inheritdoc />
public Task<IReadOnlyList<string>> ListAsync(
string? tenantId = null,
int limit = 100,
CancellationToken cancellationToken = default)
{
var prefix = tenantId is null ? string.Empty : $"{tenantId}:";
var ids = _gated.Keys
.Where(k => string.IsNullOrEmpty(prefix) || k.StartsWith(prefix, StringComparison.Ordinal))
.Select(k => string.IsNullOrEmpty(prefix) ? k : k[prefix.Length..])
.OrderByDescending(id => _timestamps.TryGetValue(MakeKey(id, tenantId), out var ts) ? ts : DateTimeOffset.MinValue)
.Take(limit)
.ToList();
return Task.FromResult<IReadOnlyList<string>>(ids);
}
private static string MakeKey(string snapshotId, string? tenantId) =>
tenantId is null ? snapshotId : $"{tenantId}:{snapshotId}";
}

View File

@@ -0,0 +1,364 @@
// Licensed to StellaOps under the AGPL-3.0-or-later license.
using FluentAssertions;
using Microsoft.Extensions.Time.Testing;
using StellaOps.VexLens.Delta;
using StellaOps.VexLens.Models;
using Xunit;
namespace StellaOps.VexLens.Tests.Delta;
/// <summary>
/// Unit tests for <see cref="DeltaReportBuilder"/>.
/// </summary>
[Trait("Category", "Unit")]
public class DeltaReportBuilderTests
{
private readonly FakeTimeProvider _timeProvider;
public DeltaReportBuilderTests()
{
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2026, 1, 4, 12, 0, 0, TimeSpan.Zero));
}
[Fact]
public void Build_EmptyReport_ShouldHaveZeroCounts()
{
// Arrange
var builder = new DeltaReportBuilder(_timeProvider)
.WithSnapshots("sha256:from", "sha256:to");
// Act
var report = builder.Build();
// Assert
report.Summary.TotalCount.Should().Be(0);
report.Summary.NewCount.Should().Be(0);
report.Summary.ResolvedCount.Should().Be(0);
report.HasActionableChanges.Should().BeFalse();
}
[Fact]
public void AddNew_ShouldAddNewEntry()
{
// Arrange
var builder = new DeltaReportBuilder(_timeProvider)
.WithSnapshots("sha256:from", "sha256:to");
// Act
builder.AddNew(
"CVE-2024-1234",
"pkg:npm/lodash@4.17.21",
VexStatus.Affected,
0.85,
"binary",
null,
["nvd", "github"]);
var report = builder.Build();
// Assert
report.Summary.NewCount.Should().Be(1);
report.HasActionableChanges.Should().BeTrue();
report.GetSection(DeltaSection.New).Should().HaveCount(1);
var entry = report.GetSection(DeltaSection.New)[0];
entry.VulnerabilityId.Should().Be("CVE-2024-1234");
entry.ProductKey.Should().Be("pkg:npm/lodash@4.17.21");
entry.ToStatus.Should().Be(VexStatus.Affected);
entry.ContributingSources.Should().Contain("nvd");
}
[Fact]
public void AddResolved_ShouldAddResolvedEntry()
{
// Arrange
var builder = new DeltaReportBuilder(_timeProvider)
.WithSnapshots("sha256:from", "sha256:to");
// Act
builder.AddResolved(
"CVE-2024-1234",
"pkg:npm/lodash@4.17.21",
VexStatus.Affected,
VexStatus.NotAffected,
0.80,
0.95,
VexJustification.VulnerableCodeNotPresent);
var report = builder.Build();
// Assert
report.Summary.ResolvedCount.Should().Be(1);
var entry = report.GetSection(DeltaSection.Resolved)[0];
entry.FromStatus.Should().Be(VexStatus.Affected);
entry.ToStatus.Should().Be(VexStatus.NotAffected);
entry.Justification.Should().Be(VexJustification.VulnerableCodeNotPresent);
}
[Fact]
public void AddConfidenceChange_AboveThreshold_ShouldAddEntry()
{
// Arrange
var builder = new DeltaReportBuilder(_timeProvider)
.WithSnapshots("sha256:from", "sha256:to")
.WithOptions(new DeltaReportOptions { ConfidenceChangeThreshold = 0.15 });
// Act
builder.AddConfidenceChange(
"CVE-2024-1234",
"pkg:npm/lodash@4.17.21",
VexStatus.Affected,
0.50,
0.90); // 40% increase
var report = builder.Build();
// Assert
report.Summary.ConfidenceUpCount.Should().Be(1);
var entry = report.GetSection(DeltaSection.ConfidenceUp)[0];
entry.FromConfidence.Should().Be(0.50);
entry.ToConfidence.Should().Be(0.90);
}
[Fact]
public void AddConfidenceChange_BelowThreshold_ShouldNotAddEntry()
{
// Arrange
var builder = new DeltaReportBuilder(_timeProvider)
.WithSnapshots("sha256:from", "sha256:to")
.WithOptions(new DeltaReportOptions { ConfidenceChangeThreshold = 0.15 });
// Act
builder.AddConfidenceChange(
"CVE-2024-1234",
"pkg:npm/lodash@4.17.21",
VexStatus.Affected,
0.80,
0.85); // Only 5% increase
var report = builder.Build();
// Assert
report.Summary.ConfidenceUpCount.Should().Be(0);
report.Entries.Should().BeEmpty();
}
[Fact]
public void AddConfidenceChange_Decrease_ShouldAddConfidenceDownEntry()
{
// Arrange
var builder = new DeltaReportBuilder(_timeProvider)
.WithSnapshots("sha256:from", "sha256:to")
.WithOptions(new DeltaReportOptions { ConfidenceChangeThreshold = 0.15 });
// Act
builder.AddConfidenceChange(
"CVE-2024-1234",
"pkg:npm/lodash@4.17.21",
VexStatus.Affected,
0.90,
0.50); // 40% decrease
var report = builder.Build();
// Assert
report.Summary.ConfidenceDownCount.Should().Be(1);
report.GetSection(DeltaSection.ConfidenceDown).Should().HaveCount(1);
}
[Fact]
public void AddPolicyImpact_ShouldAddPolicyImpactEntry()
{
// Arrange
var builder = new DeltaReportBuilder(_timeProvider)
.WithSnapshots("sha256:from", "sha256:to");
// Act
builder.AddPolicyImpact(
"CVE-2024-1234",
"pkg:npm/lodash@4.17.21",
VexStatus.Affected,
0.85,
"Gate decision changed: pass -> fail");
var report = builder.Build();
// Assert
report.Summary.PolicyImpactCount.Should().Be(1);
report.HasActionableChanges.Should().BeTrue();
var entry = report.GetSection(DeltaSection.PolicyImpact)[0];
entry.Summary.Should().Contain("Gate decision changed");
}
[Fact]
public void AddDamped_WhenExcluded_ShouldNotAddEntry()
{
// Arrange
var builder = new DeltaReportBuilder(_timeProvider)
.WithSnapshots("sha256:from", "sha256:to")
.WithOptions(new DeltaReportOptions { IncludeDamped = false });
// Act
builder.AddDamped(
"CVE-2024-1234",
"pkg:npm/lodash@4.17.21",
VexStatus.Affected,
VexStatus.NotAffected,
0.80,
0.75,
"Duration threshold not met");
var report = builder.Build();
// Assert
report.Summary.DampedCount.Should().Be(0);
}
[Fact]
public void AddDamped_WhenIncluded_ShouldAddEntry()
{
// Arrange
var builder = new DeltaReportBuilder(_timeProvider)
.WithSnapshots("sha256:from", "sha256:to")
.WithOptions(new DeltaReportOptions { IncludeDamped = true });
// Act
builder.AddDamped(
"CVE-2024-1234",
"pkg:npm/lodash@4.17.21",
VexStatus.Affected,
VexStatus.NotAffected,
0.80,
0.75,
"Duration threshold not met");
var report = builder.Build();
// Assert
report.Summary.DampedCount.Should().Be(1);
report.GetSection(DeltaSection.Damped).Should().HaveCount(1);
}
[Fact]
public void AddEvidenceChange_ShouldAddEvidenceChangedEntry()
{
// Arrange
var builder = new DeltaReportBuilder(_timeProvider)
.WithSnapshots("sha256:from", "sha256:to")
.WithOptions(new DeltaReportOptions { IncludeEvidenceChanges = true });
// Act
builder.AddEvidenceChange(
"CVE-2024-1234",
"pkg:npm/lodash@4.17.21",
VexStatus.Affected,
0.85,
"heuristic",
"binary");
var report = builder.Build();
// Assert
report.Summary.EvidenceChangedCount.Should().Be(1);
var entry = report.GetSection(DeltaSection.EvidenceChanged)[0];
entry.FromRationaleClass.Should().Be("heuristic");
entry.ToRationaleClass.Should().Be("binary");
}
[Fact]
public void Build_ShouldSortEntriesDeterministically()
{
// Arrange
var builder = new DeltaReportBuilder(_timeProvider)
.WithSnapshots("sha256:from", "sha256:to");
// Add entries in non-sorted order
builder.AddNew("CVE-2024-0002", "pkg:b", VexStatus.Affected, 0.8);
builder.AddNew("CVE-2024-0001", "pkg:a", VexStatus.Affected, 0.8);
builder.AddResolved("CVE-2024-0003", "pkg:c", VexStatus.Affected, VexStatus.Fixed, 0.8, 0.9);
// Act
var report = builder.Build();
// Assert - entries should be sorted by section then vuln ID then product key
report.Entries[0].Section.Should().Be(DeltaSection.New);
report.Entries[0].VulnerabilityId.Should().Be("CVE-2024-0001");
report.Entries[1].VulnerabilityId.Should().Be("CVE-2024-0002");
report.Entries[2].Section.Should().Be(DeltaSection.Resolved);
}
[Fact]
public void Build_ReportId_ShouldBeDeterministic()
{
// Arrange
var builder1 = new DeltaReportBuilder(_timeProvider)
.WithSnapshots("sha256:from", "sha256:to")
.AddNew("CVE-2024-1234", "pkg:npm/lodash", VexStatus.Affected, 0.8);
var builder2 = new DeltaReportBuilder(_timeProvider)
.WithSnapshots("sha256:from", "sha256:to")
.AddNew("CVE-2024-1234", "pkg:npm/lodash", VexStatus.Affected, 0.8);
// Act
var report1 = builder1.Build();
var report2 = builder2.Build();
// Assert - same inputs should produce same report ID
report1.ReportId.Should().Be(report2.ReportId);
}
[Fact]
public void ToNotificationSummary_WithMultipleChanges_ShouldFormatCorrectly()
{
// Arrange
var builder = new DeltaReportBuilder(_timeProvider)
.WithSnapshots("sha256:from", "sha256:to")
.AddNew("CVE-2024-0001", "pkg:a", VexStatus.Affected, 0.8)
.AddNew("CVE-2024-0002", "pkg:b", VexStatus.Affected, 0.8)
.AddResolved("CVE-2024-0003", "pkg:c", VexStatus.Affected, VexStatus.Fixed, 0.8, 0.9);
// Act
var report = builder.Build();
var summary = report.ToNotificationSummary();
// Assert
summary.Should().Contain("2 new");
summary.Should().Contain("1 resolved");
}
[Fact]
public void ToNotificationSummary_NoChanges_ShouldReturnNoSignificantChanges()
{
// Arrange
var builder = new DeltaReportBuilder(_timeProvider)
.WithSnapshots("sha256:from", "sha256:to");
// Act
var report = builder.Build();
var summary = report.ToNotificationSummary();
// Assert
summary.Should().Be("No significant changes");
}
[Fact]
public void BySection_ShouldGroupEntriesCorrectly()
{
// Arrange
var builder = new DeltaReportBuilder(_timeProvider)
.WithSnapshots("sha256:from", "sha256:to")
.AddNew("CVE-2024-0001", "pkg:a", VexStatus.Affected, 0.8)
.AddResolved("CVE-2024-0002", "pkg:b", VexStatus.Affected, VexStatus.Fixed, 0.8, 0.9);
// Act
var report = builder.Build();
var bySection = report.BySection;
// Assert
bySection.Should().ContainKey(DeltaSection.New);
bySection.Should().ContainKey(DeltaSection.Resolved);
bySection[DeltaSection.New].Should().HaveCount(1);
bySection[DeltaSection.Resolved].Should().HaveCount(1);
}
}

View File

@@ -0,0 +1,451 @@
// Licensed to StellaOps under the AGPL-3.0-or-later license.
using System.Collections.Immutable;
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Microsoft.Extensions.Time.Testing;
using NSubstitute;
using StellaOps.Policy.Engine.Gates;
using StellaOps.ReachGraph.Deduplication;
using StellaOps.ReachGraph.Schema;
using StellaOps.VexLens.Models;
using StellaOps.VexLens.NoiseGate;
using Xunit;
namespace StellaOps.VexLens.Tests.NoiseGate;
/// <summary>
/// Unit tests for <see cref="NoiseGateService"/>.
/// </summary>
[Trait("Category", "Unit")]
public class NoiseGateServiceTests
{
private readonly FakeTimeProvider _timeProvider;
private readonly NoiseGateOptions _defaultOptions;
public NoiseGateServiceTests()
{
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2026, 1, 4, 12, 0, 0, TimeSpan.Zero));
_defaultOptions = new NoiseGateOptions
{
Enabled = true,
EdgeDeduplicationEnabled = true,
StabilityDampingEnabled = true,
MinConfidenceThreshold = 0.0,
ConfidenceChangeThreshold = 0.15
};
}
private NoiseGateService CreateService(
IEdgeDeduplicator? edgeDeduplicator = null,
IStabilityDampingGate? dampingGate = null,
NoiseGateOptions? options = null)
{
var opts = options ?? _defaultOptions;
var optionsMonitor = new TestOptionsMonitor<NoiseGateOptions>(opts);
edgeDeduplicator ??= new EdgeDeduplicator();
if (dampingGate is null)
{
var dampingOptions = new StabilityDampingOptions { Enabled = true };
var dampingOptionsMonitor = new TestOptionsMonitor<StabilityDampingOptions>(dampingOptions);
dampingGate = new StabilityDampingGate(
dampingOptionsMonitor,
_timeProvider,
NullLogger<StabilityDampingGate>.Instance);
}
return new NoiseGateService(
edgeDeduplicator,
dampingGate,
optionsMonitor,
_timeProvider,
NullLogger<NoiseGateService>.Instance);
}
[Fact]
public async Task DedupeEdgesAsync_WithDuplicateEdges_ShouldDeduplicates()
{
// Arrange
var service = CreateService();
var edges = new List<ReachGraphEdge>
{
new()
{
From = "node-a",
To = "node-b",
Why = new EdgeExplanation { Type = EdgeExplanationType.DirectCall, Confidence = 0.9, Loc = "file1.cs:10" }
},
new()
{
From = "node-a",
To = "node-b",
Why = new EdgeExplanation { Type = EdgeExplanationType.DirectCall, Confidence = 0.85, Loc = "file2.cs:20" }
}
};
// Act
var result = await service.DedupeEdgesAsync(edges);
// Assert
result.Should().HaveCount(1);
result[0].EntryPointId.Should().Be("node-a");
result[0].SinkId.Should().Be("node-b");
result[0].ProvenanceCount.Should().Be(2);
}
[Fact]
public async Task DedupeEdgesAsync_WhenDisabled_ShouldPassThrough()
{
// Arrange
var options = new NoiseGateOptions { Enabled = false };
var service = CreateService(options: options);
var edges = new List<ReachGraphEdge>
{
new()
{
From = "node-a",
To = "node-b",
Why = new EdgeExplanation { Type = EdgeExplanationType.DirectCall, Confidence = 0.9 }
},
new()
{
From = "node-a",
To = "node-b",
Why = new EdgeExplanation { Type = EdgeExplanationType.DirectCall, Confidence = 0.85 }
}
};
// Act
var result = await service.DedupeEdgesAsync(edges);
// Assert
result.Should().HaveCount(2); // Not deduplicated
}
[Fact]
public async Task ResolveVerdictAsync_NewVerdict_ShouldSurface()
{
// Arrange
var service = CreateService();
var request = new VerdictResolutionRequest
{
Key = "artifact:CVE-2024-1234",
VulnerabilityId = "CVE-2024-1234",
ProductKey = "pkg:npm/lodash@4.17.21",
ProposedStatus = VexStatus.Affected,
ProposedConfidence = 0.85
};
// Act
var result = await service.ResolveVerdictAsync(request);
// Assert
result.WasSurfaced.Should().BeTrue();
result.VulnerabilityId.Should().Be("CVE-2024-1234");
result.Status.Should().Be(VexStatus.Affected);
}
[Fact]
public async Task ResolveVerdictAsync_WhenDampingDisabled_ShouldAlwaysSurface()
{
// Arrange
var options = new NoiseGateOptions { StabilityDampingEnabled = false };
var service = CreateService(options: options);
var request = new VerdictResolutionRequest
{
Key = "artifact:CVE-2024-1234",
VulnerabilityId = "CVE-2024-1234",
ProductKey = "pkg:npm/lodash@4.17.21",
ProposedStatus = VexStatus.Affected,
ProposedConfidence = 0.85
};
// Act
var result = await service.ResolveVerdictAsync(request);
// Assert
result.WasSurfaced.Should().BeTrue();
result.DampingReason.Should().BeNull();
}
[Fact]
public async Task GateAsync_ShouldDeduplicateAndResolve()
{
// Arrange
var service = CreateService();
var graph = new ReachGraphMinimal
{
SchemaVersion = "reachgraph.min@v1",
Artifact = new ReachGraphArtifact("test", "sha256:abc123", []),
Scope = new ReachGraphScope(["main"], ["*"]),
Nodes = [new ReachGraphNode { Id = "node-a" }, new ReachGraphNode { Id = "node-b" }],
Edges =
[
new ReachGraphEdge
{
From = "node-a",
To = "node-b",
Why = new EdgeExplanation { Type = EdgeExplanationType.DirectCall, Confidence = 0.9 }
},
new ReachGraphEdge
{
From = "node-a",
To = "node-b",
Why = new EdgeExplanation { Type = EdgeExplanationType.DirectCall, Confidence = 0.85 }
}
],
Provenance = new ReachGraphProvenance("scanner", "1.0", _timeProvider.GetUtcNow())
};
var request = new NoiseGateRequest
{
Graph = graph,
SnapshotId = "snapshot-001",
Verdicts =
[
new VerdictResolutionRequest
{
Key = "artifact:CVE-2024-1234",
VulnerabilityId = "CVE-2024-1234",
ProductKey = "pkg:npm/lodash@4.17.21",
ProposedStatus = VexStatus.Affected,
ProposedConfidence = 0.85
}
]
};
// Act
var result = await service.GateAsync(request);
// Assert
result.SnapshotId.Should().Be("snapshot-001");
result.Edges.Should().HaveCount(1); // Deduplicated
result.Verdicts.Should().HaveCount(1);
result.Statistics.EdgeReductionPercent.Should().Be(50.0);
}
[Fact]
public async Task DiffAsync_ShouldDetectNewFindings()
{
// Arrange
var service = CreateService();
var fromSnapshot = new GatedGraphSnapshot
{
SnapshotId = "snapshot-001",
Digest = "sha256:from",
Artifact = new ReachGraphArtifact("test", "sha256:abc123", []),
Edges = [],
Verdicts = [],
CreatedAt = _timeProvider.GetUtcNow(),
Statistics = new GatingStatistics
{
OriginalEdgeCount = 0,
DeduplicatedEdgeCount = 0,
TotalVerdictCount = 0,
SurfacedVerdictCount = 0,
DampedVerdictCount = 0,
Duration = TimeSpan.Zero
}
};
var toSnapshot = new GatedGraphSnapshot
{
SnapshotId = "snapshot-002",
Digest = "sha256:to",
Artifact = new ReachGraphArtifact("test", "sha256:abc123", []),
Edges = [],
Verdicts =
[
new ResolvedVerdict
{
VulnerabilityId = "CVE-2024-1234",
ProductKey = "pkg:npm/lodash@4.17.21",
Status = VexStatus.Affected,
Confidence = 0.85,
WasSurfaced = true,
ResolvedAt = _timeProvider.GetUtcNow()
}
],
CreatedAt = _timeProvider.GetUtcNow(),
Statistics = new GatingStatistics
{
OriginalEdgeCount = 0,
DeduplicatedEdgeCount = 0,
TotalVerdictCount = 1,
SurfacedVerdictCount = 1,
DampedVerdictCount = 0,
Duration = TimeSpan.Zero
}
};
// Act
var delta = await service.DiffAsync(fromSnapshot, toSnapshot);
// Assert
delta.Summary.NewCount.Should().Be(1);
delta.Summary.ResolvedCount.Should().Be(0);
delta.Entries.Should().ContainSingle(e => e.Section == Delta.DeltaSection.New);
}
[Fact]
public async Task DiffAsync_ShouldDetectResolvedFindings()
{
// Arrange
var service = CreateService();
var fromSnapshot = new GatedGraphSnapshot
{
SnapshotId = "snapshot-001",
Digest = "sha256:from",
Artifact = new ReachGraphArtifact("test", "sha256:abc123", []),
Edges = [],
Verdicts =
[
new ResolvedVerdict
{
VulnerabilityId = "CVE-2024-1234",
ProductKey = "pkg:npm/lodash@4.17.21",
Status = VexStatus.Affected,
Confidence = 0.85,
WasSurfaced = true,
ResolvedAt = _timeProvider.GetUtcNow()
}
],
CreatedAt = _timeProvider.GetUtcNow(),
Statistics = new GatingStatistics
{
OriginalEdgeCount = 0,
DeduplicatedEdgeCount = 0,
TotalVerdictCount = 1,
SurfacedVerdictCount = 1,
DampedVerdictCount = 0,
Duration = TimeSpan.Zero
}
};
var toSnapshot = new GatedGraphSnapshot
{
SnapshotId = "snapshot-002",
Digest = "sha256:to",
Artifact = new ReachGraphArtifact("test", "sha256:abc123", []),
Edges = [],
Verdicts =
[
new ResolvedVerdict
{
VulnerabilityId = "CVE-2024-1234",
ProductKey = "pkg:npm/lodash@4.17.21",
Status = VexStatus.NotAffected,
Confidence = 0.95,
WasSurfaced = true,
Justification = VexJustification.VulnerableCodeNotPresent,
ResolvedAt = _timeProvider.GetUtcNow()
}
],
CreatedAt = _timeProvider.GetUtcNow(),
Statistics = new GatingStatistics
{
OriginalEdgeCount = 0,
DeduplicatedEdgeCount = 0,
TotalVerdictCount = 1,
SurfacedVerdictCount = 1,
DampedVerdictCount = 0,
Duration = TimeSpan.Zero
}
};
// Act
var delta = await service.DiffAsync(fromSnapshot, toSnapshot);
// Assert
delta.Summary.ResolvedCount.Should().Be(1);
delta.Entries.Should().ContainSingle(e => e.Section == Delta.DeltaSection.Resolved);
}
[Fact]
public async Task DiffAsync_ShouldDetectConfidenceChanges()
{
// Arrange
var service = CreateService();
var fromSnapshot = new GatedGraphSnapshot
{
SnapshotId = "snapshot-001",
Digest = "sha256:from",
Artifact = new ReachGraphArtifact("test", "sha256:abc123", []),
Edges = [],
Verdicts =
[
new ResolvedVerdict
{
VulnerabilityId = "CVE-2024-1234",
ProductKey = "pkg:npm/lodash@4.17.21",
Status = VexStatus.Affected,
Confidence = 0.50,
WasSurfaced = true,
ResolvedAt = _timeProvider.GetUtcNow()
}
],
CreatedAt = _timeProvider.GetUtcNow(),
Statistics = new GatingStatistics
{
OriginalEdgeCount = 0,
DeduplicatedEdgeCount = 0,
TotalVerdictCount = 1,
SurfacedVerdictCount = 1,
DampedVerdictCount = 0,
Duration = TimeSpan.Zero
}
};
var toSnapshot = new GatedGraphSnapshot
{
SnapshotId = "snapshot-002",
Digest = "sha256:to",
Artifact = new ReachGraphArtifact("test", "sha256:abc123", []),
Edges = [],
Verdicts =
[
new ResolvedVerdict
{
VulnerabilityId = "CVE-2024-1234",
ProductKey = "pkg:npm/lodash@4.17.21",
Status = VexStatus.Affected,
Confidence = 0.90, // Large increase
WasSurfaced = true,
ResolvedAt = _timeProvider.GetUtcNow()
}
],
CreatedAt = _timeProvider.GetUtcNow(),
Statistics = new GatingStatistics
{
OriginalEdgeCount = 0,
DeduplicatedEdgeCount = 0,
TotalVerdictCount = 1,
SurfacedVerdictCount = 1,
DampedVerdictCount = 0,
Duration = TimeSpan.Zero
}
};
// Act
var delta = await service.DiffAsync(fromSnapshot, toSnapshot);
// Assert
delta.Summary.ConfidenceUpCount.Should().Be(1);
delta.Entries.Should().ContainSingle(e => e.Section == Delta.DeltaSection.ConfidenceUp);
}
private sealed class TestOptionsMonitor<T> : IOptionsMonitor<T>
where T : class
{
public TestOptionsMonitor(T value) => CurrentValue = value;
public T CurrentValue { get; }
public T Get(string? name) => CurrentValue;
public IDisposable? OnChange(Action<T, string?> listener) => null;
}
}

View File

@@ -0,0 +1,25 @@
<?xml version='1.0' encoding='utf-8'?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<RootNamespace>StellaOps.VexLens.Tests</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FluentAssertions" />
<PackageReference Include="Moq" />
<PackageReference Include="NSubstitute" />
<PackageReference Include="Microsoft.Extensions.TimeProvider.Testing" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../../StellaOps.VexLens/StellaOps.VexLens.csproj" />
<ProjectReference Include="../../../__Libraries/StellaOps.TestKit/StellaOps.TestKit.csproj" />
</ItemGroup>
</Project>