partly or unimplemented features - now implemented

This commit is contained in:
master
2026-02-09 08:53:51 +02:00
parent 1bf6bbf395
commit 4bdc298ec1
674 changed files with 90194 additions and 2271 deletions

View File

@@ -0,0 +1,628 @@
// -----------------------------------------------------------------------------
// PointInTimeQueryEndpoints.cs
// Sprint: SPRINT_20260208_056_Replay_point_in_time_vulnerability_query
// Task: T1 — Point-in-Time Vulnerability Query API endpoints
// -----------------------------------------------------------------------------
using Microsoft.AspNetCore.Http.HttpResults;
using StellaOps.Replay.Core.FeedSnapshots;
namespace StellaOps.Replay.WebService;
/// <summary>
/// Extension methods for registering point-in-time vulnerability query endpoints.
/// </summary>
public static class PointInTimeQueryEndpoints
{
/// <summary>
/// Maps point-in-time vulnerability query endpoints to the application.
/// </summary>
public static void MapPointInTimeQueryEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/v1/pit/advisory")
.WithTags("Point-in-Time Advisory");
// GET /v1/pit/advisory/{cveId} - Query advisory state at a point in time
group.MapGet("/{cveId}", QueryAdvisoryAsync)
.WithName("QueryAdvisoryAtPointInTime")
.Produces<AdvisoryQueryResponse>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound)
.ProducesProblem(StatusCodes.Status400BadRequest);
// POST /v1/pit/advisory/cross-provider - Query advisory across multiple providers
group.MapPost("/cross-provider", QueryCrossProviderAsync)
.WithName("QueryCrossProviderAdvisory")
.Produces<CrossProviderQueryResponse>(StatusCodes.Status200OK)
.ProducesProblem(StatusCodes.Status400BadRequest);
// GET /v1/pit/advisory/{cveId}/timeline - Get advisory timeline
group.MapGet("/{cveId}/timeline", GetAdvisoryTimelineAsync)
.WithName("GetAdvisoryTimeline")
.Produces<AdvisoryTimelineResponse>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound);
// POST /v1/pit/advisory/diff - Compare advisory at two points in time
group.MapPost("/diff", CompareAdvisoryAtTimesAsync)
.WithName("CompareAdvisoryAtTimes")
.Produces<AdvisoryDiffResponse>(StatusCodes.Status200OK)
.ProducesProblem(StatusCodes.Status400BadRequest);
var snapshotsGroup = app.MapGroup("/v1/pit/snapshots")
.WithTags("Feed Snapshots");
// POST /v1/pit/snapshots - Capture a feed snapshot
snapshotsGroup.MapPost("/", CaptureSnapshotAsync)
.WithName("CaptureFeedSnapshot")
.Produces<SnapshotCaptureResponse>(StatusCodes.Status201Created)
.ProducesProblem(StatusCodes.Status400BadRequest);
// GET /v1/pit/snapshots/{digest} - Get a snapshot by digest
snapshotsGroup.MapGet("/{digest}", GetSnapshotAsync)
.WithName("GetFeedSnapshot")
.Produces<SnapshotResponse>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound);
// GET /v1/pit/snapshots/{digest}/verify - Verify snapshot integrity
snapshotsGroup.MapGet("/{digest}/verify", VerifySnapshotIntegrityAsync)
.WithName("VerifySnapshotIntegrity")
.Produces<SnapshotVerificationResponse>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound);
// POST /v1/pit/snapshots/bundle - Create a snapshot bundle
snapshotsGroup.MapPost("/bundle", CreateSnapshotBundleAsync)
.WithName("CreateSnapshotBundle")
.Produces<SnapshotBundleResponse>(StatusCodes.Status200OK)
.ProducesProblem(StatusCodes.Status400BadRequest);
}
private static async Task<Results<Ok<AdvisoryQueryResponse>, NotFound, ProblemHttpResult>> QueryAdvisoryAsync(
HttpContext httpContext,
string cveId,
[AsParameters] AdvisoryQueryParameters queryParams,
PointInTimeAdvisoryResolver resolver,
CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(queryParams.ProviderId))
{
return TypedResults.Problem(
statusCode: StatusCodes.Status400BadRequest,
title: "missing_provider",
detail: "Provider ID is required");
}
if (!queryParams.PointInTime.HasValue)
{
return TypedResults.Problem(
statusCode: StatusCodes.Status400BadRequest,
title: "missing_point_in_time",
detail: "Point-in-time timestamp is required");
}
var result = await resolver.ResolveAdvisoryAsync(
cveId,
queryParams.ProviderId,
queryParams.PointInTime.Value,
ct);
if (result.Status == AdvisoryResolutionStatus.NoSnapshot)
{
return TypedResults.NotFound();
}
return TypedResults.Ok(new AdvisoryQueryResponse
{
CveId = result.CveId,
ProviderId = result.ProviderId,
PointInTime = result.PointInTime,
Status = result.Status.ToString(),
Advisory = result.Advisory is not null ? MapAdvisory(result.Advisory) : null,
SnapshotDigest = result.SnapshotDigest,
SnapshotCapturedAt = result.SnapshotCapturedAt
});
}
private static async Task<Results<Ok<CrossProviderQueryResponse>, ProblemHttpResult>> QueryCrossProviderAsync(
HttpContext httpContext,
CrossProviderQueryRequest request,
PointInTimeAdvisoryResolver resolver,
CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(request.CveId))
{
return TypedResults.Problem(
statusCode: StatusCodes.Status400BadRequest,
title: "missing_cve_id",
detail: "CVE ID is required");
}
if (request.ProviderIds is null || request.ProviderIds.Count == 0)
{
return TypedResults.Problem(
statusCode: StatusCodes.Status400BadRequest,
title: "missing_providers",
detail: "At least one provider ID is required");
}
var result = await resolver.ResolveCrossProviderAsync(
request.CveId,
request.ProviderIds,
request.PointInTime,
ct);
return TypedResults.Ok(new CrossProviderQueryResponse
{
CveId = result.CveId,
PointInTime = result.PointInTime,
FoundCount = result.FoundCount,
MissingSnapshotProviders = result.MissingSnapshotProviders,
NotFoundProviders = result.NotFoundProviders,
Results = result.Results.Select(r => new ProviderAdvisoryResult
{
ProviderId = r.ProviderId,
Status = r.Status.ToString(),
Advisory = r.Advisory is not null ? MapAdvisory(r.Advisory) : null,
SnapshotDigest = r.SnapshotDigest
}).ToList(),
Consensus = result.Consensus is not null ? new ConsensusInfo
{
ProviderCount = result.Consensus.ProviderCount,
SeverityConsensus = result.Consensus.SeverityConsensus,
FixStatusConsensus = result.Consensus.FixStatusConsensus,
ConsensusSeverity = result.Consensus.ConsensusSeverity,
ConsensusFixStatus = result.Consensus.ConsensusFixStatus
} : null
});
}
private static async Task<Results<Ok<AdvisoryTimelineResponse>, NotFound>> GetAdvisoryTimelineAsync(
HttpContext httpContext,
string cveId,
[AsParameters] TimelineQueryParameters queryParams,
PointInTimeAdvisoryResolver resolver,
CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(queryParams.ProviderId))
{
return TypedResults.NotFound();
}
var timeline = await resolver.GetAdvisoryTimelineAsync(
cveId,
queryParams.ProviderId,
queryParams.From,
queryParams.To,
ct);
if (timeline.TotalSnapshots == 0)
{
return TypedResults.NotFound();
}
return TypedResults.Ok(new AdvisoryTimelineResponse
{
CveId = timeline.CveId,
ProviderId = timeline.ProviderId,
TotalSnapshots = timeline.TotalSnapshots,
ChangesCount = timeline.ChangesCount,
FirstAppearance = timeline.FirstAppearance,
LastUpdate = timeline.LastUpdate,
Entries = timeline.Entries.Select(e => new TimelineEntryDto
{
SnapshotDigest = e.SnapshotDigest,
CapturedAt = e.CapturedAt,
EpochTimestamp = e.EpochTimestamp,
ChangeType = e.ChangeType.ToString(),
HasAdvisory = e.Advisory is not null
}).ToList()
});
}
private static async Task<Results<Ok<AdvisoryDiffResponse>, ProblemHttpResult>> CompareAdvisoryAtTimesAsync(
HttpContext httpContext,
AdvisoryDiffRequest request,
PointInTimeAdvisoryResolver resolver,
CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(request.CveId) || string.IsNullOrWhiteSpace(request.ProviderId))
{
return TypedResults.Problem(
statusCode: StatusCodes.Status400BadRequest,
title: "missing_required_fields",
detail: "CVE ID and Provider ID are required");
}
var diff = await resolver.CompareAtTimesAsync(
request.CveId,
request.ProviderId,
request.Time1,
request.Time2,
ct);
return TypedResults.Ok(new AdvisoryDiffResponse
{
CveId = diff.CveId,
ProviderId = diff.ProviderId,
Time1 = diff.Time1,
Time2 = diff.Time2,
DiffType = diff.DiffType.ToString(),
Changes = diff.Changes.Select(c => new FieldChangeDto
{
Field = c.Field,
OldValue = c.OldValue,
NewValue = c.NewValue
}).ToList()
});
}
private static async Task<Results<Created<SnapshotCaptureResponse>, ProblemHttpResult>> CaptureSnapshotAsync(
HttpContext httpContext,
SnapshotCaptureRequest request,
FeedSnapshotService snapshotService,
CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(request.ProviderId) || request.FeedData is null)
{
return TypedResults.Problem(
statusCode: StatusCodes.Status400BadRequest,
title: "missing_required_fields",
detail: "Provider ID and feed data are required");
}
var result = await snapshotService.CaptureSnapshotAsync(
new CaptureSnapshotRequest
{
ProviderId = request.ProviderId,
ProviderName = request.ProviderName,
FeedType = request.FeedType,
FeedData = request.FeedData,
EpochTimestamp = request.EpochTimestamp
},
ct);
if (!result.Success)
{
return TypedResults.Problem(
statusCode: StatusCodes.Status400BadRequest,
title: "capture_failed",
detail: result.Error ?? "Failed to capture snapshot");
}
return TypedResults.Created(
$"/v1/pit/snapshots/{result.Digest}",
new SnapshotCaptureResponse
{
Digest = result.Digest,
ProviderId = result.ProviderId,
CapturedAt = result.CapturedAt,
WasExisting = result.WasExisting,
ContentSize = result.ContentSize
});
}
private static async Task<Results<Ok<SnapshotResponse>, NotFound>> GetSnapshotAsync(
HttpContext httpContext,
string digest,
FeedSnapshotService snapshotService,
CancellationToken ct)
{
var snapshot = await snapshotService.GetByDigestAsync(digest, ct);
if (snapshot is null)
{
return TypedResults.NotFound();
}
return TypedResults.Ok(new SnapshotResponse
{
Digest = snapshot.Digest,
ProviderId = snapshot.ProviderId,
ProviderName = snapshot.ProviderName,
FeedType = snapshot.FeedType,
CapturedAt = snapshot.CapturedAt,
EpochTimestamp = snapshot.EpochTimestamp,
Format = snapshot.Format.ToString(),
ContentSize = snapshot.Content.Length
});
}
private static async Task<Results<Ok<SnapshotVerificationResponse>, NotFound>> VerifySnapshotIntegrityAsync(
HttpContext httpContext,
string digest,
FeedSnapshotService snapshotService,
CancellationToken ct)
{
var result = await snapshotService.VerifyIntegrityAsync(digest, ct);
if (result.Error?.Contains("not found") == true)
{
return TypedResults.NotFound();
}
return TypedResults.Ok(new SnapshotVerificationResponse
{
Success = result.Success,
ExpectedDigest = result.ExpectedDigest,
ActualDigest = result.ActualDigest,
ProviderId = result.ProviderId,
CapturedAt = result.CapturedAt,
Error = result.Error
});
}
private static async Task<Results<Ok<SnapshotBundleResponse>, ProblemHttpResult>> CreateSnapshotBundleAsync(
HttpContext httpContext,
SnapshotBundleRequest request,
FeedSnapshotService snapshotService,
CancellationToken ct)
{
if (request.ProviderIds is null || request.ProviderIds.Count == 0)
{
return TypedResults.Problem(
statusCode: StatusCodes.Status400BadRequest,
title: "missing_providers",
detail: "At least one provider ID is required");
}
var bundle = await snapshotService.CreateBundleAsync(
request.ProviderIds,
request.PointInTime,
ct);
return TypedResults.Ok(new SnapshotBundleResponse
{
BundleDigest = bundle.BundleDigest,
PointInTime = bundle.PointInTime,
CreatedAt = bundle.CreatedAt,
IsComplete = bundle.IsComplete,
SnapshotCount = bundle.Snapshots.Length,
MissingProviders = bundle.MissingProviders
});
}
private static AdvisoryDto MapAdvisory(AdvisoryData advisory) => new()
{
CveId = advisory.CveId,
Severity = advisory.Severity,
CvssScore = advisory.CvssScore,
CvssVector = advisory.CvssVector,
Description = advisory.Description,
FixStatus = advisory.FixStatus,
AffectedProducts = advisory.AffectedProducts,
References = advisory.References,
PublishedAt = advisory.PublishedAt,
LastModifiedAt = advisory.LastModifiedAt
};
}
#region Request/Response DTOs
/// <summary>
/// Query parameters for advisory lookup.
/// </summary>
public sealed class AdvisoryQueryParameters
{
public string? ProviderId { get; init; }
public DateTimeOffset? PointInTime { get; init; }
}
/// <summary>
/// Query parameters for timeline lookup.
/// </summary>
public sealed class TimelineQueryParameters
{
public required string ProviderId { get; init; }
public DateTimeOffset? From { get; init; }
public DateTimeOffset? To { get; init; }
}
/// <summary>
/// Response for advisory query.
/// </summary>
public sealed class AdvisoryQueryResponse
{
public required string CveId { get; init; }
public required string ProviderId { get; init; }
public required DateTimeOffset PointInTime { get; init; }
public required string Status { get; init; }
public AdvisoryDto? Advisory { get; init; }
public string? SnapshotDigest { get; init; }
public DateTimeOffset? SnapshotCapturedAt { get; init; }
}
/// <summary>
/// Request for cross-provider query.
/// </summary>
public sealed class CrossProviderQueryRequest
{
public required string CveId { get; init; }
public required IReadOnlyList<string> ProviderIds { get; init; }
public required DateTimeOffset PointInTime { get; init; }
}
/// <summary>
/// Response for cross-provider query.
/// </summary>
public sealed class CrossProviderQueryResponse
{
public required string CveId { get; init; }
public required DateTimeOffset PointInTime { get; init; }
public required int FoundCount { get; init; }
public required IReadOnlyList<string> MissingSnapshotProviders { get; init; }
public required IReadOnlyList<string> NotFoundProviders { get; init; }
public required IReadOnlyList<ProviderAdvisoryResult> Results { get; init; }
public ConsensusInfo? Consensus { get; init; }
}
/// <summary>
/// Result for a single provider in cross-provider query.
/// </summary>
public sealed class ProviderAdvisoryResult
{
public required string ProviderId { get; init; }
public required string Status { get; init; }
public AdvisoryDto? Advisory { get; init; }
public string? SnapshotDigest { get; init; }
}
/// <summary>
/// Consensus information across providers.
/// </summary>
public sealed class ConsensusInfo
{
public required int ProviderCount { get; init; }
public required bool SeverityConsensus { get; init; }
public required bool FixStatusConsensus { get; init; }
public string? ConsensusSeverity { get; init; }
public string? ConsensusFixStatus { get; init; }
}
/// <summary>
/// Request for advisory diff.
/// </summary>
public sealed class AdvisoryDiffRequest
{
public required string CveId { get; init; }
public required string ProviderId { get; init; }
public required DateTimeOffset Time1 { get; init; }
public required DateTimeOffset Time2 { get; init; }
}
/// <summary>
/// Response for advisory diff.
/// </summary>
public sealed class AdvisoryDiffResponse
{
public required string CveId { get; init; }
public required string ProviderId { get; init; }
public required DateTimeOffset Time1 { get; init; }
public required DateTimeOffset Time2 { get; init; }
public required string DiffType { get; init; }
public required IReadOnlyList<FieldChangeDto> Changes { get; init; }
}
/// <summary>
/// A field change in a diff.
/// </summary>
public sealed class FieldChangeDto
{
public required string Field { get; init; }
public string? OldValue { get; init; }
public string? NewValue { get; init; }
}
/// <summary>
/// Response for advisory timeline.
/// </summary>
public sealed class AdvisoryTimelineResponse
{
public required string CveId { get; init; }
public required string ProviderId { get; init; }
public required int TotalSnapshots { get; init; }
public required int ChangesCount { get; init; }
public DateTimeOffset? FirstAppearance { get; init; }
public DateTimeOffset? LastUpdate { get; init; }
public required IReadOnlyList<TimelineEntryDto> Entries { get; init; }
}
/// <summary>
/// Timeline entry DTO.
/// </summary>
public sealed class TimelineEntryDto
{
public required string SnapshotDigest { get; init; }
public required DateTimeOffset CapturedAt { get; init; }
public required DateTimeOffset EpochTimestamp { get; init; }
public required string ChangeType { get; init; }
public required bool HasAdvisory { get; init; }
}
/// <summary>
/// Request to capture a feed snapshot.
/// </summary>
public sealed class SnapshotCaptureRequest
{
public required string ProviderId { get; init; }
public string? ProviderName { get; init; }
public string? FeedType { get; init; }
public required object FeedData { get; init; }
public DateTimeOffset? EpochTimestamp { get; init; }
}
/// <summary>
/// Response for snapshot capture.
/// </summary>
public sealed class SnapshotCaptureResponse
{
public required string Digest { get; init; }
public required string ProviderId { get; init; }
public required DateTimeOffset CapturedAt { get; init; }
public required bool WasExisting { get; init; }
public required long ContentSize { get; init; }
}
/// <summary>
/// Response for snapshot retrieval.
/// </summary>
public sealed class SnapshotResponse
{
public required string Digest { get; init; }
public required string ProviderId { get; init; }
public string? ProviderName { get; init; }
public string? FeedType { get; init; }
public required DateTimeOffset CapturedAt { get; init; }
public required DateTimeOffset EpochTimestamp { get; init; }
public required string Format { get; init; }
public required int ContentSize { get; init; }
}
/// <summary>
/// Response for snapshot verification.
/// </summary>
public sealed class SnapshotVerificationResponse
{
public required bool Success { get; init; }
public required string ExpectedDigest { get; init; }
public string? ActualDigest { get; init; }
public string? ProviderId { get; init; }
public DateTimeOffset? CapturedAt { get; init; }
public string? Error { get; init; }
}
/// <summary>
/// Request to create a snapshot bundle.
/// </summary>
public sealed class SnapshotBundleRequest
{
public required IReadOnlyList<string> ProviderIds { get; init; }
public required DateTimeOffset PointInTime { get; init; }
}
/// <summary>
/// Response for snapshot bundle.
/// </summary>
public sealed class SnapshotBundleResponse
{
public required string BundleDigest { get; init; }
public required DateTimeOffset PointInTime { get; init; }
public required DateTimeOffset CreatedAt { get; init; }
public required bool IsComplete { get; init; }
public required int SnapshotCount { get; init; }
public required IReadOnlyList<string> MissingProviders { get; init; }
}
/// <summary>
/// DTO for advisory data.
/// </summary>
public sealed class AdvisoryDto
{
public required string CveId { get; init; }
public string? Severity { get; init; }
public decimal? CvssScore { get; init; }
public string? CvssVector { get; init; }
public string? Description { get; init; }
public string? FixStatus { get; init; }
public IReadOnlyList<string>? AffectedProducts { get; init; }
public IReadOnlyList<string>? References { get; init; }
public DateTimeOffset? PublishedAt { get; init; }
public DateTimeOffset? LastModifiedAt { get; init; }
}
#endregion

View File

@@ -0,0 +1,545 @@
// <copyright file="FeedSnapshotService.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
// </copyright>
using Microsoft.Extensions.Logging;
using System.Collections.Immutable;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
namespace StellaOps.Replay.Core.FeedSnapshots;
/// <summary>
/// Service for capturing and retrieving immutable advisory feed snapshots.
/// Supports per-provider snapshots with content-addressable storage.
/// </summary>
public sealed class FeedSnapshotService
{
private readonly IFeedSnapshotBlobStore _blobStore;
private readonly IFeedSnapshotIndexStore _indexStore;
private readonly TimeProvider _timeProvider;
private readonly FeedSnapshotServiceOptions _options;
private readonly ILogger<FeedSnapshotService> _logger;
public FeedSnapshotService(
IFeedSnapshotBlobStore blobStore,
IFeedSnapshotIndexStore indexStore,
TimeProvider timeProvider,
FeedSnapshotServiceOptions options,
ILogger<FeedSnapshotService> logger)
{
_blobStore = blobStore;
_indexStore = indexStore;
_timeProvider = timeProvider;
_options = options;
_logger = logger;
}
/// <summary>
/// Captures an immutable snapshot of advisory feed data from a provider.
/// </summary>
public async Task<FeedSnapshotResult> CaptureSnapshotAsync(
CaptureSnapshotRequest request,
CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(request);
ArgumentException.ThrowIfNullOrWhiteSpace(request.ProviderId);
var capturedAt = _timeProvider.GetUtcNow();
_logger.LogInformation(
"Capturing feed snapshot for provider {ProviderId}",
request.ProviderId);
// Serialize feed data to canonical JSON format for deterministic hashing
var canonicalContent = SerializeToCanonicalJson(request.FeedData);
// Compute content-addressable digest
var digest = ComputeDigest(canonicalContent);
// Check if this exact snapshot already exists
var existingSnapshot = await _blobStore.GetByDigestAsync(digest, ct);
if (existingSnapshot is not null)
{
_logger.LogDebug(
"Snapshot with digest {Digest} already exists",
digest);
// Update index with new timestamp if this is a re-capture
await _indexStore.IndexSnapshotAsync(
new FeedSnapshotIndexEntry
{
ProviderId = request.ProviderId,
Digest = digest,
CapturedAt = capturedAt,
EpochTimestamp = request.EpochTimestamp ?? capturedAt
},
ct);
return new FeedSnapshotResult
{
Success = true,
Digest = digest,
ProviderId = request.ProviderId,
CapturedAt = capturedAt,
WasExisting = true,
ContentSize = canonicalContent.Length
};
}
// Create the snapshot blob
var snapshot = new FeedSnapshotBlob
{
Digest = digest,
ProviderId = request.ProviderId,
ProviderName = request.ProviderName,
FeedType = request.FeedType,
Content = canonicalContent,
CapturedAt = capturedAt,
EpochTimestamp = request.EpochTimestamp ?? capturedAt,
Metadata = request.Metadata ?? ImmutableDictionary<string, string>.Empty,
ContentHash = digest,
Format = FeedSnapshotFormat.CanonicalJson
};
// Store the blob
await _blobStore.StoreAsync(snapshot, ct);
// Index for temporal queries
await _indexStore.IndexSnapshotAsync(
new FeedSnapshotIndexEntry
{
ProviderId = request.ProviderId,
Digest = digest,
CapturedAt = capturedAt,
EpochTimestamp = request.EpochTimestamp ?? capturedAt
},
ct);
_logger.LogInformation(
"Captured snapshot for provider {ProviderId}: digest={Digest}, size={Size}",
request.ProviderId, digest, canonicalContent.Length);
return new FeedSnapshotResult
{
Success = true,
Digest = digest,
ProviderId = request.ProviderId,
CapturedAt = capturedAt,
WasExisting = false,
ContentSize = canonicalContent.Length
};
}
/// <summary>
/// Retrieves a snapshot by its content-addressable digest.
/// </summary>
public async Task<FeedSnapshotBlob?> GetByDigestAsync(
string digest,
CancellationToken ct = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(digest);
return await _blobStore.GetByDigestAsync(digest, ct);
}
/// <summary>
/// Queries snapshots for a provider at or before a specific point in time.
/// Returns the most recent snapshot that was captured before or at the given timestamp.
/// </summary>
public async Task<FeedSnapshotBlob?> GetSnapshotAtTimeAsync(
string providerId,
DateTimeOffset pointInTime,
CancellationToken ct = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(providerId);
_logger.LogDebug(
"Querying snapshot for provider {ProviderId} at time {PointInTime}",
providerId, pointInTime);
// Find the most recent snapshot index entry at or before the given time
var entry = await _indexStore.FindSnapshotAtTimeAsync(
providerId,
pointInTime,
ct);
if (entry is null)
{
_logger.LogDebug(
"No snapshot found for provider {ProviderId} at time {PointInTime}",
providerId, pointInTime);
return null;
}
return await _blobStore.GetByDigestAsync(entry.Digest, ct);
}
/// <summary>
/// Lists all snapshots for a provider within a time range.
/// </summary>
public async Task<ImmutableArray<FeedSnapshotSummary>> ListSnapshotsAsync(
string providerId,
DateTimeOffset? from = null,
DateTimeOffset? to = null,
int? limit = null,
CancellationToken ct = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(providerId);
var entries = await _indexStore.ListSnapshotsAsync(
providerId,
from ?? DateTimeOffset.MinValue,
to ?? DateTimeOffset.MaxValue,
limit ?? _options.DefaultListLimit,
ct);
return entries.Select(e => new FeedSnapshotSummary
{
Digest = e.Digest,
ProviderId = e.ProviderId,
CapturedAt = e.CapturedAt,
EpochTimestamp = e.EpochTimestamp
}).ToImmutableArray();
}
/// <summary>
/// Verifies the integrity of a snapshot by recomputing its digest.
/// </summary>
public async Task<SnapshotVerificationResult> VerifyIntegrityAsync(
string digest,
CancellationToken ct = default)
{
var snapshot = await _blobStore.GetByDigestAsync(digest, ct);
if (snapshot is null)
{
return new SnapshotVerificationResult
{
Success = false,
ExpectedDigest = digest,
Error = "Snapshot not found"
};
}
var computedDigest = ComputeDigest(snapshot.Content);
return new SnapshotVerificationResult
{
Success = computedDigest == digest,
ExpectedDigest = digest,
ActualDigest = computedDigest,
ProviderId = snapshot.ProviderId,
CapturedAt = snapshot.CapturedAt,
Error = computedDigest == digest ? null : "Digest mismatch - snapshot may be corrupted"
};
}
/// <summary>
/// Creates a snapshot bundle containing multiple provider snapshots at a point in time.
/// Useful for capturing a complete feed state across all providers.
/// </summary>
public async Task<FeedSnapshotBundle> CreateBundleAsync(
IReadOnlyList<string> providerIds,
DateTimeOffset pointInTime,
CancellationToken ct = default)
{
var snapshots = new List<FeedSnapshotBundleEntry>();
var missingProviders = new List<string>();
foreach (var providerId in providerIds)
{
var snapshot = await GetSnapshotAtTimeAsync(providerId, pointInTime, ct);
if (snapshot is not null)
{
snapshots.Add(new FeedSnapshotBundleEntry
{
ProviderId = providerId,
Digest = snapshot.Digest,
CapturedAt = snapshot.CapturedAt
});
}
else
{
missingProviders.Add(providerId);
}
}
// Compute bundle digest from sorted provider digests
var bundleContent = string.Join(",",
snapshots.OrderBy(s => s.ProviderId).Select(s => $"{s.ProviderId}:{s.Digest}"));
var bundleDigest = ComputeDigest(Encoding.UTF8.GetBytes(bundleContent));
return new FeedSnapshotBundle
{
BundleDigest = bundleDigest,
Snapshots = snapshots.ToImmutableArray(),
MissingProviders = missingProviders.ToImmutableArray(),
PointInTime = pointInTime,
CreatedAt = _timeProvider.GetUtcNow(),
IsComplete = missingProviders.Count == 0
};
}
private static byte[] SerializeToCanonicalJson(object data)
{
// Use sorted keys and no formatting for deterministic output
var options = new JsonSerializerOptions
{
WriteIndented = false,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull
};
return JsonSerializer.SerializeToUtf8Bytes(data, options);
}
private static string ComputeDigest(byte[] content)
{
var hash = SHA256.HashData(content);
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
}
}
/// <summary>
/// Configuration options for the feed snapshot service.
/// </summary>
public sealed record FeedSnapshotServiceOptions
{
/// <summary>
/// Default limit for list queries.
/// </summary>
public int DefaultListLimit { get; init; } = 100;
/// <summary>
/// Maximum content size for a single snapshot.
/// </summary>
public long MaxContentSize { get; init; } = 100 * 1024 * 1024; // 100 MB
}
/// <summary>
/// Request to capture a feed snapshot.
/// </summary>
public sealed record CaptureSnapshotRequest
{
/// <summary>
/// Unique identifier for the feed provider (e.g., "nvd", "ghsa", "redhat").
/// </summary>
public required string ProviderId { get; init; }
/// <summary>
/// Human-readable name of the provider.
/// </summary>
public string? ProviderName { get; init; }
/// <summary>
/// Type of feed (e.g., "cve", "advisory", "package").
/// </summary>
public string? FeedType { get; init; }
/// <summary>
/// The feed data to snapshot.
/// </summary>
public required object FeedData { get; init; }
/// <summary>
/// Optional epoch timestamp for the feed data.
/// If not provided, current time is used.
/// </summary>
public DateTimeOffset? EpochTimestamp { get; init; }
/// <summary>
/// Additional metadata to store with the snapshot.
/// </summary>
public ImmutableDictionary<string, string>? Metadata { get; init; }
}
/// <summary>
/// Result of capturing a snapshot.
/// </summary>
public sealed record FeedSnapshotResult
{
public required bool Success { get; init; }
public required string Digest { get; init; }
public required string ProviderId { get; init; }
public required DateTimeOffset CapturedAt { get; init; }
public required bool WasExisting { get; init; }
public required long ContentSize { get; init; }
public string? Error { get; init; }
}
/// <summary>
/// An immutable feed snapshot blob with content-addressable storage.
/// </summary>
public sealed record FeedSnapshotBlob
{
/// <summary>
/// Content-addressable digest (sha256:...).
/// </summary>
public required string Digest { get; init; }
/// <summary>
/// Provider identifier.
/// </summary>
public required string ProviderId { get; init; }
/// <summary>
/// Provider display name.
/// </summary>
public string? ProviderName { get; init; }
/// <summary>
/// Feed type (cve, advisory, etc.).
/// </summary>
public string? FeedType { get; init; }
/// <summary>
/// Canonical serialized content.
/// </summary>
public required byte[] Content { get; init; }
/// <summary>
/// When the snapshot was captured.
/// </summary>
public required DateTimeOffset CapturedAt { get; init; }
/// <summary>
/// The epoch timestamp of the feed data.
/// </summary>
public required DateTimeOffset EpochTimestamp { get; init; }
/// <summary>
/// Additional metadata.
/// </summary>
public required ImmutableDictionary<string, string> Metadata { get; init; }
/// <summary>
/// Content hash for integrity verification.
/// </summary>
public required string ContentHash { get; init; }
/// <summary>
/// Serialization format.
/// </summary>
public required FeedSnapshotFormat Format { get; init; }
}
/// <summary>
/// Index entry for temporal queries.
/// </summary>
public sealed record FeedSnapshotIndexEntry
{
public required string ProviderId { get; init; }
public required string Digest { get; init; }
public required DateTimeOffset CapturedAt { get; init; }
public required DateTimeOffset EpochTimestamp { get; init; }
}
/// <summary>
/// Summary of a snapshot for listing.
/// </summary>
public sealed record FeedSnapshotSummary
{
public required string Digest { get; init; }
public required string ProviderId { get; init; }
public required DateTimeOffset CapturedAt { get; init; }
public required DateTimeOffset EpochTimestamp { get; init; }
}
/// <summary>
/// Result of verifying snapshot integrity.
/// </summary>
public sealed record SnapshotVerificationResult
{
public required bool Success { get; init; }
public required string ExpectedDigest { get; init; }
public string? ActualDigest { get; init; }
public string? ProviderId { get; init; }
public DateTimeOffset? CapturedAt { get; init; }
public string? Error { get; init; }
}
/// <summary>
/// Bundle of snapshots from multiple providers at a point in time.
/// </summary>
public sealed record FeedSnapshotBundle
{
/// <summary>
/// Combined digest of all provider snapshots.
/// </summary>
public required string BundleDigest { get; init; }
/// <summary>
/// Individual provider snapshots.
/// </summary>
public required ImmutableArray<FeedSnapshotBundleEntry> Snapshots { get; init; }
/// <summary>
/// Providers that had no snapshot at the requested time.
/// </summary>
public required ImmutableArray<string> MissingProviders { get; init; }
/// <summary>
/// The point in time for the bundle.
/// </summary>
public required DateTimeOffset PointInTime { get; init; }
/// <summary>
/// When the bundle was created.
/// </summary>
public required DateTimeOffset CreatedAt { get; init; }
/// <summary>
/// Whether all requested providers have snapshots.
/// </summary>
public required bool IsComplete { get; init; }
}
/// <summary>
/// Entry in a snapshot bundle.
/// </summary>
public sealed record FeedSnapshotBundleEntry
{
public required string ProviderId { get; init; }
public required string Digest { get; init; }
public required DateTimeOffset CapturedAt { get; init; }
}
/// <summary>
/// Format of snapshot serialization.
/// </summary>
public enum FeedSnapshotFormat
{
CanonicalJson,
Cbor,
Protobuf
}
/// <summary>
/// Interface for content-addressable blob storage.
/// </summary>
public interface IFeedSnapshotBlobStore
{
Task StoreAsync(FeedSnapshotBlob blob, CancellationToken ct = default);
Task<FeedSnapshotBlob?> GetByDigestAsync(string digest, CancellationToken ct = default);
Task<bool> ExistsAsync(string digest, CancellationToken ct = default);
Task DeleteAsync(string digest, CancellationToken ct = default);
}
/// <summary>
/// Interface for snapshot index storage.
/// </summary>
public interface IFeedSnapshotIndexStore
{
Task IndexSnapshotAsync(FeedSnapshotIndexEntry entry, CancellationToken ct = default);
Task<FeedSnapshotIndexEntry?> FindSnapshotAtTimeAsync(
string providerId,
DateTimeOffset pointInTime,
CancellationToken ct = default);
Task<ImmutableArray<FeedSnapshotIndexEntry>> ListSnapshotsAsync(
string providerId,
DateTimeOffset from,
DateTimeOffset to,
int limit,
CancellationToken ct = default);
}

View File

@@ -0,0 +1,527 @@
// <copyright file="PointInTimeAdvisoryResolver.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
// </copyright>
using Microsoft.Extensions.Logging;
using System.Collections.Immutable;
using System.Text.Json;
namespace StellaOps.Replay.Core.FeedSnapshots;
/// <summary>
/// Resolves advisory state for CVEs at a specific point in time.
/// Enables deterministic replay by querying historical feed snapshots.
/// </summary>
public sealed class PointInTimeAdvisoryResolver
{
private readonly FeedSnapshotService _snapshotService;
private readonly IAdvisoryExtractor _advisoryExtractor;
private readonly ILogger<PointInTimeAdvisoryResolver> _logger;
public PointInTimeAdvisoryResolver(
FeedSnapshotService snapshotService,
IAdvisoryExtractor advisoryExtractor,
ILogger<PointInTimeAdvisoryResolver> logger)
{
_snapshotService = snapshotService;
_advisoryExtractor = advisoryExtractor;
_logger = logger;
}
/// <summary>
/// Resolves the advisory state for a CVE at a specific point in time from a provider.
/// </summary>
public async Task<AdvisoryAtTimeResult> ResolveAdvisoryAsync(
string cveId,
string providerId,
DateTimeOffset pointInTime,
CancellationToken ct = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(cveId);
ArgumentException.ThrowIfNullOrWhiteSpace(providerId);
_logger.LogDebug(
"Resolving advisory for CVE {CveId} from provider {ProviderId} at {PointInTime}",
cveId, providerId, pointInTime);
var snapshot = await _snapshotService.GetSnapshotAtTimeAsync(providerId, pointInTime, ct);
if (snapshot is null)
{
return new AdvisoryAtTimeResult
{
CveId = cveId,
ProviderId = providerId,
PointInTime = pointInTime,
Status = AdvisoryResolutionStatus.NoSnapshot,
Advisory = null,
SnapshotDigest = null,
SnapshotCapturedAt = null
};
}
var advisory = await _advisoryExtractor.ExtractAdvisoryAsync(
cveId,
snapshot.Content,
snapshot.Format,
ct);
return new AdvisoryAtTimeResult
{
CveId = cveId,
ProviderId = providerId,
PointInTime = pointInTime,
Status = advisory is null
? AdvisoryResolutionStatus.NotFound
: AdvisoryResolutionStatus.Found,
Advisory = advisory,
SnapshotDigest = snapshot.Digest,
SnapshotCapturedAt = snapshot.CapturedAt
};
}
/// <summary>
/// Resolves advisory state for a CVE across all known providers at a point in time.
/// </summary>
public async Task<CrossProviderAdvisoryResult> ResolveCrossProviderAsync(
string cveId,
IReadOnlyList<string> providerIds,
DateTimeOffset pointInTime,
CancellationToken ct = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(cveId);
_logger.LogInformation(
"Cross-provider advisory lookup for CVE {CveId} at {PointInTime} across {Count} providers",
cveId, pointInTime, providerIds.Count);
var results = new List<AdvisoryAtTimeResult>();
foreach (var providerId in providerIds)
{
var result = await ResolveAdvisoryAsync(cveId, providerId, pointInTime, ct);
results.Add(result);
}
var foundAdvisories = results
.Where(r => r.Status == AdvisoryResolutionStatus.Found && r.Advisory is not null)
.ToImmutableArray();
var missingProviders = results
.Where(r => r.Status == AdvisoryResolutionStatus.NoSnapshot)
.Select(r => r.ProviderId)
.ToImmutableArray();
var notFoundProviders = results
.Where(r => r.Status == AdvisoryResolutionStatus.NotFound)
.Select(r => r.ProviderId)
.ToImmutableArray();
return new CrossProviderAdvisoryResult
{
CveId = cveId,
PointInTime = pointInTime,
Results = results.ToImmutableArray(),
FoundCount = foundAdvisories.Length,
MissingSnapshotProviders = missingProviders,
NotFoundProviders = notFoundProviders,
Consensus = DetermineConsensus(foundAdvisories)
};
}
/// <summary>
/// Compares advisory state between two points in time for a CVE.
/// Useful for understanding how advisory data evolved.
/// </summary>
public async Task<AdvisoryDiffResult> CompareAtTimesAsync(
string cveId,
string providerId,
DateTimeOffset time1,
DateTimeOffset time2,
CancellationToken ct = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(cveId);
ArgumentException.ThrowIfNullOrWhiteSpace(providerId);
var result1 = await ResolveAdvisoryAsync(cveId, providerId, time1, ct);
var result2 = await ResolveAdvisoryAsync(cveId, providerId, time2, ct);
return new AdvisoryDiffResult
{
CveId = cveId,
ProviderId = providerId,
Time1 = time1,
Time2 = time2,
Result1 = result1,
Result2 = result2,
DiffType = ComputeDiffType(result1, result2),
Changes = ComputeChanges(result1.Advisory, result2.Advisory)
};
}
/// <summary>
/// Retrieves the full advisory timeline for a CVE from a provider.
/// Shows how advisory data changed over time.
/// </summary>
public async Task<AdvisoryTimeline> GetAdvisoryTimelineAsync(
string cveId,
string providerId,
DateTimeOffset? from = null,
DateTimeOffset? to = null,
CancellationToken ct = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(cveId);
ArgumentException.ThrowIfNullOrWhiteSpace(providerId);
var snapshots = await _snapshotService.ListSnapshotsAsync(
providerId,
from,
to,
limit: null,
ct);
var entries = new List<AdvisoryTimelineEntry>();
AdvisoryData? previousAdvisory = null;
foreach (var snapshotSummary in snapshots.OrderBy(s => s.CapturedAt))
{
var snapshot = await _snapshotService.GetByDigestAsync(snapshotSummary.Digest, ct);
if (snapshot is null)
{
continue;
}
var advisory = await _advisoryExtractor.ExtractAdvisoryAsync(
cveId,
snapshot.Content,
snapshot.Format,
ct);
var entry = new AdvisoryTimelineEntry
{
SnapshotDigest = snapshot.Digest,
CapturedAt = snapshot.CapturedAt,
EpochTimestamp = snapshot.EpochTimestamp,
Advisory = advisory,
ChangeType = DetermineChangeType(previousAdvisory, advisory)
};
entries.Add(entry);
previousAdvisory = advisory;
}
return new AdvisoryTimeline
{
CveId = cveId,
ProviderId = providerId,
Entries = entries.ToImmutableArray(),
FirstAppearance = entries.FirstOrDefault(e => e.Advisory is not null)?.CapturedAt,
LastUpdate = entries.LastOrDefault(e => e.Advisory is not null)?.CapturedAt,
TotalSnapshots = entries.Count,
ChangesCount = entries.Count(e => e.ChangeType != AdvisoryChangeType.NoChange)
};
}
private static AdvisoryConsensus? DetermineConsensus(
ImmutableArray<AdvisoryAtTimeResult> foundAdvisories)
{
if (foundAdvisories.Length == 0)
{
return null;
}
// Collect all unique severity ratings
var severities = foundAdvisories
.Where(a => a.Advisory?.Severity is not null)
.Select(a => a.Advisory!.Severity!)
.Distinct()
.ToImmutableArray();
// Collect all unique fix statuses
var fixStatuses = foundAdvisories
.Where(a => a.Advisory?.FixStatus is not null)
.Select(a => a.Advisory!.FixStatus!)
.Distinct()
.ToImmutableArray();
// Determine if there's consensus
var severityConsensus = severities.Length == 1;
var fixStatusConsensus = fixStatuses.Length == 1;
return new AdvisoryConsensus
{
ProviderCount = foundAdvisories.Length,
SeverityConsensus = severityConsensus,
FixStatusConsensus = fixStatusConsensus,
ConsensusSeverity = severityConsensus && severities.Length > 0 ? severities[0] : null,
ConsensusFixStatus = fixStatusConsensus && fixStatuses.Length > 0 ? fixStatuses[0] : null,
SeverityValues = severities,
FixStatusValues = fixStatuses
};
}
private static AdvisoryDiffType ComputeDiffType(
AdvisoryAtTimeResult result1,
AdvisoryAtTimeResult result2)
{
if (result1.Advisory is null && result2.Advisory is null)
{
return AdvisoryDiffType.NeitherExists;
}
if (result1.Advisory is null)
{
return AdvisoryDiffType.AddedInTime2;
}
if (result2.Advisory is null)
{
return AdvisoryDiffType.RemovedInTime2;
}
// Compare advisory content
if (JsonSerializer.Serialize(result1.Advisory) == JsonSerializer.Serialize(result2.Advisory))
{
return AdvisoryDiffType.Unchanged;
}
return AdvisoryDiffType.Modified;
}
private static ImmutableArray<AdvisoryFieldChange> ComputeChanges(
AdvisoryData? advisory1,
AdvisoryData? advisory2)
{
if (advisory1 is null || advisory2 is null)
{
return ImmutableArray<AdvisoryFieldChange>.Empty;
}
var changes = new List<AdvisoryFieldChange>();
if (advisory1.Severity != advisory2.Severity)
{
changes.Add(new AdvisoryFieldChange
{
Field = "Severity",
OldValue = advisory1.Severity,
NewValue = advisory2.Severity
});
}
if (advisory1.CvssScore != advisory2.CvssScore)
{
changes.Add(new AdvisoryFieldChange
{
Field = "CvssScore",
OldValue = advisory1.CvssScore?.ToString(),
NewValue = advisory2.CvssScore?.ToString()
});
}
if (advisory1.FixStatus != advisory2.FixStatus)
{
changes.Add(new AdvisoryFieldChange
{
Field = "FixStatus",
OldValue = advisory1.FixStatus,
NewValue = advisory2.FixStatus
});
}
if (advisory1.Description != advisory2.Description)
{
changes.Add(new AdvisoryFieldChange
{
Field = "Description",
OldValue = "[changed]",
NewValue = "[changed]"
});
}
return changes.ToImmutableArray();
}
private static AdvisoryChangeType DetermineChangeType(
AdvisoryData? previous,
AdvisoryData? current)
{
if (previous is null && current is null)
{
return AdvisoryChangeType.NoChange;
}
if (previous is null)
{
return AdvisoryChangeType.Added;
}
if (current is null)
{
return AdvisoryChangeType.Removed;
}
if (JsonSerializer.Serialize(previous) == JsonSerializer.Serialize(current))
{
return AdvisoryChangeType.NoChange;
}
return AdvisoryChangeType.Modified;
}
}
/// <summary>
/// Result of resolving an advisory at a point in time.
/// </summary>
public sealed record AdvisoryAtTimeResult
{
public required string CveId { get; init; }
public required string ProviderId { get; init; }
public required DateTimeOffset PointInTime { get; init; }
public required AdvisoryResolutionStatus Status { get; init; }
public AdvisoryData? Advisory { get; init; }
public string? SnapshotDigest { get; init; }
public DateTimeOffset? SnapshotCapturedAt { get; init; }
}
/// <summary>
/// Status of advisory resolution.
/// </summary>
public enum AdvisoryResolutionStatus
{
Found,
NotFound,
NoSnapshot
}
/// <summary>
/// Advisory data extracted from a feed snapshot.
/// </summary>
public sealed record AdvisoryData
{
public required string CveId { get; init; }
public string? Severity { get; init; }
public decimal? CvssScore { get; init; }
public string? CvssVector { get; init; }
public string? Description { get; init; }
public string? FixStatus { get; init; }
public ImmutableArray<string> AffectedProducts { get; init; } = ImmutableArray<string>.Empty;
public ImmutableArray<string> References { get; init; } = ImmutableArray<string>.Empty;
public DateTimeOffset? PublishedAt { get; init; }
public DateTimeOffset? LastModifiedAt { get; init; }
public ImmutableDictionary<string, object> RawData { get; init; } =
ImmutableDictionary<string, object>.Empty;
}
/// <summary>
/// Result of cross-provider advisory resolution.
/// </summary>
public sealed record CrossProviderAdvisoryResult
{
public required string CveId { get; init; }
public required DateTimeOffset PointInTime { get; init; }
public required ImmutableArray<AdvisoryAtTimeResult> Results { get; init; }
public required int FoundCount { get; init; }
public required ImmutableArray<string> MissingSnapshotProviders { get; init; }
public required ImmutableArray<string> NotFoundProviders { get; init; }
public AdvisoryConsensus? Consensus { get; init; }
}
/// <summary>
/// Consensus analysis across providers.
/// </summary>
public sealed record AdvisoryConsensus
{
public required int ProviderCount { get; init; }
public required bool SeverityConsensus { get; init; }
public required bool FixStatusConsensus { get; init; }
public string? ConsensusSeverity { get; init; }
public string? ConsensusFixStatus { get; init; }
public required ImmutableArray<string> SeverityValues { get; init; }
public required ImmutableArray<string> FixStatusValues { get; init; }
}
/// <summary>
/// Result of comparing advisories at two points in time.
/// </summary>
public sealed record AdvisoryDiffResult
{
public required string CveId { get; init; }
public required string ProviderId { get; init; }
public required DateTimeOffset Time1 { get; init; }
public required DateTimeOffset Time2 { get; init; }
public required AdvisoryAtTimeResult Result1 { get; init; }
public required AdvisoryAtTimeResult Result2 { get; init; }
public required AdvisoryDiffType DiffType { get; init; }
public required ImmutableArray<AdvisoryFieldChange> Changes { get; init; }
}
/// <summary>
/// Type of diff between advisory states.
/// </summary>
public enum AdvisoryDiffType
{
Unchanged,
Modified,
AddedInTime2,
RemovedInTime2,
NeitherExists
}
/// <summary>
/// A field change in an advisory.
/// </summary>
public sealed record AdvisoryFieldChange
{
public required string Field { get; init; }
public string? OldValue { get; init; }
public string? NewValue { get; init; }
}
/// <summary>
/// Timeline of an advisory's evolution.
/// </summary>
public sealed record AdvisoryTimeline
{
public required string CveId { get; init; }
public required string ProviderId { get; init; }
public required ImmutableArray<AdvisoryTimelineEntry> Entries { get; init; }
public DateTimeOffset? FirstAppearance { get; init; }
public DateTimeOffset? LastUpdate { get; init; }
public required int TotalSnapshots { get; init; }
public required int ChangesCount { get; init; }
}
/// <summary>
/// Entry in an advisory timeline.
/// </summary>
public sealed record AdvisoryTimelineEntry
{
public required string SnapshotDigest { get; init; }
public required DateTimeOffset CapturedAt { get; init; }
public required DateTimeOffset EpochTimestamp { get; init; }
public AdvisoryData? Advisory { get; init; }
public required AdvisoryChangeType ChangeType { get; init; }
}
/// <summary>
/// Type of change in advisory timeline.
/// </summary>
public enum AdvisoryChangeType
{
NoChange,
Added,
Modified,
Removed
}
/// <summary>
/// Interface for extracting advisory data from feed content.
/// </summary>
public interface IAdvisoryExtractor
{
Task<AdvisoryData?> ExtractAdvisoryAsync(
string cveId,
byte[] content,
FeedSnapshotFormat format,
CancellationToken ct = default);
}

View File

@@ -0,0 +1,395 @@
// <copyright file="FeedSnapshotServiceTests.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
// </copyright>
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Time.Testing;
using StellaOps.Replay.Core.FeedSnapshots;
using System.Collections.Concurrent;
using System.Collections.Immutable;
using Xunit;
namespace StellaOps.Replay.Core.Tests.FeedSnapshots;
public sealed class FeedSnapshotServiceTests
{
private readonly FakeTimeProvider _timeProvider = new();
private readonly InMemoryFeedSnapshotBlobStore _blobStore = new();
private readonly InMemoryFeedSnapshotIndexStore _indexStore = new();
private readonly FeedSnapshotServiceOptions _options = new();
private FeedSnapshotService CreateService() => new(
_blobStore,
_indexStore,
_timeProvider,
_options,
NullLogger<FeedSnapshotService>.Instance);
[Fact]
public async Task CaptureSnapshotAsync_CreatesNewSnapshot_WhenNotExists()
{
// Arrange
var service = CreateService();
var feedData = new { cves = new[] { "CVE-2024-1234", "CVE-2024-5678" } };
var request = new CaptureSnapshotRequest
{
ProviderId = "nvd",
ProviderName = "National Vulnerability Database",
FeedType = "cve",
FeedData = feedData
};
// Act
var result = await service.CaptureSnapshotAsync(request);
// Assert
result.Success.Should().BeTrue();
result.ProviderId.Should().Be("nvd");
result.WasExisting.Should().BeFalse();
result.Digest.Should().StartWith("sha256:");
result.ContentSize.Should().BeGreaterThan(0);
}
[Fact]
public async Task CaptureSnapshotAsync_ReturnsExisting_WhenDuplicateContent()
{
// Arrange
var service = CreateService();
var feedData = new { cves = new[] { "CVE-2024-1234" } };
var request = new CaptureSnapshotRequest
{
ProviderId = "nvd",
FeedData = feedData
};
// Act
var first = await service.CaptureSnapshotAsync(request);
_timeProvider.Advance(TimeSpan.FromMinutes(5));
var second = await service.CaptureSnapshotAsync(request);
// Assert
first.WasExisting.Should().BeFalse();
second.WasExisting.Should().BeTrue();
first.Digest.Should().Be(second.Digest);
}
[Fact]
public async Task GetByDigestAsync_ReturnsSnapshot_WhenExists()
{
// Arrange
var service = CreateService();
var request = new CaptureSnapshotRequest
{
ProviderId = "ghsa",
FeedData = new { advisories = new[] { "GHSA-abcd-1234" } }
};
var captured = await service.CaptureSnapshotAsync(request);
// Act
var snapshot = await service.GetByDigestAsync(captured.Digest);
// Assert
snapshot.Should().NotBeNull();
snapshot!.Digest.Should().Be(captured.Digest);
snapshot.ProviderId.Should().Be("ghsa");
}
[Fact]
public async Task GetByDigestAsync_ReturnsNull_WhenNotExists()
{
// Arrange
var service = CreateService();
// Act
var snapshot = await service.GetByDigestAsync("sha256:nonexistent");
// Assert
snapshot.Should().BeNull();
}
[Fact]
public async Task GetSnapshotAtTimeAsync_ReturnsMostRecentSnapshot_BeforePointInTime()
{
// Arrange
var service = CreateService();
var baseTime = new DateTimeOffset(2024, 6, 1, 12, 0, 0, TimeSpan.Zero);
_timeProvider.SetUtcNow(baseTime);
await service.CaptureSnapshotAsync(new CaptureSnapshotRequest
{
ProviderId = "nvd",
FeedData = new { version = 1 }
});
_timeProvider.Advance(TimeSpan.FromHours(1));
var secondCapture = await service.CaptureSnapshotAsync(new CaptureSnapshotRequest
{
ProviderId = "nvd",
FeedData = new { version = 2 }
});
_timeProvider.Advance(TimeSpan.FromHours(1));
await service.CaptureSnapshotAsync(new CaptureSnapshotRequest
{
ProviderId = "nvd",
FeedData = new { version = 3 }
});
// Act
var queryTime = baseTime.AddHours(1).AddMinutes(30);
var snapshot = await service.GetSnapshotAtTimeAsync("nvd", queryTime);
// Assert
snapshot.Should().NotBeNull();
snapshot!.Digest.Should().Be(secondCapture.Digest);
}
[Fact]
public async Task GetSnapshotAtTimeAsync_ReturnsNull_WhenNoSnapshotsExist()
{
// Arrange
var service = CreateService();
// Act
var snapshot = await service.GetSnapshotAtTimeAsync("nvd", DateTimeOffset.UtcNow);
// Assert
snapshot.Should().BeNull();
}
[Fact]
public async Task ListSnapshotsAsync_ReturnsSnapshotsInTimeRange()
{
// Arrange
var service = CreateService();
var baseTime = new DateTimeOffset(2024, 6, 1, 0, 0, 0, TimeSpan.Zero);
_timeProvider.SetUtcNow(baseTime);
// Create 5 snapshots
for (int i = 0; i < 5; i++)
{
await service.CaptureSnapshotAsync(new CaptureSnapshotRequest
{
ProviderId = "nvd",
FeedData = new { index = i }
});
_timeProvider.Advance(TimeSpan.FromHours(1));
}
// Act
var fromTime = baseTime.AddHours(1);
var toTime = baseTime.AddHours(3);
var snapshots = await service.ListSnapshotsAsync("nvd", fromTime, toTime);
// Assert
snapshots.Length.Should().Be(3);
}
[Fact]
public async Task VerifyIntegrityAsync_ReturnsSuccess_WhenDigestMatches()
{
// Arrange
var service = CreateService();
var result = await service.CaptureSnapshotAsync(new CaptureSnapshotRequest
{
ProviderId = "nvd",
FeedData = new { test = "data" }
});
// Act
var verification = await service.VerifyIntegrityAsync(result.Digest);
// Assert
verification.Success.Should().BeTrue();
verification.ExpectedDigest.Should().Be(result.Digest);
verification.ActualDigest.Should().Be(result.Digest);
verification.Error.Should().BeNull();
}
[Fact]
public async Task VerifyIntegrityAsync_ReturnsFailure_WhenSnapshotNotFound()
{
// Arrange
var service = CreateService();
// Act
var verification = await service.VerifyIntegrityAsync("sha256:nonexistent");
// Assert
verification.Success.Should().BeFalse();
verification.Error.Should().Contain("not found");
}
[Fact]
public async Task CreateBundleAsync_CreatesBundle_WithAllProviders()
{
// Arrange
var service = CreateService();
var baseTime = new DateTimeOffset(2024, 6, 1, 12, 0, 0, TimeSpan.Zero);
_timeProvider.SetUtcNow(baseTime);
await service.CaptureSnapshotAsync(new CaptureSnapshotRequest
{
ProviderId = "nvd",
FeedData = new { source = "nvd" }
});
await service.CaptureSnapshotAsync(new CaptureSnapshotRequest
{
ProviderId = "ghsa",
FeedData = new { source = "ghsa" }
});
// Act
var bundle = await service.CreateBundleAsync(
new[] { "nvd", "ghsa" },
baseTime.AddMinutes(1));
// Assert
bundle.IsComplete.Should().BeTrue();
bundle.Snapshots.Length.Should().Be(2);
bundle.MissingProviders.Should().BeEmpty();
bundle.BundleDigest.Should().StartWith("sha256:");
}
[Fact]
public async Task CreateBundleAsync_ReportsMissingProviders()
{
// Arrange
var service = CreateService();
var baseTime = new DateTimeOffset(2024, 6, 1, 12, 0, 0, TimeSpan.Zero);
_timeProvider.SetUtcNow(baseTime);
await service.CaptureSnapshotAsync(new CaptureSnapshotRequest
{
ProviderId = "nvd",
FeedData = new { source = "nvd" }
});
// Act
var bundle = await service.CreateBundleAsync(
new[] { "nvd", "ghsa", "redhat" },
baseTime.AddMinutes(1));
// Assert
bundle.IsComplete.Should().BeFalse();
bundle.Snapshots.Length.Should().Be(1);
bundle.MissingProviders.Should().Contain("ghsa");
bundle.MissingProviders.Should().Contain("redhat");
}
[Fact]
public async Task CaptureSnapshotAsync_UsesCustomEpochTimestamp_WhenProvided()
{
// Arrange
var service = CreateService();
var epochTime = new DateTimeOffset(2024, 5, 15, 0, 0, 0, TimeSpan.Zero);
var request = new CaptureSnapshotRequest
{
ProviderId = "nvd",
FeedData = new { test = "epoch" },
EpochTimestamp = epochTime
};
// Act
var result = await service.CaptureSnapshotAsync(request);
var snapshot = await service.GetByDigestAsync(result.Digest);
// Assert
snapshot!.EpochTimestamp.Should().Be(epochTime);
}
}
#region Test helpers
internal sealed class InMemoryFeedSnapshotBlobStore : IFeedSnapshotBlobStore
{
private readonly ConcurrentDictionary<string, FeedSnapshotBlob> _blobs = new();
public Task StoreAsync(FeedSnapshotBlob blob, CancellationToken ct = default)
{
_blobs[blob.Digest] = blob;
return Task.CompletedTask;
}
public Task<FeedSnapshotBlob?> GetByDigestAsync(string digest, CancellationToken ct = default)
{
_blobs.TryGetValue(digest, out var blob);
return Task.FromResult(blob);
}
public Task<bool> ExistsAsync(string digest, CancellationToken ct = default)
{
return Task.FromResult(_blobs.ContainsKey(digest));
}
public Task DeleteAsync(string digest, CancellationToken ct = default)
{
_blobs.TryRemove(digest, out _);
return Task.CompletedTask;
}
}
internal sealed class InMemoryFeedSnapshotIndexStore : IFeedSnapshotIndexStore
{
private readonly ConcurrentDictionary<string, List<FeedSnapshotIndexEntry>> _index = new();
public Task IndexSnapshotAsync(FeedSnapshotIndexEntry entry, CancellationToken ct = default)
{
var entries = _index.GetOrAdd(entry.ProviderId, _ => new List<FeedSnapshotIndexEntry>());
lock (entries)
{
entries.Add(entry);
}
return Task.CompletedTask;
}
public Task<FeedSnapshotIndexEntry?> FindSnapshotAtTimeAsync(
string providerId,
DateTimeOffset pointInTime,
CancellationToken ct = default)
{
if (!_index.TryGetValue(providerId, out var entries))
{
return Task.FromResult<FeedSnapshotIndexEntry?>(null);
}
lock (entries)
{
var entry = entries
.Where(e => e.CapturedAt <= pointInTime)
.OrderByDescending(e => e.CapturedAt)
.FirstOrDefault();
return Task.FromResult(entry);
}
}
public Task<ImmutableArray<FeedSnapshotIndexEntry>> ListSnapshotsAsync(
string providerId,
DateTimeOffset from,
DateTimeOffset to,
int limit,
CancellationToken ct = default)
{
if (!_index.TryGetValue(providerId, out var entries))
{
return Task.FromResult(ImmutableArray<FeedSnapshotIndexEntry>.Empty);
}
lock (entries)
{
var result = entries
.Where(e => e.CapturedAt >= from && e.CapturedAt <= to)
.OrderBy(e => e.CapturedAt)
.Take(limit)
.ToImmutableArray();
return Task.FromResult(result);
}
}
}
#endregion

View File

@@ -0,0 +1,532 @@
// <copyright file="PointInTimeAdvisoryResolverTests.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
// </copyright>
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Time.Testing;
using StellaOps.Replay.Core.FeedSnapshots;
using System.Collections.Concurrent;
using System.Collections.Immutable;
using System.Text.Json;
using Xunit;
namespace StellaOps.Replay.Core.Tests.FeedSnapshots;
public sealed class PointInTimeAdvisoryResolverTests
{
private readonly FakeTimeProvider _timeProvider = new();
private readonly InMemoryFeedSnapshotBlobStore _blobStore = new();
private readonly InMemoryFeedSnapshotIndexStore _indexStore = new();
private readonly TestAdvisoryExtractor _advisoryExtractor = new();
private FeedSnapshotService CreateSnapshotService() => new(
_blobStore,
_indexStore,
_timeProvider,
new FeedSnapshotServiceOptions(),
NullLogger<FeedSnapshotService>.Instance);
private PointInTimeAdvisoryResolver CreateResolver(FeedSnapshotService? service = null) => new(
service ?? CreateSnapshotService(),
_advisoryExtractor,
NullLogger<PointInTimeAdvisoryResolver>.Instance);
[Fact]
public async Task ResolveAdvisoryAsync_ReturnsAdvisory_WhenFoundInSnapshot()
{
// Arrange
var service = CreateSnapshotService();
var resolver = CreateResolver(service);
var baseTime = new DateTimeOffset(2024, 6, 1, 12, 0, 0, TimeSpan.Zero);
_timeProvider.SetUtcNow(baseTime);
_advisoryExtractor.SetAdvisory("CVE-2024-1234", new AdvisoryData
{
CveId = "CVE-2024-1234",
Severity = "HIGH",
CvssScore = 7.5m
});
await service.CaptureSnapshotAsync(new CaptureSnapshotRequest
{
ProviderId = "nvd",
FeedData = new { cves = new[] { "CVE-2024-1234" } }
});
// Act
var result = await resolver.ResolveAdvisoryAsync(
"CVE-2024-1234",
"nvd",
baseTime.AddMinutes(1));
// Assert
result.Status.Should().Be(AdvisoryResolutionStatus.Found);
result.Advisory.Should().NotBeNull();
result.Advisory!.CveId.Should().Be("CVE-2024-1234");
result.Advisory.Severity.Should().Be("HIGH");
result.SnapshotDigest.Should().NotBeNullOrEmpty();
}
[Fact]
public async Task ResolveAdvisoryAsync_ReturnsNoSnapshot_WhenNoSnapshotExists()
{
// Arrange
var resolver = CreateResolver();
// Act
var result = await resolver.ResolveAdvisoryAsync(
"CVE-2024-9999",
"nvd",
DateTimeOffset.UtcNow);
// Assert
result.Status.Should().Be(AdvisoryResolutionStatus.NoSnapshot);
result.Advisory.Should().BeNull();
}
[Fact]
public async Task ResolveAdvisoryAsync_ReturnsNotFound_WhenCveNotInSnapshot()
{
// Arrange
var service = CreateSnapshotService();
var resolver = CreateResolver(service);
var baseTime = new DateTimeOffset(2024, 6, 1, 12, 0, 0, TimeSpan.Zero);
_timeProvider.SetUtcNow(baseTime);
// No advisory registered for CVE-2024-9999
await service.CaptureSnapshotAsync(new CaptureSnapshotRequest
{
ProviderId = "nvd",
FeedData = new { cves = new[] { "CVE-2024-1234" } }
});
// Act
var result = await resolver.ResolveAdvisoryAsync(
"CVE-2024-9999",
"nvd",
baseTime.AddMinutes(1));
// Assert
result.Status.Should().Be(AdvisoryResolutionStatus.NotFound);
result.Advisory.Should().BeNull();
}
[Fact]
public async Task ResolveCrossProviderAsync_ReturnsResultsFromAllProviders()
{
// Arrange
var service = CreateSnapshotService();
var resolver = CreateResolver(service);
var baseTime = new DateTimeOffset(2024, 6, 1, 12, 0, 0, TimeSpan.Zero);
_timeProvider.SetUtcNow(baseTime);
_advisoryExtractor.SetAdvisory("CVE-2024-1234", new AdvisoryData
{
CveId = "CVE-2024-1234",
Severity = "HIGH"
});
await service.CaptureSnapshotAsync(new CaptureSnapshotRequest
{
ProviderId = "nvd",
FeedData = new { source = "nvd" }
});
await service.CaptureSnapshotAsync(new CaptureSnapshotRequest
{
ProviderId = "ghsa",
FeedData = new { source = "ghsa" }
});
// Act
var result = await resolver.ResolveCrossProviderAsync(
"CVE-2024-1234",
new[] { "nvd", "ghsa", "redhat" },
baseTime.AddMinutes(1));
// Assert
result.Results.Length.Should().Be(3);
result.FoundCount.Should().Be(2);
result.MissingSnapshotProviders.Should().Contain("redhat");
}
[Fact]
public async Task ResolveCrossProviderAsync_ReportsConsensus_WhenProviderAgree()
{
// Arrange
var service = CreateSnapshotService();
var resolver = CreateResolver(service);
var baseTime = new DateTimeOffset(2024, 6, 1, 12, 0, 0, TimeSpan.Zero);
_timeProvider.SetUtcNow(baseTime);
_advisoryExtractor.SetAdvisory("CVE-2024-1234", new AdvisoryData
{
CveId = "CVE-2024-1234",
Severity = "HIGH",
FixStatus = "fixed"
});
await service.CaptureSnapshotAsync(new CaptureSnapshotRequest
{
ProviderId = "nvd",
FeedData = new { source = "nvd" }
});
await service.CaptureSnapshotAsync(new CaptureSnapshotRequest
{
ProviderId = "ghsa",
FeedData = new { source = "ghsa" }
});
// Act
var result = await resolver.ResolveCrossProviderAsync(
"CVE-2024-1234",
new[] { "nvd", "ghsa" },
baseTime.AddMinutes(1));
// Assert
result.Consensus.Should().NotBeNull();
result.Consensus!.SeverityConsensus.Should().BeTrue();
result.Consensus.ConsensusSeverity.Should().Be("HIGH");
result.Consensus.FixStatusConsensus.Should().BeTrue();
result.Consensus.ConsensusFixStatus.Should().Be("fixed");
}
[Fact]
public async Task CompareAtTimesAsync_DetectsModification()
{
// Arrange
var service = CreateSnapshotService();
var resolver = CreateResolver(service);
var baseTime = new DateTimeOffset(2024, 6, 1, 12, 0, 0, TimeSpan.Zero);
_timeProvider.SetUtcNow(baseTime);
_advisoryExtractor.SetAdvisory("CVE-2024-1234", new AdvisoryData
{
CveId = "CVE-2024-1234",
Severity = "MEDIUM",
CvssScore = 5.0m
});
await service.CaptureSnapshotAsync(new CaptureSnapshotRequest
{
ProviderId = "nvd",
FeedData = new { version = 1 }
});
var time1 = baseTime.AddMinutes(1);
_timeProvider.Advance(TimeSpan.FromHours(1));
// Update the advisory
_advisoryExtractor.SetAdvisory("CVE-2024-1234", new AdvisoryData
{
CveId = "CVE-2024-1234",
Severity = "HIGH",
CvssScore = 8.0m
});
await service.CaptureSnapshotAsync(new CaptureSnapshotRequest
{
ProviderId = "nvd",
FeedData = new { version = 2 }
});
var time2 = baseTime.AddHours(1).AddMinutes(1);
// Act
var diff = await resolver.CompareAtTimesAsync("CVE-2024-1234", "nvd", time1, time2);
// Assert
diff.DiffType.Should().Be(AdvisoryDiffType.Modified);
diff.Changes.Should().Contain(c => c.Field == "Severity");
diff.Changes.Should().Contain(c => c.Field == "CvssScore");
}
[Fact]
public async Task CompareAtTimesAsync_DetectsAddition()
{
// Arrange
var service = CreateSnapshotService();
var resolver = CreateResolver(service);
var baseTime = new DateTimeOffset(2024, 6, 1, 12, 0, 0, TimeSpan.Zero);
_timeProvider.SetUtcNow(baseTime);
// No advisory at first snapshot
await service.CaptureSnapshotAsync(new CaptureSnapshotRequest
{
ProviderId = "nvd",
FeedData = new { version = 1 }
});
var time1 = baseTime.AddMinutes(1);
_timeProvider.Advance(TimeSpan.FromHours(1));
// Add advisory
_advisoryExtractor.SetAdvisory("CVE-2024-1234", new AdvisoryData
{
CveId = "CVE-2024-1234",
Severity = "HIGH"
});
await service.CaptureSnapshotAsync(new CaptureSnapshotRequest
{
ProviderId = "nvd",
FeedData = new { version = 2 }
});
var time2 = baseTime.AddHours(1).AddMinutes(1);
// Act
var diff = await resolver.CompareAtTimesAsync("CVE-2024-1234", "nvd", time1, time2);
// Assert
diff.DiffType.Should().Be(AdvisoryDiffType.AddedInTime2);
}
[Fact]
public async Task GetAdvisoryTimelineAsync_ReturnsCompleteTimeline()
{
// Arrange
var service = CreateSnapshotService();
var resolver = CreateResolver(service);
var baseTime = new DateTimeOffset(2024, 6, 1, 12, 0, 0, TimeSpan.Zero);
_timeProvider.SetUtcNow(baseTime);
// Snapshot 1: No advisory
await service.CaptureSnapshotAsync(new CaptureSnapshotRequest
{
ProviderId = "nvd",
FeedData = new { index = 1 }
});
_timeProvider.Advance(TimeSpan.FromHours(1));
// Snapshot 2: Advisory added
_advisoryExtractor.SetAdvisory("CVE-2024-1234", new AdvisoryData
{
CveId = "CVE-2024-1234",
Severity = "MEDIUM"
});
await service.CaptureSnapshotAsync(new CaptureSnapshotRequest
{
ProviderId = "nvd",
FeedData = new { index = 2 }
});
_timeProvider.Advance(TimeSpan.FromHours(1));
// Snapshot 3: Advisory modified
_advisoryExtractor.SetAdvisory("CVE-2024-1234", new AdvisoryData
{
CveId = "CVE-2024-1234",
Severity = "HIGH"
});
await service.CaptureSnapshotAsync(new CaptureSnapshotRequest
{
ProviderId = "nvd",
FeedData = new { index = 3 }
});
// Act
var timeline = await resolver.GetAdvisoryTimelineAsync("CVE-2024-1234", "nvd");
// Assert
timeline.TotalSnapshots.Should().Be(3);
timeline.ChangesCount.Should().BeGreaterThan(0);
timeline.FirstAppearance.Should().NotBeNull();
}
[Fact]
public async Task ResolveCrossProviderAsync_ReportsNoConsensus_WhenProvidersDisagree()
{
// Arrange
var service = CreateSnapshotService();
var resolver = CreateResolver(service);
var baseTime = new DateTimeOffset(2024, 6, 1, 12, 0, 0, TimeSpan.Zero);
_timeProvider.SetUtcNow(baseTime);
_advisoryExtractor.SetProviderAdvisory("nvd", "CVE-2024-1234", new AdvisoryData
{
CveId = "CVE-2024-1234",
Severity = "HIGH"
});
_advisoryExtractor.SetProviderAdvisory("ghsa", "CVE-2024-1234", new AdvisoryData
{
CveId = "CVE-2024-1234",
Severity = "MEDIUM"
});
await service.CaptureSnapshotAsync(new CaptureSnapshotRequest
{
ProviderId = "nvd",
FeedData = new { source = "nvd" }
});
await service.CaptureSnapshotAsync(new CaptureSnapshotRequest
{
ProviderId = "ghsa",
FeedData = new { source = "ghsa" }
});
// Act
var result = await resolver.ResolveCrossProviderAsync(
"CVE-2024-1234",
new[] { "nvd", "ghsa" },
baseTime.AddMinutes(1));
// Assert
result.Consensus.Should().NotBeNull();
result.Consensus!.SeverityConsensus.Should().BeFalse();
result.Consensus.SeverityValues.Should().Contain("HIGH");
result.Consensus.SeverityValues.Should().Contain("MEDIUM");
}
}
#region Test helpers
internal sealed class InMemoryFeedSnapshotBlobStore : IFeedSnapshotBlobStore
{
private readonly ConcurrentDictionary<string, FeedSnapshotBlob> _blobs = new();
public Task StoreAsync(FeedSnapshotBlob blob, CancellationToken ct = default)
{
_blobs[blob.Digest] = blob;
return Task.CompletedTask;
}
public Task<FeedSnapshotBlob?> GetByDigestAsync(string digest, CancellationToken ct = default)
{
_blobs.TryGetValue(digest, out var blob);
return Task.FromResult(blob);
}
public Task<bool> ExistsAsync(string digest, CancellationToken ct = default)
{
return Task.FromResult(_blobs.ContainsKey(digest));
}
public Task DeleteAsync(string digest, CancellationToken ct = default)
{
_blobs.TryRemove(digest, out _);
return Task.CompletedTask;
}
}
internal sealed class InMemoryFeedSnapshotIndexStore : IFeedSnapshotIndexStore
{
private readonly ConcurrentDictionary<string, List<FeedSnapshotIndexEntry>> _index = new();
public Task IndexSnapshotAsync(FeedSnapshotIndexEntry entry, CancellationToken ct = default)
{
var entries = _index.GetOrAdd(entry.ProviderId, _ => new List<FeedSnapshotIndexEntry>());
lock (entries)
{
entries.Add(entry);
}
return Task.CompletedTask;
}
public Task<FeedSnapshotIndexEntry?> FindSnapshotAtTimeAsync(
string providerId,
DateTimeOffset pointInTime,
CancellationToken ct = default)
{
if (!_index.TryGetValue(providerId, out var entries))
{
return Task.FromResult<FeedSnapshotIndexEntry?>(null);
}
lock (entries)
{
var entry = entries
.Where(e => e.CapturedAt <= pointInTime)
.OrderByDescending(e => e.CapturedAt)
.FirstOrDefault();
return Task.FromResult(entry);
}
}
public Task<ImmutableArray<FeedSnapshotIndexEntry>> ListSnapshotsAsync(
string providerId,
DateTimeOffset from,
DateTimeOffset to,
int limit,
CancellationToken ct = default)
{
if (!_index.TryGetValue(providerId, out var entries))
{
return Task.FromResult(ImmutableArray<FeedSnapshotIndexEntry>.Empty);
}
lock (entries)
{
var result = entries
.Where(e => e.CapturedAt >= from && e.CapturedAt <= to)
.OrderBy(e => e.CapturedAt)
.Take(limit)
.ToImmutableArray();
return Task.FromResult(result);
}
}
}
internal sealed class TestAdvisoryExtractor : IAdvisoryExtractor
{
private readonly ConcurrentDictionary<string, AdvisoryData> _advisories = new();
private readonly ConcurrentDictionary<string, ConcurrentDictionary<string, AdvisoryData>> _providerAdvisories = new();
public void SetAdvisory(string cveId, AdvisoryData advisory)
{
_advisories[cveId] = advisory;
}
public void SetProviderAdvisory(string providerId, string cveId, AdvisoryData advisory)
{
var providerDict = _providerAdvisories.GetOrAdd(providerId, _ => new ConcurrentDictionary<string, AdvisoryData>());
providerDict[cveId] = advisory;
}
public Task<AdvisoryData?> ExtractAdvisoryAsync(
string cveId,
byte[] content,
FeedSnapshotFormat format,
CancellationToken ct = default)
{
// Try to extract provider ID from content to support per-provider advisories
try
{
var json = JsonSerializer.Deserialize<JsonElement>(content);
if (json.TryGetProperty("source", out var sourceElement))
{
var providerId = sourceElement.GetString();
if (providerId != null &&
_providerAdvisories.TryGetValue(providerId, out var providerDict) &&
providerDict.TryGetValue(cveId, out var providerAdvisory))
{
return Task.FromResult<AdvisoryData?>(providerAdvisory);
}
}
}
catch
{
// Ignore JSON parsing errors, fall through to default lookup
}
_advisories.TryGetValue(cveId, out var advisory);
return Task.FromResult(advisory);
}
}
#endregion

View File

@@ -0,0 +1,445 @@
// <copyright file="PointInTimeQueryEndpointsTests.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
// </copyright>
using FluentAssertions;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Time.Testing;
using StellaOps.Replay.Core.FeedSnapshots;
using StellaOps.Replay.WebService;
using System.Collections.Concurrent;
using System.Collections.Immutable;
using System.Text.Json;
using Xunit;
namespace StellaOps.Replay.Core.Tests.FeedSnapshots;
public sealed class PointInTimeQueryEndpointsTests
{
private readonly FakeTimeProvider _timeProvider = new();
private readonly InMemoryFeedSnapshotBlobStore _blobStore = new();
private readonly InMemoryFeedSnapshotIndexStore _indexStore = new();
private readonly TestAdvisoryExtractor _advisoryExtractor = new();
private FeedSnapshotService CreateSnapshotService() => new(
_blobStore,
_indexStore,
_timeProvider,
new FeedSnapshotServiceOptions(),
NullLogger<FeedSnapshotService>.Instance);
private PointInTimeAdvisoryResolver CreateResolver(FeedSnapshotService? service = null) => new(
service ?? CreateSnapshotService(),
_advisoryExtractor,
NullLogger<PointInTimeAdvisoryResolver>.Instance);
[Fact]
public async Task QueryAdvisoryAsync_ReturnsAdvisory_WhenFound()
{
// Arrange
var service = CreateSnapshotService();
var resolver = CreateResolver(service);
var baseTime = new DateTimeOffset(2024, 6, 1, 12, 0, 0, TimeSpan.Zero);
_timeProvider.SetUtcNow(baseTime);
_advisoryExtractor.SetAdvisory("CVE-2024-1234", new AdvisoryData
{
CveId = "CVE-2024-1234",
Severity = "HIGH",
CvssScore = 8.5m
});
await service.CaptureSnapshotAsync(new CaptureSnapshotRequest
{
ProviderId = "nvd",
FeedData = new { test = true }
});
var queryParams = new AdvisoryQueryParameters
{
ProviderId = "nvd",
PointInTime = baseTime.AddMinutes(1)
};
// Act - Simulate endpoint call (testing the resolver directly since we can't easily test minimal API)
var result = await resolver.ResolveAdvisoryAsync(
"CVE-2024-1234",
queryParams.ProviderId!,
queryParams.PointInTime!.Value);
// Assert
result.Status.Should().Be(AdvisoryResolutionStatus.Found);
result.Advisory.Should().NotBeNull();
result.Advisory!.Severity.Should().Be("HIGH");
}
[Fact]
public async Task QueryAdvisoryAsync_ReturnsNoSnapshot_WhenNoneExists()
{
// Arrange
var resolver = CreateResolver();
var queryParams = new AdvisoryQueryParameters
{
ProviderId = "nvd",
PointInTime = DateTimeOffset.UtcNow
};
// Act
var result = await resolver.ResolveAdvisoryAsync(
"CVE-2024-1234",
queryParams.ProviderId!,
queryParams.PointInTime!.Value);
// Assert
result.Status.Should().Be(AdvisoryResolutionStatus.NoSnapshot);
}
[Fact]
public async Task QueryCrossProviderAsync_ReturnsAggregatedResults()
{
// Arrange
var service = CreateSnapshotService();
var resolver = CreateResolver(service);
var baseTime = new DateTimeOffset(2024, 6, 1, 12, 0, 0, TimeSpan.Zero);
_timeProvider.SetUtcNow(baseTime);
_advisoryExtractor.SetAdvisory("CVE-2024-1234", new AdvisoryData
{
CveId = "CVE-2024-1234",
Severity = "HIGH"
});
await service.CaptureSnapshotAsync(new CaptureSnapshotRequest
{
ProviderId = "nvd",
FeedData = new { source = "nvd" }
});
await service.CaptureSnapshotAsync(new CaptureSnapshotRequest
{
ProviderId = "ghsa",
FeedData = new { source = "ghsa" }
});
var request = new CrossProviderQueryRequest
{
CveId = "CVE-2024-1234",
ProviderIds = new[] { "nvd", "ghsa", "missing" },
PointInTime = baseTime.AddMinutes(1)
};
// Act
var result = await resolver.ResolveCrossProviderAsync(
request.CveId,
request.ProviderIds,
request.PointInTime);
// Assert
result.FoundCount.Should().Be(2);
result.MissingSnapshotProviders.Should().Contain("missing");
}
[Fact]
public async Task CompareAdvisoryAtTimesAsync_DetectsChanges()
{
// Arrange
var service = CreateSnapshotService();
var resolver = CreateResolver(service);
var baseTime = new DateTimeOffset(2024, 6, 1, 12, 0, 0, TimeSpan.Zero);
_timeProvider.SetUtcNow(baseTime);
_advisoryExtractor.SetAdvisory("CVE-2024-1234", new AdvisoryData
{
CveId = "CVE-2024-1234",
Severity = "MEDIUM"
});
await service.CaptureSnapshotAsync(new CaptureSnapshotRequest
{
ProviderId = "nvd",
FeedData = new { v = 1 }
});
_timeProvider.Advance(TimeSpan.FromHours(1));
_advisoryExtractor.SetAdvisory("CVE-2024-1234", new AdvisoryData
{
CveId = "CVE-2024-1234",
Severity = "HIGH"
});
await service.CaptureSnapshotAsync(new CaptureSnapshotRequest
{
ProviderId = "nvd",
FeedData = new { v = 2 }
});
// Act
var diff = await resolver.CompareAtTimesAsync(
"CVE-2024-1234",
"nvd",
baseTime.AddMinutes(1),
baseTime.AddHours(1).AddMinutes(1));
// Assert
diff.DiffType.Should().Be(AdvisoryDiffType.Modified);
diff.Changes.Should().Contain(c => c.Field == "Severity");
}
[Fact]
public async Task CaptureSnapshotAsync_StoresSnapshot()
{
// Arrange
var service = CreateSnapshotService();
var request = new SnapshotCaptureRequest
{
ProviderId = "nvd",
ProviderName = "NVD",
FeedType = "cve",
FeedData = new { cves = new[] { "CVE-2024-1234" } }
};
// Act
var result = await service.CaptureSnapshotAsync(new CaptureSnapshotRequest
{
ProviderId = request.ProviderId,
ProviderName = request.ProviderName,
FeedType = request.FeedType,
FeedData = request.FeedData
});
// Assert
result.Success.Should().BeTrue();
result.Digest.Should().StartWith("sha256:");
}
[Fact]
public async Task GetSnapshotAsync_ReturnsSnapshot_WhenExists()
{
// Arrange
var service = CreateSnapshotService();
var captureResult = await service.CaptureSnapshotAsync(new CaptureSnapshotRequest
{
ProviderId = "nvd",
FeedData = new { test = true }
});
// Act
var snapshot = await service.GetByDigestAsync(captureResult.Digest);
// Assert
snapshot.Should().NotBeNull();
snapshot!.ProviderId.Should().Be("nvd");
}
[Fact]
public async Task VerifySnapshotIntegrityAsync_ReturnsSuccess_WhenValid()
{
// Arrange
var service = CreateSnapshotService();
var captureResult = await service.CaptureSnapshotAsync(new CaptureSnapshotRequest
{
ProviderId = "nvd",
FeedData = new { test = true }
});
// Act
var verification = await service.VerifyIntegrityAsync(captureResult.Digest);
// Assert
verification.Success.Should().BeTrue();
verification.ActualDigest.Should().Be(verification.ExpectedDigest);
}
[Fact]
public async Task CreateSnapshotBundleAsync_CreatesBundle()
{
// Arrange
var service = CreateSnapshotService();
var baseTime = new DateTimeOffset(2024, 6, 1, 12, 0, 0, TimeSpan.Zero);
_timeProvider.SetUtcNow(baseTime);
await service.CaptureSnapshotAsync(new CaptureSnapshotRequest
{
ProviderId = "nvd",
FeedData = new { source = "nvd" }
});
await service.CaptureSnapshotAsync(new CaptureSnapshotRequest
{
ProviderId = "ghsa",
FeedData = new { source = "ghsa" }
});
var request = new SnapshotBundleRequest
{
ProviderIds = new[] { "nvd", "ghsa" },
PointInTime = baseTime.AddMinutes(1)
};
// Act
var bundle = await service.CreateBundleAsync(
request.ProviderIds,
request.PointInTime);
// Assert
bundle.IsComplete.Should().BeTrue();
bundle.Snapshots.Length.Should().Be(2);
}
[Fact]
public async Task GetAdvisoryTimelineAsync_ReturnsTimeline()
{
// Arrange
var service = CreateSnapshotService();
var resolver = CreateResolver(service);
var baseTime = new DateTimeOffset(2024, 6, 1, 12, 0, 0, TimeSpan.Zero);
_timeProvider.SetUtcNow(baseTime);
await service.CaptureSnapshotAsync(new CaptureSnapshotRequest
{
ProviderId = "nvd",
FeedData = new { v = 1 }
});
_timeProvider.Advance(TimeSpan.FromHours(1));
_advisoryExtractor.SetAdvisory("CVE-2024-1234", new AdvisoryData
{
CveId = "CVE-2024-1234",
Severity = "HIGH"
});
await service.CaptureSnapshotAsync(new CaptureSnapshotRequest
{
ProviderId = "nvd",
FeedData = new { v = 2 }
});
// Act
var timeline = await resolver.GetAdvisoryTimelineAsync("CVE-2024-1234", "nvd");
// Assert
timeline.TotalSnapshots.Should().Be(2);
timeline.FirstAppearance.Should().NotBeNull();
}
}
#region Test helpers (duplicated for standalone test execution)
internal sealed class InMemoryFeedSnapshotBlobStore : IFeedSnapshotBlobStore
{
private readonly ConcurrentDictionary<string, FeedSnapshotBlob> _blobs = new();
public Task StoreAsync(FeedSnapshotBlob blob, CancellationToken ct = default)
{
_blobs[blob.Digest] = blob;
return Task.CompletedTask;
}
public Task<FeedSnapshotBlob?> GetByDigestAsync(string digest, CancellationToken ct = default)
{
_blobs.TryGetValue(digest, out var blob);
return Task.FromResult(blob);
}
public Task<bool> ExistsAsync(string digest, CancellationToken ct = default)
{
return Task.FromResult(_blobs.ContainsKey(digest));
}
public Task DeleteAsync(string digest, CancellationToken ct = default)
{
_blobs.TryRemove(digest, out _);
return Task.CompletedTask;
}
}
internal sealed class InMemoryFeedSnapshotIndexStore : IFeedSnapshotIndexStore
{
private readonly ConcurrentDictionary<string, List<FeedSnapshotIndexEntry>> _index = new();
public Task IndexSnapshotAsync(FeedSnapshotIndexEntry entry, CancellationToken ct = default)
{
var entries = _index.GetOrAdd(entry.ProviderId, _ => new List<FeedSnapshotIndexEntry>());
lock (entries)
{
entries.Add(entry);
}
return Task.CompletedTask;
}
public Task<FeedSnapshotIndexEntry?> FindSnapshotAtTimeAsync(
string providerId,
DateTimeOffset pointInTime,
CancellationToken ct = default)
{
if (!_index.TryGetValue(providerId, out var entries))
{
return Task.FromResult<FeedSnapshotIndexEntry?>(null);
}
lock (entries)
{
var entry = entries
.Where(e => e.CapturedAt <= pointInTime)
.OrderByDescending(e => e.CapturedAt)
.FirstOrDefault();
return Task.FromResult(entry);
}
}
public Task<ImmutableArray<FeedSnapshotIndexEntry>> ListSnapshotsAsync(
string providerId,
DateTimeOffset from,
DateTimeOffset to,
int limit,
CancellationToken ct = default)
{
if (!_index.TryGetValue(providerId, out var entries))
{
return Task.FromResult(ImmutableArray<FeedSnapshotIndexEntry>.Empty);
}
lock (entries)
{
var result = entries
.Where(e => e.CapturedAt >= from && e.CapturedAt <= to)
.OrderBy(e => e.CapturedAt)
.Take(limit)
.ToImmutableArray();
return Task.FromResult(result);
}
}
}
internal sealed class TestAdvisoryExtractor : IAdvisoryExtractor
{
private readonly ConcurrentDictionary<string, AdvisoryData> _advisories = new();
public void SetAdvisory(string cveId, AdvisoryData advisory)
{
_advisories[cveId] = advisory;
}
public Task<AdvisoryData?> ExtractAdvisoryAsync(
string cveId,
byte[] content,
FeedSnapshotFormat format,
CancellationToken ct = default)
{
_advisories.TryGetValue(cveId, out var advisory);
return Task.FromResult(advisory);
}
}
#endregion