up
Some checks failed
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled

This commit is contained in:
StellaOps Bot
2025-12-01 21:16:22 +02:00
parent c11d87d252
commit 909d9b6220
208 changed files with 860954 additions and 832 deletions

View File

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

View File

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

View File

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

View File

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

View File

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