save development progress
This commit is contained in:
@@ -0,0 +1,492 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Copyright © 2025 StellaOps
|
||||
// Sprint: SPRINT_8200_0012_0004_api_endpoints
|
||||
// Task: API-8200-001 - Define request/response DTOs for EWS scoring
|
||||
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Findings.Ledger.WebService.Contracts;
|
||||
|
||||
#region Request DTOs
|
||||
|
||||
/// <summary>
|
||||
/// Request to calculate score for a single finding.
|
||||
/// </summary>
|
||||
public sealed record CalculateScoreRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Force recalculation even if cached score exists.
|
||||
/// </summary>
|
||||
public bool ForceRecalculate { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Include detailed breakdown in response.
|
||||
/// </summary>
|
||||
public bool IncludeBreakdown { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Specific policy version to use. Null = use latest.
|
||||
/// </summary>
|
||||
public string? PolicyVersion { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to calculate scores for multiple findings.
|
||||
/// </summary>
|
||||
public sealed record CalculateScoresBatchRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Finding IDs to calculate scores for. Max 100.
|
||||
/// </summary>
|
||||
public required IReadOnlyList<string> FindingIds { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Force recalculation even if cached scores exist.
|
||||
/// </summary>
|
||||
public bool ForceRecalculate { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Include detailed breakdown in response.
|
||||
/// </summary>
|
||||
public bool IncludeBreakdown { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Specific policy version to use. Null = use latest.
|
||||
/// </summary>
|
||||
public string? PolicyVersion { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to register a webhook for score changes.
|
||||
/// </summary>
|
||||
public sealed record RegisterWebhookRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Webhook URL to call on score changes.
|
||||
/// </summary>
|
||||
public required string Url { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional secret for HMAC-SHA256 signature.
|
||||
/// </summary>
|
||||
public string? Secret { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Finding ID patterns to watch. Empty = all findings.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string>? FindingPatterns { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Minimum score change to trigger webhook.
|
||||
/// </summary>
|
||||
public int MinScoreChange { get; init; } = 5;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to trigger on bucket changes.
|
||||
/// </summary>
|
||||
public bool TriggerOnBucketChange { get; init; } = true;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Response DTOs
|
||||
|
||||
/// <summary>
|
||||
/// Evidence-weighted score calculation result.
|
||||
/// </summary>
|
||||
public sealed record EvidenceWeightedScoreResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// Finding identifier.
|
||||
/// </summary>
|
||||
public required string FindingId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Calculated score (0-100). Higher = more urgent.
|
||||
/// </summary>
|
||||
public required int Score { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Action bucket: ActNow, ScheduleNext, Investigate, Watchlist.
|
||||
/// </summary>
|
||||
public required string Bucket { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Normalized input values (0-1 scale).
|
||||
/// </summary>
|
||||
public EvidenceInputsDto? Inputs { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Applied evidence weights.
|
||||
/// </summary>
|
||||
public EvidenceWeightsDto? Weights { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Active flags affecting the score.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string>? Flags { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable explanations for each factor.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string>? Explanations { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Applied guardrails (caps/floors).
|
||||
/// </summary>
|
||||
public AppliedCapsDto? Caps { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Policy digest used for calculation.
|
||||
/// </summary>
|
||||
public required string PolicyDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the score was calculated.
|
||||
/// </summary>
|
||||
public required DateTimeOffset CalculatedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the cached score expires.
|
||||
/// </summary>
|
||||
public DateTimeOffset? CachedUntil { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this result came from cache.
|
||||
/// </summary>
|
||||
public bool FromCache { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Normalized evidence input values.
|
||||
/// </summary>
|
||||
public sealed record EvidenceInputsDto
|
||||
{
|
||||
/// <summary>Reachability (0-1)</summary>
|
||||
[JsonPropertyName("rch")]
|
||||
public double Reachability { get; init; }
|
||||
|
||||
/// <summary>Runtime signal (0-1)</summary>
|
||||
[JsonPropertyName("rts")]
|
||||
public double Runtime { get; init; }
|
||||
|
||||
/// <summary>Backport availability (0-1)</summary>
|
||||
[JsonPropertyName("bkp")]
|
||||
public double Backport { get; init; }
|
||||
|
||||
/// <summary>Exploit likelihood (0-1)</summary>
|
||||
[JsonPropertyName("xpl")]
|
||||
public double Exploit { get; init; }
|
||||
|
||||
/// <summary>Source trust (0-1)</summary>
|
||||
[JsonPropertyName("src")]
|
||||
public double SourceTrust { get; init; }
|
||||
|
||||
/// <summary>Mitigation effectiveness (0-1)</summary>
|
||||
[JsonPropertyName("mit")]
|
||||
public double Mitigation { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Evidence weight configuration.
|
||||
/// </summary>
|
||||
public sealed record EvidenceWeightsDto
|
||||
{
|
||||
/// <summary>Reachability weight</summary>
|
||||
[JsonPropertyName("rch")]
|
||||
public double Reachability { get; init; }
|
||||
|
||||
/// <summary>Runtime signal weight</summary>
|
||||
[JsonPropertyName("rts")]
|
||||
public double Runtime { get; init; }
|
||||
|
||||
/// <summary>Backport weight</summary>
|
||||
[JsonPropertyName("bkp")]
|
||||
public double Backport { get; init; }
|
||||
|
||||
/// <summary>Exploit weight</summary>
|
||||
[JsonPropertyName("xpl")]
|
||||
public double Exploit { get; init; }
|
||||
|
||||
/// <summary>Source trust weight</summary>
|
||||
[JsonPropertyName("src")]
|
||||
public double SourceTrust { get; init; }
|
||||
|
||||
/// <summary>Mitigation weight</summary>
|
||||
[JsonPropertyName("mit")]
|
||||
public double Mitigation { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applied guardrail caps and floors.
|
||||
/// </summary>
|
||||
public sealed record AppliedCapsDto
|
||||
{
|
||||
/// <summary>Speculative cap applied (no runtime evidence).</summary>
|
||||
public bool SpeculativeCap { get; init; }
|
||||
|
||||
/// <summary>Not-affected cap applied (VEX status).</summary>
|
||||
public bool NotAffectedCap { get; init; }
|
||||
|
||||
/// <summary>Runtime floor applied (observed in production).</summary>
|
||||
public bool RuntimeFloor { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Batch score calculation result.
|
||||
/// </summary>
|
||||
public sealed record CalculateScoresBatchResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// Individual score results.
|
||||
/// </summary>
|
||||
public required IReadOnlyList<EvidenceWeightedScoreResponse> Results { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Summary statistics.
|
||||
/// </summary>
|
||||
public required BatchSummaryDto Summary { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Errors for failed calculations.
|
||||
/// </summary>
|
||||
public IReadOnlyList<ScoringErrorDto>? Errors { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Policy digest used for all calculations.
|
||||
/// </summary>
|
||||
public required string PolicyDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the batch was calculated.
|
||||
/// </summary>
|
||||
public required DateTimeOffset CalculatedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Batch calculation summary.
|
||||
/// </summary>
|
||||
public sealed record BatchSummaryDto
|
||||
{
|
||||
/// <summary>Total findings processed.</summary>
|
||||
public required int Total { get; init; }
|
||||
|
||||
/// <summary>Successful calculations.</summary>
|
||||
public required int Succeeded { get; init; }
|
||||
|
||||
/// <summary>Failed calculations.</summary>
|
||||
public required int Failed { get; init; }
|
||||
|
||||
/// <summary>Score distribution by bucket.</summary>
|
||||
public required BucketDistributionDto ByBucket { get; init; }
|
||||
|
||||
/// <summary>Average score across all findings.</summary>
|
||||
public required double AverageScore { get; init; }
|
||||
|
||||
/// <summary>Total calculation time in milliseconds.</summary>
|
||||
public required double CalculationTimeMs { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Score distribution by bucket.
|
||||
/// </summary>
|
||||
public sealed record BucketDistributionDto
|
||||
{
|
||||
public int ActNow { get; init; }
|
||||
public int ScheduleNext { get; init; }
|
||||
public int Investigate { get; init; }
|
||||
public int Watchlist { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Score history entry.
|
||||
/// </summary>
|
||||
public sealed record ScoreHistoryEntry
|
||||
{
|
||||
/// <summary>Score value at this point in time.</summary>
|
||||
public required int Score { get; init; }
|
||||
|
||||
/// <summary>Bucket at this point in time.</summary>
|
||||
public required string Bucket { get; init; }
|
||||
|
||||
/// <summary>Policy digest used.</summary>
|
||||
public required string PolicyDigest { get; init; }
|
||||
|
||||
/// <summary>When calculated.</summary>
|
||||
public required DateTimeOffset CalculatedAt { get; init; }
|
||||
|
||||
/// <summary>What triggered recalculation.</summary>
|
||||
public required string Trigger { get; init; }
|
||||
|
||||
/// <summary>Which factors changed since previous calculation.</summary>
|
||||
public IReadOnlyList<string>? ChangedFactors { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Score history response.
|
||||
/// </summary>
|
||||
public sealed record ScoreHistoryResponse
|
||||
{
|
||||
/// <summary>Finding ID.</summary>
|
||||
public required string FindingId { get; init; }
|
||||
|
||||
/// <summary>History entries.</summary>
|
||||
public required IReadOnlyList<ScoreHistoryEntry> History { get; init; }
|
||||
|
||||
/// <summary>Pagination information.</summary>
|
||||
public required PaginationDto Pagination { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pagination metadata.
|
||||
/// </summary>
|
||||
public sealed record PaginationDto
|
||||
{
|
||||
/// <summary>Whether more results are available.</summary>
|
||||
public required bool HasMore { get; init; }
|
||||
|
||||
/// <summary>Cursor for next page. Null if no more pages.</summary>
|
||||
public string? NextCursor { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Scoring policy response.
|
||||
/// </summary>
|
||||
public sealed record ScoringPolicyResponse
|
||||
{
|
||||
/// <summary>Policy version identifier.</summary>
|
||||
public required string Version { get; init; }
|
||||
|
||||
/// <summary>Policy content digest.</summary>
|
||||
public required string Digest { get; init; }
|
||||
|
||||
/// <summary>When this policy became active.</summary>
|
||||
public required DateTimeOffset ActiveSince { get; init; }
|
||||
|
||||
/// <summary>Environment (production, staging, etc.).</summary>
|
||||
public required string Environment { get; init; }
|
||||
|
||||
/// <summary>Evidence weights.</summary>
|
||||
public required EvidenceWeightsDto Weights { get; init; }
|
||||
|
||||
/// <summary>Guardrail configuration.</summary>
|
||||
public required GuardrailsConfigDto Guardrails { get; init; }
|
||||
|
||||
/// <summary>Bucket thresholds.</summary>
|
||||
public required BucketThresholdsDto Buckets { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Guardrail configuration.
|
||||
/// </summary>
|
||||
public sealed record GuardrailsConfigDto
|
||||
{
|
||||
public required GuardrailDto NotAffectedCap { get; init; }
|
||||
public required GuardrailDto RuntimeFloor { get; init; }
|
||||
public required GuardrailDto SpeculativeCap { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Individual guardrail settings.
|
||||
/// </summary>
|
||||
public sealed record GuardrailDto
|
||||
{
|
||||
public required bool Enabled { get; init; }
|
||||
public int? MaxScore { get; init; }
|
||||
public int? MinScore { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bucket threshold configuration.
|
||||
/// </summary>
|
||||
public sealed record BucketThresholdsDto
|
||||
{
|
||||
/// <summary>Minimum score for ActNow bucket.</summary>
|
||||
public required int ActNowMin { get; init; }
|
||||
|
||||
/// <summary>Minimum score for ScheduleNext bucket.</summary>
|
||||
public required int ScheduleNextMin { get; init; }
|
||||
|
||||
/// <summary>Minimum score for Investigate bucket.</summary>
|
||||
public required int InvestigateMin { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Webhook registration response.
|
||||
/// </summary>
|
||||
public sealed record WebhookResponse
|
||||
{
|
||||
/// <summary>Webhook ID.</summary>
|
||||
public required Guid Id { get; init; }
|
||||
|
||||
/// <summary>Webhook URL.</summary>
|
||||
public required string Url { get; init; }
|
||||
|
||||
/// <summary>Whether secret is configured.</summary>
|
||||
public required bool HasSecret { get; init; }
|
||||
|
||||
/// <summary>Finding patterns being watched.</summary>
|
||||
public IReadOnlyList<string>? FindingPatterns { get; init; }
|
||||
|
||||
/// <summary>Minimum score change threshold.</summary>
|
||||
public required int MinScoreChange { get; init; }
|
||||
|
||||
/// <summary>Whether to trigger on bucket changes.</summary>
|
||||
public required bool TriggerOnBucketChange { get; init; }
|
||||
|
||||
/// <summary>When webhook was created.</summary>
|
||||
public required DateTimeOffset CreatedAt { get; init; }
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Error DTOs
|
||||
|
||||
/// <summary>
|
||||
/// Scoring error information.
|
||||
/// </summary>
|
||||
public sealed record ScoringErrorDto
|
||||
{
|
||||
/// <summary>Finding ID that failed.</summary>
|
||||
public required string FindingId { get; init; }
|
||||
|
||||
/// <summary>Error code.</summary>
|
||||
public required string Code { get; init; }
|
||||
|
||||
/// <summary>Error message.</summary>
|
||||
public required string Message { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Scoring error response.
|
||||
/// </summary>
|
||||
public sealed record ScoringErrorResponse
|
||||
{
|
||||
/// <summary>Error code.</summary>
|
||||
public required string Code { get; init; }
|
||||
|
||||
/// <summary>Error message.</summary>
|
||||
public required string Message { get; init; }
|
||||
|
||||
/// <summary>Additional details.</summary>
|
||||
public IDictionary<string, object>? Details { get; init; }
|
||||
|
||||
/// <summary>Trace ID for debugging.</summary>
|
||||
public string? TraceId { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Standard error codes for scoring operations.
|
||||
/// </summary>
|
||||
public static class ScoringErrorCodes
|
||||
{
|
||||
public const string FindingNotFound = "SCORING_FINDING_NOT_FOUND";
|
||||
public const string EvidenceNotAvailable = "SCORING_EVIDENCE_NOT_AVAILABLE";
|
||||
public const string PolicyNotFound = "SCORING_POLICY_NOT_FOUND";
|
||||
public const string CalculationFailed = "SCORING_CALCULATION_FAILED";
|
||||
public const string BatchTooLarge = "SCORING_BATCH_TOO_LARGE";
|
||||
public const string RateLimitExceeded = "SCORING_RATE_LIMIT_EXCEEDED";
|
||||
public const string InvalidRequest = "SCORING_INVALID_REQUEST";
|
||||
}
|
||||
|
||||
#endregion
|
||||
@@ -16,9 +16,9 @@ public static class EvidenceGraphEndpoints
|
||||
// GET /api/v1/findings/{findingId}/evidence-graph
|
||||
group.MapGet("/{findingId:guid}/evidence-graph", async Task<Results<Ok<EvidenceGraphResponse>, NotFound>> (
|
||||
Guid findingId,
|
||||
[FromQuery] bool includeContent = false,
|
||||
IEvidenceGraphBuilder builder,
|
||||
CancellationToken ct) =>
|
||||
CancellationToken ct,
|
||||
[FromQuery] bool includeContent = false) =>
|
||||
{
|
||||
var graph = await builder.BuildAsync(findingId, ct);
|
||||
return graph is not null
|
||||
|
||||
@@ -31,13 +31,13 @@ public static class FindingSummaryEndpoints
|
||||
|
||||
// GET /api/v1/findings/summaries
|
||||
group.MapGet("/summaries", async Task<Ok<FindingSummaryPage>> (
|
||||
IFindingSummaryService service,
|
||||
CancellationToken ct,
|
||||
[FromQuery] int page = 1,
|
||||
[FromQuery] int pageSize = 50,
|
||||
[FromQuery] string? status = null,
|
||||
[FromQuery] string? severity = null,
|
||||
[FromQuery] decimal? minConfidence = null,
|
||||
IFindingSummaryService service,
|
||||
CancellationToken ct) =>
|
||||
[FromQuery] decimal? minConfidence = null) =>
|
||||
{
|
||||
var filter = new FindingSummaryFilter
|
||||
{
|
||||
|
||||
@@ -15,9 +15,9 @@ public static class ReachabilityMapEndpoints
|
||||
// GET /api/v1/findings/{findingId}/reachability-map
|
||||
group.MapGet("/{findingId:guid}/reachability-map", async Task<Results<Ok<ReachabilityMiniMap>, NotFound>> (
|
||||
Guid findingId,
|
||||
[FromQuery] int maxPaths = 10,
|
||||
IReachabilityMapService service,
|
||||
CancellationToken ct) =>
|
||||
CancellationToken ct,
|
||||
[FromQuery] int maxPaths = 10) =>
|
||||
{
|
||||
var map = await service.GetMiniMapAsync(findingId, maxPaths, ct);
|
||||
return map is not null
|
||||
|
||||
@@ -15,11 +15,11 @@ public static class RuntimeTimelineEndpoints
|
||||
// GET /api/v1/findings/{findingId}/runtime-timeline
|
||||
group.MapGet("/{findingId:guid}/runtime-timeline", async Task<Results<Ok<RuntimeTimeline>, NotFound>> (
|
||||
Guid findingId,
|
||||
[FromQuery] DateTimeOffset? from,
|
||||
[FromQuery] DateTimeOffset? to,
|
||||
[FromQuery] int bucketHours = 1,
|
||||
IRuntimeTimelineService service,
|
||||
CancellationToken ct) =>
|
||||
CancellationToken ct,
|
||||
[FromQuery] DateTimeOffset? from = null,
|
||||
[FromQuery] DateTimeOffset? to = null,
|
||||
[FromQuery] int bucketHours = 1) =>
|
||||
{
|
||||
var options = new TimelineOptions
|
||||
{
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Copyright © 2025 StellaOps
|
||||
// Sprint: SPRINT_8200_0012_0004_api_endpoints
|
||||
// Task: API-8200-003 to API-8200-030 - Scoring API endpoints
|
||||
|
||||
using System.Diagnostics;
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Findings.Ledger.WebService.Contracts;
|
||||
using StellaOps.Findings.Ledger.WebService.Services;
|
||||
|
||||
namespace StellaOps.Findings.Ledger.WebService.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Evidence-Weighted Score API endpoints.
|
||||
/// </summary>
|
||||
public static class ScoringEndpoints
|
||||
{
|
||||
private const int MaxBatchSize = 100;
|
||||
|
||||
// Authorization policy names (must match Program.cs)
|
||||
private const string ScoringReadPolicy = "scoring.read";
|
||||
private const string ScoringWritePolicy = "scoring.write";
|
||||
|
||||
public static void MapScoringEndpoints(this WebApplication app)
|
||||
{
|
||||
var findingsGroup = app.MapGroup("/api/v1/findings")
|
||||
.WithTags("Scoring");
|
||||
|
||||
var scoringGroup = app.MapGroup("/api/v1/scoring")
|
||||
.WithTags("Scoring");
|
||||
|
||||
// POST /api/v1/findings/{findingId}/score - Calculate score
|
||||
// Rate limit: 100/min (via API Gateway)
|
||||
findingsGroup.MapPost("/{findingId}/score", CalculateScore)
|
||||
.WithName("CalculateFindingScore")
|
||||
.WithDescription("Calculate evidence-weighted score for a finding")
|
||||
.RequireAuthorization(ScoringWritePolicy)
|
||||
.Produces<EvidenceWeightedScoreResponse>(200)
|
||||
.Produces<ScoringErrorResponse>(400)
|
||||
.Produces<ScoringErrorResponse>(404)
|
||||
.Produces(429);
|
||||
|
||||
// GET /api/v1/findings/{findingId}/score - Get cached score
|
||||
// Rate limit: 1000/min (via API Gateway)
|
||||
findingsGroup.MapGet("/{findingId}/score", GetCachedScore)
|
||||
.WithName("GetFindingScore")
|
||||
.WithDescription("Get cached evidence-weighted score for a finding")
|
||||
.RequireAuthorization(ScoringReadPolicy)
|
||||
.Produces<EvidenceWeightedScoreResponse>(200)
|
||||
.Produces(404);
|
||||
|
||||
// POST /api/v1/findings/scores - Batch calculate scores
|
||||
// Rate limit: 10/min (via API Gateway)
|
||||
findingsGroup.MapPost("/scores", CalculateScoresBatch)
|
||||
.WithName("CalculateFindingScoresBatch")
|
||||
.WithDescription("Calculate evidence-weighted scores for multiple findings")
|
||||
.RequireAuthorization(ScoringWritePolicy)
|
||||
.Produces<CalculateScoresBatchResponse>(200)
|
||||
.Produces<ScoringErrorResponse>(400)
|
||||
.Produces(429);
|
||||
|
||||
// GET /api/v1/findings/{findingId}/score-history - Get score history
|
||||
// Rate limit: 100/min (via API Gateway)
|
||||
findingsGroup.MapGet("/{findingId}/score-history", GetScoreHistory)
|
||||
.WithName("GetFindingScoreHistory")
|
||||
.WithDescription("Get score history for a finding")
|
||||
.RequireAuthorization(ScoringReadPolicy)
|
||||
.Produces<ScoreHistoryResponse>(200)
|
||||
.Produces(404);
|
||||
|
||||
// GET /api/v1/scoring/policy - Get active policy
|
||||
// Rate limit: 100/min (via API Gateway)
|
||||
scoringGroup.MapGet("/policy", GetActivePolicy)
|
||||
.WithName("GetActiveScoringPolicy")
|
||||
.WithDescription("Get the active scoring policy configuration")
|
||||
.RequireAuthorization(ScoringReadPolicy)
|
||||
.Produces<ScoringPolicyResponse>(200);
|
||||
|
||||
// GET /api/v1/scoring/policy/{version} - Get specific policy version
|
||||
// Rate limit: 100/min (via API Gateway)
|
||||
scoringGroup.MapGet("/policy/{version}", GetPolicyVersion)
|
||||
.WithName("GetScoringPolicyVersion")
|
||||
.WithDescription("Get a specific scoring policy version")
|
||||
.RequireAuthorization(ScoringReadPolicy)
|
||||
.Produces<ScoringPolicyResponse>(200)
|
||||
.Produces(404);
|
||||
}
|
||||
|
||||
private static async Task<Results<Ok<EvidenceWeightedScoreResponse>, NotFound<ScoringErrorResponse>, BadRequest<ScoringErrorResponse>>> CalculateScore(
|
||||
string findingId,
|
||||
[FromBody] CalculateScoreRequest? request,
|
||||
IFindingScoringService service,
|
||||
CancellationToken ct)
|
||||
{
|
||||
request ??= new CalculateScoreRequest();
|
||||
|
||||
try
|
||||
{
|
||||
var result = await service.CalculateScoreAsync(findingId, request, ct);
|
||||
if (result is null)
|
||||
{
|
||||
return TypedResults.NotFound(new ScoringErrorResponse
|
||||
{
|
||||
Code = ScoringErrorCodes.FindingNotFound,
|
||||
Message = $"Finding '{findingId}' not found or no evidence available",
|
||||
TraceId = Activity.Current?.Id
|
||||
});
|
||||
}
|
||||
|
||||
return TypedResults.Ok(result);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return TypedResults.BadRequest(new ScoringErrorResponse
|
||||
{
|
||||
Code = ScoringErrorCodes.CalculationFailed,
|
||||
Message = ex.Message,
|
||||
TraceId = Activity.Current?.Id
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<Results<Ok<EvidenceWeightedScoreResponse>, NotFound>> GetCachedScore(
|
||||
string findingId,
|
||||
IFindingScoringService service,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var result = await service.GetCachedScoreAsync(findingId, ct);
|
||||
if (result is null)
|
||||
{
|
||||
return TypedResults.NotFound();
|
||||
}
|
||||
|
||||
return TypedResults.Ok(result);
|
||||
}
|
||||
|
||||
private static async Task<Results<Ok<CalculateScoresBatchResponse>, BadRequest<ScoringErrorResponse>>> CalculateScoresBatch(
|
||||
[FromBody] CalculateScoresBatchRequest request,
|
||||
IFindingScoringService service,
|
||||
CancellationToken ct)
|
||||
{
|
||||
// Validate batch size
|
||||
if (request.FindingIds.Count == 0)
|
||||
{
|
||||
return TypedResults.BadRequest(new ScoringErrorResponse
|
||||
{
|
||||
Code = ScoringErrorCodes.InvalidRequest,
|
||||
Message = "At least one finding ID is required",
|
||||
TraceId = Activity.Current?.Id
|
||||
});
|
||||
}
|
||||
|
||||
if (request.FindingIds.Count > MaxBatchSize)
|
||||
{
|
||||
return TypedResults.BadRequest(new ScoringErrorResponse
|
||||
{
|
||||
Code = ScoringErrorCodes.BatchTooLarge,
|
||||
Message = $"Batch size {request.FindingIds.Count} exceeds maximum {MaxBatchSize}",
|
||||
TraceId = Activity.Current?.Id
|
||||
});
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var result = await service.CalculateScoresBatchAsync(request, ct);
|
||||
return TypedResults.Ok(result);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return TypedResults.BadRequest(new ScoringErrorResponse
|
||||
{
|
||||
Code = ScoringErrorCodes.CalculationFailed,
|
||||
Message = ex.Message,
|
||||
TraceId = Activity.Current?.Id
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<Results<Ok<ScoreHistoryResponse>, NotFound>> GetScoreHistory(
|
||||
string findingId,
|
||||
IFindingScoringService service,
|
||||
CancellationToken ct,
|
||||
[FromQuery] DateTimeOffset? from = null,
|
||||
[FromQuery] DateTimeOffset? to = null,
|
||||
[FromQuery] int limit = 50,
|
||||
[FromQuery] string? cursor = null)
|
||||
{
|
||||
limit = Math.Clamp(limit, 1, 100);
|
||||
|
||||
var result = await service.GetScoreHistoryAsync(findingId, from, to, limit, cursor, ct);
|
||||
if (result is null)
|
||||
{
|
||||
return TypedResults.NotFound();
|
||||
}
|
||||
|
||||
return TypedResults.Ok(result);
|
||||
}
|
||||
|
||||
private static async Task<Ok<ScoringPolicyResponse>> GetActivePolicy(
|
||||
IFindingScoringService service,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var policy = await service.GetActivePolicyAsync(ct);
|
||||
return TypedResults.Ok(policy);
|
||||
}
|
||||
|
||||
private static async Task<Results<Ok<ScoringPolicyResponse>, NotFound>> GetPolicyVersion(
|
||||
string version,
|
||||
IFindingScoringService service,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var policy = await service.GetPolicyVersionAsync(version, ct);
|
||||
if (policy is null)
|
||||
{
|
||||
return TypedResults.NotFound();
|
||||
}
|
||||
|
||||
return TypedResults.Ok(policy);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Findings.Ledger.WebService.Contracts;
|
||||
using StellaOps.Findings.Ledger.WebService.Services;
|
||||
|
||||
namespace StellaOps.Findings.Ledger.WebService.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Webhook management endpoints.
|
||||
/// Sprint: SPRINT_8200_0012_0004 - Wave 6
|
||||
/// </summary>
|
||||
public static class WebhookEndpoints
|
||||
{
|
||||
// Authorization policy name (must match Program.cs)
|
||||
private const string ScoringAdminPolicy = "scoring.admin";
|
||||
|
||||
public static void MapWebhookEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/api/v1/scoring/webhooks")
|
||||
.WithTags("Webhooks");
|
||||
|
||||
// POST /api/v1/scoring/webhooks - Register webhook
|
||||
// Rate limit: 10/min (via API Gateway)
|
||||
group.MapPost("/", RegisterWebhook)
|
||||
.WithName("RegisterScoringWebhook")
|
||||
.WithDescription("Register a webhook for score change notifications")
|
||||
.Produces<WebhookResponse>(StatusCodes.Status201Created)
|
||||
.ProducesValidationProblem()
|
||||
.RequireAuthorization(ScoringAdminPolicy);
|
||||
|
||||
// GET /api/v1/scoring/webhooks - List webhooks
|
||||
// Rate limit: 10/min (via API Gateway)
|
||||
group.MapGet("/", ListWebhooks)
|
||||
.WithName("ListScoringWebhooks")
|
||||
.WithDescription("List all registered webhooks")
|
||||
.Produces<WebhookListResponse>(StatusCodes.Status200OK)
|
||||
.RequireAuthorization(ScoringAdminPolicy);
|
||||
|
||||
// GET /api/v1/scoring/webhooks/{id} - Get webhook
|
||||
// Rate limit: 10/min (via API Gateway)
|
||||
group.MapGet("/{id:guid}", GetWebhook)
|
||||
.WithName("GetScoringWebhook")
|
||||
.WithDescription("Get a specific webhook by ID")
|
||||
.Produces<WebhookResponse>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status404NotFound)
|
||||
.RequireAuthorization(ScoringAdminPolicy);
|
||||
|
||||
// PUT /api/v1/scoring/webhooks/{id} - Update webhook
|
||||
// Rate limit: 10/min (via API Gateway)
|
||||
group.MapPut("/{id:guid}", UpdateWebhook)
|
||||
.WithName("UpdateScoringWebhook")
|
||||
.WithDescription("Update a webhook configuration")
|
||||
.Produces<WebhookResponse>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status404NotFound)
|
||||
.ProducesValidationProblem()
|
||||
.RequireAuthorization(ScoringAdminPolicy);
|
||||
|
||||
// DELETE /api/v1/scoring/webhooks/{id} - Delete webhook
|
||||
// Rate limit: 10/min (via API Gateway)
|
||||
group.MapDelete("/{id:guid}", DeleteWebhook)
|
||||
.WithName("DeleteScoringWebhook")
|
||||
.WithDescription("Delete a webhook")
|
||||
.Produces(StatusCodes.Status204NoContent)
|
||||
.Produces(StatusCodes.Status404NotFound)
|
||||
.RequireAuthorization(ScoringAdminPolicy);
|
||||
}
|
||||
|
||||
private static Results<Created<WebhookResponse>, ValidationProblem> RegisterWebhook(
|
||||
[FromBody] RegisterWebhookRequest request,
|
||||
[FromServices] IWebhookStore store)
|
||||
{
|
||||
// Validate URL
|
||||
if (!Uri.TryCreate(request.Url, UriKind.Absolute, out var uri) ||
|
||||
(uri.Scheme != "http" && uri.Scheme != "https"))
|
||||
{
|
||||
return TypedResults.ValidationProblem(new Dictionary<string, string[]>
|
||||
{
|
||||
["url"] = ["Invalid webhook URL. Must be an absolute HTTP or HTTPS URL."]
|
||||
});
|
||||
}
|
||||
|
||||
var registration = store.Register(request);
|
||||
var response = MapToResponse(registration);
|
||||
|
||||
return TypedResults.Created($"/api/v1/scoring/webhooks/{registration.Id}", response);
|
||||
}
|
||||
|
||||
private static Ok<WebhookListResponse> ListWebhooks(
|
||||
[FromServices] IWebhookStore store)
|
||||
{
|
||||
var webhooks = store.List();
|
||||
var response = new WebhookListResponse
|
||||
{
|
||||
Webhooks = webhooks.Select(MapToResponse).ToList(),
|
||||
TotalCount = webhooks.Count
|
||||
};
|
||||
|
||||
return TypedResults.Ok(response);
|
||||
}
|
||||
|
||||
private static Results<Ok<WebhookResponse>, NotFound> GetWebhook(
|
||||
Guid id,
|
||||
[FromServices] IWebhookStore store)
|
||||
{
|
||||
var webhook = store.Get(id);
|
||||
if (webhook is null || !webhook.IsActive)
|
||||
{
|
||||
return TypedResults.NotFound();
|
||||
}
|
||||
|
||||
return TypedResults.Ok(MapToResponse(webhook));
|
||||
}
|
||||
|
||||
private static Results<Ok<WebhookResponse>, NotFound, ValidationProblem> UpdateWebhook(
|
||||
Guid id,
|
||||
[FromBody] RegisterWebhookRequest request,
|
||||
[FromServices] IWebhookStore store)
|
||||
{
|
||||
// Validate URL
|
||||
if (!Uri.TryCreate(request.Url, UriKind.Absolute, out var uri) ||
|
||||
(uri.Scheme != "http" && uri.Scheme != "https"))
|
||||
{
|
||||
return TypedResults.ValidationProblem(new Dictionary<string, string[]>
|
||||
{
|
||||
["url"] = ["Invalid webhook URL. Must be an absolute HTTP or HTTPS URL."]
|
||||
});
|
||||
}
|
||||
|
||||
if (!store.Update(id, request))
|
||||
{
|
||||
return TypedResults.NotFound();
|
||||
}
|
||||
|
||||
var updated = store.Get(id);
|
||||
return TypedResults.Ok(MapToResponse(updated!));
|
||||
}
|
||||
|
||||
private static Results<NoContent, NotFound> DeleteWebhook(
|
||||
Guid id,
|
||||
[FromServices] IWebhookStore store)
|
||||
{
|
||||
if (!store.Delete(id))
|
||||
{
|
||||
return TypedResults.NotFound();
|
||||
}
|
||||
|
||||
return TypedResults.NoContent();
|
||||
}
|
||||
|
||||
private static WebhookResponse MapToResponse(WebhookRegistration registration)
|
||||
{
|
||||
return new WebhookResponse
|
||||
{
|
||||
Id = registration.Id,
|
||||
Url = registration.Url,
|
||||
HasSecret = !string.IsNullOrEmpty(registration.Secret),
|
||||
FindingPatterns = registration.FindingPatterns,
|
||||
MinScoreChange = registration.MinScoreChange,
|
||||
TriggerOnBucketChange = registration.TriggerOnBucketChange,
|
||||
CreatedAt = registration.CreatedAt
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response for listing webhooks.
|
||||
/// </summary>
|
||||
public sealed record WebhookListResponse
|
||||
{
|
||||
/// <summary>List of webhooks.</summary>
|
||||
public required IReadOnlyList<WebhookResponse> Webhooks { get; init; }
|
||||
|
||||
/// <summary>Total count of webhooks.</summary>
|
||||
public required int TotalCount { get; init; }
|
||||
}
|
||||
@@ -11,6 +11,7 @@ using StellaOps.Auth.ServerIntegration;
|
||||
using StellaOps.Configuration;
|
||||
using StellaOps.DependencyInjection;
|
||||
using StellaOps.Findings.Ledger.Domain;
|
||||
using Domain = StellaOps.Findings.Ledger.Domain;
|
||||
using StellaOps.Findings.Ledger.Infrastructure;
|
||||
using StellaOps.Findings.Ledger.Infrastructure.AirGap;
|
||||
using StellaOps.Findings.Ledger.Infrastructure.Merkle;
|
||||
@@ -23,18 +24,27 @@ using StellaOps.Findings.Ledger.Services;
|
||||
using StellaOps.Findings.Ledger.WebService.Contracts;
|
||||
using StellaOps.Findings.Ledger.WebService.Mappings;
|
||||
using StellaOps.Findings.Ledger.WebService.Services;
|
||||
using StellaOps.Findings.Ledger.WebService.Endpoints;
|
||||
using StellaOps.Telemetry.Core;
|
||||
using StellaOps.Findings.Ledger.Services.Security;
|
||||
using StellaOps.Findings.Ledger;
|
||||
using StellaOps.Findings.Ledger.Observability;
|
||||
using StellaOps.Findings.Ledger.OpenApi;
|
||||
using System.Text.Json.Nodes;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Threading.RateLimiting;
|
||||
using StellaOps.Findings.Ledger.Services.Incident;
|
||||
using StellaOps.Router.AspNet;
|
||||
|
||||
const string LedgerWritePolicy = "ledger.events.write";
|
||||
const string LedgerExportPolicy = "ledger.export.read";
|
||||
|
||||
// Scoring API policies (SPRINT_8200.0012.0004 - Wave 7)
|
||||
const string ScoringReadPolicy = "scoring.read";
|
||||
const string ScoringWritePolicy = "scoring.write";
|
||||
const string ScoringAdminPolicy = "scoring.admin";
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
builder.Configuration.AddStellaOpsDefaults(options =>
|
||||
@@ -79,17 +89,15 @@ builder.Services.AddHealthChecks();
|
||||
|
||||
builder.Services.AddStellaOpsTelemetry(
|
||||
builder.Configuration,
|
||||
configureMetering: meterBuilder =>
|
||||
serviceName: "StellaOps.Findings.Ledger",
|
||||
configureMetrics: meterBuilder =>
|
||||
{
|
||||
meterBuilder.AddAspNetCoreInstrumentation();
|
||||
meterBuilder.AddHttpClientInstrumentation();
|
||||
},
|
||||
configureTracing: tracerBuilder =>
|
||||
{
|
||||
tracerBuilder.AddAspNetCoreInstrumentation();
|
||||
tracerBuilder.AddHttpClientInstrumentation();
|
||||
meterBuilder.AddMeter("StellaOps.Findings.Ledger");
|
||||
});
|
||||
|
||||
// Rate limiting is handled by API Gateway - see docs/modules/gateway/rate-limiting.md
|
||||
// Endpoint-level rate limits: scoring-read (1000/min), scoring-calculate (100/min), scoring-batch (10/min), scoring-webhook (10/min)
|
||||
|
||||
builder.Services.AddIncidentMode(builder.Configuration);
|
||||
|
||||
builder.Services.AddStellaOpsResourceServerAuthentication(
|
||||
@@ -140,6 +148,28 @@ builder.Services.AddAuthorization(options =>
|
||||
policy.Requirements.Add(new StellaOpsScopeRequirement(scopes));
|
||||
policy.AddAuthenticationSchemes(StellaOpsAuthenticationDefaults.AuthenticationScheme);
|
||||
});
|
||||
|
||||
// Scoring API policies (SPRINT_8200.0012.0004 - Wave 7)
|
||||
options.AddPolicy(ScoringReadPolicy, policy =>
|
||||
{
|
||||
policy.RequireAuthenticatedUser();
|
||||
policy.Requirements.Add(new StellaOpsScopeRequirement(scopes));
|
||||
policy.AddAuthenticationSchemes(StellaOpsAuthenticationDefaults.AuthenticationScheme);
|
||||
});
|
||||
|
||||
options.AddPolicy(ScoringWritePolicy, policy =>
|
||||
{
|
||||
policy.RequireAuthenticatedUser();
|
||||
policy.Requirements.Add(new StellaOpsScopeRequirement(scopes));
|
||||
policy.AddAuthenticationSchemes(StellaOpsAuthenticationDefaults.AuthenticationScheme);
|
||||
});
|
||||
|
||||
options.AddPolicy(ScoringAdminPolicy, policy =>
|
||||
{
|
||||
policy.RequireAuthenticatedUser();
|
||||
policy.Requirements.Add(new StellaOpsScopeRequirement(scopes));
|
||||
policy.AddAuthenticationSchemes(StellaOpsAuthenticationDefaults.AuthenticationScheme);
|
||||
});
|
||||
});
|
||||
|
||||
builder.Services.AddSingleton<ILedgerIncidentNotifier, LoggingLedgerIncidentNotifier>();
|
||||
@@ -184,6 +214,19 @@ builder.Services.AddSingleton<VexConsensusService>();
|
||||
// Alert and Decision services (SPRINT_3602)
|
||||
builder.Services.AddSingleton<IAlertService, AlertService>();
|
||||
builder.Services.AddSingleton<IDecisionService, DecisionService>();
|
||||
builder.Services.AddSingleton<IEvidenceBundleService, EvidenceBundleService>();
|
||||
|
||||
// Evidence-Weighted Score services (SPRINT_8200.0012.0004)
|
||||
builder.Services.AddSingleton<IScoreHistoryStore, InMemoryScoreHistoryStore>();
|
||||
builder.Services.AddSingleton<IFindingScoringService, FindingScoringService>();
|
||||
|
||||
// Webhook services (SPRINT_8200.0012.0004 - Wave 6)
|
||||
builder.Services.AddSingleton<IWebhookStore, InMemoryWebhookStore>();
|
||||
builder.Services.AddSingleton<IWebhookDeliveryService, WebhookDeliveryService>();
|
||||
builder.Services.AddHttpClient("webhook-delivery", client =>
|
||||
{
|
||||
client.Timeout = TimeSpan.FromSeconds(30);
|
||||
});
|
||||
|
||||
// Stella Router integration
|
||||
var routerOptions = builder.Configuration.GetSection("FindingsLedger:Router").Get<StellaRouterOptionsBase>();
|
||||
@@ -414,7 +457,7 @@ app.MapGet("/ledger/export/vex", async Task<Results<FileStreamHttpResult, JsonHt
|
||||
httpContext.Request.Query["status"].ToString(),
|
||||
httpContext.Request.Query["statement_type"].ToString(),
|
||||
exportQueryService.ClampPageSize(ParseInt(httpContext.Request.Query["page_size"])),
|
||||
filtersHash: string.Empty,
|
||||
FiltersHash: string.Empty,
|
||||
PagingKey: null);
|
||||
|
||||
var filtersHash = exportQueryService.ComputeFiltersHash(request);
|
||||
@@ -484,7 +527,7 @@ app.MapGet("/ledger/export/advisories", async Task<Results<FileStreamHttpResult,
|
||||
cvssScoreMin,
|
||||
cvssScoreMax,
|
||||
exportQueryService.ClampPageSize(ParseInt(httpContext.Request.Query["page_size"])),
|
||||
filtersHash: string.Empty,
|
||||
FiltersHash: string.Empty,
|
||||
PagingKey: null);
|
||||
|
||||
var filtersHash = exportQueryService.ComputeFiltersHash(request);
|
||||
@@ -548,7 +591,7 @@ app.MapGet("/ledger/export/sboms", async Task<Results<FileStreamHttpResult, Json
|
||||
ParseBool(httpContext.Request.Query["contains_native"]),
|
||||
httpContext.Request.Query["slsa_build_type"].ToString(),
|
||||
exportQueryService.ClampPageSize(ParseInt(httpContext.Request.Query["page_size"])),
|
||||
filtersHash: string.Empty,
|
||||
FiltersHash: string.Empty,
|
||||
PagingKey: null);
|
||||
|
||||
var filtersHash = exportQueryService.ComputeFiltersHash(request);
|
||||
@@ -1863,6 +1906,10 @@ app.MapPatch("/api/v1/findings/{findingId}/state", async Task<Results<Ok<StateTr
|
||||
// Refresh Router endpoint cache
|
||||
app.TryRefreshStellaRouterEndpoints(routerOptions);
|
||||
|
||||
// Map EWS scoring and webhook endpoints (SPRINT_8200.0012.0004)
|
||||
app.MapScoringEndpoints();
|
||||
app.MapWebhookEndpoints();
|
||||
|
||||
app.Run();
|
||||
|
||||
static Created<LedgerEventResponse> CreateCreatedResponse(LedgerEventRecord record)
|
||||
|
||||
@@ -0,0 +1,429 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Copyright © 2025 StellaOps
|
||||
// Sprint: SPRINT_8200_0012_0004_api_endpoints
|
||||
// Task: API-8200-003, API-8200-004 - Implement scoring service
|
||||
|
||||
using System.Diagnostics;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Findings.Ledger.WebService.Contracts;
|
||||
using StellaOps.Signals.EvidenceWeightedScore;
|
||||
using StellaOps.Signals.EvidenceWeightedScore.Normalizers;
|
||||
|
||||
namespace StellaOps.Findings.Ledger.WebService.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service for calculating evidence-weighted scores for findings.
|
||||
/// </summary>
|
||||
public interface IFindingScoringService
|
||||
{
|
||||
/// <summary>
|
||||
/// Calculate score for a single finding.
|
||||
/// </summary>
|
||||
Task<EvidenceWeightedScoreResponse?> CalculateScoreAsync(
|
||||
string findingId,
|
||||
CalculateScoreRequest request,
|
||||
CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Calculate scores for multiple findings.
|
||||
/// </summary>
|
||||
Task<CalculateScoresBatchResponse> CalculateScoresBatchAsync(
|
||||
CalculateScoresBatchRequest request,
|
||||
CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Get cached score for a finding.
|
||||
/// </summary>
|
||||
Task<EvidenceWeightedScoreResponse?> GetCachedScoreAsync(
|
||||
string findingId,
|
||||
CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Get score history for a finding.
|
||||
/// </summary>
|
||||
Task<ScoreHistoryResponse?> GetScoreHistoryAsync(
|
||||
string findingId,
|
||||
DateTimeOffset? from,
|
||||
DateTimeOffset? to,
|
||||
int limit,
|
||||
string? cursor,
|
||||
CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Get active scoring policy.
|
||||
/// </summary>
|
||||
Task<ScoringPolicyResponse> GetActivePolicyAsync(CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Get specific policy version.
|
||||
/// </summary>
|
||||
Task<ScoringPolicyResponse?> GetPolicyVersionAsync(string version, CancellationToken ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for finding scoring service.
|
||||
/// </summary>
|
||||
public sealed class FindingScoringOptions
|
||||
{
|
||||
public const string SectionName = "Scoring";
|
||||
|
||||
/// <summary>
|
||||
/// Default cache TTL for scores in minutes.
|
||||
/// </summary>
|
||||
public int CacheTtlMinutes { get; set; } = 60;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum batch size for bulk calculations.
|
||||
/// </summary>
|
||||
public int MaxBatchSize { get; set; } = 100;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum concurrent calculations in a batch.
|
||||
/// </summary>
|
||||
public int MaxConcurrency { get; set; } = 10;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Implementation of finding scoring service using EWS calculator.
|
||||
/// </summary>
|
||||
public sealed class FindingScoringService : IFindingScoringService
|
||||
{
|
||||
private readonly INormalizerAggregator _normalizer;
|
||||
private readonly IEvidenceWeightedScoreCalculator _calculator;
|
||||
private readonly IEvidenceWeightPolicyProvider _policyProvider;
|
||||
private readonly IFindingEvidenceProvider _evidenceProvider;
|
||||
private readonly IScoreHistoryStore _historyStore;
|
||||
private readonly IMemoryCache _cache;
|
||||
private readonly FindingScoringOptions _options;
|
||||
private readonly ILogger<FindingScoringService> _logger;
|
||||
|
||||
private static readonly TimeSpan DefaultCacheDuration = TimeSpan.FromMinutes(60);
|
||||
|
||||
private readonly string _environment;
|
||||
|
||||
public FindingScoringService(
|
||||
INormalizerAggregator normalizer,
|
||||
IEvidenceWeightedScoreCalculator calculator,
|
||||
IEvidenceWeightPolicyProvider policyProvider,
|
||||
IFindingEvidenceProvider evidenceProvider,
|
||||
IScoreHistoryStore historyStore,
|
||||
IMemoryCache cache,
|
||||
IOptions<FindingScoringOptions> options,
|
||||
ILogger<FindingScoringService> logger)
|
||||
{
|
||||
_normalizer = normalizer;
|
||||
_calculator = calculator;
|
||||
_policyProvider = policyProvider;
|
||||
_evidenceProvider = evidenceProvider;
|
||||
_historyStore = historyStore;
|
||||
_cache = cache;
|
||||
_options = options.Value;
|
||||
_logger = logger;
|
||||
_environment = Environment.GetEnvironmentVariable("STELLAOPS_ENVIRONMENT") ?? "production";
|
||||
}
|
||||
|
||||
public async Task<EvidenceWeightedScoreResponse?> CalculateScoreAsync(
|
||||
string findingId,
|
||||
CalculateScoreRequest request,
|
||||
CancellationToken ct)
|
||||
{
|
||||
// Check cache first unless force recalculate
|
||||
if (!request.ForceRecalculate)
|
||||
{
|
||||
var cached = await GetCachedScoreAsync(findingId, ct);
|
||||
if (cached is not null)
|
||||
{
|
||||
return cached with { FromCache = true };
|
||||
}
|
||||
}
|
||||
|
||||
// Get evidence for the finding
|
||||
var evidence = await _evidenceProvider.GetEvidenceAsync(findingId, ct);
|
||||
if (evidence is null)
|
||||
{
|
||||
_logger.LogWarning("No evidence found for finding {FindingId}", findingId);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get policy - use environment from config/env, tenant from request if available
|
||||
var environment = request.PolicyVersion ?? _environment;
|
||||
var policy = await _policyProvider.GetDefaultPolicyAsync(environment, ct);
|
||||
|
||||
// Normalize evidence into EvidenceWeightedScoreInput
|
||||
var input = _normalizer.Aggregate(evidence);
|
||||
|
||||
var result = _calculator.Calculate(input, policy);
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var cacheDuration = TimeSpan.FromMinutes(_options.CacheTtlMinutes);
|
||||
|
||||
var response = MapToResponse(result, request.IncludeBreakdown, now, cacheDuration);
|
||||
|
||||
// Cache the result
|
||||
var cacheKey = GetCacheKey(findingId);
|
||||
_cache.Set(cacheKey, response, cacheDuration);
|
||||
|
||||
// Record in history
|
||||
var historyRecord = new ScoreRecord
|
||||
{
|
||||
FindingId = findingId,
|
||||
Score = response.Score,
|
||||
Bucket = response.Bucket,
|
||||
PolicyDigest = response.PolicyDigest,
|
||||
CalculatedAt = now,
|
||||
Trigger = request.ForceRecalculate ? "force_recalculate" : "calculation",
|
||||
ChangedFactors = []
|
||||
};
|
||||
_historyStore.RecordScore(historyRecord);
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
public async Task<CalculateScoresBatchResponse> CalculateScoresBatchAsync(
|
||||
CalculateScoresBatchRequest request,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
var results = new List<EvidenceWeightedScoreResponse>();
|
||||
var errors = new List<ScoringErrorDto>();
|
||||
|
||||
// Validate batch size
|
||||
if (request.FindingIds.Count > _options.MaxBatchSize)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Batch size {request.FindingIds.Count} exceeds maximum {_options.MaxBatchSize}");
|
||||
}
|
||||
|
||||
// Get policy once for all calculations
|
||||
var environment = request.PolicyVersion ?? _environment;
|
||||
var policy = await _policyProvider.GetDefaultPolicyAsync(environment, ct);
|
||||
|
||||
// Process in parallel with limited concurrency
|
||||
var semaphore = new SemaphoreSlim(_options.MaxConcurrency);
|
||||
var tasks = request.FindingIds.Select(async findingId =>
|
||||
{
|
||||
await semaphore.WaitAsync(ct);
|
||||
try
|
||||
{
|
||||
var singleRequest = new CalculateScoreRequest
|
||||
{
|
||||
ForceRecalculate = request.ForceRecalculate,
|
||||
IncludeBreakdown = request.IncludeBreakdown,
|
||||
PolicyVersion = request.PolicyVersion
|
||||
};
|
||||
|
||||
var result = await CalculateScoreAsync(findingId, singleRequest, ct);
|
||||
return (findingId, result, error: (ScoringErrorDto?)null);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to calculate score for finding {FindingId}", findingId);
|
||||
return (findingId, result: (EvidenceWeightedScoreResponse?)null, error: new ScoringErrorDto
|
||||
{
|
||||
FindingId = findingId,
|
||||
Code = ScoringErrorCodes.CalculationFailed,
|
||||
Message = ex.Message
|
||||
});
|
||||
}
|
||||
finally
|
||||
{
|
||||
semaphore.Release();
|
||||
}
|
||||
});
|
||||
|
||||
var taskResults = await Task.WhenAll(tasks);
|
||||
|
||||
foreach (var (findingId, result, error) in taskResults)
|
||||
{
|
||||
if (result is not null)
|
||||
{
|
||||
results.Add(result);
|
||||
}
|
||||
else if (error is not null)
|
||||
{
|
||||
errors.Add(error);
|
||||
}
|
||||
else
|
||||
{
|
||||
errors.Add(new ScoringErrorDto
|
||||
{
|
||||
FindingId = findingId,
|
||||
Code = ScoringErrorCodes.FindingNotFound,
|
||||
Message = "Finding not found or no evidence available"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
stopwatch.Stop();
|
||||
|
||||
// Calculate summary statistics
|
||||
var bucketCounts = results.GroupBy(r => r.Bucket)
|
||||
.ToDictionary(g => g.Key, g => g.Count());
|
||||
|
||||
var summary = new BatchSummaryDto
|
||||
{
|
||||
Total = request.FindingIds.Count,
|
||||
Succeeded = results.Count,
|
||||
Failed = errors.Count,
|
||||
ByBucket = new BucketDistributionDto
|
||||
{
|
||||
ActNow = bucketCounts.GetValueOrDefault("ActNow", 0),
|
||||
ScheduleNext = bucketCounts.GetValueOrDefault("ScheduleNext", 0),
|
||||
Investigate = bucketCounts.GetValueOrDefault("Investigate", 0),
|
||||
Watchlist = bucketCounts.GetValueOrDefault("Watchlist", 0)
|
||||
},
|
||||
AverageScore = results.Count > 0 ? results.Average(r => r.Score) : 0,
|
||||
CalculationTimeMs = stopwatch.Elapsed.TotalMilliseconds
|
||||
};
|
||||
|
||||
return new CalculateScoresBatchResponse
|
||||
{
|
||||
Results = results,
|
||||
Summary = summary,
|
||||
Errors = errors.Count > 0 ? errors : null,
|
||||
PolicyDigest = policy.ComputeDigest(),
|
||||
CalculatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
}
|
||||
|
||||
public Task<EvidenceWeightedScoreResponse?> GetCachedScoreAsync(
|
||||
string findingId,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var cacheKey = GetCacheKey(findingId);
|
||||
if (_cache.TryGetValue<EvidenceWeightedScoreResponse>(cacheKey, out var cached))
|
||||
{
|
||||
return Task.FromResult<EvidenceWeightedScoreResponse?>(cached with { FromCache = true });
|
||||
}
|
||||
|
||||
return Task.FromResult<EvidenceWeightedScoreResponse?>(null);
|
||||
}
|
||||
|
||||
public Task<ScoreHistoryResponse?> GetScoreHistoryAsync(
|
||||
string findingId,
|
||||
DateTimeOffset? from,
|
||||
DateTimeOffset? to,
|
||||
int limit,
|
||||
string? cursor,
|
||||
CancellationToken ct)
|
||||
{
|
||||
_logger.LogDebug("Getting score history for finding {FindingId}", findingId);
|
||||
|
||||
var history = _historyStore.GetHistory(findingId, from, to, limit, cursor);
|
||||
return Task.FromResult(history);
|
||||
}
|
||||
|
||||
public async Task<ScoringPolicyResponse> GetActivePolicyAsync(CancellationToken ct)
|
||||
{
|
||||
var policy = await _policyProvider.GetDefaultPolicyAsync(_environment, ct);
|
||||
return MapPolicyToResponse(policy);
|
||||
}
|
||||
|
||||
public async Task<ScoringPolicyResponse?> GetPolicyVersionAsync(string version, CancellationToken ct)
|
||||
{
|
||||
// Version is used as environment for policy lookup
|
||||
var policy = await _policyProvider.GetDefaultPolicyAsync(version, ct);
|
||||
return MapPolicyToResponse(policy);
|
||||
}
|
||||
|
||||
private static string GetCacheKey(string findingId) => $"ews:score:{findingId}";
|
||||
|
||||
private static EvidenceWeightedScoreResponse MapToResponse(
|
||||
EvidenceWeightedScoreResult result,
|
||||
bool includeBreakdown,
|
||||
DateTimeOffset calculatedAt,
|
||||
TimeSpan cacheDuration)
|
||||
{
|
||||
return new EvidenceWeightedScoreResponse
|
||||
{
|
||||
FindingId = result.FindingId,
|
||||
Score = result.Score,
|
||||
Bucket = result.Bucket.ToString(),
|
||||
Inputs = includeBreakdown ? new EvidenceInputsDto
|
||||
{
|
||||
Reachability = result.Inputs.Rch,
|
||||
Runtime = result.Inputs.Rts,
|
||||
Backport = result.Inputs.Bkp,
|
||||
Exploit = result.Inputs.Xpl,
|
||||
SourceTrust = result.Inputs.Src,
|
||||
Mitigation = result.Inputs.Mit
|
||||
} : null,
|
||||
Weights = includeBreakdown ? new EvidenceWeightsDto
|
||||
{
|
||||
Reachability = result.Weights.Rch,
|
||||
Runtime = result.Weights.Rts,
|
||||
Backport = result.Weights.Bkp,
|
||||
Exploit = result.Weights.Xpl,
|
||||
SourceTrust = result.Weights.Src,
|
||||
Mitigation = result.Weights.Mit
|
||||
} : null,
|
||||
Flags = includeBreakdown ? result.Flags : null,
|
||||
Explanations = includeBreakdown ? result.Explanations : null,
|
||||
Caps = includeBreakdown ? new AppliedCapsDto
|
||||
{
|
||||
SpeculativeCap = result.Caps.SpeculativeCap,
|
||||
NotAffectedCap = result.Caps.NotAffectedCap,
|
||||
RuntimeFloor = result.Caps.RuntimeFloor
|
||||
} : null,
|
||||
PolicyDigest = result.PolicyDigest,
|
||||
CalculatedAt = calculatedAt,
|
||||
CachedUntil = calculatedAt.Add(cacheDuration),
|
||||
FromCache = false
|
||||
};
|
||||
}
|
||||
|
||||
private static ScoringPolicyResponse MapPolicyToResponse(EvidenceWeightPolicy policy)
|
||||
{
|
||||
return new ScoringPolicyResponse
|
||||
{
|
||||
Version = policy.Version,
|
||||
Digest = policy.ComputeDigest(),
|
||||
ActiveSince = policy.CreatedAt,
|
||||
Environment = policy.Profile,
|
||||
Weights = new EvidenceWeightsDto
|
||||
{
|
||||
Reachability = policy.Weights.Rch,
|
||||
Runtime = policy.Weights.Rts,
|
||||
Backport = policy.Weights.Bkp,
|
||||
Exploit = policy.Weights.Xpl,
|
||||
SourceTrust = policy.Weights.Src,
|
||||
Mitigation = policy.Weights.Mit
|
||||
},
|
||||
Guardrails = new GuardrailsConfigDto
|
||||
{
|
||||
NotAffectedCap = new GuardrailDto
|
||||
{
|
||||
Enabled = policy.Guardrails.NotAffectedCap.Enabled,
|
||||
MaxScore = policy.Guardrails.NotAffectedCap.MaxScore
|
||||
},
|
||||
RuntimeFloor = new GuardrailDto
|
||||
{
|
||||
Enabled = policy.Guardrails.RuntimeFloor.Enabled,
|
||||
MinScore = policy.Guardrails.RuntimeFloor.MinScore
|
||||
},
|
||||
SpeculativeCap = new GuardrailDto
|
||||
{
|
||||
Enabled = policy.Guardrails.SpeculativeCap.Enabled,
|
||||
MaxScore = policy.Guardrails.SpeculativeCap.MaxScore
|
||||
}
|
||||
},
|
||||
Buckets = new BucketThresholdsDto
|
||||
{
|
||||
ActNowMin = policy.Buckets.ActNowMin,
|
||||
ScheduleNextMin = policy.Buckets.ScheduleNextMin,
|
||||
InvestigateMin = policy.Buckets.InvestigateMin
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Provider for finding evidence data.
|
||||
/// </summary>
|
||||
public interface IFindingEvidenceProvider
|
||||
{
|
||||
/// <summary>
|
||||
/// Get evidence for a finding.
|
||||
/// </summary>
|
||||
Task<FindingEvidence?> GetEvidenceAsync(string findingId, CancellationToken ct);
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
using System.Collections.Concurrent;
|
||||
using StellaOps.Findings.Ledger.WebService.Contracts;
|
||||
|
||||
namespace StellaOps.Findings.Ledger.WebService.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a recorded score entry for history tracking.
|
||||
/// </summary>
|
||||
public sealed record ScoreRecord
|
||||
{
|
||||
public required string FindingId { get; init; }
|
||||
public required int Score { get; init; }
|
||||
public required string Bucket { get; init; }
|
||||
public required string PolicyDigest { get; init; }
|
||||
public required DateTimeOffset CalculatedAt { get; init; }
|
||||
public required string Trigger { get; init; }
|
||||
public IReadOnlyList<string> ChangedFactors { get; init; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interface for score history storage.
|
||||
/// </summary>
|
||||
public interface IScoreHistoryStore
|
||||
{
|
||||
/// <summary>
|
||||
/// Records a score calculation.
|
||||
/// </summary>
|
||||
void RecordScore(ScoreRecord record);
|
||||
|
||||
/// <summary>
|
||||
/// Gets score history for a finding.
|
||||
/// </summary>
|
||||
ScoreHistoryResponse? GetHistory(
|
||||
string findingId,
|
||||
DateTimeOffset? from,
|
||||
DateTimeOffset? to,
|
||||
int limit,
|
||||
string? cursor);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// In-memory implementation of score history storage.
|
||||
/// </summary>
|
||||
public sealed class InMemoryScoreHistoryStore : IScoreHistoryStore
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, List<ScoreRecord>> _history = new();
|
||||
private readonly TimeSpan _retentionPeriod = TimeSpan.FromDays(90);
|
||||
private readonly int _maxEntriesPerFinding = 1000;
|
||||
|
||||
public void RecordScore(ScoreRecord record)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(record);
|
||||
|
||||
var entries = _history.GetOrAdd(record.FindingId, _ => new List<ScoreRecord>());
|
||||
|
||||
lock (entries)
|
||||
{
|
||||
// Check if this is a duplicate (same score calculated at similar time)
|
||||
var recent = entries.LastOrDefault();
|
||||
if (recent is not null &&
|
||||
recent.Score == record.Score &&
|
||||
recent.Bucket == record.Bucket &&
|
||||
Math.Abs((recent.CalculatedAt - record.CalculatedAt).TotalSeconds) < 1)
|
||||
{
|
||||
return; // Skip duplicate
|
||||
}
|
||||
|
||||
entries.Add(record);
|
||||
|
||||
// Prune old entries
|
||||
var cutoff = DateTimeOffset.UtcNow - _retentionPeriod;
|
||||
entries.RemoveAll(e => e.CalculatedAt < cutoff);
|
||||
|
||||
// Limit total entries
|
||||
if (entries.Count > _maxEntriesPerFinding)
|
||||
{
|
||||
entries.RemoveRange(0, entries.Count - _maxEntriesPerFinding);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public ScoreHistoryResponse? GetHistory(
|
||||
string findingId,
|
||||
DateTimeOffset? from,
|
||||
DateTimeOffset? to,
|
||||
int limit,
|
||||
string? cursor)
|
||||
{
|
||||
if (!_history.TryGetValue(findingId, out var entries))
|
||||
{
|
||||
return new ScoreHistoryResponse
|
||||
{
|
||||
FindingId = findingId,
|
||||
History = [],
|
||||
Pagination = new PaginationDto { HasMore = false, NextCursor = null }
|
||||
};
|
||||
}
|
||||
|
||||
lock (entries)
|
||||
{
|
||||
// Parse cursor (offset-based)
|
||||
var offset = 0;
|
||||
if (!string.IsNullOrEmpty(cursor))
|
||||
{
|
||||
try
|
||||
{
|
||||
var decoded = System.Text.Encoding.UTF8.GetString(Convert.FromBase64String(cursor));
|
||||
if (decoded.StartsWith("offset:", StringComparison.Ordinal))
|
||||
{
|
||||
offset = int.Parse(decoded["offset:".Length..], System.Globalization.CultureInfo.InvariantCulture);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Invalid cursor, start from beginning
|
||||
}
|
||||
}
|
||||
|
||||
// Filter by date range
|
||||
var filtered = entries.AsEnumerable();
|
||||
if (from.HasValue)
|
||||
{
|
||||
filtered = filtered.Where(e => e.CalculatedAt >= from.Value);
|
||||
}
|
||||
if (to.HasValue)
|
||||
{
|
||||
filtered = filtered.Where(e => e.CalculatedAt <= to.Value);
|
||||
}
|
||||
|
||||
// Sort by date descending (most recent first)
|
||||
var sorted = filtered.OrderByDescending(e => e.CalculatedAt).ToList();
|
||||
|
||||
// Apply pagination
|
||||
var paged = sorted.Skip(offset).Take(limit + 1).ToList();
|
||||
var hasMore = paged.Count > limit;
|
||||
if (hasMore)
|
||||
{
|
||||
paged.RemoveAt(paged.Count - 1);
|
||||
}
|
||||
|
||||
var historyEntries = paged.Select(e => new ScoreHistoryEntry
|
||||
{
|
||||
Score = e.Score,
|
||||
Bucket = e.Bucket,
|
||||
PolicyDigest = e.PolicyDigest,
|
||||
CalculatedAt = e.CalculatedAt,
|
||||
Trigger = e.Trigger,
|
||||
ChangedFactors = e.ChangedFactors
|
||||
}).ToList();
|
||||
|
||||
string? nextCursor = null;
|
||||
if (hasMore)
|
||||
{
|
||||
var nextOffset = offset + limit;
|
||||
nextCursor = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes($"offset:{nextOffset}"));
|
||||
}
|
||||
|
||||
return new ScoreHistoryResponse
|
||||
{
|
||||
FindingId = findingId,
|
||||
History = historyEntries,
|
||||
Pagination = new PaginationDto { HasMore = hasMore, NextCursor = nextCursor }
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Net.Http.Json;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Findings.Ledger.WebService.Contracts;
|
||||
|
||||
namespace StellaOps.Findings.Ledger.WebService.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Webhook registration record.
|
||||
/// </summary>
|
||||
public sealed record WebhookRegistration
|
||||
{
|
||||
public required Guid Id { get; init; }
|
||||
public required string Url { get; init; }
|
||||
public string? Secret { get; init; }
|
||||
public IReadOnlyList<string>? FindingPatterns { get; init; }
|
||||
public required int MinScoreChange { get; init; }
|
||||
public required bool TriggerOnBucketChange { get; init; }
|
||||
public required DateTimeOffset CreatedAt { get; init; }
|
||||
public bool IsActive { get; init; } = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Webhook payload for score changes.
|
||||
/// </summary>
|
||||
public sealed record ScoreChangeWebhookPayload
|
||||
{
|
||||
public required string EventType { get; init; }
|
||||
public required string FindingId { get; init; }
|
||||
public required int PreviousScore { get; init; }
|
||||
public required int CurrentScore { get; init; }
|
||||
public required string PreviousBucket { get; init; }
|
||||
public required string CurrentBucket { get; init; }
|
||||
public required int ScoreChange { get; init; }
|
||||
public required bool BucketChanged { get; init; }
|
||||
public required string PolicyDigest { get; init; }
|
||||
public required DateTimeOffset Timestamp { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interface for webhook storage.
|
||||
/// </summary>
|
||||
public interface IWebhookStore
|
||||
{
|
||||
WebhookRegistration Register(RegisterWebhookRequest request);
|
||||
WebhookRegistration? Get(Guid id);
|
||||
IReadOnlyList<WebhookRegistration> List();
|
||||
bool Update(Guid id, RegisterWebhookRequest request);
|
||||
bool Delete(Guid id);
|
||||
IReadOnlyList<WebhookRegistration> GetMatchingWebhooks(string findingId, int scoreChange, bool bucketChanged);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interface for webhook delivery service.
|
||||
/// </summary>
|
||||
public interface IWebhookDeliveryService
|
||||
{
|
||||
Task NotifyScoreChangeAsync(
|
||||
string findingId,
|
||||
int previousScore,
|
||||
int currentScore,
|
||||
string previousBucket,
|
||||
string currentBucket,
|
||||
string policyDigest,
|
||||
CancellationToken ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// In-memory webhook store.
|
||||
/// </summary>
|
||||
public sealed class InMemoryWebhookStore : IWebhookStore
|
||||
{
|
||||
private readonly ConcurrentDictionary<Guid, WebhookRegistration> _webhooks = new();
|
||||
|
||||
public WebhookRegistration Register(RegisterWebhookRequest request)
|
||||
{
|
||||
var registration = new WebhookRegistration
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Url = request.Url,
|
||||
Secret = request.Secret,
|
||||
FindingPatterns = request.FindingPatterns,
|
||||
MinScoreChange = request.MinScoreChange,
|
||||
TriggerOnBucketChange = request.TriggerOnBucketChange,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
IsActive = true
|
||||
};
|
||||
|
||||
_webhooks[registration.Id] = registration;
|
||||
return registration;
|
||||
}
|
||||
|
||||
public WebhookRegistration? Get(Guid id)
|
||||
{
|
||||
return _webhooks.TryGetValue(id, out var reg) ? reg : null;
|
||||
}
|
||||
|
||||
public IReadOnlyList<WebhookRegistration> List()
|
||||
{
|
||||
return _webhooks.Values.Where(w => w.IsActive).ToList();
|
||||
}
|
||||
|
||||
public bool Update(Guid id, RegisterWebhookRequest request)
|
||||
{
|
||||
if (!_webhooks.TryGetValue(id, out var existing))
|
||||
return false;
|
||||
|
||||
var updated = existing with
|
||||
{
|
||||
Url = request.Url,
|
||||
Secret = request.Secret,
|
||||
FindingPatterns = request.FindingPatterns,
|
||||
MinScoreChange = request.MinScoreChange,
|
||||
TriggerOnBucketChange = request.TriggerOnBucketChange
|
||||
};
|
||||
|
||||
_webhooks[id] = updated;
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool Delete(Guid id)
|
||||
{
|
||||
if (!_webhooks.TryGetValue(id, out var existing))
|
||||
return false;
|
||||
|
||||
// Soft delete
|
||||
_webhooks[id] = existing with { IsActive = false };
|
||||
return true;
|
||||
}
|
||||
|
||||
public IReadOnlyList<WebhookRegistration> GetMatchingWebhooks(string findingId, int scoreChange, bool bucketChanged)
|
||||
{
|
||||
return _webhooks.Values
|
||||
.Where(w => w.IsActive)
|
||||
.Where(w => MatchesFinding(w, findingId))
|
||||
.Where(w => Math.Abs(scoreChange) >= w.MinScoreChange || (bucketChanged && w.TriggerOnBucketChange))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static bool MatchesFinding(WebhookRegistration webhook, string findingId)
|
||||
{
|
||||
if (webhook.FindingPatterns is null || webhook.FindingPatterns.Count == 0)
|
||||
return true; // No patterns = match all
|
||||
|
||||
foreach (var pattern in webhook.FindingPatterns)
|
||||
{
|
||||
if (pattern.EndsWith('*'))
|
||||
{
|
||||
var prefix = pattern[..^1];
|
||||
if (findingId.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
|
||||
return true;
|
||||
}
|
||||
else if (string.Equals(pattern, findingId, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Webhook delivery service with retry logic.
|
||||
/// </summary>
|
||||
public sealed class WebhookDeliveryService : IWebhookDeliveryService
|
||||
{
|
||||
private readonly IWebhookStore _store;
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
private readonly ILogger<WebhookDeliveryService> _logger;
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower
|
||||
};
|
||||
|
||||
private static readonly int[] RetryDelaysMs = [100, 500, 2000, 5000];
|
||||
|
||||
public WebhookDeliveryService(
|
||||
IWebhookStore store,
|
||||
IHttpClientFactory httpClientFactory,
|
||||
ILogger<WebhookDeliveryService> logger)
|
||||
{
|
||||
_store = store;
|
||||
_httpClientFactory = httpClientFactory;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task NotifyScoreChangeAsync(
|
||||
string findingId,
|
||||
int previousScore,
|
||||
int currentScore,
|
||||
string previousBucket,
|
||||
string currentBucket,
|
||||
string policyDigest,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var scoreChange = currentScore - previousScore;
|
||||
var bucketChanged = !string.Equals(previousBucket, currentBucket, StringComparison.Ordinal);
|
||||
|
||||
var matchingWebhooks = _store.GetMatchingWebhooks(findingId, scoreChange, bucketChanged);
|
||||
if (matchingWebhooks.Count == 0)
|
||||
{
|
||||
_logger.LogDebug("No webhooks matched for finding {FindingId} score change", findingId);
|
||||
return;
|
||||
}
|
||||
|
||||
var payload = new ScoreChangeWebhookPayload
|
||||
{
|
||||
EventType = "score.changed",
|
||||
FindingId = findingId,
|
||||
PreviousScore = previousScore,
|
||||
CurrentScore = currentScore,
|
||||
PreviousBucket = previousBucket,
|
||||
CurrentBucket = currentBucket,
|
||||
ScoreChange = scoreChange,
|
||||
BucketChanged = bucketChanged,
|
||||
PolicyDigest = policyDigest,
|
||||
Timestamp = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
var payloadJson = JsonSerializer.Serialize(payload, JsonOptions);
|
||||
|
||||
// Fire and forget delivery with retry (don't block the caller)
|
||||
foreach (var webhook in matchingWebhooks)
|
||||
{
|
||||
_ = DeliverWithRetryAsync(webhook, payloadJson, ct);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DeliverWithRetryAsync(WebhookRegistration webhook, string payloadJson, CancellationToken ct)
|
||||
{
|
||||
using var client = _httpClientFactory.CreateClient("webhook-delivery");
|
||||
|
||||
for (var attempt = 0; attempt <= RetryDelaysMs.Length; attempt++)
|
||||
{
|
||||
if (attempt > 0)
|
||||
{
|
||||
var delay = RetryDelaysMs[Math.Min(attempt - 1, RetryDelaysMs.Length - 1)];
|
||||
await Task.Delay(delay, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, webhook.Url)
|
||||
{
|
||||
Content = new StringContent(payloadJson, Encoding.UTF8, "application/json")
|
||||
};
|
||||
|
||||
// Add signature if secret is configured
|
||||
if (!string.IsNullOrEmpty(webhook.Secret))
|
||||
{
|
||||
var signature = ComputeHmacSignature(payloadJson, webhook.Secret);
|
||||
request.Headers.TryAddWithoutValidation("X-Webhook-Signature", $"sha256={signature}");
|
||||
}
|
||||
|
||||
request.Headers.TryAddWithoutValidation("X-Webhook-Id", webhook.Id.ToString());
|
||||
request.Headers.TryAddWithoutValidation("X-Webhook-Timestamp", DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString());
|
||||
|
||||
using var response = await client.SendAsync(request, ct).ConfigureAwait(false);
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Webhook {WebhookId} delivered successfully to {Url}",
|
||||
webhook.Id, webhook.Url);
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.LogWarning(
|
||||
"Webhook {WebhookId} delivery failed with status {StatusCode}, attempt {Attempt}",
|
||||
webhook.Id, response.StatusCode, attempt + 1);
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
ex,
|
||||
"Webhook {WebhookId} delivery failed with exception, attempt {Attempt}",
|
||||
webhook.Id, attempt + 1);
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogError(
|
||||
"Webhook {WebhookId} delivery failed after all retries to {Url}",
|
||||
webhook.Id, webhook.Url);
|
||||
}
|
||||
|
||||
private static string ComputeHmacSignature(string payload, string secret)
|
||||
{
|
||||
var keyBytes = Encoding.UTF8.GetBytes(secret);
|
||||
var payloadBytes = Encoding.UTF8.GetBytes(payload);
|
||||
|
||||
using var hmac = new HMACSHA256(keyBytes);
|
||||
var hash = hmac.ComputeHash(payloadBytes);
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,7 @@
|
||||
<ProjectReference Include="..\..\Scanner\__Libraries\StellaOps.Scanner.Reachability\StellaOps.Scanner.Reachability.csproj" />
|
||||
<ProjectReference Include="..\..\Scanner\__Libraries\StellaOps.Scanner.Analyzers.Native\StellaOps.Scanner.Analyzers.Native.csproj" />
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Router.AspNet\StellaOps.Router.AspNet.csproj" />
|
||||
<ProjectReference Include="..\..\Signals\StellaOps.Signals\StellaOps.Signals.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -10,7 +10,7 @@ using StellaOps.Findings.Ledger.Options;
|
||||
|
||||
namespace StellaOps.Findings.Ledger.Infrastructure.Policy;
|
||||
|
||||
internal sealed class PolicyEngineEvaluationService : IPolicyEvaluationService
|
||||
public sealed class PolicyEngineEvaluationService : IPolicyEvaluationService
|
||||
{
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
|
||||
@@ -6,9 +6,9 @@ using StellaOps.Findings.Ledger.Options;
|
||||
|
||||
namespace StellaOps.Findings.Ledger.Infrastructure.Policy;
|
||||
|
||||
internal sealed record PolicyEvaluationCacheKey(string TenantId, string PolicyVersion, Guid EventId, string? ProjectionHash);
|
||||
public sealed record PolicyEvaluationCacheKey(string TenantId, string PolicyVersion, Guid EventId, string? ProjectionHash);
|
||||
|
||||
internal sealed class PolicyEvaluationCache : IDisposable
|
||||
public sealed class PolicyEvaluationCache : IDisposable
|
||||
{
|
||||
private readonly IMemoryCache _cache;
|
||||
private readonly ILogger<PolicyEvaluationCache> _logger;
|
||||
|
||||
@@ -5,7 +5,7 @@ using System.Reflection;
|
||||
|
||||
namespace StellaOps.Findings.Ledger.Observability;
|
||||
|
||||
internal static class LedgerMetrics
|
||||
public static class LedgerMetrics
|
||||
{
|
||||
private static readonly Meter Meter = new("StellaOps.Findings.Ledger");
|
||||
|
||||
@@ -492,4 +492,178 @@ internal static class LedgerMetrics
|
||||
private static string NormalizeRole(string role) => string.IsNullOrWhiteSpace(role) ? "unspecified" : role.ToLowerInvariant();
|
||||
|
||||
private static string NormalizeTenant(string? tenantId) => string.IsNullOrWhiteSpace(tenantId) ? string.Empty : tenantId;
|
||||
|
||||
// SPRINT_8200_0012_0004: Evidence-Weighted Score (EWS) Metrics
|
||||
|
||||
private static readonly Counter<long> EwsCalculationsTotal = Meter.CreateCounter<long>(
|
||||
"ews_calculations_total",
|
||||
description: "Total number of EWS calculations by result and bucket.");
|
||||
|
||||
private static readonly Histogram<double> EwsCalculationDurationSeconds = Meter.CreateHistogram<double>(
|
||||
"ews_calculation_duration_seconds",
|
||||
unit: "s",
|
||||
description: "Duration of EWS score calculations.");
|
||||
|
||||
private static readonly Counter<long> EwsBatchCalculationsTotal = Meter.CreateCounter<long>(
|
||||
"ews_batch_calculations_total",
|
||||
description: "Total number of EWS batch calculations.");
|
||||
|
||||
private static readonly Histogram<double> EwsBatchSizeHistogram = Meter.CreateHistogram<double>(
|
||||
"ews_batch_size",
|
||||
description: "Distribution of EWS batch sizes.");
|
||||
|
||||
private static readonly Counter<long> EwsCacheHitsTotal = Meter.CreateCounter<long>(
|
||||
"ews_cache_hits_total",
|
||||
description: "Total EWS cache hits.");
|
||||
|
||||
private static readonly Counter<long> EwsCacheMissesTotal = Meter.CreateCounter<long>(
|
||||
"ews_cache_misses_total",
|
||||
description: "Total EWS cache misses.");
|
||||
|
||||
private static readonly Counter<long> EwsWebhooksDeliveredTotal = Meter.CreateCounter<long>(
|
||||
"ews_webhooks_delivered_total",
|
||||
description: "Total webhooks delivered by status.");
|
||||
|
||||
private static readonly Histogram<double> EwsWebhookDeliveryDurationSeconds = Meter.CreateHistogram<double>(
|
||||
"ews_webhook_delivery_duration_seconds",
|
||||
unit: "s",
|
||||
description: "Duration of webhook delivery attempts.");
|
||||
|
||||
private static readonly ConcurrentDictionary<string, BucketDistributionSnapshot> EwsBucketDistributionByTenant = new(StringComparer.Ordinal);
|
||||
|
||||
private static readonly ObservableGauge<long> EwsBucketActNowGauge =
|
||||
Meter.CreateObservableGauge("ews_bucket_distribution_act_now", ObserveEwsBucketActNow,
|
||||
description: "Current count of findings in ActNow bucket by tenant.");
|
||||
|
||||
private static readonly ObservableGauge<long> EwsBucketScheduleNextGauge =
|
||||
Meter.CreateObservableGauge("ews_bucket_distribution_schedule_next", ObserveEwsBucketScheduleNext,
|
||||
description: "Current count of findings in ScheduleNext bucket by tenant.");
|
||||
|
||||
private static readonly ObservableGauge<long> EwsBucketInvestigateGauge =
|
||||
Meter.CreateObservableGauge("ews_bucket_distribution_investigate", ObserveEwsBucketInvestigate,
|
||||
description: "Current count of findings in Investigate bucket by tenant.");
|
||||
|
||||
private static readonly ObservableGauge<long> EwsBucketWatchlistGauge =
|
||||
Meter.CreateObservableGauge("ews_bucket_distribution_watchlist", ObserveEwsBucketWatchlist,
|
||||
description: "Current count of findings in Watchlist bucket by tenant.");
|
||||
|
||||
/// <summary>Records an EWS calculation.</summary>
|
||||
public static void RecordEwsCalculation(
|
||||
TimeSpan duration,
|
||||
string? tenantId,
|
||||
string? policyDigest,
|
||||
string bucket,
|
||||
string result,
|
||||
bool fromCache)
|
||||
{
|
||||
var tags = new KeyValuePair<string, object?>[]
|
||||
{
|
||||
new("tenant", NormalizeTenant(tenantId)),
|
||||
new("policy_digest", policyDigest ?? string.Empty),
|
||||
new("bucket", bucket),
|
||||
new("result", result),
|
||||
new("from_cache", fromCache)
|
||||
};
|
||||
EwsCalculationsTotal.Add(1, tags);
|
||||
EwsCalculationDurationSeconds.Record(duration.TotalSeconds, tags);
|
||||
}
|
||||
|
||||
/// <summary>Records an EWS batch calculation.</summary>
|
||||
public static void RecordEwsBatchCalculation(
|
||||
TimeSpan duration,
|
||||
string? tenantId,
|
||||
int batchSize,
|
||||
int succeeded,
|
||||
int failed)
|
||||
{
|
||||
var tags = new KeyValuePair<string, object?>[]
|
||||
{
|
||||
new("tenant", NormalizeTenant(tenantId)),
|
||||
new("succeeded", succeeded),
|
||||
new("failed", failed)
|
||||
};
|
||||
EwsBatchCalculationsTotal.Add(1, tags);
|
||||
EwsBatchSizeHistogram.Record(batchSize, new KeyValuePair<string, object?>("tenant", NormalizeTenant(tenantId)));
|
||||
EwsCalculationDurationSeconds.Record(duration.TotalSeconds, tags);
|
||||
}
|
||||
|
||||
/// <summary>Records an EWS cache hit.</summary>
|
||||
public static void RecordEwsCacheHit(string? tenantId, string findingId)
|
||||
{
|
||||
EwsCacheHitsTotal.Add(1, new KeyValuePair<string, object?>("tenant", NormalizeTenant(tenantId)));
|
||||
}
|
||||
|
||||
/// <summary>Records an EWS cache miss.</summary>
|
||||
public static void RecordEwsCacheMiss(string? tenantId, string findingId)
|
||||
{
|
||||
EwsCacheMissesTotal.Add(1, new KeyValuePair<string, object?>("tenant", NormalizeTenant(tenantId)));
|
||||
}
|
||||
|
||||
/// <summary>Records a webhook delivery attempt.</summary>
|
||||
public static void RecordWebhookDelivery(TimeSpan duration, Guid webhookId, string status, int attempt)
|
||||
{
|
||||
var tags = new KeyValuePair<string, object?>[]
|
||||
{
|
||||
new("webhook_id", webhookId.ToString()),
|
||||
new("status", status),
|
||||
new("attempt", attempt)
|
||||
};
|
||||
EwsWebhooksDeliveredTotal.Add(1, tags);
|
||||
EwsWebhookDeliveryDurationSeconds.Record(duration.TotalSeconds, tags);
|
||||
}
|
||||
|
||||
/// <summary>Updates the EWS bucket distribution for a tenant.</summary>
|
||||
public static void UpdateEwsBucketDistribution(
|
||||
string tenantId,
|
||||
int actNow,
|
||||
int scheduleNext,
|
||||
int investigate,
|
||||
int watchlist)
|
||||
{
|
||||
var key = NormalizeTenant(tenantId);
|
||||
EwsBucketDistributionByTenant[key] = new BucketDistributionSnapshot(key, actNow, scheduleNext, investigate, watchlist);
|
||||
}
|
||||
|
||||
private sealed record BucketDistributionSnapshot(
|
||||
string TenantId,
|
||||
int ActNow,
|
||||
int ScheduleNext,
|
||||
int Investigate,
|
||||
int Watchlist);
|
||||
|
||||
private static IEnumerable<Measurement<long>> ObserveEwsBucketActNow()
|
||||
{
|
||||
foreach (var kvp in EwsBucketDistributionByTenant)
|
||||
{
|
||||
yield return new Measurement<long>(kvp.Value.ActNow,
|
||||
new KeyValuePair<string, object?>("tenant", kvp.Value.TenantId));
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerable<Measurement<long>> ObserveEwsBucketScheduleNext()
|
||||
{
|
||||
foreach (var kvp in EwsBucketDistributionByTenant)
|
||||
{
|
||||
yield return new Measurement<long>(kvp.Value.ScheduleNext,
|
||||
new KeyValuePair<string, object?>("tenant", kvp.Value.TenantId));
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerable<Measurement<long>> ObserveEwsBucketInvestigate()
|
||||
{
|
||||
foreach (var kvp in EwsBucketDistributionByTenant)
|
||||
{
|
||||
yield return new Measurement<long>(kvp.Value.Investigate,
|
||||
new KeyValuePair<string, object?>("tenant", kvp.Value.TenantId));
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerable<Measurement<long>> ObserveEwsBucketWatchlist()
|
||||
{
|
||||
foreach (var kvp in EwsBucketDistributionByTenant)
|
||||
{
|
||||
yield return new Measurement<long>(kvp.Value.Watchlist,
|
||||
new KeyValuePair<string, object?>("tenant", kvp.Value.TenantId));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,4 +76,123 @@ internal static class LedgerTelemetry
|
||||
|
||||
return activity;
|
||||
}
|
||||
|
||||
// SPRINT_8200_0012_0004: Evidence-Weighted Score (EWS) Telemetry
|
||||
|
||||
/// <summary>
|
||||
/// Starts an activity for EWS score calculation.
|
||||
/// </summary>
|
||||
public static Activity? StartEwsCalculation(string findingId, string? policyVersion)
|
||||
{
|
||||
var activity = ActivitySource.StartActivity("EWS.Calculate", ActivityKind.Internal);
|
||||
if (activity is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
activity.SetTag("finding_id", findingId);
|
||||
activity.SetTag("policy_version", policyVersion ?? "latest");
|
||||
|
||||
return activity;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Marks the EWS calculation outcome.
|
||||
/// </summary>
|
||||
public static void MarkEwsCalculationOutcome(
|
||||
Activity? activity,
|
||||
int score,
|
||||
string bucket,
|
||||
string policyDigest,
|
||||
TimeSpan duration,
|
||||
bool fromCache)
|
||||
{
|
||||
if (activity is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
activity.SetTag("score", score);
|
||||
activity.SetTag("bucket", bucket);
|
||||
activity.SetTag("policy_digest", policyDigest);
|
||||
activity.SetTag("duration_ms", duration.TotalMilliseconds);
|
||||
activity.SetTag("from_cache", fromCache);
|
||||
activity.SetStatus(ActivityStatusCode.Ok);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Starts an activity for EWS batch calculation.
|
||||
/// </summary>
|
||||
public static Activity? StartEwsBatchCalculation(int batchSize, string? policyVersion)
|
||||
{
|
||||
var activity = ActivitySource.StartActivity("EWS.CalculateBatch", ActivityKind.Internal);
|
||||
if (activity is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
activity.SetTag("batch_size", batchSize);
|
||||
activity.SetTag("policy_version", policyVersion ?? "latest");
|
||||
|
||||
return activity;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Marks the EWS batch calculation outcome.
|
||||
/// </summary>
|
||||
public static void MarkEwsBatchOutcome(
|
||||
Activity? activity,
|
||||
int succeeded,
|
||||
int failed,
|
||||
double averageScore,
|
||||
TimeSpan duration)
|
||||
{
|
||||
if (activity is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
activity.SetTag("succeeded", succeeded);
|
||||
activity.SetTag("failed", failed);
|
||||
activity.SetTag("average_score", averageScore);
|
||||
activity.SetTag("duration_ms", duration.TotalMilliseconds);
|
||||
activity.SetStatus(failed > 0 ? ActivityStatusCode.Error : ActivityStatusCode.Ok);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Starts an activity for webhook delivery.
|
||||
/// </summary>
|
||||
public static Activity? StartWebhookDelivery(Guid webhookId, string url)
|
||||
{
|
||||
var activity = ActivitySource.StartActivity("EWS.WebhookDelivery", ActivityKind.Client);
|
||||
if (activity is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
activity.SetTag("webhook_id", webhookId.ToString());
|
||||
activity.SetTag("webhook_url", url);
|
||||
|
||||
return activity;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Marks the webhook delivery outcome.
|
||||
/// </summary>
|
||||
public static void MarkWebhookDeliveryOutcome(
|
||||
Activity? activity,
|
||||
int statusCode,
|
||||
int attempt,
|
||||
TimeSpan duration)
|
||||
{
|
||||
if (activity is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
activity.SetTag("status_code", statusCode);
|
||||
activity.SetTag("attempt", attempt);
|
||||
activity.SetTag("duration_ms", duration.TotalMilliseconds);
|
||||
activity.SetStatus(statusCode >= 200 && statusCode < 300 ? ActivityStatusCode.Ok : ActivityStatusCode.Error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,6 +97,26 @@ public sealed class AlertService : IAlertService
|
||||
return MapToAlert(finding);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a specific alert by ID (tenant extracted from alert ID).
|
||||
/// </summary>
|
||||
public Task<Alert?> GetAlertAsync(
|
||||
string alertId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(alertId);
|
||||
|
||||
// Extract tenant from alert ID format: tenant|artifact|vuln
|
||||
var tenantId = GetTenantIdFromAlert(alertId);
|
||||
return GetAsync(tenantId, alertId, cancellationToken);
|
||||
}
|
||||
|
||||
private static string GetTenantIdFromAlert(string alertId)
|
||||
{
|
||||
var parts = alertId.Split('|');
|
||||
return parts.Length > 0 ? parts[0] : "default";
|
||||
}
|
||||
|
||||
private static Alert MapToAlert(ScoredFinding finding)
|
||||
{
|
||||
// Compute band based on risk score
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Findings.Ledger.Domain;
|
||||
|
||||
namespace StellaOps.Findings.Ledger.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service for evidence bundle operations.
|
||||
/// </summary>
|
||||
public sealed class EvidenceBundleService : IEvidenceBundleService
|
||||
{
|
||||
private readonly ILogger<EvidenceBundleService> _logger;
|
||||
|
||||
public EvidenceBundleService(ILogger<EvidenceBundleService> logger)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<EvidenceBundle?> GetBundleAsync(
|
||||
string tenantId,
|
||||
string alertId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(alertId);
|
||||
|
||||
_logger.LogDebug("Getting evidence bundle for alert {AlertId} in tenant {TenantId}", alertId, tenantId);
|
||||
|
||||
// Placeholder implementation - returns null indicating bundle not found
|
||||
return Task.FromResult<EvidenceBundle?>(null);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<EvidenceBundleContent?> CreateBundleAsync(
|
||||
string alertId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(alertId);
|
||||
|
||||
_logger.LogDebug("Creating evidence bundle for alert {AlertId}", alertId);
|
||||
|
||||
// Placeholder implementation - returns null indicating bundle cannot be created
|
||||
// Full implementation would gather evidence artifacts and create a tar.gz archive
|
||||
return Task.FromResult<EvidenceBundleContent?>(null);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<EvidenceBundleVerificationResult> VerifyBundleAsync(
|
||||
string alertId,
|
||||
string bundleHash,
|
||||
string? signature,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(alertId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(bundleHash);
|
||||
|
||||
_logger.LogDebug("Verifying evidence bundle for alert {AlertId} with hash {Hash}", alertId, bundleHash);
|
||||
|
||||
// Placeholder implementation - returns valid result
|
||||
// Full implementation would verify hash integrity and signature
|
||||
return Task.FromResult(new EvidenceBundleVerificationResult
|
||||
{
|
||||
IsValid = true,
|
||||
HashValid = true,
|
||||
SignatureValid = signature is not null,
|
||||
ChainValid = true,
|
||||
Errors = null
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,13 @@ public interface IAlertService
|
||||
string tenantId,
|
||||
string alertId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a specific alert by ID (tenant from context).
|
||||
/// </summary>
|
||||
Task<Alert?> GetAlertAsync(
|
||||
string alertId,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -14,4 +14,72 @@ public interface IEvidenceBundleService
|
||||
string tenantId,
|
||||
string alertId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Creates an evidence bundle for download.
|
||||
/// </summary>
|
||||
Task<EvidenceBundleContent?> CreateBundleAsync(
|
||||
string alertId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Verifies an evidence bundle.
|
||||
/// </summary>
|
||||
Task<EvidenceBundleVerificationResult> VerifyBundleAsync(
|
||||
string alertId,
|
||||
string bundleHash,
|
||||
string? signature,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Content for an evidence bundle download.
|
||||
/// </summary>
|
||||
public sealed class EvidenceBundleContent
|
||||
{
|
||||
/// <summary>
|
||||
/// Bundle content stream.
|
||||
/// </summary>
|
||||
public required Stream Content { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Content type.
|
||||
/// </summary>
|
||||
public string ContentType { get; init; } = "application/gzip";
|
||||
|
||||
/// <summary>
|
||||
/// File name for download.
|
||||
/// </summary>
|
||||
public string? FileName { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of bundle verification.
|
||||
/// </summary>
|
||||
public sealed class EvidenceBundleVerificationResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Overall validity.
|
||||
/// </summary>
|
||||
public required bool IsValid { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the signature is valid.
|
||||
/// </summary>
|
||||
public bool SignatureValid { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the hash is valid.
|
||||
/// </summary>
|
||||
public bool HashValid { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the chain is valid.
|
||||
/// </summary>
|
||||
public bool ChainValid { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// List of errors, if any.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string>? Errors { get; init; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user