feat(telemetry): add telemetry client and services for tracking events
- Implemented TelemetryClient to handle event queuing and flushing to the telemetry endpoint. - Created TtfsTelemetryService for emitting specific telemetry events related to TTFS. - Added tests for TelemetryClient to ensure event queuing and flushing functionality. - Introduced models for reachability drift detection, including DriftResult and DriftedSink. - Developed DriftApiService for interacting with the drift detection API. - Updated FirstSignalCardComponent to emit telemetry events on signal appearance. - Enhanced localization support for first signal component with i18n strings.
This commit is contained in:
@@ -0,0 +1,320 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// EpssEndpoints.cs
|
||||
// Sprint: SPRINT_3410_0002_0001_epss_scanner_integration
|
||||
// Task: EPSS-SCAN-008, EPSS-SCAN-009
|
||||
// Description: EPSS lookup API endpoints.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using StellaOps.Scanner.Core.Epss;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// EPSS lookup API endpoints.
|
||||
/// Provides bulk lookup and history APIs for EPSS scores.
|
||||
/// </summary>
|
||||
public static class EpssEndpoints
|
||||
{
|
||||
/// <summary>
|
||||
/// Maps EPSS endpoints to the route builder.
|
||||
/// </summary>
|
||||
public static IEndpointRouteBuilder MapEpssEndpoints(this IEndpointRouteBuilder endpoints)
|
||||
{
|
||||
var group = endpoints.MapGroup("/epss")
|
||||
.WithTags("EPSS")
|
||||
.WithOpenApi();
|
||||
|
||||
group.MapPost("/current", GetCurrentBatch)
|
||||
.WithName("GetCurrentEpss")
|
||||
.WithSummary("Get current EPSS scores for multiple CVEs")
|
||||
.WithDescription("Returns the latest EPSS scores and percentiles for the specified CVE IDs. " +
|
||||
"Maximum batch size is 1000 CVEs per request.")
|
||||
.Produces<EpssBatchResponse>(StatusCodes.Status200OK)
|
||||
.Produces<ProblemDetails>(StatusCodes.Status400BadRequest)
|
||||
.Produces<ProblemDetails>(StatusCodes.Status503ServiceUnavailable);
|
||||
|
||||
group.MapGet("/current/{cveId}", GetCurrent)
|
||||
.WithName("GetCurrentEpssSingle")
|
||||
.WithSummary("Get current EPSS score for a single CVE")
|
||||
.WithDescription("Returns the latest EPSS score and percentile for the specified CVE ID.")
|
||||
.Produces<EpssEvidence>(StatusCodes.Status200OK)
|
||||
.Produces<ProblemDetails>(StatusCodes.Status404NotFound);
|
||||
|
||||
group.MapGet("/history/{cveId}", GetHistory)
|
||||
.WithName("GetEpssHistory")
|
||||
.WithSummary("Get EPSS score history for a CVE")
|
||||
.WithDescription("Returns the EPSS score time series for the specified CVE ID and date range.")
|
||||
.Produces<EpssHistoryResponse>(StatusCodes.Status200OK)
|
||||
.Produces<ProblemDetails>(StatusCodes.Status400BadRequest)
|
||||
.Produces<ProblemDetails>(StatusCodes.Status404NotFound);
|
||||
|
||||
group.MapGet("/status", GetStatus)
|
||||
.WithName("GetEpssStatus")
|
||||
.WithSummary("Get EPSS data availability status")
|
||||
.WithDescription("Returns the current status of the EPSS data provider.")
|
||||
.Produces<EpssStatusResponse>(StatusCodes.Status200OK);
|
||||
|
||||
return endpoints;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// POST /epss/current - Bulk lookup of current EPSS scores.
|
||||
/// </summary>
|
||||
private static async Task<IResult> GetCurrentBatch(
|
||||
[FromBody] EpssBatchRequest request,
|
||||
[FromServices] IEpssProvider epssProvider,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (request.CveIds is null || request.CveIds.Count == 0)
|
||||
{
|
||||
return Results.BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Invalid request",
|
||||
Detail = "At least one CVE ID is required.",
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
});
|
||||
}
|
||||
|
||||
if (request.CveIds.Count > 1000)
|
||||
{
|
||||
return Results.BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Batch size exceeded",
|
||||
Detail = "Maximum batch size is 1000 CVE IDs.",
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
});
|
||||
}
|
||||
|
||||
var isAvailable = await epssProvider.IsAvailableAsync(cancellationToken);
|
||||
if (!isAvailable)
|
||||
{
|
||||
return Results.Problem(
|
||||
detail: "EPSS data is not available. Please ensure EPSS data has been ingested.",
|
||||
statusCode: StatusCodes.Status503ServiceUnavailable);
|
||||
}
|
||||
|
||||
var result = await epssProvider.GetCurrentBatchAsync(request.CveIds, cancellationToken);
|
||||
|
||||
return Results.Ok(new EpssBatchResponse
|
||||
{
|
||||
Found = result.Found,
|
||||
NotFound = result.NotFound,
|
||||
ModelDate = result.ModelDate.ToString("yyyy-MM-dd"),
|
||||
LookupTimeMs = result.LookupTimeMs,
|
||||
PartiallyFromCache = result.PartiallyFromCache
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// GET /epss/current/{cveId} - Get current EPSS score for a single CVE.
|
||||
/// </summary>
|
||||
private static async Task<IResult> GetCurrent(
|
||||
[FromRoute] string cveId,
|
||||
[FromServices] IEpssProvider epssProvider,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(cveId))
|
||||
{
|
||||
return Results.BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Invalid CVE ID",
|
||||
Detail = "CVE ID is required.",
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
});
|
||||
}
|
||||
|
||||
var evidence = await epssProvider.GetCurrentAsync(cveId, cancellationToken);
|
||||
|
||||
if (evidence is null)
|
||||
{
|
||||
return Results.NotFound(new ProblemDetails
|
||||
{
|
||||
Title = "CVE not found",
|
||||
Detail = $"No EPSS score found for {cveId}.",
|
||||
Status = StatusCodes.Status404NotFound
|
||||
});
|
||||
}
|
||||
|
||||
return Results.Ok(evidence);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// GET /epss/history/{cveId} - Get EPSS score history for a CVE.
|
||||
/// </summary>
|
||||
private static async Task<IResult> GetHistory(
|
||||
[FromRoute] string cveId,
|
||||
[FromServices] IEpssProvider epssProvider,
|
||||
[FromQuery] string? startDate = null,
|
||||
[FromQuery] string? endDate = null,
|
||||
[FromQuery] int days = 30,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(cveId))
|
||||
{
|
||||
return Results.BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Invalid CVE ID",
|
||||
Detail = "CVE ID is required.",
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
});
|
||||
}
|
||||
|
||||
DateOnly start, end;
|
||||
|
||||
if (!string.IsNullOrEmpty(startDate) && !string.IsNullOrEmpty(endDate))
|
||||
{
|
||||
if (!DateOnly.TryParse(startDate, out start) || !DateOnly.TryParse(endDate, out end))
|
||||
{
|
||||
return Results.BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Invalid date format",
|
||||
Detail = "Dates must be in yyyy-MM-dd format.",
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
});
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Default to last N days
|
||||
end = DateOnly.FromDateTime(DateTime.UtcNow);
|
||||
start = end.AddDays(-days);
|
||||
}
|
||||
|
||||
var history = await epssProvider.GetHistoryAsync(cveId, start, end, cancellationToken);
|
||||
|
||||
if (history.Count == 0)
|
||||
{
|
||||
return Results.NotFound(new ProblemDetails
|
||||
{
|
||||
Title = "No history found",
|
||||
Detail = $"No EPSS history found for {cveId} in the specified date range.",
|
||||
Status = StatusCodes.Status404NotFound
|
||||
});
|
||||
}
|
||||
|
||||
return Results.Ok(new EpssHistoryResponse
|
||||
{
|
||||
CveId = cveId,
|
||||
StartDate = start.ToString("yyyy-MM-dd"),
|
||||
EndDate = end.ToString("yyyy-MM-dd"),
|
||||
History = history
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// GET /epss/status - Get EPSS data availability status.
|
||||
/// </summary>
|
||||
private static async Task<IResult> GetStatus(
|
||||
[FromServices] IEpssProvider epssProvider,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var isAvailable = await epssProvider.IsAvailableAsync(cancellationToken);
|
||||
var modelDate = await epssProvider.GetLatestModelDateAsync(cancellationToken);
|
||||
|
||||
return Results.Ok(new EpssStatusResponse
|
||||
{
|
||||
Available = isAvailable,
|
||||
LatestModelDate = modelDate?.ToString("yyyy-MM-dd"),
|
||||
LastCheckedUtc = DateTimeOffset.UtcNow
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#region Request/Response Models
|
||||
|
||||
/// <summary>
|
||||
/// Request for bulk EPSS lookup.
|
||||
/// </summary>
|
||||
public sealed record EpssBatchRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// List of CVE IDs to look up (max 1000).
|
||||
/// </summary>
|
||||
[Required]
|
||||
public required IReadOnlyList<string> CveIds { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response for bulk EPSS lookup.
|
||||
/// </summary>
|
||||
public sealed record EpssBatchResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// EPSS evidence for found CVEs.
|
||||
/// </summary>
|
||||
public required IReadOnlyList<EpssEvidence> Found { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// CVE IDs that were not found in the EPSS dataset.
|
||||
/// </summary>
|
||||
public required IReadOnlyList<string> NotFound { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// EPSS model date used for this lookup.
|
||||
/// </summary>
|
||||
public required string ModelDate { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Total lookup time in milliseconds.
|
||||
/// </summary>
|
||||
public long LookupTimeMs { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether any results came from cache.
|
||||
/// </summary>
|
||||
public bool PartiallyFromCache { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response for EPSS history lookup.
|
||||
/// </summary>
|
||||
public sealed record EpssHistoryResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// CVE identifier.
|
||||
/// </summary>
|
||||
public required string CveId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Start of date range.
|
||||
/// </summary>
|
||||
public required string StartDate { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// End of date range.
|
||||
/// </summary>
|
||||
public required string EndDate { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Historical EPSS evidence records.
|
||||
/// </summary>
|
||||
public required IReadOnlyList<EpssEvidence> History { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response for EPSS status check.
|
||||
/// </summary>
|
||||
public sealed record EpssStatusResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether EPSS data is available.
|
||||
/// </summary>
|
||||
public bool Available { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Latest EPSS model date available.
|
||||
/// </summary>
|
||||
public string? LatestModelDate { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When this status was checked.
|
||||
/// </summary>
|
||||
public DateTimeOffset LastCheckedUtc { get; init; }
|
||||
}
|
||||
|
||||
#endregion
|
||||
Reference in New Issue
Block a user