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:
master
2025-12-18 16:19:16 +02:00
parent 00d2c99af9
commit 811f35cba7
114 changed files with 13702 additions and 268 deletions

View File

@@ -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