save development progress

This commit is contained in:
StellaOps Bot
2025-12-25 23:09:58 +02:00
parent d71853ad7e
commit aa70af062e
351 changed files with 37683 additions and 150156 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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);
}
}

View File

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

View File

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

View File

@@ -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);
}

View File

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

View File

@@ -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();
}
}

View File

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

View File

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

View File

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

View File

@@ -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));
}
}
}

View File

@@ -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);
}
}

View File

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

View File

@@ -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
});
}
}

View File

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

View File

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