311 lines
13 KiB
C#
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);
|