partly or unimplemented features - now implemented
This commit is contained in:
@@ -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
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user