save progress
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
205
src/VexLens/StellaOps.VexLens/Api/NoiseGatingApiModels.cs
Normal file
205
src/VexLens/StellaOps.VexLens/Api/NoiseGatingApiModels.cs
Normal 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
|
||||
};
|
||||
}
|
||||
}
|
||||
88
src/VexLens/StellaOps.VexLens/Delta/DeltaEntry.cs
Normal file
88
src/VexLens/StellaOps.VexLens/Delta/DeltaEntry.cs
Normal 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;
|
||||
}
|
||||
183
src/VexLens/StellaOps.VexLens/Delta/DeltaReport.cs
Normal file
183
src/VexLens/StellaOps.VexLens/Delta/DeltaReport.cs
Normal 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)
|
||||
};
|
||||
}
|
||||
}
|
||||
347
src/VexLens/StellaOps.VexLens/Delta/DeltaReportBuilder.cs
Normal file
347
src/VexLens/StellaOps.VexLens/Delta/DeltaReportBuilder.cs
Normal 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]}";
|
||||
}
|
||||
}
|
||||
86
src/VexLens/StellaOps.VexLens/Delta/DeltaSection.cs
Normal file
86
src/VexLens/StellaOps.VexLens/Delta/DeltaSection.cs
Normal 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
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
310
src/VexLens/StellaOps.VexLens/NoiseGate/INoiseGate.cs
Normal file
310
src/VexLens/StellaOps.VexLens/NoiseGate/INoiseGate.cs
Normal 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; }
|
||||
}
|
||||
122
src/VexLens/StellaOps.VexLens/NoiseGate/NoiseGateOptions.cs
Normal file
122
src/VexLens/StellaOps.VexLens/NoiseGate/NoiseGateOptions.cs
Normal 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;
|
||||
}
|
||||
471
src/VexLens/StellaOps.VexLens/NoiseGate/NoiseGateService.cs
Normal file
471
src/VexLens/StellaOps.VexLens/NoiseGate/NoiseGateService.cs
Normal 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)}";
|
||||
}
|
||||
}
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
96
src/VexLens/StellaOps.VexLens/Storage/ISnapshotStore.cs
Normal file
96
src/VexLens/StellaOps.VexLens/Storage/ISnapshotStore.cs
Normal 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; }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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}";
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user