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
Mirror Thin Bundle Sign & Verify / mirror-sign (push) Has been cancelled
api-governance / spectral-lint (push) Has been cancelled
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
Mirror Thin Bundle Sign & Verify / mirror-sign (push) Has been cancelled
api-governance / spectral-lint (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,27 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Excititor.WebService.Contracts;
|
||||
|
||||
public sealed record GraphOverlaysResponse(
|
||||
[property: JsonPropertyName("items")] IReadOnlyList<GraphOverlayItem> Items,
|
||||
[property: JsonPropertyName("cached")] bool Cached,
|
||||
[property: JsonPropertyName("cacheAgeMs")] long? CacheAgeMs);
|
||||
|
||||
public sealed record GraphOverlayItem(
|
||||
[property: JsonPropertyName("purl")] string Purl,
|
||||
[property: JsonPropertyName("summary")] GraphOverlaySummary Summary,
|
||||
[property: JsonPropertyName("latestModifiedAt")] DateTimeOffset? LatestModifiedAt,
|
||||
[property: JsonPropertyName("justifications")] IReadOnlyList<string> Justifications,
|
||||
[property: JsonPropertyName("provenance")] GraphOverlayProvenance Provenance);
|
||||
|
||||
public sealed record GraphOverlaySummary(
|
||||
[property: JsonPropertyName("open")] int Open,
|
||||
[property: JsonPropertyName("not_affected")] int NotAffected,
|
||||
[property: JsonPropertyName("under_investigation")] int UnderInvestigation,
|
||||
[property: JsonPropertyName("no_statement")] int NoStatement);
|
||||
|
||||
public sealed record GraphOverlayProvenance(
|
||||
[property: JsonPropertyName("sources")] IReadOnlyList<string> Sources,
|
||||
[property: JsonPropertyName("lastEvidenceHash")] string? LastEvidenceHash);
|
||||
@@ -1,58 +0,0 @@
|
||||
using System.Collections.Generic;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Excititor.WebService.Options;
|
||||
|
||||
namespace StellaOps.Excititor.WebService.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("v1/graph")]
|
||||
public class GraphController : ControllerBase
|
||||
{
|
||||
private readonly GraphOptions _options;
|
||||
|
||||
public GraphController(IOptions<GraphOptions> options)
|
||||
{
|
||||
_options = options.Value;
|
||||
}
|
||||
|
||||
[HttpPost("linkouts")]
|
||||
public IActionResult Linkouts([FromBody] LinkoutRequest request)
|
||||
{
|
||||
if (request == null || request.Purls == null || request.Purls.Count == 0)
|
||||
{
|
||||
return BadRequest("purls are required");
|
||||
}
|
||||
|
||||
if (request.Purls.Count > _options.MaxPurls)
|
||||
{
|
||||
return BadRequest($"purls limit exceeded (max {_options.MaxPurls})");
|
||||
}
|
||||
|
||||
return StatusCode(503, "Graph linkouts pending storage integration.");
|
||||
}
|
||||
|
||||
[HttpGet("overlays")]
|
||||
public IActionResult Overlays([FromQuery(Name = "purl")] List<string> purls, [FromQuery] bool includeJustifications = false)
|
||||
{
|
||||
if (purls == null || purls.Count == 0)
|
||||
{
|
||||
return BadRequest("purl query parameter is required");
|
||||
}
|
||||
|
||||
if (purls.Count > _options.MaxPurls)
|
||||
{
|
||||
return BadRequest($"purls limit exceeded (max {_options.MaxPurls})");
|
||||
}
|
||||
|
||||
return StatusCode(503, "Graph overlays pending storage integration.");
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record LinkoutRequest
|
||||
{
|
||||
public string Tenant { get; init; } = string.Empty;
|
||||
public List<string> Purls { get; init; } = new();
|
||||
public bool IncludeJustifications { get; init; }
|
||||
public bool IncludeProvenance { get; init; } = true;
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
using StellaOps.Excititor.Core;
|
||||
using StellaOps.Excititor.Core.Observations;
|
||||
using StellaOps.Excititor.WebService.Contracts;
|
||||
|
||||
namespace StellaOps.Excititor.WebService.Graph;
|
||||
|
||||
internal static class GraphOverlayFactory
|
||||
{
|
||||
public static IReadOnlyList<GraphOverlayItem> Build(
|
||||
IReadOnlyList<string> orderedPurls,
|
||||
IReadOnlyList<VexObservation> observations,
|
||||
bool includeJustifications)
|
||||
{
|
||||
if (orderedPurls is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(orderedPurls));
|
||||
}
|
||||
|
||||
if (observations is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(observations));
|
||||
}
|
||||
|
||||
var observationsByPurl = observations
|
||||
.SelectMany(obs => obs.Linkset.Purls.Select(purl => (purl, obs)))
|
||||
.GroupBy(tuple => tuple.purl, StringComparer.OrdinalIgnoreCase)
|
||||
.ToDictionary(g => g.Key, g => g.Select(t => t.obs).ToImmutableArray(), StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var items = new List<GraphOverlayItem>(orderedPurls.Count);
|
||||
|
||||
foreach (var input in orderedPurls)
|
||||
{
|
||||
if (!observationsByPurl.TryGetValue(input, out var obsForPurl) || obsForPurl.Length == 0)
|
||||
{
|
||||
items.Add(new GraphOverlayItem(
|
||||
Purl: input,
|
||||
Summary: new GraphOverlaySummary(0, 0, 0, 0),
|
||||
LatestModifiedAt: null,
|
||||
Justifications: Array.Empty<string>(),
|
||||
Provenance: new GraphOverlayProvenance(Array.Empty<string>(), null)));
|
||||
continue;
|
||||
}
|
||||
|
||||
var open = 0;
|
||||
var notAffected = 0;
|
||||
var underInvestigation = 0;
|
||||
var noStatement = 0;
|
||||
var justifications = new SortedSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
var sources = new SortedSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
string? lastEvidenceHash = null;
|
||||
DateTimeOffset? latestModifiedAt = null;
|
||||
|
||||
foreach (var obs in obsForPurl)
|
||||
{
|
||||
sources.Add(obs.ProviderId);
|
||||
if (latestModifiedAt is null || obs.CreatedAt > latestModifiedAt.Value)
|
||||
{
|
||||
latestModifiedAt = obs.CreatedAt;
|
||||
lastEvidenceHash = obs.Upstream.ContentHash;
|
||||
}
|
||||
|
||||
var matchingStatements = obs.Statements
|
||||
.Where(stmt => PurlMatches(stmt, input, obs.Linkset.Purls))
|
||||
.ToArray();
|
||||
|
||||
if (matchingStatements.Length == 0)
|
||||
{
|
||||
noStatement++;
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var stmt in matchingStatements)
|
||||
{
|
||||
switch (stmt.Status)
|
||||
{
|
||||
case VexClaimStatus.NotAffected:
|
||||
notAffected++;
|
||||
break;
|
||||
case VexClaimStatus.UnderInvestigation:
|
||||
underInvestigation++;
|
||||
break;
|
||||
default:
|
||||
open++;
|
||||
break;
|
||||
}
|
||||
|
||||
if (includeJustifications && stmt.Justification is not null)
|
||||
{
|
||||
justifications.Add(stmt.Justification!.ToString()!);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
items.Add(new GraphOverlayItem(
|
||||
Purl: input,
|
||||
Summary: new GraphOverlaySummary(open, notAffected, underInvestigation, noStatement),
|
||||
LatestModifiedAt: latestModifiedAt,
|
||||
Justifications: includeJustifications
|
||||
? justifications.ToArray()
|
||||
: Array.Empty<string>(),
|
||||
Provenance: new GraphOverlayProvenance(sources.ToArray(), lastEvidenceHash)));
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
private static bool PurlMatches(VexObservationStatement stmt, string inputPurl, ImmutableArray<string> linksetPurls)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(stmt.Purl) && stmt.Purl.Equals(inputPurl, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (linksetPurls.IsDefaultOrEmpty)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return linksetPurls.Any(p => p.Equals(inputPurl, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
}
|
||||
@@ -199,6 +199,33 @@ public partial class Program
|
||||
return Math.Clamp(parsed, min, max);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> NormalizePurls(string[]? purls)
|
||||
{
|
||||
if (purls is null || purls.Length == 0)
|
||||
{
|
||||
return Array.Empty<string>();
|
||||
}
|
||||
|
||||
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
var ordered = new List<string>(purls.Length);
|
||||
foreach (var purl in purls)
|
||||
{
|
||||
var trimmed = purl?.Trim();
|
||||
if (string.IsNullOrWhiteSpace(trimmed))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var normalized = trimmed.ToLowerInvariant();
|
||||
if (seen.Add(normalized))
|
||||
{
|
||||
ordered.Add(normalized);
|
||||
}
|
||||
}
|
||||
|
||||
return ordered;
|
||||
}
|
||||
|
||||
private static VexObservationStatementResponse ToResponse(VexObservationStatementProjection projection)
|
||||
{
|
||||
var scope = projection.Scope;
|
||||
@@ -234,4 +261,8 @@ public partial class Program
|
||||
signature.Issuer,
|
||||
signature.VerifiedAt));
|
||||
}
|
||||
|
||||
private sealed record CachedGraphOverlay(
|
||||
IReadOnlyList<GraphOverlayItem> Items,
|
||||
DateTimeOffset CachedAt);
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@ using MongoDB.Driver;
|
||||
using MongoDB.Bson;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using StellaOps.Excititor.WebService.Contracts;
|
||||
using StellaOps.Excititor.WebService.Graph;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
var configuration = builder.Configuration;
|
||||
@@ -523,10 +524,70 @@ var options = new VexObservationQueryOptions(
|
||||
NextCursor: advisories.Count >= 200 ? $"{advisories[^1].AdvisoryId}:{advisories[^1].Source}" : null));
|
||||
}
|
||||
|
||||
var response = new GraphLinkoutsResponse(items, notFound);
|
||||
var response = new GraphLinkoutsResponse(items, notFound);
|
||||
return Results.Ok(response);
|
||||
}).WithName("PostGraphLinkouts");
|
||||
|
||||
// Cartographer overlays
|
||||
app.MapGet("/v1/graph/overlays", async (
|
||||
HttpContext context,
|
||||
[FromQuery(Name = "purl")] string[]? purls,
|
||||
[FromQuery] bool includeJustifications,
|
||||
IOptions<VexMongoStorageOptions> storageOptions,
|
||||
IOptions<GraphOptions> graphOptions,
|
||||
IVexObservationQueryService queryService,
|
||||
IMemoryCache cache,
|
||||
TimeProvider timeProvider,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveTenant(context, storageOptions.Value, requireHeader: true, out var tenant, out var tenantError))
|
||||
{
|
||||
return tenantError;
|
||||
}
|
||||
|
||||
var orderedPurls = NormalizePurls(purls);
|
||||
if (orderedPurls.Count == 0)
|
||||
{
|
||||
return Results.BadRequest("purl query parameter is required");
|
||||
}
|
||||
|
||||
if (orderedPurls.Count > graphOptions.Value.MaxPurls)
|
||||
{
|
||||
return Results.BadRequest($"purls limit exceeded (max {graphOptions.Value.MaxPurls})");
|
||||
}
|
||||
|
||||
var cacheKey = $"graph-overlays:{tenant}:{includeJustifications}:{string.Join('|', orderedPurls)}";
|
||||
var now = timeProvider.GetUtcNow();
|
||||
|
||||
if (cache.TryGetValue<CachedGraphOverlay>(cacheKey, out var cached) && cached is not null)
|
||||
{
|
||||
var ageMs = (long)Math.Max(0, (now - cached.CachedAt).TotalMilliseconds);
|
||||
return Results.Ok(new GraphOverlaysResponse(cached.Items, true, ageMs));
|
||||
}
|
||||
|
||||
var options = new VexObservationQueryOptions(
|
||||
tenant: tenant,
|
||||
purls: orderedPurls,
|
||||
limit: graphOptions.Value.MaxAdvisoriesPerPurl * orderedPurls.Count);
|
||||
|
||||
VexObservationQueryResult result;
|
||||
try
|
||||
{
|
||||
result = await queryService.QueryAsync(options, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (FormatException ex)
|
||||
{
|
||||
return Results.BadRequest(ex.Message);
|
||||
}
|
||||
|
||||
var overlays = GraphOverlayFactory.Build(orderedPurls, result.Observations, includeJustifications);
|
||||
var response = new GraphOverlaysResponse(overlays, false, null);
|
||||
|
||||
cache.Set(cacheKey, new CachedGraphOverlay(overlays, now), TimeSpan.FromSeconds(graphOptions.Value.OverlayTtlSeconds));
|
||||
|
||||
return Results.Ok(response);
|
||||
}).WithName("GetGraphOverlays");
|
||||
|
||||
app.MapPost("/ingest/vex", async (
|
||||
HttpContext context,
|
||||
VexIngestRequest request,
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
using System;
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json.Nodes;
|
||||
using StellaOps.Excititor.Core;
|
||||
using StellaOps.Excititor.Core.Observations;
|
||||
using StellaOps.Excititor.WebService.Graph;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Excititor.WebService.Tests;
|
||||
|
||||
public sealed class GraphOverlayFactoryTests
|
||||
{
|
||||
[Fact]
|
||||
public void Build_ComputesSummariesAndProvenancePerPurl()
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var observations = new[]
|
||||
{
|
||||
CreateObservation(
|
||||
providerId: "redhat",
|
||||
createdAt: now.AddMinutes(-5),
|
||||
purls: new[] { "pkg:rpm/redhat/openssl@1.1.1" },
|
||||
statements: new[]
|
||||
{
|
||||
new VexObservationStatement(
|
||||
vulnerabilityId: "CVE-2025-1000",
|
||||
productKey: "pkg:rpm/redhat/openssl@1.1.1",
|
||||
status: VexClaimStatus.NotAffected,
|
||||
lastObserved: now,
|
||||
justification: VexJustification.ComponentNotPresent,
|
||||
purl: "pkg:rpm/redhat/openssl@1.1.1")
|
||||
},
|
||||
contentHash: "hash-old"),
|
||||
CreateObservation(
|
||||
providerId: "ubuntu",
|
||||
createdAt: now,
|
||||
purls: new[] { "pkg:rpm/redhat/openssl@1.1.1" },
|
||||
statements: new[]
|
||||
{
|
||||
new VexObservationStatement(
|
||||
vulnerabilityId: "CVE-2025-1001",
|
||||
productKey: "pkg:rpm/redhat/openssl@1.1.1",
|
||||
status: VexClaimStatus.UnderInvestigation,
|
||||
lastObserved: now,
|
||||
justification: null,
|
||||
purl: "pkg:rpm/redhat/openssl@1.1.1")
|
||||
},
|
||||
contentHash: "hash-new"),
|
||||
CreateObservation(
|
||||
providerId: "oracle",
|
||||
createdAt: now.AddMinutes(-1),
|
||||
purls: new[] { "pkg:rpm/redhat/openssl@1.1.1" },
|
||||
statements: Array.Empty<VexObservationStatement>(),
|
||||
contentHash: "hash-oracle")
|
||||
};
|
||||
|
||||
var overlays = GraphOverlayFactory.Build(
|
||||
orderedPurls: new[] { "pkg:rpm/redhat/openssl@1.1.1" },
|
||||
observations: observations,
|
||||
includeJustifications: true);
|
||||
|
||||
var overlay = Assert.Single(overlays);
|
||||
Assert.Equal("pkg:rpm/redhat/openssl@1.1.1", overlay.Purl);
|
||||
Assert.Equal(0, overlay.Summary.Open);
|
||||
Assert.Equal(1, overlay.Summary.NotAffected);
|
||||
Assert.Equal(1, overlay.Summary.UnderInvestigation);
|
||||
Assert.Equal(1, overlay.Summary.NoStatement);
|
||||
Assert.Equal(now, overlay.LatestModifiedAt);
|
||||
Assert.Equal(new[] { "ComponentNotPresent" }, overlay.Justifications);
|
||||
Assert.Equal("hash-new", overlay.Provenance.LastEvidenceHash);
|
||||
Assert.Equal(new[] { "oracle", "redhat", "ubuntu" }, overlay.Provenance.Sources);
|
||||
}
|
||||
|
||||
private static VexObservation CreateObservation(
|
||||
string providerId,
|
||||
DateTimeOffset createdAt,
|
||||
string[] purls,
|
||||
VexObservationStatement[] statements,
|
||||
string contentHash)
|
||||
{
|
||||
return new VexObservation(
|
||||
observationId: $"obs-{providerId}-{createdAt.ToUnixTimeMilliseconds()}",
|
||||
tenant: "tenant-a",
|
||||
providerId: providerId,
|
||||
streamId: "csaf",
|
||||
upstream: new VexObservationUpstream(
|
||||
upstreamId: Guid.NewGuid().ToString("N"),
|
||||
documentVersion: "1",
|
||||
fetchedAt: createdAt,
|
||||
receivedAt: createdAt,
|
||||
contentHash: contentHash,
|
||||
signature: new VexObservationSignature(present: true, format: "sig", keyId: null, signature: null)),
|
||||
statements: statements.ToImmutableArray(),
|
||||
content: new VexObservationContent(
|
||||
format: "csaf",
|
||||
specVersion: "1",
|
||||
raw: JsonValue.Create("raw")!,
|
||||
metadata: ImmutableDictionary<string, string>.Empty),
|
||||
linkset: new VexObservationLinkset(
|
||||
aliases: Array.Empty<string>(),
|
||||
purls: purls,
|
||||
cpes: Array.Empty<string>(),
|
||||
references: Array.Empty<VexObservationReference>()),
|
||||
createdAt: createdAt,
|
||||
supersedes: ImmutableArray<string>.Empty,
|
||||
attributes: ImmutableDictionary<string, string>.Empty);
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,7 @@
|
||||
<Compile Include="TestAuthentication.cs" />
|
||||
<Compile Include="TestServiceOverrides.cs" />
|
||||
<Compile Include="TestWebApplicationFactory.cs" />
|
||||
<Compile Include="GraphOverlayFactoryTests.cs" />
|
||||
<Compile Include="AttestationVerifyEndpointTests.cs" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
Reference in New Issue
Block a user