up
This commit is contained in:
@@ -0,0 +1,51 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Text.Json.Serialization;
|
||||
using StellaOps.Excititor.Core;
|
||||
|
||||
namespace StellaOps.Excititor.WebService.Contracts;
|
||||
|
||||
public sealed record PolicyVexLookupRequest
|
||||
{
|
||||
[JsonPropertyName("advisory_keys")]
|
||||
public IReadOnlyList<string> AdvisoryKeys { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("purls")]
|
||||
public IReadOnlyList<string> Purls { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("providers")]
|
||||
public IReadOnlyList<string> Providers { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("statuses")]
|
||||
public IReadOnlyList<string> Statuses { get; init; } = Array.Empty<string>();
|
||||
|
||||
[Range(1, 500)]
|
||||
[JsonPropertyName("limit")]
|
||||
public int Limit { get; init; } = 200;
|
||||
}
|
||||
|
||||
public sealed record PolicyVexLookupResponse(
|
||||
IReadOnlyList<PolicyVexLookupItem> Results,
|
||||
int TotalStatements,
|
||||
DateTimeOffset GeneratedAtUtc);
|
||||
|
||||
public sealed record PolicyVexLookupItem(
|
||||
string AdvisoryKey,
|
||||
IReadOnlyList<string> Aliases,
|
||||
IReadOnlyList<PolicyVexStatement> Statements);
|
||||
|
||||
public sealed record PolicyVexStatement(
|
||||
string ObservationId,
|
||||
string ProviderId,
|
||||
string Status,
|
||||
string ProductKey,
|
||||
string? Purl,
|
||||
string? Cpe,
|
||||
string? Version,
|
||||
string? Justification,
|
||||
string? Detail,
|
||||
DateTimeOffset FirstSeen,
|
||||
DateTimeOffset LastSeen,
|
||||
VexSignatureMetadata? Signature,
|
||||
IReadOnlyDictionary<string, string> Metadata);
|
||||
@@ -13,6 +13,7 @@ public sealed record VexLinksetListItem(
|
||||
[property: JsonPropertyName("tenant")] string Tenant,
|
||||
[property: JsonPropertyName("vulnerabilityId")] string VulnerabilityId,
|
||||
[property: JsonPropertyName("productKey")] string ProductKey,
|
||||
[property: JsonPropertyName("scope")] VexLinksetScope Scope,
|
||||
[property: JsonPropertyName("providerIds")] IReadOnlyList<string> ProviderIds,
|
||||
[property: JsonPropertyName("statuses")] IReadOnlyList<string> Statuses,
|
||||
[property: JsonPropertyName("aliases")] IReadOnlyList<string> Aliases,
|
||||
@@ -38,3 +39,11 @@ public sealed record VexLinksetObservationRef(
|
||||
[property: JsonPropertyName("providerId")] string ProviderId,
|
||||
[property: JsonPropertyName("status")] string Status,
|
||||
[property: JsonPropertyName("confidence")] double? Confidence);
|
||||
|
||||
public sealed record VexLinksetScope(
|
||||
[property: JsonPropertyName("productKey")] string ProductKey,
|
||||
[property: JsonPropertyName("type")] string Type,
|
||||
[property: JsonPropertyName("version")] string? Version,
|
||||
[property: JsonPropertyName("purl")] string? Purl,
|
||||
[property: JsonPropertyName("cpe")] string? Cpe,
|
||||
[property: JsonPropertyName("identifiers")] IReadOnlyList<string> Identifiers);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Diagnostics;
|
||||
using System.Linq;
|
||||
using System.Text.Json.Serialization;
|
||||
@@ -7,11 +8,13 @@ using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Excititor.Core.Canonicalization;
|
||||
using StellaOps.Excititor.Core.Observations;
|
||||
using StellaOps.Excititor.Storage.Mongo;
|
||||
using StellaOps.Excititor.WebService.Contracts;
|
||||
using StellaOps.Excititor.WebService.Services;
|
||||
using StellaOps.Excititor.WebService.Telemetry;
|
||||
using DomainVexProductScope = StellaOps.Excititor.Core.Observations.VexProductScope;
|
||||
|
||||
namespace StellaOps.Excititor.WebService.Endpoints;
|
||||
|
||||
@@ -267,11 +270,13 @@ public static class LinksetEndpoints
|
||||
|
||||
private static VexLinksetListItem ToListItem(VexLinkset linkset)
|
||||
{
|
||||
var scope = BuildScope(linkset.Scope);
|
||||
return new VexLinksetListItem(
|
||||
LinksetId: linkset.LinksetId,
|
||||
Tenant: linkset.Tenant,
|
||||
VulnerabilityId: linkset.VulnerabilityId,
|
||||
ProductKey: linkset.ProductKey,
|
||||
Scope: scope,
|
||||
ProviderIds: linkset.ProviderIds.ToList(),
|
||||
Statuses: linkset.Statuses.ToList(),
|
||||
Aliases: Array.Empty<string>(), // Aliases are in observations, not linksets
|
||||
@@ -289,11 +294,13 @@ public static class LinksetEndpoints
|
||||
|
||||
private static VexLinksetDetailResponse ToDetailResponse(VexLinkset linkset)
|
||||
{
|
||||
var scope = BuildScope(linkset.Scope);
|
||||
return new VexLinksetDetailResponse(
|
||||
LinksetId: linkset.LinksetId,
|
||||
Tenant: linkset.Tenant,
|
||||
VulnerabilityId: linkset.VulnerabilityId,
|
||||
ProductKey: linkset.ProductKey,
|
||||
Scope: scope,
|
||||
ProviderIds: linkset.ProviderIds.ToList(),
|
||||
Statuses: linkset.Statuses.ToList(),
|
||||
Confidence: linkset.Confidence.ToString().ToLowerInvariant(),
|
||||
@@ -343,6 +350,17 @@ public static class LinksetEndpoints
|
||||
var raw = $"{timestamp:O}|{id}";
|
||||
return Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(raw));
|
||||
}
|
||||
|
||||
private static VexLinksetScope BuildScope(DomainVexProductScope scope)
|
||||
{
|
||||
return new VexLinksetScope(
|
||||
ProductKey: scope.ProductKey,
|
||||
Type: scope.Type,
|
||||
Version: scope.Version,
|
||||
Purl: scope.Purl,
|
||||
Cpe: scope.Cpe,
|
||||
Identifiers: scope.Identifiers.ToList());
|
||||
}
|
||||
}
|
||||
|
||||
// Detail response for single linkset
|
||||
@@ -351,6 +369,7 @@ public sealed record VexLinksetDetailResponse(
|
||||
[property: JsonPropertyName("tenant")] string Tenant,
|
||||
[property: JsonPropertyName("vulnerabilityId")] string VulnerabilityId,
|
||||
[property: JsonPropertyName("productKey")] string ProductKey,
|
||||
[property: JsonPropertyName("scope")] VexLinksetScope Scope,
|
||||
[property: JsonPropertyName("providerIds")] IReadOnlyList<string> ProviderIds,
|
||||
[property: JsonPropertyName("statuses")] IReadOnlyList<string> Statuses,
|
||||
[property: JsonPropertyName("confidence")] string Confidence,
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Excititor.Core;
|
||||
using StellaOps.Excititor.Core.Canonicalization;
|
||||
using StellaOps.Excititor.Core.Orchestration;
|
||||
using StellaOps.Excititor.Storage.Mongo;
|
||||
using StellaOps.Excititor.WebService.Contracts;
|
||||
using StellaOps.Excititor.WebService.Services;
|
||||
|
||||
namespace StellaOps.Excititor.WebService.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Policy-facing VEX lookup endpoints (EXCITITOR-POLICY-20-001).
|
||||
/// Aggregation-only: returns raw observations/statements without consensus or severity.
|
||||
/// </summary>
|
||||
public static class PolicyEndpoints
|
||||
{
|
||||
public static void MapPolicyEndpoints(this WebApplication app)
|
||||
{
|
||||
app.MapPost("/policy/v1/vex/lookup", LookupVexAsync)
|
||||
.WithName("Policy_VexLookup")
|
||||
.WithDescription("Batch VEX lookup by advisory_key and product (aggregation-only)");
|
||||
}
|
||||
|
||||
private static async Task<IResult> LookupVexAsync(
|
||||
HttpContext context,
|
||||
[FromBody] PolicyVexLookupRequest request,
|
||||
IOptions<VexMongoStorageOptions> storageOptions,
|
||||
[FromServices] IVexClaimStore claimStore,
|
||||
TimeProvider timeProvider,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// AuthN/Z
|
||||
var scopeResult = ScopeAuthorization.RequireScope(context, "vex.read");
|
||||
if (scopeResult is not null)
|
||||
{
|
||||
return scopeResult;
|
||||
}
|
||||
|
||||
if (!TryResolveTenant(context, storageOptions.Value, out _, out var tenantError))
|
||||
{
|
||||
return tenantError!;
|
||||
}
|
||||
|
||||
// Validate input
|
||||
if ((request.AdvisoryKeys.Count == 0) && (request.Purls.Count == 0))
|
||||
{
|
||||
return Results.BadRequest(new { error = new { code = "ERR_REQUEST", message = "advisory_keys or purls must be provided" } });
|
||||
}
|
||||
|
||||
var canonicalizer = new VexAdvisoryKeyCanonicalizer();
|
||||
var productCanonicalizer = new VexProductKeyCanonicalizer();
|
||||
|
||||
var canonicalAdvisories = request.AdvisoryKeys
|
||||
.Where(a => !string.IsNullOrWhiteSpace(a))
|
||||
.Select(a => canonicalizer.Canonicalize(a.Trim()))
|
||||
.ToList();
|
||||
|
||||
var canonicalProducts = request.Purls
|
||||
.Where(p => !string.IsNullOrWhiteSpace(p))
|
||||
.Select(p => productCanonicalizer.Canonicalize(p.Trim(), purl: p.Trim()))
|
||||
.ToList();
|
||||
|
||||
// Map requested statuses/providers for filtering
|
||||
var statusFilter = request.Statuses
|
||||
.Select(s => Enum.TryParse<VexClaimStatus>(s, true, out var parsed) ? parsed : (VexClaimStatus?)null)
|
||||
.Where(p => p.HasValue)
|
||||
.Select(p => p!.Value)
|
||||
.ToImmutableHashSet();
|
||||
|
||||
var providerFilter = request.Providers
|
||||
.Where(p => !string.IsNullOrWhiteSpace(p))
|
||||
.Select(p => p.Trim())
|
||||
.ToImmutableHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var limit = Math.Clamp(request.Limit, 1, 500);
|
||||
var now = timeProvider.GetUtcNow();
|
||||
|
||||
var results = new List<PolicyVexLookupItem>();
|
||||
var totalStatements = 0;
|
||||
|
||||
// For each advisory key, fetch claims and filter by product/provider/status
|
||||
foreach (var advisory in canonicalAdvisories)
|
||||
{
|
||||
var claims = await claimStore
|
||||
.FindByVulnerabilityAsync(advisory.AdvisoryKey, limit, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var filtered = claims
|
||||
.Where(claim => MatchesProvider(providerFilter, claim))
|
||||
.Where(claim => MatchesStatus(statusFilter, claim))
|
||||
.Where(claim => MatchesProduct(canonicalProducts, claim))
|
||||
.OrderByDescending(claim => claim.LastSeen)
|
||||
.ThenBy(claim => claim.ProviderId, StringComparer.Ordinal)
|
||||
.ThenBy(claim => claim.Product.Key, StringComparer.Ordinal)
|
||||
.Take(limit)
|
||||
.ToList();
|
||||
|
||||
totalStatements += filtered.Count;
|
||||
|
||||
var statements = filtered.Select(MapStatement).ToList();
|
||||
var aliases = advisory.Aliases.ToList();
|
||||
if (!aliases.Contains(advisory.AdvisoryKey, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
aliases.Add(advisory.AdvisoryKey);
|
||||
}
|
||||
|
||||
results.Add(new PolicyVexLookupItem(
|
||||
advisory.AdvisoryKey,
|
||||
aliases,
|
||||
statements));
|
||||
}
|
||||
|
||||
var response = new PolicyVexLookupResponse(results, totalStatements, now);
|
||||
return Results.Ok(response);
|
||||
}
|
||||
|
||||
private static bool MatchesProvider(ISet<string> providers, VexClaim claim)
|
||||
=> providers.Count == 0 || providers.Contains(claim.ProviderId, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
private static bool MatchesStatus(ISet<VexClaimStatus> statuses, VexClaim claim)
|
||||
=> statuses.Count == 0 || statuses.Contains(claim.Status);
|
||||
|
||||
private static bool MatchesProduct(IEnumerable<VexCanonicalProductKey> requestedProducts, VexClaim claim)
|
||||
{
|
||||
if (!requestedProducts.Any())
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return requestedProducts.Any(product =>
|
||||
string.Equals(product.ProductKey, claim.Product.Key, StringComparison.OrdinalIgnoreCase) ||
|
||||
product.Links.Any(link => string.Equals(link.Identifier, claim.Product.Key, StringComparison.OrdinalIgnoreCase)) ||
|
||||
(!string.IsNullOrWhiteSpace(product.Purl) && string.Equals(product.Purl, claim.Product.Purl, StringComparison.OrdinalIgnoreCase)));
|
||||
}
|
||||
|
||||
private static PolicyVexStatement MapStatement(VexClaim claim)
|
||||
{
|
||||
var observationId = $"{claim.ProviderId}:{claim.Document.Digest}";
|
||||
var metadata = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["document_digest"] = claim.Document.Digest,
|
||||
["document_uri"] = claim.Document.SourceUri.ToString()
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(claim.Document.Revision))
|
||||
{
|
||||
metadata["document_revision"] = claim.Document.Revision!;
|
||||
}
|
||||
|
||||
return new PolicyVexStatement(
|
||||
ObservationId: observationId,
|
||||
ProviderId: claim.ProviderId,
|
||||
Status: claim.Status.ToString(),
|
||||
ProductKey: claim.Product.Key,
|
||||
Purl: claim.Product.Purl,
|
||||
Cpe: claim.Product.Cpe,
|
||||
Version: claim.Product.Version,
|
||||
Justification: claim.Justification?.ToString(),
|
||||
Detail: claim.Detail,
|
||||
FirstSeen: claim.FirstSeen,
|
||||
LastSeen: claim.LastSeen,
|
||||
Signature: claim.Document.Signature,
|
||||
Metadata: metadata);
|
||||
}
|
||||
|
||||
private static bool TryResolveTenant(
|
||||
HttpContext context,
|
||||
VexMongoStorageOptions 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;
|
||||
}
|
||||
}
|
||||
@@ -2282,6 +2282,7 @@ MirrorRegistrationEndpoints.MapMirrorRegistrationEndpoints(app);
|
||||
// Evidence and Attestation APIs (WEB-OBS-53-001, WEB-OBS-54-001)
|
||||
EvidenceEndpoints.MapEvidenceEndpoints(app);
|
||||
AttestationEndpoints.MapAttestationEndpoints(app);
|
||||
PolicyEndpoints.MapPolicyEndpoints(app);
|
||||
|
||||
// Observation and Linkset APIs (EXCITITOR-LNM-21-201, EXCITITOR-LNM-21-202)
|
||||
ObservationEndpoints.MapObservationEndpoints(app);
|
||||
|
||||
Reference in New Issue
Block a user