Files
git.stella-ops.org/src/Excititor/StellaOps.Excititor.WebService/Endpoints/ObservationEndpoints.cs
StellaOps Bot bc0762e97d up
2025-12-09 00:20:52 +02:00

311 lines
13 KiB
C#

using System;
using System.Collections.Generic;
using System.Linq;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using StellaOps.Excititor.Core.Observations;
using StellaOps.Excititor.Core.Storage;
using StellaOps.Excititor.WebService.Contracts;
using StellaOps.Excititor.WebService.Services;
namespace StellaOps.Excititor.WebService.Endpoints;
/// <summary>
/// Observation API endpoints (EXCITITOR-LNM-21-201).
/// Exposes /vex/observations/* endpoints with filters for advisory/product/provider,
/// strict RBAC, and deterministic pagination (no derived verdict fields).
/// </summary>
public static class ObservationEndpoints
{
public static void MapObservationEndpoints(this WebApplication app)
{
var group = app.MapGroup("/vex/observations");
// GET /vex/observations - List observations with filters
group.MapGet("", async (
HttpContext context,
IOptions<VexStorageOptions> storageOptions,
[FromServices] IVexObservationStore observationStore,
TimeProvider timeProvider,
[FromQuery] int? limit,
[FromQuery] string? cursor,
[FromQuery] string? vulnerabilityId,
[FromQuery] string? productKey,
[FromQuery] string? providerId,
CancellationToken cancellationToken) =>
{
var scopeResult = ScopeAuthorization.RequireScope(context, "vex.read");
if (scopeResult is not null)
{
return scopeResult;
}
if (!TryResolveTenant(context, storageOptions.Value, out var tenant, out var tenantError))
{
return tenantError;
}
var take = Math.Clamp(limit.GetValueOrDefault(50), 1, 100);
IReadOnlyList<VexObservation> observations;
// Route to appropriate query method based on filters
if (!string.IsNullOrWhiteSpace(vulnerabilityId) && !string.IsNullOrWhiteSpace(productKey))
{
observations = await observationStore
.FindByVulnerabilityAndProductAsync(tenant, vulnerabilityId.Trim(), productKey.Trim(), cancellationToken)
.ConfigureAwait(false);
}
else if (!string.IsNullOrWhiteSpace(providerId))
{
observations = await observationStore
.FindByProviderAsync(tenant, providerId.Trim(), take, cancellationToken)
.ConfigureAwait(false);
}
else
{
// No filter - return empty for now (full list requires pagination infrastructure)
return Results.BadRequest(new
{
error = new
{
code = "ERR_PARAMS",
message = "At least one filter is required: vulnerabilityId+productKey or providerId"
}
});
}
var items = observations
.Take(take)
.Select(obs => ToListItem(obs))
.ToList();
var hasMore = observations.Count > take;
string? nextCursor = null;
if (hasMore && items.Count > 0)
{
var last = observations[items.Count - 1];
nextCursor = EncodeCursor(last.CreatedAt.UtcDateTime, last.ObservationId);
}
var response = new VexObservationListResponse(items, nextCursor);
return Results.Ok(response);
}).WithName("ListVexObservations");
// GET /vex/observations/{observationId} - Get observation by ID
group.MapGet("/{observationId}", async (
HttpContext context,
string observationId,
IOptions<VexStorageOptions> storageOptions,
[FromServices] IVexObservationStore observationStore,
CancellationToken cancellationToken) =>
{
var scopeResult = ScopeAuthorization.RequireScope(context, "vex.read");
if (scopeResult is not null)
{
return scopeResult;
}
if (!TryResolveTenant(context, storageOptions.Value, out var tenant, out var tenantError))
{
return tenantError;
}
if (string.IsNullOrWhiteSpace(observationId))
{
return Results.BadRequest(new
{
error = new { code = "ERR_PARAMS", message = "observationId is required" }
});
}
var observation = await observationStore
.GetByIdAsync(tenant, observationId.Trim(), cancellationToken)
.ConfigureAwait(false);
if (observation is null)
{
return Results.NotFound(new
{
error = new { code = "ERR_NOT_FOUND", message = $"Observation '{observationId}' not found" }
});
}
var response = ToDetailResponse(observation);
return Results.Ok(response);
}).WithName("GetVexObservation");
// GET /vex/observations/count - Get observation count for tenant
group.MapGet("/count", async (
HttpContext context,
IOptions<VexStorageOptions> storageOptions,
[FromServices] IVexObservationStore observationStore,
CancellationToken cancellationToken) =>
{
var scopeResult = ScopeAuthorization.RequireScope(context, "vex.read");
if (scopeResult is not null)
{
return scopeResult;
}
if (!TryResolveTenant(context, storageOptions.Value, out var tenant, out var tenantError))
{
return tenantError;
}
var count = await observationStore
.CountAsync(tenant, cancellationToken)
.ConfigureAwait(false);
return Results.Ok(new { count });
}).WithName("CountVexObservations");
}
private static VexObservationListItem ToListItem(VexObservation obs)
{
var firstStatement = obs.Statements.FirstOrDefault();
return new VexObservationListItem(
ObservationId: obs.ObservationId,
Tenant: obs.Tenant,
ProviderId: obs.ProviderId,
VulnerabilityId: firstStatement?.VulnerabilityId ?? string.Empty,
ProductKey: firstStatement?.ProductKey ?? string.Empty,
Status: firstStatement?.Status.ToString().ToLowerInvariant() ?? "unknown",
CreatedAt: obs.CreatedAt,
LastObserved: firstStatement?.LastObserved,
Purls: obs.Linkset.Purls.ToList());
}
private static VexObservationDetailResponse ToDetailResponse(VexObservation obs)
{
var upstream = new VexObservationUpstreamResponse(
obs.Upstream.UpstreamId,
obs.Upstream.DocumentVersion,
obs.Upstream.FetchedAt,
obs.Upstream.ReceivedAt,
obs.Upstream.ContentHash,
obs.Upstream.Signature.Present
? new VexObservationSignatureResponse(
obs.Upstream.Signature.Format ?? "dsse",
obs.Upstream.Signature.KeyId,
Issuer: null,
VerifiedAtUtc: null)
: null);
var content = new VexObservationContentResponse(
obs.Content.Format,
obs.Content.SpecVersion);
var statements = obs.Statements
.Select(stmt => new VexObservationStatementItem(
stmt.VulnerabilityId,
stmt.ProductKey,
stmt.Status.ToString().ToLowerInvariant(),
stmt.LastObserved,
stmt.Locator,
stmt.Justification?.ToString().ToLowerInvariant(),
stmt.IntroducedVersion,
stmt.FixedVersion))
.ToList();
var linkset = new VexObservationLinksetResponse(
obs.Linkset.Aliases.ToList(),
obs.Linkset.Purls.ToList(),
obs.Linkset.Cpes.ToList(),
obs.Linkset.References.Select(r => new VexObservationReferenceItem(r.Type, r.Url)).ToList());
return new VexObservationDetailResponse(
obs.ObservationId,
obs.Tenant,
obs.ProviderId,
obs.StreamId,
upstream,
content,
statements,
linkset,
obs.CreatedAt);
}
private static bool TryResolveTenant(
HttpContext context,
VexStorageOptions options,
out string tenant,
out IResult? problem)
{
problem = null;
tenant = string.Empty;
var headerTenant = context.Request.Headers["X-Stella-Tenant"].FirstOrDefault();
if (!string.IsNullOrWhiteSpace(headerTenant))
{
tenant = headerTenant.Trim().ToLowerInvariant();
}
else if (!string.IsNullOrWhiteSpace(options.DefaultTenant))
{
tenant = options.DefaultTenant.Trim().ToLowerInvariant();
}
else
{
problem = Results.BadRequest(new
{
error = new { code = "ERR_TENANT", message = "X-Stella-Tenant header is required" }
});
return false;
}
return true;
}
private static string EncodeCursor(DateTime timestamp, string id)
{
var raw = $"{timestamp:O}|{id}";
return Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(raw));
}
}
// Additional response DTOs for observation detail
public sealed record VexObservationUpstreamResponse(
[property: System.Text.Json.Serialization.JsonPropertyName("upstreamId")] string UpstreamId,
[property: System.Text.Json.Serialization.JsonPropertyName("documentVersion")] string? DocumentVersion,
[property: System.Text.Json.Serialization.JsonPropertyName("fetchedAt")] DateTimeOffset FetchedAt,
[property: System.Text.Json.Serialization.JsonPropertyName("receivedAt")] DateTimeOffset ReceivedAt,
[property: System.Text.Json.Serialization.JsonPropertyName("contentHash")] string ContentHash,
[property: System.Text.Json.Serialization.JsonPropertyName("signature")] VexObservationSignatureResponse? Signature);
public sealed record VexObservationContentResponse(
[property: System.Text.Json.Serialization.JsonPropertyName("format")] string Format,
[property: System.Text.Json.Serialization.JsonPropertyName("specVersion")] string? SpecVersion);
public sealed record VexObservationStatementItem(
[property: System.Text.Json.Serialization.JsonPropertyName("vulnerabilityId")] string VulnerabilityId,
[property: System.Text.Json.Serialization.JsonPropertyName("productKey")] string ProductKey,
[property: System.Text.Json.Serialization.JsonPropertyName("status")] string Status,
[property: System.Text.Json.Serialization.JsonPropertyName("lastObserved")] DateTimeOffset? LastObserved,
[property: System.Text.Json.Serialization.JsonPropertyName("locator")] string? Locator,
[property: System.Text.Json.Serialization.JsonPropertyName("justification")] string? Justification,
[property: System.Text.Json.Serialization.JsonPropertyName("introducedVersion")] string? IntroducedVersion,
[property: System.Text.Json.Serialization.JsonPropertyName("fixedVersion")] string? FixedVersion);
public sealed record VexObservationLinksetResponse(
[property: System.Text.Json.Serialization.JsonPropertyName("aliases")] IReadOnlyList<string> Aliases,
[property: System.Text.Json.Serialization.JsonPropertyName("purls")] IReadOnlyList<string> Purls,
[property: System.Text.Json.Serialization.JsonPropertyName("cpes")] IReadOnlyList<string> Cpes,
[property: System.Text.Json.Serialization.JsonPropertyName("references")] IReadOnlyList<VexObservationReferenceItem> References);
public sealed record VexObservationReferenceItem(
[property: System.Text.Json.Serialization.JsonPropertyName("type")] string Type,
[property: System.Text.Json.Serialization.JsonPropertyName("url")] string Url);
public sealed record VexObservationDetailResponse(
[property: System.Text.Json.Serialization.JsonPropertyName("observationId")] string ObservationId,
[property: System.Text.Json.Serialization.JsonPropertyName("tenant")] string Tenant,
[property: System.Text.Json.Serialization.JsonPropertyName("providerId")] string ProviderId,
[property: System.Text.Json.Serialization.JsonPropertyName("streamId")] string StreamId,
[property: System.Text.Json.Serialization.JsonPropertyName("upstream")] VexObservationUpstreamResponse Upstream,
[property: System.Text.Json.Serialization.JsonPropertyName("content")] VexObservationContentResponse Content,
[property: System.Text.Json.Serialization.JsonPropertyName("statements")] IReadOnlyList<VexObservationStatementItem> Statements,
[property: System.Text.Json.Serialization.JsonPropertyName("linkset")] VexObservationLinksetResponse Linkset,
[property: System.Text.Json.Serialization.JsonPropertyName("createdAt")] DateTimeOffset CreatedAt);