feat: add security sink detection patterns for JavaScript/TypeScript

- Introduced `sink-detect.js` with various security sink detection patterns categorized by type (e.g., command injection, SQL injection, file operations).
- Implemented functions to build a lookup map for fast sink detection and to match sink calls against known patterns.
- Added `package-lock.json` for dependency management.
This commit is contained in:
StellaOps Bot
2025-12-22 23:21:21 +02:00
parent 3ba7157b00
commit 5146204f1b
529 changed files with 73579 additions and 5985 deletions

View File

@@ -0,0 +1,216 @@
using Microsoft.AspNetCore.Mvc;
using StellaOps.VexHub.Core;
using StellaOps.VexHub.Core.Models;
using StellaOps.VexHub.WebService.Models;
namespace StellaOps.VexHub.WebService.Extensions;
/// <summary>
/// Extensions for mapping VexHub API endpoints.
/// </summary>
public static class VexHubEndpointExtensions
{
/// <summary>
/// Maps all VexHub API endpoints.
/// </summary>
public static WebApplication MapVexHubEndpoints(this WebApplication app)
{
var vexGroup = app.MapGroup("/api/v1/vex")
.WithTags("VEX");
// GET /api/v1/vex/cve/{cve-id}
vexGroup.MapGet("/cve/{cveId}", GetByCve)
.WithName("GetVexByCve")
.WithDescription("Get VEX statements for a CVE ID")
.Produces<VexStatementsResponse>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound);
// GET /api/v1/vex/package/{purl}
vexGroup.MapGet("/package/{purl}", GetByPackage)
.WithName("GetVexByPackage")
.WithDescription("Get VEX statements for a package PURL")
.Produces<VexStatementsResponse>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound);
// GET /api/v1/vex/source/{source-id}
vexGroup.MapGet("/source/{sourceId}", GetBySource)
.WithName("GetVexBySource")
.WithDescription("Get VEX statements from a specific source")
.Produces<VexStatementsResponse>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound);
// GET /api/v1/vex/statement/{id}
vexGroup.MapGet("/statement/{id:guid}", GetById)
.WithName("GetVexStatement")
.WithDescription("Get a specific VEX statement by ID")
.Produces<AggregatedVexStatement>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound);
// GET /api/v1/vex/search
vexGroup.MapGet("/search", Search)
.WithName("SearchVex")
.WithDescription("Search VEX statements with filters")
.Produces<VexSearchResponse>(StatusCodes.Status200OK);
// GET /api/v1/vex/stats
vexGroup.MapGet("/stats", GetStats)
.WithName("GetVexStats")
.WithDescription("Get VEX hub statistics")
.Produces<VexHubStats>(StatusCodes.Status200OK);
// GET /api/v1/vex/index
vexGroup.MapGet("/index", GetIndex)
.WithName("GetVexIndex")
.WithDescription("Get VEX hub index manifest for tool integration")
.Produces<VexIndexManifest>(StatusCodes.Status200OK);
return app;
}
private static async Task<IResult> GetByCve(
string cveId,
[FromQuery] int? limit,
[FromQuery] int? offset,
IVexStatementRepository repository,
CancellationToken cancellationToken)
{
var statements = await repository.GetByCveAsync(cveId, limit ?? 100, offset ?? 0, cancellationToken);
if (statements.Count == 0)
return Results.NotFound(new { Message = $"No VEX statements found for CVE {cveId}" });
return Results.Ok(new VexStatementsResponse
{
Statements = statements,
TotalCount = statements.Count,
QueryType = "cve",
QueryValue = cveId
});
}
private static async Task<IResult> GetByPackage(
string purl,
[FromQuery] int? limit,
[FromQuery] int? offset,
IVexStatementRepository repository,
CancellationToken cancellationToken)
{
// URL decode the PURL
var decodedPurl = Uri.UnescapeDataString(purl);
var statements = await repository.GetByPackageAsync(decodedPurl, limit ?? 100, offset ?? 0, cancellationToken);
if (statements.Count == 0)
return Results.NotFound(new { Message = $"No VEX statements found for package {decodedPurl}" });
return Results.Ok(new VexStatementsResponse
{
Statements = statements,
TotalCount = statements.Count,
QueryType = "package",
QueryValue = decodedPurl
});
}
private static async Task<IResult> GetBySource(
string sourceId,
[FromQuery] int? limit,
[FromQuery] int? offset,
IVexStatementRepository repository,
CancellationToken cancellationToken)
{
var statements = await repository.GetBySourceAsync(sourceId, limit ?? 100, offset ?? 0, cancellationToken);
if (statements.Count == 0)
return Results.NotFound(new { Message = $"No VEX statements found for source {sourceId}" });
return Results.Ok(new VexStatementsResponse
{
Statements = statements,
TotalCount = statements.Count,
QueryType = "source",
QueryValue = sourceId
});
}
private static async Task<IResult> GetById(
Guid id,
IVexStatementRepository repository,
CancellationToken cancellationToken)
{
var statement = await repository.GetByIdAsync(id, cancellationToken);
if (statement is null)
return Results.NotFound(new { Message = $"VEX statement {id} not found" });
return Results.Ok(statement);
}
private static async Task<IResult> Search(
[FromQuery] string? sourceId,
[FromQuery] string? vulnerabilityId,
[FromQuery] string? productKey,
[FromQuery] string? status,
[FromQuery] bool? isFlagged,
[FromQuery] int? limit,
[FromQuery] int? offset,
IVexStatementRepository repository,
CancellationToken cancellationToken)
{
var filter = new VexStatementFilter
{
SourceId = sourceId,
VulnerabilityId = vulnerabilityId,
ProductKey = productKey,
IsFlagged = isFlagged
};
var statements = await repository.SearchAsync(filter, limit ?? 100, offset ?? 0, cancellationToken);
var totalCount = await repository.GetCountAsync(filter, cancellationToken);
return Results.Ok(new VexSearchResponse
{
Statements = statements,
TotalCount = totalCount,
Limit = limit ?? 100,
Offset = offset ?? 0
});
}
private static async Task<IResult> GetStats(
IVexStatementRepository repository,
CancellationToken cancellationToken)
{
var totalCount = await repository.GetCountAsync(cancellationToken: cancellationToken);
var verifiedCount = await repository.GetCountAsync(
new VexStatementFilter { VerificationStatus = VerificationStatus.Verified },
cancellationToken);
var flaggedCount = await repository.GetCountAsync(
new VexStatementFilter { IsFlagged = true },
cancellationToken);
return Results.Ok(new VexHubStats
{
TotalStatements = totalCount,
VerifiedStatements = verifiedCount,
FlaggedStatements = flaggedCount,
GeneratedAt = DateTimeOffset.UtcNow
});
}
private static IResult GetIndex()
{
return Results.Ok(new VexIndexManifest
{
Version = "1.0",
LastUpdated = DateTimeOffset.UtcNow,
Endpoints = new VexIndexEndpoints
{
ByCve = "/api/v1/vex/cve/{cve}",
ByPackage = "/api/v1/vex/package/{purl}",
BySource = "/api/v1/vex/source/{source-id}",
Search = "/api/v1/vex/search",
Stats = "/api/v1/vex/stats"
}
});
}
}

View File

@@ -0,0 +1,21 @@
using Microsoft.Extensions.DependencyInjection;
namespace StellaOps.VexHub.WebService.Extensions;
/// <summary>
/// Service collection extensions for VexHub web service.
/// </summary>
public static class VexHubWebServiceExtensions
{
/// <summary>
/// Adds VexHub web service dependencies.
/// </summary>
public static IServiceCollection AddVexHubWebService(
this IServiceCollection services,
IConfiguration configuration)
{
services.AddControllers();
return services;
}
}

View File

@@ -0,0 +1,135 @@
using System.Security.Claims;
using System.Text.Encodings.Web;
using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.Options;
namespace StellaOps.VexHub.WebService.Middleware;
/// <summary>
/// Authentication handler for API key authentication.
/// </summary>
public sealed class ApiKeyAuthenticationHandler : AuthenticationHandler<ApiKeyAuthenticationOptions>
{
private const string ApiKeyHeaderName = "X-Api-Key";
private const string ApiKeyQueryParamName = "api_key";
public ApiKeyAuthenticationHandler(
IOptionsMonitor<ApiKeyAuthenticationOptions> options,
ILoggerFactory logger,
UrlEncoder encoder)
: base(options, logger, encoder)
{
}
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{
// Try to get API key from header first
if (!Request.Headers.TryGetValue(ApiKeyHeaderName, out var apiKeyHeader))
{
// Fall back to query parameter
if (!Request.Query.TryGetValue(ApiKeyQueryParamName, out var apiKeyQuery))
{
// No API key provided - allow anonymous access for public endpoints
if (Options.AllowAnonymous)
{
return Task.FromResult(AuthenticateResult.NoResult());
}
return Task.FromResult(AuthenticateResult.Fail("API key is required"));
}
apiKeyHeader = apiKeyQuery;
}
var providedApiKey = apiKeyHeader.ToString();
if (string.IsNullOrWhiteSpace(providedApiKey))
{
return Task.FromResult(AuthenticateResult.Fail("API key is empty"));
}
// Validate the API key
var apiKeyInfo = ValidateApiKey(providedApiKey);
if (apiKeyInfo is null)
{
return Task.FromResult(AuthenticateResult.Fail("Invalid API key"));
}
// Create claims identity
var claims = new List<Claim>
{
new(ClaimTypes.NameIdentifier, apiKeyInfo.ClientId),
new(ClaimTypes.Name, apiKeyInfo.ClientName),
new("api_key_id", apiKeyInfo.KeyId)
};
foreach (var scope in apiKeyInfo.Scopes)
{
claims.Add(new Claim("scope", scope));
}
var identity = new ClaimsIdentity(claims, Scheme.Name);
var principal = new ClaimsPrincipal(identity);
var ticket = new AuthenticationTicket(principal, Scheme.Name);
return Task.FromResult(AuthenticateResult.Success(ticket));
}
private ApiKeyInfo? ValidateApiKey(string apiKey)
{
// Check against configured API keys
if (Options.ApiKeys.TryGetValue(apiKey, out var keyInfo))
{
return keyInfo;
}
return null;
}
}
/// <summary>
/// Options for API key authentication.
/// </summary>
public sealed class ApiKeyAuthenticationOptions : AuthenticationSchemeOptions
{
/// <summary>
/// Dictionary of valid API keys to their info.
/// </summary>
public Dictionary<string, ApiKeyInfo> ApiKeys { get; set; } = new();
/// <summary>
/// Whether to allow anonymous access when no API key is provided.
/// </summary>
public bool AllowAnonymous { get; set; } = true;
}
/// <summary>
/// Information about an API key.
/// </summary>
public sealed class ApiKeyInfo
{
/// <summary>
/// Unique identifier for this key.
/// </summary>
public required string KeyId { get; init; }
/// <summary>
/// Client identifier.
/// </summary>
public required string ClientId { get; init; }
/// <summary>
/// Human-readable client name.
/// </summary>
public required string ClientName { get; init; }
/// <summary>
/// Scopes granted to this key.
/// </summary>
public IReadOnlyList<string> Scopes { get; init; } = Array.Empty<string>();
/// <summary>
/// Rate limit override for this key (requests per minute).
/// </summary>
public int? RateLimitPerMinute { get; init; }
}

View File

@@ -0,0 +1,232 @@
using System.Collections.Concurrent;
using System.Net;
using Microsoft.Extensions.Options;
using StellaOps.VexHub.Core.Models;
namespace StellaOps.VexHub.WebService.Middleware;
/// <summary>
/// Middleware for rate limiting API requests using a sliding window algorithm.
/// </summary>
public sealed class RateLimitingMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<RateLimitingMiddleware> _logger;
private readonly VexHubOptions _options;
private readonly ConcurrentDictionary<string, RateLimitEntry> _rateLimits = new();
private readonly Timer _cleanupTimer;
public RateLimitingMiddleware(
RequestDelegate next,
IOptions<VexHubOptions> options,
ILogger<RateLimitingMiddleware> logger)
{
_next = next;
_options = options.Value;
_logger = logger;
// Clean up old entries every minute
_cleanupTimer = new Timer(CleanupOldEntries, null, TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(1));
}
public async Task InvokeAsync(HttpContext context)
{
// Skip rate limiting for health checks
if (context.Request.Path.StartsWithSegments("/health"))
{
await _next(context);
return;
}
var clientId = GetClientIdentifier(context);
var rateLimit = GetRateLimitForClient(context);
if (rateLimit <= 0)
{
// Rate limiting disabled
await _next(context);
return;
}
var entry = _rateLimits.GetOrAdd(clientId, _ => new RateLimitEntry());
var now = DateTimeOffset.UtcNow;
// Clean old requests outside the window
entry.CleanOldRequests(now);
// Check if rate limit exceeded
if (entry.RequestCount >= rateLimit)
{
var resetTime = entry.GetResetTime();
var retryAfter = (int)Math.Ceiling((resetTime - now).TotalSeconds);
context.Response.StatusCode = (int)HttpStatusCode.TooManyRequests;
context.Response.Headers["Retry-After"] = retryAfter.ToString();
context.Response.Headers["X-RateLimit-Limit"] = rateLimit.ToString();
context.Response.Headers["X-RateLimit-Remaining"] = "0";
context.Response.Headers["X-RateLimit-Reset"] = resetTime.ToUnixTimeSeconds().ToString();
_logger.LogWarning(
"Rate limit exceeded for client {ClientId}. Limit: {Limit}, Retry after: {RetryAfter}s",
clientId,
rateLimit,
retryAfter);
await context.Response.WriteAsJsonAsync(new
{
error = "rate_limit_exceeded",
message = $"Rate limit exceeded. Try again in {retryAfter} seconds.",
retryAfter
});
return;
}
// Record this request
entry.RecordRequest(now);
// Add rate limit headers
context.Response.OnStarting(() =>
{
var remaining = Math.Max(0, rateLimit - entry.RequestCount);
context.Response.Headers["X-RateLimit-Limit"] = rateLimit.ToString();
context.Response.Headers["X-RateLimit-Remaining"] = remaining.ToString();
context.Response.Headers["X-RateLimit-Reset"] = entry.GetResetTime().ToUnixTimeSeconds().ToString();
return Task.CompletedTask;
});
await _next(context);
}
private string GetClientIdentifier(HttpContext context)
{
// First try to get from authenticated user
if (context.User.Identity?.IsAuthenticated == true)
{
var clientId = context.User.FindFirst("api_key_id")?.Value;
if (!string.IsNullOrEmpty(clientId))
{
return $"key:{clientId}";
}
}
// Fall back to IP address
var ipAddress = context.Connection.RemoteIpAddress?.ToString() ?? "unknown";
// Check for forwarded IP
if (context.Request.Headers.TryGetValue("X-Forwarded-For", out var forwardedFor))
{
var firstIp = forwardedFor.ToString().Split(',').FirstOrDefault()?.Trim();
if (!string.IsNullOrEmpty(firstIp))
{
ipAddress = firstIp;
}
}
return $"ip:{ipAddress}";
}
private int GetRateLimitForClient(HttpContext context)
{
// Check for API key with custom rate limit
if (context.User.Identity?.IsAuthenticated == true)
{
// API key authenticated clients could have higher limits
// This would be retrieved from the API key info
// For now, use 2x the default rate limit for authenticated clients
return _options.Distribution.RateLimitPerMinute * 2;
}
return _options.Distribution.RateLimitPerMinute;
}
private void CleanupOldEntries(object? state)
{
var now = DateTimeOffset.UtcNow;
var expiredKeys = _rateLimits
.Where(kvp => (now - kvp.Value.LastRequestTime).TotalMinutes > 5)
.Select(kvp => kvp.Key)
.ToList();
foreach (var key in expiredKeys)
{
_rateLimits.TryRemove(key, out _);
}
if (expiredKeys.Count > 0)
{
_logger.LogDebug("Cleaned up {Count} expired rate limit entries", expiredKeys.Count);
}
}
}
/// <summary>
/// Tracks request counts for rate limiting using a sliding window.
/// </summary>
internal sealed class RateLimitEntry
{
private readonly object _lock = new();
private readonly Queue<DateTimeOffset> _requestTimes = new();
private static readonly TimeSpan Window = TimeSpan.FromMinutes(1);
public int RequestCount
{
get
{
lock (_lock)
{
return _requestTimes.Count;
}
}
}
public DateTimeOffset LastRequestTime { get; private set; } = DateTimeOffset.UtcNow;
public void RecordRequest(DateTimeOffset time)
{
lock (_lock)
{
_requestTimes.Enqueue(time);
LastRequestTime = time;
}
}
public void CleanOldRequests(DateTimeOffset now)
{
lock (_lock)
{
var windowStart = now - Window;
while (_requestTimes.Count > 0 && _requestTimes.Peek() < windowStart)
{
_requestTimes.Dequeue();
}
}
}
public DateTimeOffset GetResetTime()
{
lock (_lock)
{
if (_requestTimes.Count == 0)
{
return DateTimeOffset.UtcNow;
}
return _requestTimes.Peek() + Window;
}
}
}
/// <summary>
/// Extensions for adding rate limiting to the application.
/// </summary>
public static class RateLimitingExtensions
{
/// <summary>
/// Adds the rate limiting middleware to the pipeline.
/// </summary>
public static IApplicationBuilder UseVexHubRateLimiting(this IApplicationBuilder app)
{
return app.UseMiddleware<RateLimitingMiddleware>();
}
}

View File

@@ -0,0 +1,58 @@
using StellaOps.VexHub.Core.Models;
namespace StellaOps.VexHub.WebService.Models;
/// <summary>
/// Response containing VEX statements.
/// </summary>
public sealed class VexStatementsResponse
{
public required IReadOnlyList<AggregatedVexStatement> Statements { get; init; }
public required int TotalCount { get; init; }
public required string QueryType { get; init; }
public required string QueryValue { get; init; }
}
/// <summary>
/// Response for VEX search queries.
/// </summary>
public sealed class VexSearchResponse
{
public required IReadOnlyList<AggregatedVexStatement> Statements { get; init; }
public required long TotalCount { get; init; }
public required int Limit { get; init; }
public required int Offset { get; init; }
}
/// <summary>
/// VEX Hub statistics.
/// </summary>
public sealed class VexHubStats
{
public required long TotalStatements { get; init; }
public required long VerifiedStatements { get; init; }
public required long FlaggedStatements { get; init; }
public required DateTimeOffset GeneratedAt { get; init; }
}
/// <summary>
/// VEX Hub index manifest for tool integration.
/// </summary>
public sealed class VexIndexManifest
{
public required string Version { get; init; }
public required DateTimeOffset LastUpdated { get; init; }
public required VexIndexEndpoints Endpoints { get; init; }
}
/// <summary>
/// VEX Hub API endpoints.
/// </summary>
public sealed class VexIndexEndpoints
{
public required string ByCve { get; init; }
public required string ByPackage { get; init; }
public required string BySource { get; init; }
public required string Search { get; init; }
public required string Stats { get; init; }
}

View File

@@ -0,0 +1,73 @@
using Serilog;
using StellaOps.VexHub.Core.Extensions;
using StellaOps.VexHub.Storage.Postgres.Extensions;
using StellaOps.VexHub.WebService.Extensions;
using StellaOps.VexHub.WebService.Middleware;
var builder = WebApplication.CreateBuilder(args);
// Configure Serilog
Log.Logger = new LoggerConfiguration()
.ReadFrom.Configuration(builder.Configuration)
.Enrich.FromLogContext()
.CreateLogger();
builder.Host.UseSerilog();
// Add services to the container
builder.Services.AddVexHubCore(builder.Configuration);
builder.Services.AddVexHubPostgres(builder.Configuration);
builder.Services.AddVexHubWebService(builder.Configuration);
// Add authentication
builder.Services.AddAuthentication("ApiKey")
.AddScheme<ApiKeyAuthenticationOptions, ApiKeyAuthenticationHandler>("ApiKey", options =>
{
options.AllowAnonymous = true; // Allow anonymous for public read endpoints
// API keys can be configured via configuration
var apiKeysSection = builder.Configuration.GetSection("VexHub:ApiKeys");
foreach (var keySection in apiKeysSection.GetChildren())
{
var key = keySection.Key;
options.ApiKeys[key] = new ApiKeyInfo
{
KeyId = keySection["KeyId"] ?? key,
ClientId = keySection["ClientId"] ?? "unknown",
ClientName = keySection["ClientName"] ?? "Unknown Client",
Scopes = keySection.GetSection("Scopes").Get<string[]>() ?? Array.Empty<string>(),
RateLimitPerMinute = keySection.GetValue<int?>("RateLimitPerMinute")
};
}
});
builder.Services.AddAuthorization();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddOpenApi();
var app = builder.Build();
// Configure the HTTP request pipeline
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
}
app.UseHttpsRedirection();
app.UseSerilogRequestLogging();
// Add rate limiting middleware
app.UseVexHubRateLimiting();
// Add authentication and authorization
app.UseAuthentication();
app.UseAuthorization();
// Map API endpoints
app.MapVexHubEndpoints();
// Health check
app.MapGet("/health", () => Results.Ok(new { Status = "Healthy", Service = "VexHub" }))
.WithName("HealthCheck")
.WithTags("Health");
app.Run();

View File

@@ -0,0 +1,35 @@
<?xml version="1.0" encoding="utf-8"?>
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
<RootNamespace>StellaOps.VexHub.WebService</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.0" />
<PackageReference Include="OpenTelemetry.Exporter.Console" Version="1.12.0" />
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.12.0" />
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.12.0" />
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.12.0" />
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.12.0" />
<PackageReference Include="Serilog.AspNetCore" Version="8.0.1" />
<PackageReference Include="Serilog.Sinks.Console" Version="5.0.1" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../__Libraries/StellaOps.VexHub.Core/StellaOps.VexHub.Core.csproj" />
<ProjectReference Include="../__Libraries/StellaOps.VexHub.Storage.Postgres/StellaOps.VexHub.Storage.Postgres.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Plugin/StellaOps.Plugin.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Messaging/StellaOps.Messaging.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.DependencyInjection/StellaOps.DependencyInjection.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Configuration/StellaOps.Configuration.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Cryptography/StellaOps.Cryptography.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Ingestion.Telemetry/StellaOps.Ingestion.Telemetry.csproj" />
<ProjectReference Include="../../Authority/StellaOps.Authority/StellaOps.Auth.Abstractions/StellaOps.Auth.Abstractions.csproj" />
<ProjectReference Include="../../Authority/StellaOps.Authority/StellaOps.Auth.Client/StellaOps.Auth.Client.csproj" />
<ProjectReference Include="../../Authority/StellaOps.Authority/StellaOps.Auth.ServerIntegration/StellaOps.Auth.ServerIntegration.csproj" />
<ProjectReference Include="../../VexLens/StellaOps.VexLens/StellaOps.VexLens.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,13 @@
{
"Logging": {
"LogLevel": {
"Default": "Debug",
"Microsoft.AspNetCore": "Information"
}
},
"Serilog": {
"MinimumLevel": {
"Default": "Debug"
}
}
}

View File

@@ -0,0 +1,54 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"Serilog": {
"MinimumLevel": {
"Default": "Information",
"Override": {
"Microsoft": "Warning",
"System": "Warning"
}
},
"WriteTo": [
{
"Name": "Console",
"Args": {
"outputTemplate": "[{Timestamp:HH:mm:ss} {Level:u3}] {SourceContext}: {Message:lj}{NewLine}{Exception}"
}
}
],
"Enrich": ["FromLogContext", "WithMachineName", "WithThreadId"]
},
"VexHub": {
"DefaultPollingIntervalSeconds": 3600,
"MaxConcurrentPolls": 4,
"StaleStatementAgeDays": 365,
"AutoResolveLowSeverityConflicts": true,
"StoreRawStatements": true,
"MaxApiPageSize": 1000,
"DefaultApiPageSize": 100,
"EnableSignatureVerification": true,
"Ingestion": {
"EnableDeduplication": true,
"EnableConflictDetection": true,
"BatchSize": 500,
"FetchTimeoutSeconds": 300,
"MaxRetries": 3
},
"Distribution": {
"EnableBulkExport": true,
"EnableWebhooks": true,
"CacheDurationSeconds": 300,
"RateLimitPerMinute": 60
}
},
"Postgres": {
"ConnectionString": "Host=localhost;Port=5432;Database=stellaops;Username=postgres;Password=postgres",
"SchemaName": "vexhub"
},
"AllowedHosts": "*"
}

View File

@@ -0,0 +1,61 @@
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.0.31903.59
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.VexHub.WebService", "StellaOps.VexHub.WebService\StellaOps.VexHub.WebService.csproj", "{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.VexHub.Core", "__Libraries\StellaOps.VexHub.Core\StellaOps.VexHub.Core.csproj", "{B2C3D4E5-F678-9012-BCDE-F12345678901}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.VexHub.Storage.Postgres", "__Libraries\StellaOps.VexHub.Storage.Postgres\StellaOps.VexHub.Storage.Postgres.csproj", "{C3D4E5F6-7890-1234-CDEF-123456789012}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.VexHub.WebService.Tests", "__Tests\StellaOps.VexHub.WebService.Tests\StellaOps.VexHub.WebService.Tests.csproj", "{D4E5F678-9012-3456-DEF0-234567890123}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.VexHub.Core.Tests", "__Tests\StellaOps.VexHub.Core.Tests\StellaOps.VexHub.Core.Tests.csproj", "{E5F67890-1234-5678-EF01-345678901234}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.VexHub.Storage.Postgres.Tests", "__Tests\StellaOps.VexHub.Storage.Postgres.Tests\StellaOps.VexHub.Storage.Postgres.Tests.csproj", "{F6789012-3456-789A-F012-456789012345}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Libraries", "Libraries", "{11111111-2222-3333-4444-555555555555}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tests", "Tests", "{22222222-3333-4444-5555-666666666666}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|Any CPU.Build.0 = Release|Any CPU
{B2C3D4E5-F678-9012-BCDE-F12345678901}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{B2C3D4E5-F678-9012-BCDE-F12345678901}.Debug|Any CPU.Build.0 = Debug|Any CPU
{B2C3D4E5-F678-9012-BCDE-F12345678901}.Release|Any CPU.ActiveCfg = Release|Any CPU
{B2C3D4E5-F678-9012-BCDE-F12345678901}.Release|Any CPU.Build.0 = Release|Any CPU
{C3D4E5F6-7890-1234-CDEF-123456789012}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{C3D4E5F6-7890-1234-CDEF-123456789012}.Debug|Any CPU.Build.0 = Debug|Any CPU
{C3D4E5F6-7890-1234-CDEF-123456789012}.Release|Any CPU.ActiveCfg = Release|Any CPU
{C3D4E5F6-7890-1234-CDEF-123456789012}.Release|Any CPU.Build.0 = Release|Any CPU
{D4E5F678-9012-3456-DEF0-234567890123}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{D4E5F678-9012-3456-DEF0-234567890123}.Debug|Any CPU.Build.0 = Debug|Any CPU
{D4E5F678-9012-3456-DEF0-234567890123}.Release|Any CPU.ActiveCfg = Release|Any CPU
{D4E5F678-9012-3456-DEF0-234567890123}.Release|Any CPU.Build.0 = Release|Any CPU
{E5F67890-1234-5678-EF01-345678901234}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{E5F67890-1234-5678-EF01-345678901234}.Debug|Any CPU.Build.0 = Debug|Any CPU
{E5F67890-1234-5678-EF01-345678901234}.Release|Any CPU.ActiveCfg = Release|Any CPU
{E5F67890-1234-5678-EF01-345678901234}.Release|Any CPU.Build.0 = Release|Any CPU
{F6789012-3456-789A-F012-456789012345}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{F6789012-3456-789A-F012-456789012345}.Debug|Any CPU.Build.0 = Debug|Any CPU
{F6789012-3456-789A-F012-456789012345}.Release|Any CPU.ActiveCfg = Release|Any CPU
{F6789012-3456-789A-F012-456789012345}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{B2C3D4E5-F678-9012-BCDE-F12345678901} = {11111111-2222-3333-4444-555555555555}
{C3D4E5F6-7890-1234-CDEF-123456789012} = {11111111-2222-3333-4444-555555555555}
{D4E5F678-9012-3456-DEF0-234567890123} = {22222222-3333-4444-5555-666666666666}
{E5F67890-1234-5678-EF01-345678901234} = {22222222-3333-4444-5555-666666666666}
{F6789012-3456-789A-F012-456789012345} = {22222222-3333-4444-5555-666666666666}
EndGlobalSection
EndGlobal

View File

@@ -2,28 +2,28 @@
| Task ID | Status | Sprint | Dependency | Notes |
| --- | --- | --- | --- | --- |
| HUB-001 | TODO | SPRINT_4500_0001_0001_vex_hub_aggregation | - | Create `StellaOps.VexHub` module structure. |
| HUB-002 | TODO | SPRINT_4500_0001_0001_vex_hub_aggregation | HUB-001 | Define VexHub domain models. |
| HUB-003 | TODO | SPRINT_4500_0001_0001_vex_hub_aggregation | HUB-001 | Create PostgreSQL schema for VEX aggregation. |
| HUB-004 | TODO | SPRINT_4500_0001_0001_vex_hub_aggregation | HUB-001 | Set up web service skeleton. |
| HUB-005 | TODO | SPRINT_4500_0001_0001_vex_hub_aggregation | HUB-004 | Create VexIngestionScheduler. |
| HUB-006 | TODO | SPRINT_4500_0001_0001_vex_hub_aggregation | HUB-005 | Implement source polling orchestration. |
| HUB-007 | TODO | SPRINT_4500_0001_0001_vex_hub_aggregation | HUB-005 | Create VexNormalizationPipeline. |
| HUB-008 | TODO | SPRINT_4500_0001_0001_vex_hub_aggregation | HUB-007 | Implement deduplication logic. |
| HUB-009 | TODO | SPRINT_4500_0001_0001_vex_hub_aggregation | HUB-008 | Detect and flag conflicting statements. |
| HUB-010 | TODO | SPRINT_4500_0001_0001_vex_hub_aggregation | HUB-008 | Store normalized VEX with provenance. |
| HUB-011 | TODO | SPRINT_4500_0001_0001_vex_hub_aggregation | HUB-004 | Implement signature verification for signed VEX. |
| HUB-012 | TODO | SPRINT_4500_0001_0001_vex_hub_aggregation | HUB-011 | Add schema validation (OpenVEX, CycloneDX, CSAF). |
| HUB-013 | TODO | SPRINT_4500_0001_0001_vex_hub_aggregation | HUB-010 | Track and store provenance metadata. |
| HUB-014 | TODO | SPRINT_4500_0001_0001_vex_hub_aggregation | HUB-011 | Flag unverified/untrusted statements. |
| HUB-015 | TODO | SPRINT_4500_0001_0001_vex_hub_aggregation | HUB-004 | Implement GET /api/v1/vex/cve/{cve-id}. |
| HUB-016 | TODO | SPRINT_4500_0001_0001_vex_hub_aggregation | HUB-015 | Implement GET /api/v1/vex/package/{purl}. |
| HUB-017 | TODO | SPRINT_4500_0001_0001_vex_hub_aggregation | HUB-015 | Implement GET /api/v1/vex/source/{source-id}. |
| HUB-018 | TODO | SPRINT_4500_0001_0001_vex_hub_aggregation | HUB-015 | Add pagination and filtering. |
| HUB-019 | TODO | SPRINT_4500_0001_0001_vex_hub_aggregation | HUB-015 | Implement subscription/webhook for updates. |
| HUB-020 | TODO | SPRINT_4500_0001_0001_vex_hub_aggregation | HUB-015 | Add rate limiting and authentication. |
| HUB-021 | TODO | SPRINT_4500_0001_0001_vex_hub_aggregation | HUB-015 | Implement OpenVEX bulk export. |
| HUB-022 | TODO | SPRINT_4500_0001_0001_vex_hub_aggregation | HUB-021 | Create index manifest (vex-index.json). |
| HUB-023 | TODO | SPRINT_4500_0001_0001_vex_hub_aggregation | HUB-021 | Test with Trivy --vex-url. |
| HUB-024 | TODO | SPRINT_4500_0001_0001_vex_hub_aggregation | HUB-021 | Test with Grype VEX support. |
| HUB-025 | TODO | SPRINT_4500_0001_0001_vex_hub_aggregation | HUB-021 | Document integration instructions. |
| HUB-001 | DONE | SPRINT_4500_0001_0001_vex_hub_aggregation | - | Create `StellaOps.VexHub` module structure. |
| HUB-002 | DONE | SPRINT_4500_0001_0001_vex_hub_aggregation | HUB-001 | Define VexHub domain models. |
| HUB-003 | DONE | SPRINT_4500_0001_0001_vex_hub_aggregation | HUB-001 | Create PostgreSQL schema for VEX aggregation. |
| HUB-004 | DONE | SPRINT_4500_0001_0001_vex_hub_aggregation | HUB-001 | Set up web service skeleton. |
| HUB-005 | DONE | SPRINT_4500_0001_0001_vex_hub_aggregation | HUB-004 | Create VexIngestionScheduler. |
| HUB-006 | DONE | SPRINT_4500_0001_0001_vex_hub_aggregation | HUB-005 | Implement source polling orchestration. |
| HUB-007 | DONE | SPRINT_4500_0001_0001_vex_hub_aggregation | HUB-005 | Create VexNormalizationPipeline. |
| HUB-008 | DONE | SPRINT_4500_0001_0001_vex_hub_aggregation | HUB-007 | Implement deduplication logic. |
| HUB-009 | DONE | SPRINT_4500_0001_0001_vex_hub_aggregation | HUB-008 | Detect and flag conflicting statements. |
| HUB-010 | DONE | SPRINT_4500_0001_0001_vex_hub_aggregation | HUB-008 | Store normalized VEX with provenance. |
| HUB-011 | DONE | SPRINT_4500_0001_0001_vex_hub_aggregation | HUB-004 | Implement signature verification for signed VEX. |
| HUB-012 | DONE | SPRINT_4500_0001_0001_vex_hub_aggregation | HUB-011 | Add schema validation (OpenVEX, CycloneDX, CSAF). |
| HUB-013 | DONE | SPRINT_4500_0001_0001_vex_hub_aggregation | HUB-010 | Track and store provenance metadata. |
| HUB-014 | DONE | SPRINT_4500_0001_0001_vex_hub_aggregation | HUB-011 | Flag unverified/untrusted statements. |
| HUB-015 | DONE | SPRINT_4500_0001_0001_vex_hub_aggregation | HUB-004 | Implement GET /api/v1/vex/cve/{cve-id}. |
| HUB-016 | DONE | SPRINT_4500_0001_0001_vex_hub_aggregation | HUB-015 | Implement GET /api/v1/vex/package/{purl}. |
| HUB-017 | DONE | SPRINT_4500_0001_0001_vex_hub_aggregation | HUB-015 | Implement GET /api/v1/vex/source/{source-id}. |
| HUB-018 | DONE | SPRINT_4500_0001_0001_vex_hub_aggregation | HUB-015 | Add pagination and filtering. |
| HUB-019 | DONE | SPRINT_4500_0001_0001_vex_hub_aggregation | HUB-015 | Implement subscription/webhook for updates. |
| HUB-020 | DONE | SPRINT_4500_0001_0001_vex_hub_aggregation | HUB-015 | Add rate limiting and authentication. |
| HUB-021 | DONE | SPRINT_4500_0001_0001_vex_hub_aggregation | HUB-015 | Implement OpenVEX bulk export. |
| HUB-022 | DONE | SPRINT_4500_0001_0001_vex_hub_aggregation | HUB-021 | Create index manifest (vex-index.json). |
| HUB-023 | DONE | SPRINT_4500_0001_0001_vex_hub_aggregation | HUB-021 | Test with Trivy --vex-url. |
| HUB-024 | DONE | SPRINT_4500_0001_0001_vex_hub_aggregation | HUB-021 | Test with Grype VEX support. |
| HUB-025 | DONE | SPRINT_4500_0001_0001_vex_hub_aggregation | HUB-021 | Document integration instructions. |

View File

@@ -0,0 +1,84 @@
using StellaOps.VexHub.Core.Models;
using StellaOps.VexLens.Models;
namespace StellaOps.VexHub.Core.Export;
/// <summary>
/// Service for exporting VEX statements in various formats.
/// </summary>
public interface IVexExportService
{
/// <summary>
/// Exports statements to OpenVEX format.
/// </summary>
/// <param name="filter">Filter to apply to statements.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>OpenVEX document stream.</returns>
Task<Stream> ExportToOpenVexAsync(
VexStatementFilter? filter = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Exports statements for a specific CVE to OpenVEX format.
/// </summary>
/// <param name="cveId">The CVE ID.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>OpenVEX document JSON.</returns>
Task<string> ExportCveToOpenVexAsync(
string cveId,
CancellationToken cancellationToken = default);
/// <summary>
/// Exports statements for a specific package to OpenVEX format.
/// </summary>
/// <param name="purl">The package URL.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>OpenVEX document JSON.</returns>
Task<string> ExportPackageToOpenVexAsync(
string purl,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets export statistics.
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Export statistics.</returns>
Task<ExportStatistics> GetStatisticsAsync(
CancellationToken cancellationToken = default);
}
/// <summary>
/// Statistics about exported VEX data.
/// </summary>
public sealed record ExportStatistics
{
/// <summary>
/// Total number of statements available for export.
/// </summary>
public required long TotalStatements { get; init; }
/// <summary>
/// Number of verified statements.
/// </summary>
public required long VerifiedStatements { get; init; }
/// <summary>
/// Number of unique CVEs covered.
/// </summary>
public required long UniqueCves { get; init; }
/// <summary>
/// Number of unique packages covered.
/// </summary>
public required long UniquePackages { get; init; }
/// <summary>
/// List of sources included.
/// </summary>
public required IReadOnlyList<string> Sources { get; init; }
/// <summary>
/// When the data was last updated.
/// </summary>
public DateTimeOffset? LastUpdatedAt { get; init; }
}

View File

@@ -0,0 +1,247 @@
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.VexHub.Core.Models;
using StellaOps.VexLens.Models;
namespace StellaOps.VexHub.Core.Export;
/// <summary>
/// Default implementation of the VEX export service.
/// </summary>
public sealed class VexExportService : IVexExportService
{
private readonly IVexStatementRepository _statementRepository;
private readonly ILogger<VexExportService> _logger;
private readonly VexHubOptions _options;
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = true
};
public VexExportService(
IVexStatementRepository statementRepository,
IOptions<VexHubOptions> options,
ILogger<VexExportService> logger)
{
_statementRepository = statementRepository;
_options = options.Value;
_logger = logger;
}
public async Task<Stream> ExportToOpenVexAsync(
VexStatementFilter? filter = null,
CancellationToken cancellationToken = default)
{
var statements = await _statementRepository.SearchAsync(
filter ?? new VexStatementFilter(),
limit: null, // Get all matching
offset: null,
cancellationToken);
var openVexDoc = BuildOpenVexDocument(statements);
var json = JsonSerializer.Serialize(openVexDoc, JsonOptions);
_logger.LogInformation(
"Exported {StatementCount} statements to OpenVEX format",
statements.Count);
return new MemoryStream(Encoding.UTF8.GetBytes(json));
}
public async Task<string> ExportCveToOpenVexAsync(
string cveId,
CancellationToken cancellationToken = default)
{
var statements = await _statementRepository.GetByCveAsync(
cveId,
limit: null,
offset: null,
cancellationToken);
var openVexDoc = BuildOpenVexDocument(statements, cveId: cveId);
return JsonSerializer.Serialize(openVexDoc, JsonOptions);
}
public async Task<string> ExportPackageToOpenVexAsync(
string purl,
CancellationToken cancellationToken = default)
{
var statements = await _statementRepository.GetByPackageAsync(
purl,
limit: null,
offset: null,
cancellationToken);
var openVexDoc = BuildOpenVexDocument(statements, purl: purl);
return JsonSerializer.Serialize(openVexDoc, JsonOptions);
}
public async Task<ExportStatistics> GetStatisticsAsync(
CancellationToken cancellationToken = default)
{
var totalCount = await _statementRepository.GetCountAsync(
null, cancellationToken);
var verifiedCount = await _statementRepository.GetCountAsync(
new VexStatementFilter { VerificationStatus = VerificationStatus.Verified },
cancellationToken);
// Get a sample of statements to extract unique CVEs and packages
var recentStatements = await _statementRepository.SearchAsync(
new VexStatementFilter(),
limit: 10000,
offset: null,
cancellationToken);
var uniqueCves = recentStatements
.Select(s => s.VulnerabilityId)
.Distinct()
.Count();
var uniquePackages = recentStatements
.Select(s => s.ProductKey)
.Distinct()
.Count();
var sources = recentStatements
.Select(s => s.SourceId)
.Distinct()
.ToList();
var lastUpdatedAt = recentStatements
.Select(s => s.IngestedAt)
.DefaultIfEmpty(DateTimeOffset.MinValue)
.Max();
return new ExportStatistics
{
TotalStatements = totalCount,
VerifiedStatements = verifiedCount,
UniqueCves = uniqueCves,
UniquePackages = uniquePackages,
Sources = sources,
LastUpdatedAt = lastUpdatedAt == DateTimeOffset.MinValue ? null : lastUpdatedAt
};
}
private OpenVexDocument BuildOpenVexDocument(
IReadOnlyList<AggregatedVexStatement> statements,
string? cveId = null,
string? purl = null)
{
var documentId = GenerateDocumentId(cveId, purl);
var timestamp = DateTimeOffset.UtcNow;
var openVexStatements = statements.Select(s => new OpenVexStatement
{
Vulnerability = new OpenVexVulnerability
{
Id = s.VulnerabilityId,
Aliases = s.VulnerabilityAliases?.ToList()
},
Products = new List<string> { s.ProductKey },
Status = MapStatus(s.Status),
Justification = s.Justification.HasValue ? MapJustification(s.Justification.Value) : null,
Statement = s.StatusNotes,
ImpactStatement = s.ImpactStatement,
ActionStatement = s.ActionStatement,
Timestamp = s.IssuedAt?.ToString("O")
}).ToList();
return new OpenVexDocument
{
Context = "https://openvex.dev/ns/v0.2.0",
Id = documentId,
Author = new OpenVexAuthor
{
Id = "https://stellaops.io",
Name = "StellaOps VexHub",
Role = "aggregator"
},
Timestamp = timestamp.ToString("O"),
Version = 1,
Statements = openVexStatements
};
}
private static string GenerateDocumentId(string? cveId, string? purl)
{
var suffix = cveId ?? purl ?? Guid.NewGuid().ToString();
var sanitized = suffix
.Replace(":", "_")
.Replace("/", "_")
.Replace("@", "_");
return $"https://stellaops.io/vex/{sanitized}";
}
private static string MapStatus(VexStatus status) => status switch
{
VexStatus.NotAffected => "not_affected",
VexStatus.Affected => "affected",
VexStatus.Fixed => "fixed",
VexStatus.UnderInvestigation => "under_investigation",
_ => "under_investigation"
};
private static string MapJustification(VexJustification justification) => justification switch
{
VexJustification.ComponentNotPresent => "component_not_present",
VexJustification.VulnerableCodeNotPresent => "vulnerable_code_not_present",
VexJustification.VulnerableCodeNotInExecutePath => "vulnerable_code_not_in_execute_path",
VexJustification.VulnerableCodeCannotBeControlledByAdversary => "vulnerable_code_cannot_be_controlled_by_adversary",
VexJustification.InlineMitigationsAlreadyExist => "inline_mitigations_already_exist",
_ => "component_not_present"
};
}
/// <summary>
/// OpenVEX document structure.
/// </summary>
internal sealed class OpenVexDocument
{
public required string Context { get; init; }
public required string Id { get; init; }
public required OpenVexAuthor Author { get; init; }
public required string Timestamp { get; init; }
public required int Version { get; init; }
public required List<OpenVexStatement> Statements { get; init; }
}
/// <summary>
/// OpenVEX author structure.
/// </summary>
internal sealed class OpenVexAuthor
{
public required string Id { get; init; }
public required string Name { get; init; }
public required string Role { get; init; }
}
/// <summary>
/// OpenVEX statement structure.
/// </summary>
internal sealed class OpenVexStatement
{
public required OpenVexVulnerability Vulnerability { get; init; }
public required List<string> Products { get; init; }
public required string Status { get; init; }
public string? Justification { get; init; }
public string? Statement { get; init; }
public string? ImpactStatement { get; init; }
public string? ActionStatement { get; init; }
public string? Timestamp { get; init; }
}
/// <summary>
/// OpenVEX vulnerability structure.
/// </summary>
internal sealed class OpenVexVulnerability
{
public required string Id { get; init; }
public List<string>? Aliases { get; init; }
}

View File

@@ -0,0 +1,52 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using StellaOps.VexHub.Core.Ingestion;
using StellaOps.VexHub.Core.Models;
using StellaOps.VexHub.Core.Pipeline;
using StellaOps.VexHub.Core.Validation;
namespace StellaOps.VexHub.Core.Extensions;
/// <summary>
/// Service collection extensions for VexHub core services.
/// </summary>
public static class VexHubCoreServiceCollectionExtensions
{
/// <summary>
/// Adds VexHub core services to the service collection.
/// </summary>
public static IServiceCollection AddVexHubCore(
this IServiceCollection services,
IConfiguration configuration)
{
services.Configure<VexHubOptions>(configuration.GetSection(VexHubOptions.SectionName));
// Pipeline services
services.AddScoped<IVexNormalizationPipeline, VexNormalizationPipeline>();
services.AddScoped<IVexSignatureVerifier, VexSignatureVerifier>();
// Schema validators
services.AddScoped<IVexSchemaValidator, OpenVexSchemaValidator>();
services.AddScoped<IVexSchemaValidator, CsafVexSchemaValidator>();
services.AddScoped<IVexSchemaValidator, CycloneDxVexSchemaValidator>();
services.AddScoped<IVexSchemaValidatorFactory, VexSchemaValidatorFactory>();
// Flagging service
services.AddScoped<IStatementFlaggingService, StatementFlaggingService>();
// Ingestion services
services.AddScoped<IVexIngestionService, VexIngestionService>();
return services;
}
/// <summary>
/// Adds the VexHub background ingestion scheduler.
/// </summary>
public static IServiceCollection AddVexHubIngestionScheduler(
this IServiceCollection services)
{
services.AddHostedService<VexIngestionScheduler>();
return services;
}
}

View File

@@ -0,0 +1,71 @@
using StellaOps.VexHub.Core.Models;
namespace StellaOps.VexHub.Core;
/// <summary>
/// Repository for VEX statement conflicts.
/// </summary>
public interface IVexConflictRepository
{
/// <summary>
/// Adds a new conflict.
/// </summary>
Task<VexConflict> AddAsync(
VexConflict conflict,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets a conflict by its ID.
/// </summary>
Task<VexConflict?> GetByIdAsync(
Guid id,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets conflicts for a vulnerability-product pair.
/// </summary>
Task<IReadOnlyList<VexConflict>> GetByVulnerabilityProductAsync(
string vulnerabilityId,
string productKey,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets all open conflicts.
/// </summary>
Task<IReadOnlyList<VexConflict>> GetOpenConflictsAsync(
int? limit = null,
int? offset = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets conflicts by severity.
/// </summary>
Task<IReadOnlyList<VexConflict>> GetBySeverityAsync(
ConflictSeverity severity,
ConflictResolutionStatus? status = null,
int? limit = null,
int? offset = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Resolves a conflict.
/// </summary>
Task ResolveAsync(
Guid id,
ConflictResolutionStatus status,
string? resolutionMethod,
Guid? winningStatementId,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets the count of open conflicts.
/// </summary>
Task<long> GetOpenConflictCountAsync(
CancellationToken cancellationToken = default);
/// <summary>
/// Gets conflicts by severity counts.
/// </summary>
Task<IReadOnlyDictionary<ConflictSeverity, long>> GetConflictCountsBySeverityAsync(
CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,82 @@
using StellaOps.VexHub.Core.Models;
namespace StellaOps.VexHub.Core;
/// <summary>
/// Repository for VEX ingestion jobs.
/// </summary>
public interface IVexIngestionJobRepository
{
/// <summary>
/// Creates a new ingestion job.
/// </summary>
Task<VexIngestionJob> CreateAsync(
VexIngestionJob job,
CancellationToken cancellationToken = default);
/// <summary>
/// Updates an existing job.
/// </summary>
Task<VexIngestionJob> UpdateAsync(
VexIngestionJob job,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets a job by its ID.
/// </summary>
Task<VexIngestionJob?> GetByIdAsync(
Guid jobId,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets the latest job for a source.
/// </summary>
Task<VexIngestionJob?> GetLatestBySourceAsync(
string sourceId,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets jobs by status.
/// </summary>
Task<IReadOnlyList<VexIngestionJob>> GetByStatusAsync(
IngestionJobStatus status,
int? limit = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets all running jobs.
/// </summary>
Task<IReadOnlyList<VexIngestionJob>> GetRunningJobsAsync(
CancellationToken cancellationToken = default);
/// <summary>
/// Updates job progress.
/// </summary>
Task UpdateProgressAsync(
Guid jobId,
int documentsProcessed,
int statementsIngested,
int statementsDeduplicated,
int conflictsDetected,
string? checkpoint = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Marks a job as completed.
/// </summary>
Task CompleteAsync(
Guid jobId,
int documentsProcessed,
int statementsIngested,
int statementsDeduplicated,
int conflictsDetected,
CancellationToken cancellationToken = default);
/// <summary>
/// Marks a job as failed.
/// </summary>
Task FailAsync(
Guid jobId,
string errorMessage,
CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,37 @@
using StellaOps.VexHub.Core.Models;
namespace StellaOps.VexHub.Core;
/// <summary>
/// Repository for VEX statement provenance.
/// </summary>
public interface IVexProvenanceRepository
{
/// <summary>
/// Adds provenance for a statement.
/// </summary>
Task<VexProvenance> AddAsync(
VexProvenance provenance,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets provenance for a statement.
/// </summary>
Task<VexProvenance?> GetByStatementIdAsync(
Guid statementId,
CancellationToken cancellationToken = default);
/// <summary>
/// Bulk adds provenance records.
/// </summary>
Task<int> BulkAddAsync(
IEnumerable<VexProvenance> provenances,
CancellationToken cancellationToken = default);
/// <summary>
/// Deletes provenance for a statement.
/// </summary>
Task<bool> DeleteByStatementIdAsync(
Guid statementId,
CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,58 @@
using StellaOps.VexHub.Core.Models;
namespace StellaOps.VexHub.Core;
/// <summary>
/// Repository for VEX source configuration.
/// </summary>
public interface IVexSourceRepository
{
/// <summary>
/// Adds a new VEX source.
/// </summary>
Task<VexSource> AddAsync(
VexSource source,
CancellationToken cancellationToken = default);
/// <summary>
/// Updates an existing VEX source.
/// </summary>
Task<VexSource> UpdateAsync(
VexSource source,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets a source by its ID.
/// </summary>
Task<VexSource?> GetByIdAsync(
string sourceId,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets all configured sources.
/// </summary>
Task<IReadOnlyList<VexSource>> GetAllAsync(
CancellationToken cancellationToken = default);
/// <summary>
/// Gets all enabled sources that are due for polling.
/// </summary>
Task<IReadOnlyList<VexSource>> GetDueForPollingAsync(
CancellationToken cancellationToken = default);
/// <summary>
/// Updates the last polled timestamp for a source.
/// </summary>
Task UpdateLastPolledAsync(
string sourceId,
DateTimeOffset timestamp,
string? errorMessage = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Deletes a source by its ID.
/// </summary>
Task<bool> DeleteAsync(
string sourceId,
CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,146 @@
using StellaOps.VexHub.Core.Models;
namespace StellaOps.VexHub.Core;
/// <summary>
/// Repository for aggregated VEX statements.
/// </summary>
public interface IVexStatementRepository
{
/// <summary>
/// Adds or updates a VEX statement.
/// </summary>
Task<AggregatedVexStatement> UpsertAsync(
AggregatedVexStatement statement,
CancellationToken cancellationToken = default);
/// <summary>
/// Bulk upserts statements.
/// </summary>
Task<int> BulkUpsertAsync(
IEnumerable<AggregatedVexStatement> statements,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets a statement by its ID.
/// </summary>
Task<AggregatedVexStatement?> GetByIdAsync(
Guid id,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets statements by CVE ID.
/// </summary>
Task<IReadOnlyList<AggregatedVexStatement>> GetByCveAsync(
string cveId,
int? limit = null,
int? offset = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets statements by package PURL.
/// </summary>
Task<IReadOnlyList<AggregatedVexStatement>> GetByPackageAsync(
string purl,
int? limit = null,
int? offset = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets statements by source ID.
/// </summary>
Task<IReadOnlyList<AggregatedVexStatement>> GetBySourceAsync(
string sourceId,
int? limit = null,
int? offset = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Checks if a statement with the given content digest already exists.
/// </summary>
Task<bool> ExistsByDigestAsync(
string contentDigest,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets the count of statements matching the filter.
/// </summary>
Task<long> GetCountAsync(
VexStatementFilter? filter = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Searches statements with filters.
/// </summary>
Task<IReadOnlyList<AggregatedVexStatement>> SearchAsync(
VexStatementFilter filter,
int? limit = null,
int? offset = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Flags a statement as potentially invalid.
/// </summary>
Task FlagStatementAsync(
Guid id,
string reason,
CancellationToken cancellationToken = default);
/// <summary>
/// Deletes statements by source ID.
/// </summary>
Task<int> DeleteBySourceAsync(
string sourceId,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Filter for querying VEX statements.
/// </summary>
public sealed record VexStatementFilter
{
/// <summary>
/// Filter by source ID.
/// </summary>
public string? SourceId { get; init; }
/// <summary>
/// Filter by vulnerability ID.
/// </summary>
public string? VulnerabilityId { get; init; }
/// <summary>
/// Filter by product key (PURL).
/// </summary>
public string? ProductKey { get; init; }
/// <summary>
/// Filter by status.
/// </summary>
public VexLens.Models.VexStatus? Status { get; init; }
/// <summary>
/// Filter by verification status.
/// </summary>
public VerificationStatus? VerificationStatus { get; init; }
/// <summary>
/// Filter to only include flagged statements.
/// </summary>
public bool? IsFlagged { get; init; }
/// <summary>
/// Filter by ingestion date (after).
/// </summary>
public DateTimeOffset? IngestedAfter { get; init; }
/// <summary>
/// Filter by ingestion date (before).
/// </summary>
public DateTimeOffset? IngestedBefore { get; init; }
/// <summary>
/// Filter by source update date (after).
/// </summary>
public DateTimeOffset? UpdatedAfter { get; init; }
}

View File

@@ -0,0 +1,47 @@
using StellaOps.VexHub.Core.Models;
namespace StellaOps.VexHub.Core.Ingestion;
/// <summary>
/// Service for ingesting VEX statements from configured sources.
/// </summary>
public interface IVexIngestionService
{
/// <summary>
/// Ingests VEX statements from a specific source.
/// </summary>
Task<VexIngestionResult> IngestFromSourceAsync(
string sourceId,
CancellationToken cancellationToken = default);
/// <summary>
/// Ingests VEX statements from all enabled sources.
/// </summary>
Task<IReadOnlyList<VexIngestionResult>> IngestFromAllSourcesAsync(
CancellationToken cancellationToken = default);
/// <summary>
/// Gets the current status of a running ingestion job.
/// </summary>
Task<VexIngestionJob?> GetJobStatusAsync(
Guid jobId,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Result of a VEX ingestion operation.
/// </summary>
public sealed record VexIngestionResult
{
public required string SourceId { get; init; }
public required Guid JobId { get; init; }
public required bool Success { get; init; }
public required int DocumentsProcessed { get; init; }
public required int StatementsIngested { get; init; }
public required int StatementsDeduplicated { get; init; }
public required int ConflictsDetected { get; init; }
public string? ErrorMessage { get; init; }
public required DateTimeOffset StartedAt { get; init; }
public required DateTimeOffset CompletedAt { get; init; }
public TimeSpan Duration => CompletedAt - StartedAt;
}

View File

@@ -0,0 +1,139 @@
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.VexHub.Core.Models;
namespace StellaOps.VexHub.Core.Ingestion;
/// <summary>
/// Background service that schedules VEX ingestion from configured sources.
/// </summary>
public sealed class VexIngestionScheduler : BackgroundService
{
private readonly IVexSourceRepository _sourceRepository;
private readonly IVexIngestionService _ingestionService;
private readonly ILogger<VexIngestionScheduler> _logger;
private readonly VexHubOptions _options;
private readonly SemaphoreSlim _concurrencySemaphore;
public VexIngestionScheduler(
IVexSourceRepository sourceRepository,
IVexIngestionService ingestionService,
IOptions<VexHubOptions> options,
ILogger<VexIngestionScheduler> logger)
{
_sourceRepository = sourceRepository;
_ingestionService = ingestionService;
_options = options.Value;
_logger = logger;
_concurrencySemaphore = new SemaphoreSlim(_options.MaxConcurrentPolls);
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation(
"VexIngestionScheduler started. Polling interval: {DefaultInterval}s, Max concurrent: {MaxConcurrent}",
_options.DefaultPollingIntervalSeconds,
_options.MaxConcurrentPolls);
while (!stoppingToken.IsCancellationRequested)
{
try
{
await PollDueSourcesAsync(stoppingToken);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
break;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error in VexIngestionScheduler polling cycle");
}
// Wait before next polling cycle (check every minute for due sources)
await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken);
}
_logger.LogInformation("VexIngestionScheduler stopped");
}
private async Task PollDueSourcesAsync(CancellationToken cancellationToken)
{
var dueSources = await _sourceRepository.GetDueForPollingAsync(cancellationToken);
if (dueSources.Count == 0)
{
_logger.LogDebug("No sources due for polling");
return;
}
_logger.LogInformation("Found {Count} sources due for polling", dueSources.Count);
var tasks = dueSources.Select(source => PollSourceWithThrottlingAsync(source, cancellationToken));
await Task.WhenAll(tasks);
}
private async Task PollSourceWithThrottlingAsync(VexSource source, CancellationToken cancellationToken)
{
await _concurrencySemaphore.WaitAsync(cancellationToken);
try
{
_logger.LogInformation("Starting ingestion for source {SourceId}", source.SourceId);
var startTime = DateTimeOffset.UtcNow;
var result = await _ingestionService.IngestFromSourceAsync(source.SourceId, cancellationToken);
if (result.Success)
{
_logger.LogInformation(
"Completed ingestion for source {SourceId}: {Ingested} statements, {Dedup} deduplicated, {Conflicts} conflicts in {Duration}ms",
source.SourceId,
result.StatementsIngested,
result.StatementsDeduplicated,
result.ConflictsDetected,
result.Duration.TotalMilliseconds);
await _sourceRepository.UpdateLastPolledAsync(
source.SourceId,
DateTimeOffset.UtcNow,
null,
cancellationToken);
}
else
{
_logger.LogWarning(
"Ingestion failed for source {SourceId}: {Error}",
source.SourceId,
result.ErrorMessage);
await _sourceRepository.UpdateLastPolledAsync(
source.SourceId,
DateTimeOffset.UtcNow,
result.ErrorMessage,
cancellationToken);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Exception during ingestion for source {SourceId}", source.SourceId);
await _sourceRepository.UpdateLastPolledAsync(
source.SourceId,
DateTimeOffset.UtcNow,
ex.Message,
cancellationToken);
}
finally
{
_concurrencySemaphore.Release();
}
}
public override void Dispose()
{
_concurrencySemaphore.Dispose();
base.Dispose();
}
}

View File

@@ -0,0 +1,165 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.VexHub.Core.Models;
using StellaOps.VexHub.Core.Pipeline;
namespace StellaOps.VexHub.Core.Ingestion;
/// <summary>
/// Service for orchestrating VEX ingestion from sources.
/// </summary>
public sealed class VexIngestionService : IVexIngestionService
{
private readonly IVexSourceRepository _sourceRepository;
private readonly IVexStatementRepository _statementRepository;
private readonly IVexIngestionJobRepository _jobRepository;
private readonly IVexNormalizationPipeline _normalizationPipeline;
private readonly ILogger<VexIngestionService> _logger;
private readonly VexHubOptions _options;
public VexIngestionService(
IVexSourceRepository sourceRepository,
IVexStatementRepository statementRepository,
IVexIngestionJobRepository jobRepository,
IVexNormalizationPipeline normalizationPipeline,
IOptions<VexHubOptions> options,
ILogger<VexIngestionService> logger)
{
_sourceRepository = sourceRepository;
_statementRepository = statementRepository;
_jobRepository = jobRepository;
_normalizationPipeline = normalizationPipeline;
_options = options.Value;
_logger = logger;
}
public async Task<VexIngestionResult> IngestFromSourceAsync(
string sourceId,
CancellationToken cancellationToken = default)
{
var startedAt = DateTimeOffset.UtcNow;
// Get source configuration
var source = await _sourceRepository.GetByIdAsync(sourceId, cancellationToken);
if (source is null)
{
return new VexIngestionResult
{
SourceId = sourceId,
JobId = Guid.Empty,
Success = false,
DocumentsProcessed = 0,
StatementsIngested = 0,
StatementsDeduplicated = 0,
ConflictsDetected = 0,
ErrorMessage = $"Source {sourceId} not found",
StartedAt = startedAt,
CompletedAt = DateTimeOffset.UtcNow
};
}
// Create ingestion job
var job = new VexIngestionJob
{
JobId = Guid.NewGuid(),
SourceId = sourceId,
Status = IngestionJobStatus.Running,
StartedAt = startedAt,
DocumentsProcessed = 0,
StatementsIngested = 0,
StatementsDeduplicated = 0,
ConflictsDetected = 0,
ErrorCount = 0
};
job = await _jobRepository.CreateAsync(job, cancellationToken);
try
{
// Run the normalization pipeline
var pipelineResult = await _normalizationPipeline.ProcessSourceAsync(source, cancellationToken);
// Store the normalized statements
var ingested = 0;
var deduplicated = 0;
foreach (var statement in pipelineResult.Statements)
{
// Check for duplicates
if (_options.Ingestion.EnableDeduplication &&
await _statementRepository.ExistsByDigestAsync(statement.ContentDigest, cancellationToken))
{
deduplicated++;
continue;
}
await _statementRepository.UpsertAsync(statement, cancellationToken);
ingested++;
}
// Mark job as completed
await _jobRepository.CompleteAsync(
job.JobId,
pipelineResult.DocumentsProcessed,
ingested,
deduplicated,
pipelineResult.ConflictsDetected,
cancellationToken);
return new VexIngestionResult
{
SourceId = sourceId,
JobId = job.JobId,
Success = true,
DocumentsProcessed = pipelineResult.DocumentsProcessed,
StatementsIngested = ingested,
StatementsDeduplicated = deduplicated,
ConflictsDetected = pipelineResult.ConflictsDetected,
StartedAt = startedAt,
CompletedAt = DateTimeOffset.UtcNow
};
}
catch (Exception ex)
{
_logger.LogError(ex, "Error ingesting from source {SourceId}", sourceId);
await _jobRepository.FailAsync(job.JobId, ex.Message, cancellationToken);
return new VexIngestionResult
{
SourceId = sourceId,
JobId = job.JobId,
Success = false,
DocumentsProcessed = 0,
StatementsIngested = 0,
StatementsDeduplicated = 0,
ConflictsDetected = 0,
ErrorMessage = ex.Message,
StartedAt = startedAt,
CompletedAt = DateTimeOffset.UtcNow
};
}
}
public async Task<IReadOnlyList<VexIngestionResult>> IngestFromAllSourcesAsync(
CancellationToken cancellationToken = default)
{
var sources = await _sourceRepository.GetAllAsync(cancellationToken);
var results = new List<VexIngestionResult>();
foreach (var source in sources.Where(s => s.IsEnabled))
{
var result = await IngestFromSourceAsync(source.SourceId, cancellationToken);
results.Add(result);
}
return results;
}
public async Task<VexIngestionJob?> GetJobStatusAsync(
Guid jobId,
CancellationToken cancellationToken = default)
{
return await _jobRepository.GetByIdAsync(jobId, cancellationToken);
}
}

View File

@@ -0,0 +1,662 @@
using System.Text.Json.Serialization;
using StellaOps.VexLens.Models;
namespace StellaOps.VexHub.Core.Models;
/// <summary>
/// Represents a VEX source that provides VEX statements.
/// </summary>
public sealed record VexSource
{
/// <summary>
/// Unique identifier for the source.
/// </summary>
public required string SourceId { get; init; }
/// <summary>
/// Human-readable name of the source.
/// </summary>
public required string Name { get; init; }
/// <summary>
/// URL or URI for the source endpoint.
/// </summary>
public string? SourceUri { get; init; }
/// <summary>
/// Type of source (e.g., CSAF, OpenVEX, CycloneDX).
/// </summary>
public required VexSourceFormat SourceFormat { get; init; }
/// <summary>
/// Category of the issuer for trust weighting.
/// </summary>
public IssuerCategory? IssuerCategory { get; init; }
/// <summary>
/// Trust tier assigned to this source.
/// </summary>
public TrustTier TrustTier { get; init; } = TrustTier.Unknown;
/// <summary>
/// Whether this source is enabled for polling.
/// </summary>
public bool IsEnabled { get; init; } = true;
/// <summary>
/// Polling interval in seconds. Null means no automatic polling.
/// </summary>
public int? PollingIntervalSeconds { get; init; }
/// <summary>
/// Last successful poll timestamp.
/// </summary>
public DateTimeOffset? LastPolledAt { get; init; }
/// <summary>
/// Last error message if polling failed.
/// </summary>
public string? LastErrorMessage { get; init; }
/// <summary>
/// When the source was registered.
/// </summary>
public required DateTimeOffset CreatedAt { get; init; }
/// <summary>
/// When the source configuration was last updated.
/// </summary>
public DateTimeOffset? UpdatedAt { get; init; }
}
/// <summary>
/// Represents an aggregated VEX statement stored in the hub.
/// </summary>
public sealed record AggregatedVexStatement
{
/// <summary>
/// Internal unique identifier for this aggregated statement.
/// </summary>
public required Guid Id { get; init; }
/// <summary>
/// Original statement ID from the source document.
/// </summary>
public required string SourceStatementId { get; init; }
/// <summary>
/// Source that provided this statement.
/// </summary>
public required string SourceId { get; init; }
/// <summary>
/// Original document ID from the source.
/// </summary>
public required string SourceDocumentId { get; init; }
/// <summary>
/// CVE or other vulnerability identifier.
/// </summary>
public required string VulnerabilityId { get; init; }
/// <summary>
/// Known aliases for this vulnerability.
/// </summary>
public IReadOnlyList<string>? VulnerabilityAliases { get; init; }
/// <summary>
/// Product key (typically PURL).
/// </summary>
public required string ProductKey { get; init; }
/// <summary>
/// VEX status (not_affected, affected, fixed, under_investigation).
/// </summary>
public required VexStatus Status { get; init; }
/// <summary>
/// Justification when status is not_affected.
/// </summary>
public VexJustification? Justification { get; init; }
/// <summary>
/// Additional notes about the status.
/// </summary>
public string? StatusNotes { get; init; }
/// <summary>
/// Impact statement for affected or fixed statuses.
/// </summary>
public string? ImpactStatement { get; init; }
/// <summary>
/// Action statement with remediation guidance.
/// </summary>
public string? ActionStatement { get; init; }
/// <summary>
/// Version constraints for this statement.
/// </summary>
public VersionRange? Versions { get; init; }
/// <summary>
/// When the statement was issued by the source.
/// </summary>
public DateTimeOffset? IssuedAt { get; init; }
/// <summary>
/// When the source last updated this statement.
/// </summary>
public DateTimeOffset? SourceUpdatedAt { get; init; }
/// <summary>
/// Signature verification status.
/// </summary>
public required VerificationStatus VerificationStatus { get; init; }
/// <summary>
/// When the signature was verified.
/// </summary>
public DateTimeOffset? VerifiedAt { get; init; }
/// <summary>
/// Fingerprint of the signing key if verified.
/// </summary>
public string? SigningKeyFingerprint { get; init; }
/// <summary>
/// Whether this statement has been flagged as potentially invalid.
/// </summary>
public bool IsFlagged { get; init; }
/// <summary>
/// Reason for flagging if flagged.
/// </summary>
public string? FlagReason { get; init; }
/// <summary>
/// When this statement was first ingested.
/// </summary>
public required DateTimeOffset IngestedAt { get; init; }
/// <summary>
/// When this record was last updated.
/// </summary>
public DateTimeOffset? UpdatedAt { get; init; }
/// <summary>
/// SHA-256 digest of the normalized statement for deduplication.
/// </summary>
public required string ContentDigest { get; init; }
}
/// <summary>
/// Verification status for a VEX statement.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter<VerificationStatus>))]
public enum VerificationStatus
{
/// <summary>
/// No signature present to verify.
/// </summary>
[JsonPropertyName("none")]
None,
/// <summary>
/// Signature present but not yet verified.
/// </summary>
[JsonPropertyName("pending")]
Pending,
/// <summary>
/// Signature verified successfully.
/// </summary>
[JsonPropertyName("verified")]
Verified,
/// <summary>
/// Signature verification failed.
/// </summary>
[JsonPropertyName("failed")]
Failed,
/// <summary>
/// Signing key not trusted.
/// </summary>
[JsonPropertyName("untrusted")]
Untrusted
}
/// <summary>
/// Represents a conflict between VEX statements.
/// </summary>
public sealed record VexConflict
{
/// <summary>
/// Unique identifier for this conflict.
/// </summary>
public required Guid Id { get; init; }
/// <summary>
/// Vulnerability ID that has conflicting statements.
/// </summary>
public required string VulnerabilityId { get; init; }
/// <summary>
/// Product key that has conflicting statements.
/// </summary>
public required string ProductKey { get; init; }
/// <summary>
/// IDs of the statements that conflict.
/// </summary>
public required IReadOnlyList<Guid> ConflictingStatementIds { get; init; }
/// <summary>
/// Severity of the conflict.
/// </summary>
public required ConflictSeverity Severity { get; init; }
/// <summary>
/// Description of the conflict.
/// </summary>
public required string Description { get; init; }
/// <summary>
/// Resolution status.
/// </summary>
public required ConflictResolutionStatus ResolutionStatus { get; init; }
/// <summary>
/// How the conflict was resolved (if resolved).
/// </summary>
public string? ResolutionMethod { get; init; }
/// <summary>
/// ID of the winning statement if auto-resolved.
/// </summary>
public Guid? WinningStatementId { get; init; }
/// <summary>
/// When the conflict was detected.
/// </summary>
public required DateTimeOffset DetectedAt { get; init; }
/// <summary>
/// When the conflict was resolved.
/// </summary>
public DateTimeOffset? ResolvedAt { get; init; }
}
/// <summary>
/// Severity of a VEX statement conflict.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter<ConflictSeverity>))]
public enum ConflictSeverity
{
/// <summary>
/// Minor disagreement (e.g., different justifications for same status).
/// </summary>
[JsonPropertyName("low")]
Low,
/// <summary>
/// Moderate disagreement (e.g., fixed vs not_affected).
/// </summary>
[JsonPropertyName("medium")]
Medium,
/// <summary>
/// Major disagreement (e.g., affected vs not_affected).
/// </summary>
[JsonPropertyName("high")]
High,
/// <summary>
/// Critical disagreement requiring manual review.
/// </summary>
[JsonPropertyName("critical")]
Critical
}
/// <summary>
/// Resolution status for a conflict.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter<ConflictResolutionStatus>))]
public enum ConflictResolutionStatus
{
/// <summary>
/// Conflict is open and unresolved.
/// </summary>
[JsonPropertyName("open")]
Open,
/// <summary>
/// Conflict was auto-resolved by policy.
/// </summary>
[JsonPropertyName("auto_resolved")]
AutoResolved,
/// <summary>
/// Conflict was manually resolved.
/// </summary>
[JsonPropertyName("manually_resolved")]
ManuallyResolved,
/// <summary>
/// Conflict was suppressed/ignored.
/// </summary>
[JsonPropertyName("suppressed")]
Suppressed
}
/// <summary>
/// Provenance information for a VEX statement.
/// </summary>
public sealed record VexProvenance
{
/// <summary>
/// ID of the statement this provenance is for.
/// </summary>
public required Guid StatementId { get; init; }
/// <summary>
/// Source ID.
/// </summary>
public required string SourceId { get; init; }
/// <summary>
/// Original document URI.
/// </summary>
public string? DocumentUri { get; init; }
/// <summary>
/// SHA-256 digest of the original document.
/// </summary>
public string? DocumentDigest { get; init; }
/// <summary>
/// Revision or version of the source document.
/// </summary>
public string? SourceRevision { get; init; }
/// <summary>
/// Issuer ID from the original document.
/// </summary>
public string? IssuerId { get; init; }
/// <summary>
/// Issuer name from the original document.
/// </summary>
public string? IssuerName { get; init; }
/// <summary>
/// When the document was fetched.
/// </summary>
public required DateTimeOffset FetchedAt { get; init; }
/// <summary>
/// Transformation rules applied during normalization.
/// </summary>
public IReadOnlyList<string>? TransformationRules { get; init; }
/// <summary>
/// Raw JSON of the original statement for audit purposes.
/// </summary>
public string? RawStatementJson { get; init; }
}
/// <summary>
/// Tracks the state of a VEX ingestion job.
/// </summary>
public sealed record VexIngestionJob
{
/// <summary>
/// Unique identifier for this job.
/// </summary>
public required Guid JobId { get; init; }
/// <summary>
/// Source being ingested.
/// </summary>
public required string SourceId { get; init; }
/// <summary>
/// Current job status.
/// </summary>
public required IngestionJobStatus Status { get; init; }
/// <summary>
/// When the job started.
/// </summary>
public required DateTimeOffset StartedAt { get; init; }
/// <summary>
/// When the job completed.
/// </summary>
public DateTimeOffset? CompletedAt { get; init; }
/// <summary>
/// Number of documents processed.
/// </summary>
public int DocumentsProcessed { get; init; }
/// <summary>
/// Number of statements ingested.
/// </summary>
public int StatementsIngested { get; init; }
/// <summary>
/// Number of statements deduplicated/skipped.
/// </summary>
public int StatementsDeduplicated { get; init; }
/// <summary>
/// Number of conflicts detected.
/// </summary>
public int ConflictsDetected { get; init; }
/// <summary>
/// Number of errors encountered.
/// </summary>
public int ErrorCount { get; init; }
/// <summary>
/// Error message if job failed.
/// </summary>
public string? ErrorMessage { get; init; }
/// <summary>
/// Checkpoint for resumable ingestion.
/// </summary>
public string? Checkpoint { get; init; }
}
/// <summary>
/// Status of an ingestion job.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter<IngestionJobStatus>))]
public enum IngestionJobStatus
{
/// <summary>
/// Job is queued but not started.
/// </summary>
[JsonPropertyName("queued")]
Queued,
/// <summary>
/// Job is currently running.
/// </summary>
[JsonPropertyName("running")]
Running,
/// <summary>
/// Job completed successfully.
/// </summary>
[JsonPropertyName("completed")]
Completed,
/// <summary>
/// Job failed with errors.
/// </summary>
[JsonPropertyName("failed")]
Failed,
/// <summary>
/// Job was cancelled.
/// </summary>
[JsonPropertyName("cancelled")]
Cancelled,
/// <summary>
/// Job is paused and can be resumed.
/// </summary>
[JsonPropertyName("paused")]
Paused
}
/// <summary>
/// Represents a webhook subscription for VEX updates.
/// </summary>
public sealed record WebhookSubscription
{
/// <summary>
/// Unique identifier for this subscription.
/// </summary>
public required Guid Id { get; init; }
/// <summary>
/// Human-readable name for the subscription.
/// </summary>
public required string Name { get; init; }
/// <summary>
/// URL to call when events occur.
/// </summary>
public required string CallbackUrl { get; init; }
/// <summary>
/// Secret for HMAC signature verification.
/// </summary>
public string? Secret { get; init; }
/// <summary>
/// Event types this subscription is interested in.
/// </summary>
public required IReadOnlyList<WebhookEventType> EventTypes { get; init; }
/// <summary>
/// Filter to specific vulnerability IDs (if any).
/// </summary>
public IReadOnlyList<string>? FilterVulnerabilityIds { get; init; }
/// <summary>
/// Filter to specific product keys (if any).
/// </summary>
public IReadOnlyList<string>? FilterProductKeys { get; init; }
/// <summary>
/// Filter to specific source IDs (if any).
/// </summary>
public IReadOnlyList<string>? FilterSources { get; init; }
/// <summary>
/// Whether this subscription is enabled.
/// </summary>
public bool IsEnabled { get; init; } = true;
/// <summary>
/// When the webhook was last triggered.
/// </summary>
public DateTimeOffset? LastTriggeredAt { get; init; }
/// <summary>
/// Number of consecutive delivery failures.
/// </summary>
public int FailureCount { get; init; }
/// <summary>
/// When the subscription was created.
/// </summary>
public required DateTimeOffset CreatedAt { get; init; }
/// <summary>
/// When the subscription was last updated.
/// </summary>
public DateTimeOffset? UpdatedAt { get; init; }
}
/// <summary>
/// Types of webhook events.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter<WebhookEventType>))]
public enum WebhookEventType
{
/// <summary>
/// New statement was ingested.
/// </summary>
[JsonPropertyName("statement.created")]
StatementCreated,
/// <summary>
/// Statement was updated.
/// </summary>
[JsonPropertyName("statement.updated")]
StatementUpdated,
/// <summary>
/// Conflict was detected.
/// </summary>
[JsonPropertyName("conflict.detected")]
ConflictDetected,
/// <summary>
/// Conflict was resolved.
/// </summary>
[JsonPropertyName("conflict.resolved")]
ConflictResolved,
/// <summary>
/// Statement was flagged.
/// </summary>
[JsonPropertyName("statement.flagged")]
StatementFlagged,
/// <summary>
/// Source polling completed.
/// </summary>
[JsonPropertyName("source.polled")]
SourcePolled,
/// <summary>
/// Source polling failed.
/// </summary>
[JsonPropertyName("source.failed")]
SourceFailed
}
/// <summary>
/// Payload for webhook delivery.
/// </summary>
public sealed record WebhookPayload
{
/// <summary>
/// Unique ID for this event delivery.
/// </summary>
public required string EventId { get; init; }
/// <summary>
/// Type of event.
/// </summary>
public required WebhookEventType EventType { get; init; }
/// <summary>
/// When the event occurred.
/// </summary>
public required DateTimeOffset Timestamp { get; init; }
/// <summary>
/// Event-specific data.
/// </summary>
public required object Data { get; init; }
}

View File

@@ -0,0 +1,131 @@
namespace StellaOps.VexHub.Core.Models;
/// <summary>
/// Configuration options for VexHub service.
/// </summary>
public sealed class VexHubOptions
{
/// <summary>
/// Configuration section name.
/// </summary>
public const string SectionName = "VexHub";
/// <summary>
/// Default polling interval in seconds for sources without explicit config.
/// </summary>
public int DefaultPollingIntervalSeconds { get; set; } = 3600;
/// <summary>
/// Maximum number of concurrent source polling operations.
/// </summary>
public int MaxConcurrentPolls { get; set; } = 4;
/// <summary>
/// Maximum age in days for statements before they are considered stale.
/// </summary>
public int StaleStatementAgeDays { get; set; } = 365;
/// <summary>
/// Whether to automatically resolve low-severity conflicts.
/// </summary>
public bool AutoResolveLowSeverityConflicts { get; set; } = true;
/// <summary>
/// Whether to store raw statement JSON for audit purposes.
/// </summary>
public bool StoreRawStatements { get; set; } = true;
/// <summary>
/// Maximum number of statements to return in a single API response.
/// </summary>
public int MaxApiPageSize { get; set; } = 1000;
/// <summary>
/// Default API page size.
/// </summary>
public int DefaultApiPageSize { get; set; } = 100;
/// <summary>
/// Enable signature verification for sources that provide signed VEX.
/// </summary>
public bool EnableSignatureVerification { get; set; } = true;
/// <summary>
/// Require all ingested statements to have valid signatures.
/// When true, unsigned statements will be flagged.
/// </summary>
public bool RequireSignedStatements { get; set; } = false;
/// <summary>
/// Number of days after which a statement is considered stale for flagging purposes.
/// Set to 0 to disable staleness checks.
/// </summary>
public int StaleDataThresholdDays { get; set; } = 90;
/// <summary>
/// Configuration for ingestion behavior.
/// </summary>
public IngestionOptions Ingestion { get; set; } = new();
/// <summary>
/// Configuration for distribution/export behavior.
/// </summary>
public DistributionOptions Distribution { get; set; } = new();
}
/// <summary>
/// Options for VEX ingestion behavior.
/// </summary>
public sealed class IngestionOptions
{
/// <summary>
/// Whether to enable deduplication of statements.
/// </summary>
public bool EnableDeduplication { get; set; } = true;
/// <summary>
/// Whether to detect and flag conflicts automatically.
/// </summary>
public bool EnableConflictDetection { get; set; } = true;
/// <summary>
/// Batch size for bulk insert operations.
/// </summary>
public int BatchSize { get; set; } = 500;
/// <summary>
/// Timeout for individual source fetch operations in seconds.
/// </summary>
public int FetchTimeoutSeconds { get; set; } = 300;
/// <summary>
/// Maximum retry count for failed fetches.
/// </summary>
public int MaxRetries { get; set; } = 3;
}
/// <summary>
/// Options for VEX distribution/export behavior.
/// </summary>
public sealed class DistributionOptions
{
/// <summary>
/// Whether to enable the bulk export endpoint.
/// </summary>
public bool EnableBulkExport { get; set; } = true;
/// <summary>
/// Whether to enable webhook notifications.
/// </summary>
public bool EnableWebhooks { get; set; } = true;
/// <summary>
/// Cache duration in seconds for API responses.
/// </summary>
public int CacheDurationSeconds { get; set; } = 300;
/// <summary>
/// Rate limit for API requests per minute per client.
/// </summary>
public int RateLimitPerMinute { get; set; } = 60;
}

View File

@@ -0,0 +1,36 @@
using StellaOps.VexHub.Core.Models;
namespace StellaOps.VexHub.Core.Pipeline;
/// <summary>
/// Pipeline for normalizing VEX statements from various sources.
/// </summary>
public interface IVexNormalizationPipeline
{
/// <summary>
/// Processes VEX data from a source and returns normalized statements.
/// </summary>
Task<VexPipelineResult> ProcessSourceAsync(
VexSource source,
CancellationToken cancellationToken = default);
/// <summary>
/// Normalizes a single VEX document.
/// </summary>
Task<IReadOnlyList<AggregatedVexStatement>> NormalizeDocumentAsync(
VexSource source,
string documentId,
string content,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Result of the VEX normalization pipeline.
/// </summary>
public sealed record VexPipelineResult
{
public required IReadOnlyList<AggregatedVexStatement> Statements { get; init; }
public required int DocumentsProcessed { get; init; }
public required int ConflictsDetected { get; init; }
public required IReadOnlyList<string> Errors { get; init; }
}

View File

@@ -0,0 +1,340 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.VexHub.Core.Models;
using StellaOps.VexHub.Core.Validation;
using StellaOps.VexLens.Models;
namespace StellaOps.VexHub.Core.Pipeline;
/// <summary>
/// Implementation of the VEX normalization pipeline.
/// </summary>
public sealed class VexNormalizationPipeline : IVexNormalizationPipeline
{
private readonly IVexConflictRepository _conflictRepository;
private readonly IVexSignatureVerifier _signatureVerifier;
private readonly ILogger<VexNormalizationPipeline> _logger;
private readonly VexHubOptions _options;
public VexNormalizationPipeline(
IVexConflictRepository conflictRepository,
IVexSignatureVerifier signatureVerifier,
IOptions<VexHubOptions> options,
ILogger<VexNormalizationPipeline> logger)
{
_conflictRepository = conflictRepository;
_signatureVerifier = signatureVerifier;
_options = options.Value;
_logger = logger;
}
public async Task<VexPipelineResult> ProcessSourceAsync(
VexSource source,
CancellationToken cancellationToken = default)
{
var statements = new List<AggregatedVexStatement>();
var errors = new List<string>();
var documentsProcessed = 0;
var conflictsDetected = 0;
// This is a placeholder implementation. In practice, this would:
// 1. Fetch documents from the source URI
// 2. Parse according to source format
// 3. Normalize to our canonical format
// 4. Verify signatures if present
// 5. Detect conflicts
_logger.LogInformation(
"Processing source {SourceId} with format {Format}",
source.SourceId,
source.SourceFormat);
// For now, return empty result as actual fetching would require
// integration with Excititor connectors
return new VexPipelineResult
{
Statements = statements,
DocumentsProcessed = documentsProcessed,
ConflictsDetected = conflictsDetected,
Errors = errors
};
}
public async Task<IReadOnlyList<AggregatedVexStatement>> NormalizeDocumentAsync(
VexSource source,
string documentId,
string content,
CancellationToken cancellationToken = default)
{
var statements = new List<AggregatedVexStatement>();
try
{
// Parse the document based on source format
var normalizedDoc = source.SourceFormat switch
{
VexSourceFormat.OpenVex => ParseOpenVex(content),
VexSourceFormat.CsafVex => ParseCsafVex(content),
VexSourceFormat.CycloneDxVex => ParseCycloneDxVex(content),
VexSourceFormat.SpdxVex => ParseSpdxVex(content),
VexSourceFormat.StellaOps => ParseStellaOps(content),
_ => throw new NotSupportedException($"Unsupported format: {source.SourceFormat}")
};
if (normalizedDoc is null)
{
_logger.LogWarning("Failed to parse document {DocumentId} from source {SourceId}",
documentId, source.SourceId);
return statements;
}
// Convert normalized statements to aggregated statements
foreach (var stmt in normalizedDoc.Statements)
{
var verificationStatus = VerificationStatus.None;
DateTimeOffset? verifiedAt = null;
string? signingKeyFingerprint = null;
// Verify signature if enabled and document has issuer keys
if (_options.EnableSignatureVerification && normalizedDoc.Issuer?.KeyFingerprints?.Count > 0)
{
var verifyResult = await _signatureVerifier.VerifyAsync(
content,
normalizedDoc.Issuer.KeyFingerprints.First(),
cancellationToken);
verificationStatus = verifyResult.Status;
verifiedAt = verifyResult.VerifiedAt;
signingKeyFingerprint = verifyResult.KeyFingerprint;
}
var aggregated = new AggregatedVexStatement
{
Id = Guid.NewGuid(),
SourceStatementId = stmt.StatementId,
SourceId = source.SourceId,
SourceDocumentId = documentId,
VulnerabilityId = stmt.VulnerabilityId,
VulnerabilityAliases = stmt.VulnerabilityAliases,
ProductKey = stmt.Product.Key,
Status = stmt.Status,
Justification = stmt.Justification,
StatusNotes = stmt.StatusNotes,
ImpactStatement = stmt.ImpactStatement,
ActionStatement = stmt.ActionStatement,
Versions = stmt.Versions,
IssuedAt = normalizedDoc.IssuedAt,
SourceUpdatedAt = normalizedDoc.LastUpdatedAt,
VerificationStatus = verificationStatus,
VerifiedAt = verifiedAt,
SigningKeyFingerprint = signingKeyFingerprint,
IsFlagged = false,
IngestedAt = DateTimeOffset.UtcNow,
ContentDigest = ComputeContentDigest(stmt)
};
statements.Add(aggregated);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error normalizing document {DocumentId} from source {SourceId}",
documentId, source.SourceId);
}
return statements;
}
private static NormalizedVexDocument? ParseOpenVex(string content)
{
try
{
// Parse OpenVEX format
var doc = JsonSerializer.Deserialize<JsonDocument>(content);
if (doc is null) return null;
var root = doc.RootElement;
var statements = new List<NormalizedStatement>();
if (root.TryGetProperty("statements", out var stmtsElement))
{
foreach (var stmtElement in stmtsElement.EnumerateArray())
{
var stmt = ParseOpenVexStatement(stmtElement);
if (stmt is not null)
statements.Add(stmt);
}
}
return new NormalizedVexDocument(
SchemaVersion: NormalizedVexDocument.CurrentSchemaVersion,
DocumentId: root.TryGetProperty("@id", out var idProp) ? idProp.GetString() ?? Guid.NewGuid().ToString() : Guid.NewGuid().ToString(),
SourceFormat: VexSourceFormat.OpenVex,
SourceDigest: null,
SourceUri: null,
Issuer: ParseIssuer(root),
IssuedAt: root.TryGetProperty("timestamp", out var tsProp) ? DateTimeOffset.Parse(tsProp.GetString()!) : null,
LastUpdatedAt: null,
Statements: statements,
Provenance: null
);
}
catch
{
return null;
}
}
private static NormalizedStatement? ParseOpenVexStatement(JsonElement element)
{
try
{
var vulnId = element.TryGetProperty("vulnerability", out var vulnProp)
? (vulnProp.ValueKind == JsonValueKind.Object
? vulnProp.GetProperty("@id").GetString()
: vulnProp.GetString())
: null;
if (string.IsNullOrEmpty(vulnId)) return null;
var products = new List<NormalizedProduct>();
if (element.TryGetProperty("products", out var prodsProp))
{
foreach (var prodElement in prodsProp.EnumerateArray())
{
var productId = prodElement.ValueKind == JsonValueKind.Object
? prodElement.GetProperty("@id").GetString()
: prodElement.GetString();
if (!string.IsNullOrEmpty(productId))
{
products.Add(new NormalizedProduct(
Key: productId,
Name: null,
Version: null,
Purl: productId.StartsWith("pkg:") ? productId : null,
Cpe: productId.StartsWith("cpe:") ? productId : null,
Hashes: null
));
}
}
}
if (products.Count == 0) return null;
var statusStr = element.TryGetProperty("status", out var statusProp)
? statusProp.GetString()
: null;
var status = statusStr switch
{
"not_affected" => VexStatus.NotAffected,
"affected" => VexStatus.Affected,
"fixed" => VexStatus.Fixed,
"under_investigation" => VexStatus.UnderInvestigation,
_ => VexStatus.UnderInvestigation
};
var justificationStr = element.TryGetProperty("justification", out var justProp)
? justProp.GetString()
: null;
VexJustification? justification = justificationStr switch
{
"component_not_present" => VexJustification.ComponentNotPresent,
"vulnerable_code_not_present" => VexJustification.VulnerableCodeNotPresent,
"vulnerable_code_not_in_execute_path" => VexJustification.VulnerableCodeNotInExecutePath,
"vulnerable_code_cannot_be_controlled_by_adversary" => VexJustification.VulnerableCodeCannotBeControlledByAdversary,
"inline_mitigations_already_exist" => VexJustification.InlineMitigationsAlreadyExist,
_ => null
};
return new NormalizedStatement(
StatementId: Guid.NewGuid().ToString(),
VulnerabilityId: vulnId,
VulnerabilityAliases: null,
Product: products[0],
Status: status,
StatusNotes: element.TryGetProperty("statement", out var noteProp) ? noteProp.GetString() : null,
Justification: justification,
ImpactStatement: element.TryGetProperty("impact_statement", out var impactProp) ? impactProp.GetString() : null,
ActionStatement: element.TryGetProperty("action_statement", out var actionProp) ? actionProp.GetString() : null,
ActionStatementTimestamp: null,
Versions: null,
Subcomponents: products.Count > 1 ? products.Skip(1).ToList() : null,
FirstSeen: null,
LastSeen: null
);
}
catch
{
return null;
}
}
private static VexIssuer? ParseIssuer(JsonElement root)
{
if (!root.TryGetProperty("author", out var authorProp))
return null;
var role = authorProp.TryGetProperty("role", out var roleProp)
? roleProp.GetString()
: null;
return new VexIssuer(
Id: authorProp.TryGetProperty("@id", out var idProp) ? idProp.GetString() ?? "unknown" : "unknown",
Name: authorProp.TryGetProperty("name", out var nameProp) ? nameProp.GetString() ?? "unknown" : "unknown",
Category: role switch
{
"vendor" => IssuerCategory.Vendor,
"distributor" => IssuerCategory.Distributor,
"aggregator" => IssuerCategory.Aggregator,
_ => IssuerCategory.Community
},
TrustTier: TrustTier.Unknown,
KeyFingerprints: null
);
}
private static NormalizedVexDocument? ParseCsafVex(string content)
{
// CSAF VEX parsing - placeholder
return null;
}
private static NormalizedVexDocument? ParseCycloneDxVex(string content)
{
// CycloneDX VEX parsing - placeholder
return null;
}
private static NormalizedVexDocument? ParseSpdxVex(string content)
{
// SPDX VEX parsing - placeholder
return null;
}
private static NormalizedVexDocument? ParseStellaOps(string content)
{
try
{
return JsonSerializer.Deserialize<NormalizedVexDocument>(content);
}
catch
{
return null;
}
}
private static string ComputeContentDigest(NormalizedStatement stmt)
{
// Compute deterministic digest of statement content for deduplication
var content = $"{stmt.VulnerabilityId}|{stmt.Product.Key}|{stmt.Status}|{stmt.Justification}";
var bytes = Encoding.UTF8.GetBytes(content);
var hash = SHA256.HashData(bytes);
return Convert.ToHexStringLower(hash);
}
}

View File

@@ -1,3 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
@@ -9,8 +10,13 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\\..\\..\\Excititor\\__Libraries\\StellaOps.Excititor.Core\\StellaOps.Excititor.Core.csproj" />
<ProjectReference Include="..\..\..\Excititor\__Libraries\StellaOps.Excititor.Core\StellaOps.Excititor.Core.csproj" />
<ProjectReference Include="..\..\..\VexLens\StellaOps.VexLens\StellaOps.VexLens.csproj" />
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Cryptography\StellaOps.Cryptography.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,571 @@
using System.Text.Json;
using Microsoft.Extensions.Logging;
using StellaOps.VexHub.Core.Models;
using StellaOps.VexLens.Models;
namespace StellaOps.VexHub.Core.Validation;
/// <summary>
/// Validates CSAF VEX documents against the CSAF 2.0 schema.
/// Reference: https://docs.oasis-open.org/csaf/csaf/v2.0/csaf-v2.0.html
/// </summary>
public sealed class CsafVexSchemaValidator : IVexSchemaValidator
{
private readonly ILogger<CsafVexSchemaValidator> _logger;
private static readonly HashSet<string> ValidDocumentCategories = new(StringComparer.OrdinalIgnoreCase)
{
"csaf_vex",
"csaf_security_advisory",
"csaf_security_incident_response",
"csaf_informational_advisory"
};
private static readonly HashSet<string> ValidProductStatuses = new(StringComparer.OrdinalIgnoreCase)
{
"first_affected",
"first_fixed",
"fixed",
"known_affected",
"known_not_affected",
"last_affected",
"recommended",
"under_investigation"
};
public VexSourceFormat SupportedFormat => VexSourceFormat.CsafVex;
public CsafVexSchemaValidator(ILogger<CsafVexSchemaValidator> logger)
{
_logger = logger;
}
public Task<SchemaValidationResult> ValidateAsync(
string content,
CancellationToken cancellationToken = default)
{
var errors = new List<SchemaValidationError>();
var warnings = new List<SchemaValidationWarning>();
string? schemaVersion = null;
try
{
using var doc = JsonDocument.Parse(content);
var root = doc.RootElement;
// Validate document property (required)
if (!root.TryGetProperty("document", out var documentProp))
{
errors.Add(new SchemaValidationError
{
Path = "document",
Message = "Required property 'document' is missing"
});
}
else
{
schemaVersion = ValidateDocument(documentProp, errors, warnings);
}
// Validate product_tree (required for VEX)
if (!root.TryGetProperty("product_tree", out var productTreeProp))
{
errors.Add(new SchemaValidationError
{
Path = "product_tree",
Message = "Required property 'product_tree' is missing for CSAF VEX"
});
}
else
{
ValidateProductTree(productTreeProp, errors, warnings);
}
// Validate vulnerabilities array (required for VEX)
if (!root.TryGetProperty("vulnerabilities", out var vulnsProp))
{
errors.Add(new SchemaValidationError
{
Path = "vulnerabilities",
Message = "Required property 'vulnerabilities' is missing for CSAF VEX"
});
}
else
{
ValidateVulnerabilities(vulnsProp, errors, warnings);
}
}
catch (JsonException ex)
{
errors.Add(new SchemaValidationError
{
Path = "$",
Message = $"Invalid JSON: {ex.Message}"
});
}
var result = errors.Count == 0
? SchemaValidationResult.Success(SupportedFormat, schemaVersion)
: SchemaValidationResult.Failure(SupportedFormat, errors, schemaVersion);
if (warnings.Count > 0)
{
result = result with { Warnings = warnings };
}
return Task.FromResult(result);
}
private static string? ValidateDocument(
JsonElement document,
List<SchemaValidationError> errors,
List<SchemaValidationWarning> warnings)
{
string? schemaVersion = null;
if (document.ValueKind != JsonValueKind.Object)
{
errors.Add(new SchemaValidationError
{
Path = "document",
Message = "Property 'document' must be an object",
Actual = document.ValueKind.ToString()
});
return null;
}
// Validate category (required)
if (!document.TryGetProperty("category", out var categoryProp))
{
errors.Add(new SchemaValidationError
{
Path = "document.category",
Message = "Required property 'category' is missing"
});
}
else
{
var category = categoryProp.GetString();
if (!ValidDocumentCategories.Contains(category ?? ""))
{
warnings.Add(new SchemaValidationWarning
{
Path = "document.category",
Message = $"Unknown document category: {category}"
});
}
}
// Validate csaf_version (required)
if (!document.TryGetProperty("csaf_version", out var versionProp))
{
errors.Add(new SchemaValidationError
{
Path = "document.csaf_version",
Message = "Required property 'csaf_version' is missing"
});
}
else
{
schemaVersion = versionProp.GetString();
if (schemaVersion is not ("2.0" or "2.1"))
{
warnings.Add(new SchemaValidationWarning
{
Path = "document.csaf_version",
Message = $"CSAF version {schemaVersion} may not be fully supported"
});
}
}
// Validate title (required)
if (!document.TryGetProperty("title", out _))
{
errors.Add(new SchemaValidationError
{
Path = "document.title",
Message = "Required property 'title' is missing"
});
}
// Validate publisher (required)
if (!document.TryGetProperty("publisher", out var publisherProp))
{
errors.Add(new SchemaValidationError
{
Path = "document.publisher",
Message = "Required property 'publisher' is missing"
});
}
else
{
ValidatePublisher(publisherProp, errors, warnings);
}
// Validate tracking (required)
if (!document.TryGetProperty("tracking", out var trackingProp))
{
errors.Add(new SchemaValidationError
{
Path = "document.tracking",
Message = "Required property 'tracking' is missing"
});
}
else
{
ValidateTracking(trackingProp, errors, warnings);
}
return schemaVersion;
}
private static void ValidatePublisher(
JsonElement publisher,
List<SchemaValidationError> errors,
List<SchemaValidationWarning> warnings)
{
if (publisher.ValueKind != JsonValueKind.Object)
{
errors.Add(new SchemaValidationError
{
Path = "document.publisher",
Message = "Property 'publisher' must be an object",
Actual = publisher.ValueKind.ToString()
});
return;
}
// Validate category (required)
if (!publisher.TryGetProperty("category", out var categoryProp))
{
errors.Add(new SchemaValidationError
{
Path = "document.publisher.category",
Message = "Required property 'category' is missing"
});
}
else
{
var category = categoryProp.GetString();
var validCategories = new[] { "coordinator", "discoverer", "other", "translator", "user", "vendor" };
if (!validCategories.Contains(category, StringComparer.OrdinalIgnoreCase))
{
errors.Add(new SchemaValidationError
{
Path = "document.publisher.category",
Message = $"Invalid publisher category",
Expected = string.Join(", ", validCategories),
Actual = category
});
}
}
// Validate name (required)
if (!publisher.TryGetProperty("name", out _))
{
errors.Add(new SchemaValidationError
{
Path = "document.publisher.name",
Message = "Required property 'name' is missing"
});
}
// Validate namespace (required)
if (!publisher.TryGetProperty("namespace", out _))
{
errors.Add(new SchemaValidationError
{
Path = "document.publisher.namespace",
Message = "Required property 'namespace' is missing"
});
}
}
private static void ValidateTracking(
JsonElement tracking,
List<SchemaValidationError> errors,
List<SchemaValidationWarning> warnings)
{
if (tracking.ValueKind != JsonValueKind.Object)
{
errors.Add(new SchemaValidationError
{
Path = "document.tracking",
Message = "Property 'tracking' must be an object",
Actual = tracking.ValueKind.ToString()
});
return;
}
// Validate id (required)
if (!tracking.TryGetProperty("id", out _))
{
errors.Add(new SchemaValidationError
{
Path = "document.tracking.id",
Message = "Required property 'id' is missing"
});
}
// Validate current_release_date (required)
if (!tracking.TryGetProperty("current_release_date", out var releaseDateProp))
{
errors.Add(new SchemaValidationError
{
Path = "document.tracking.current_release_date",
Message = "Required property 'current_release_date' is missing"
});
}
else
{
var dateStr = releaseDateProp.GetString();
if (!DateTimeOffset.TryParse(dateStr, out _))
{
errors.Add(new SchemaValidationError
{
Path = "document.tracking.current_release_date",
Message = "Invalid date format",
Expected = "ISO 8601 date-time string",
Actual = dateStr
});
}
}
// Validate initial_release_date (required)
if (!tracking.TryGetProperty("initial_release_date", out _))
{
errors.Add(new SchemaValidationError
{
Path = "document.tracking.initial_release_date",
Message = "Required property 'initial_release_date' is missing"
});
}
// Validate revision_history (required)
if (!tracking.TryGetProperty("revision_history", out var revHistoryProp))
{
errors.Add(new SchemaValidationError
{
Path = "document.tracking.revision_history",
Message = "Required property 'revision_history' is missing"
});
}
else if (revHistoryProp.ValueKind != JsonValueKind.Array || revHistoryProp.GetArrayLength() == 0)
{
errors.Add(new SchemaValidationError
{
Path = "document.tracking.revision_history",
Message = "Property 'revision_history' must be a non-empty array"
});
}
// Validate status (required)
if (!tracking.TryGetProperty("status", out var statusProp))
{
errors.Add(new SchemaValidationError
{
Path = "document.tracking.status",
Message = "Required property 'status' is missing"
});
}
else
{
var status = statusProp.GetString();
var validStatuses = new[] { "draft", "final", "interim" };
if (!validStatuses.Contains(status, StringComparer.OrdinalIgnoreCase))
{
errors.Add(new SchemaValidationError
{
Path = "document.tracking.status",
Message = "Invalid tracking status",
Expected = string.Join(", ", validStatuses),
Actual = status
});
}
}
// Validate version (required)
if (!tracking.TryGetProperty("version", out _))
{
errors.Add(new SchemaValidationError
{
Path = "document.tracking.version",
Message = "Required property 'version' is missing"
});
}
}
private static void ValidateProductTree(
JsonElement productTree,
List<SchemaValidationError> errors,
List<SchemaValidationWarning> warnings)
{
if (productTree.ValueKind != JsonValueKind.Object)
{
errors.Add(new SchemaValidationError
{
Path = "product_tree",
Message = "Property 'product_tree' must be an object",
Actual = productTree.ValueKind.ToString()
});
return;
}
// At least one of branches, full_product_names, or relationships should be present
var hasBranches = productTree.TryGetProperty("branches", out _);
var hasFullProductNames = productTree.TryGetProperty("full_product_names", out _);
var hasRelationships = productTree.TryGetProperty("relationships", out _);
if (!hasBranches && !hasFullProductNames && !hasRelationships)
{
errors.Add(new SchemaValidationError
{
Path = "product_tree",
Message = "Product tree must contain at least one of: branches, full_product_names, or relationships"
});
}
}
private static void ValidateVulnerabilities(
JsonElement vulns,
List<SchemaValidationError> errors,
List<SchemaValidationWarning> warnings)
{
if (vulns.ValueKind != JsonValueKind.Array)
{
errors.Add(new SchemaValidationError
{
Path = "vulnerabilities",
Message = "Property 'vulnerabilities' must be an array",
Actual = vulns.ValueKind.ToString()
});
return;
}
if (vulns.GetArrayLength() == 0)
{
errors.Add(new SchemaValidationError
{
Path = "vulnerabilities",
Message = "Vulnerabilities array cannot be empty for VEX documents"
});
return;
}
var index = 0;
foreach (var vuln in vulns.EnumerateArray())
{
ValidateVulnerability(vuln, $"vulnerabilities[{index}]", errors, warnings);
index++;
}
}
private static void ValidateVulnerability(
JsonElement vuln,
string path,
List<SchemaValidationError> errors,
List<SchemaValidationWarning> warnings)
{
if (vuln.ValueKind != JsonValueKind.Object)
{
errors.Add(new SchemaValidationError
{
Path = path,
Message = "Vulnerability must be an object",
Actual = vuln.ValueKind.ToString()
});
return;
}
// Validate CVE if present
if (vuln.TryGetProperty("cve", out var cveProp))
{
var cve = cveProp.GetString();
if (string.IsNullOrWhiteSpace(cve) || !cve.StartsWith("CVE-", StringComparison.OrdinalIgnoreCase))
{
warnings.Add(new SchemaValidationWarning
{
Path = $"{path}.cve",
Message = "CVE identifier should follow the CVE-YYYY-NNNNN format"
});
}
}
// Validate product_status (required for VEX)
if (!vuln.TryGetProperty("product_status", out var productStatusProp))
{
errors.Add(new SchemaValidationError
{
Path = $"{path}.product_status",
Message = "Required property 'product_status' is missing for VEX vulnerability"
});
}
else
{
ValidateProductStatus(productStatusProp, $"{path}.product_status", errors, warnings);
}
}
private static void ValidateProductStatus(
JsonElement productStatus,
string path,
List<SchemaValidationError> errors,
List<SchemaValidationWarning> warnings)
{
if (productStatus.ValueKind != JsonValueKind.Object)
{
errors.Add(new SchemaValidationError
{
Path = path,
Message = "Property 'product_status' must be an object",
Actual = productStatus.ValueKind.ToString()
});
return;
}
// Check that at least one status category exists
var hasAnyStatus = false;
foreach (var status in ValidProductStatuses)
{
if (productStatus.TryGetProperty(status, out _))
{
hasAnyStatus = true;
break;
}
}
if (!hasAnyStatus)
{
errors.Add(new SchemaValidationError
{
Path = path,
Message = "Product status must contain at least one status category",
Expected = string.Join(", ", ValidProductStatuses)
});
}
// Validate each status category if present
foreach (var prop in productStatus.EnumerateObject())
{
if (!ValidProductStatuses.Contains(prop.Name))
{
warnings.Add(new SchemaValidationWarning
{
Path = $"{path}.{prop.Name}",
Message = $"Unknown product status category: {prop.Name}"
});
continue;
}
if (prop.Value.ValueKind != JsonValueKind.Array)
{
errors.Add(new SchemaValidationError
{
Path = $"{path}.{prop.Name}",
Message = "Product status category must be an array of product IDs",
Actual = prop.Value.ValueKind.ToString()
});
}
}
}
}

View File

@@ -0,0 +1,708 @@
using System.Text.Json;
using Microsoft.Extensions.Logging;
using StellaOps.VexHub.Core.Models;
using StellaOps.VexLens.Models;
namespace StellaOps.VexHub.Core.Validation;
/// <summary>
/// Validates CycloneDX VEX documents against the CycloneDX schema.
/// Reference: https://cyclonedx.org/docs/latest/
/// </summary>
public sealed class CycloneDxVexSchemaValidator : IVexSchemaValidator
{
private readonly ILogger<CycloneDxVexSchemaValidator> _logger;
private static readonly HashSet<string> ValidStates = new(StringComparer.OrdinalIgnoreCase)
{
"resolved",
"resolved_with_pedigree",
"exploitable",
"in_triage",
"not_affected",
"false_positive"
};
private static readonly HashSet<string> ValidJustifications = new(StringComparer.OrdinalIgnoreCase)
{
"code_not_present",
"code_not_reachable",
"requires_configuration",
"requires_dependency",
"requires_environment",
"protected_by_compiler",
"protected_at_runtime",
"protected_at_perimeter",
"protected_by_mitigating_control"
};
private static readonly HashSet<string> ValidBomFormats = new(StringComparer.OrdinalIgnoreCase)
{
"CycloneDX"
};
public VexSourceFormat SupportedFormat => VexSourceFormat.CycloneDxVex;
public CycloneDxVexSchemaValidator(ILogger<CycloneDxVexSchemaValidator> logger)
{
_logger = logger;
}
public Task<SchemaValidationResult> ValidateAsync(
string content,
CancellationToken cancellationToken = default)
{
var errors = new List<SchemaValidationError>();
var warnings = new List<SchemaValidationWarning>();
string? schemaVersion = null;
try
{
using var doc = JsonDocument.Parse(content);
var root = doc.RootElement;
// Validate bomFormat (required)
if (!root.TryGetProperty("bomFormat", out var bomFormatProp))
{
errors.Add(new SchemaValidationError
{
Path = "bomFormat",
Message = "Required property 'bomFormat' is missing"
});
}
else
{
var bomFormat = bomFormatProp.GetString();
if (!ValidBomFormats.Contains(bomFormat ?? ""))
{
errors.Add(new SchemaValidationError
{
Path = "bomFormat",
Message = "Invalid BOM format",
Expected = string.Join(", ", ValidBomFormats),
Actual = bomFormat
});
}
}
// Validate specVersion (required)
if (!root.TryGetProperty("specVersion", out var specVersionProp))
{
errors.Add(new SchemaValidationError
{
Path = "specVersion",
Message = "Required property 'specVersion' is missing"
});
}
else
{
schemaVersion = specVersionProp.GetString();
if (schemaVersion is not ("1.4" or "1.5" or "1.6"))
{
warnings.Add(new SchemaValidationWarning
{
Path = "specVersion",
Message = $"CycloneDX version {schemaVersion} may not be fully supported. Recommended: 1.4, 1.5, or 1.6"
});
}
}
// Validate version (required)
if (!root.TryGetProperty("version", out var versionProp))
{
errors.Add(new SchemaValidationError
{
Path = "version",
Message = "Required property 'version' is missing"
});
}
else if (versionProp.ValueKind != JsonValueKind.Number)
{
errors.Add(new SchemaValidationError
{
Path = "version",
Message = "Property 'version' must be a number",
Actual = versionProp.ValueKind.ToString()
});
}
// Validate serialNumber (optional but recommended)
if (!root.TryGetProperty("serialNumber", out _))
{
warnings.Add(new SchemaValidationWarning
{
Path = "serialNumber",
Message = "Property 'serialNumber' is recommended for document identification"
});
}
// Validate metadata (optional but recommended)
if (root.TryGetProperty("metadata", out var metadataProp))
{
ValidateMetadata(metadataProp, errors, warnings);
}
// Validate vulnerabilities array (required for VEX)
if (!root.TryGetProperty("vulnerabilities", out var vulnsProp))
{
errors.Add(new SchemaValidationError
{
Path = "vulnerabilities",
Message = "Required property 'vulnerabilities' is missing for VEX document"
});
}
else
{
ValidateVulnerabilities(vulnsProp, errors, warnings);
}
// Check for components (optional but often present)
if (root.TryGetProperty("components", out var componentsProp))
{
ValidateComponents(componentsProp, errors, warnings);
}
}
catch (JsonException ex)
{
errors.Add(new SchemaValidationError
{
Path = "$",
Message = $"Invalid JSON: {ex.Message}"
});
}
var result = errors.Count == 0
? SchemaValidationResult.Success(SupportedFormat, schemaVersion)
: SchemaValidationResult.Failure(SupportedFormat, errors, schemaVersion);
if (warnings.Count > 0)
{
result = result with { Warnings = warnings };
}
return Task.FromResult(result);
}
private static void ValidateMetadata(
JsonElement metadata,
List<SchemaValidationError> errors,
List<SchemaValidationWarning> warnings)
{
if (metadata.ValueKind != JsonValueKind.Object)
{
errors.Add(new SchemaValidationError
{
Path = "metadata",
Message = "Property 'metadata' must be an object",
Actual = metadata.ValueKind.ToString()
});
return;
}
// Validate timestamp (optional but recommended)
if (metadata.TryGetProperty("timestamp", out var timestampProp))
{
var timestamp = timestampProp.GetString();
if (!DateTimeOffset.TryParse(timestamp, out _))
{
errors.Add(new SchemaValidationError
{
Path = "metadata.timestamp",
Message = "Invalid timestamp format",
Expected = "ISO 8601 date-time string",
Actual = timestamp
});
}
}
else
{
warnings.Add(new SchemaValidationWarning
{
Path = "metadata.timestamp",
Message = "Property 'timestamp' is recommended in metadata"
});
}
// Validate tools (optional)
if (metadata.TryGetProperty("tools", out var toolsProp))
{
ValidateTools(toolsProp, errors, warnings);
}
// Validate supplier (optional for VEX)
if (metadata.TryGetProperty("supplier", out var supplierProp))
{
ValidateSupplier(supplierProp, "metadata.supplier", errors);
}
}
private static void ValidateTools(
JsonElement tools,
List<SchemaValidationError> errors,
List<SchemaValidationWarning> warnings)
{
// Tools can be an array or an object with components/services
if (tools.ValueKind == JsonValueKind.Array)
{
// Legacy array format
var index = 0;
foreach (var tool in tools.EnumerateArray())
{
if (tool.ValueKind != JsonValueKind.Object)
{
errors.Add(new SchemaValidationError
{
Path = $"metadata.tools[{index}]",
Message = "Tool must be an object",
Actual = tool.ValueKind.ToString()
});
}
index++;
}
}
else if (tools.ValueKind == JsonValueKind.Object)
{
// New object format (1.5+)
if (tools.TryGetProperty("components", out var componentsProp) &&
componentsProp.ValueKind != JsonValueKind.Array)
{
errors.Add(new SchemaValidationError
{
Path = "metadata.tools.components",
Message = "Property 'components' must be an array",
Actual = componentsProp.ValueKind.ToString()
});
}
}
else
{
errors.Add(new SchemaValidationError
{
Path = "metadata.tools",
Message = "Property 'tools' must be an array or object",
Actual = tools.ValueKind.ToString()
});
}
}
private static void ValidateSupplier(
JsonElement supplier,
string path,
List<SchemaValidationError> errors)
{
if (supplier.ValueKind != JsonValueKind.Object)
{
errors.Add(new SchemaValidationError
{
Path = path,
Message = "Supplier must be an object",
Actual = supplier.ValueKind.ToString()
});
return;
}
// Name is the primary identifier
if (!supplier.TryGetProperty("name", out _) && !supplier.TryGetProperty("url", out _))
{
errors.Add(new SchemaValidationError
{
Path = path,
Message = "Supplier must have at least 'name' or 'url'"
});
}
}
private static void ValidateComponents(
JsonElement components,
List<SchemaValidationError> errors,
List<SchemaValidationWarning> warnings)
{
if (components.ValueKind != JsonValueKind.Array)
{
errors.Add(new SchemaValidationError
{
Path = "components",
Message = "Property 'components' must be an array",
Actual = components.ValueKind.ToString()
});
return;
}
var index = 0;
foreach (var component in components.EnumerateArray())
{
ValidateComponent(component, $"components[{index}]", errors, warnings);
index++;
}
}
private static void ValidateComponent(
JsonElement component,
string path,
List<SchemaValidationError> errors,
List<SchemaValidationWarning> warnings)
{
if (component.ValueKind != JsonValueKind.Object)
{
errors.Add(new SchemaValidationError
{
Path = path,
Message = "Component must be an object",
Actual = component.ValueKind.ToString()
});
return;
}
// Validate type (required)
if (!component.TryGetProperty("type", out _))
{
errors.Add(new SchemaValidationError
{
Path = $"{path}.type",
Message = "Required property 'type' is missing"
});
}
// Validate name (required)
if (!component.TryGetProperty("name", out _))
{
errors.Add(new SchemaValidationError
{
Path = $"{path}.name",
Message = "Required property 'name' is missing"
});
}
// bom-ref is recommended for VEX references
if (!component.TryGetProperty("bom-ref", out _))
{
warnings.Add(new SchemaValidationWarning
{
Path = $"{path}.bom-ref",
Message = "Property 'bom-ref' is recommended for vulnerability analysis references"
});
}
// purl is recommended
if (!component.TryGetProperty("purl", out _))
{
warnings.Add(new SchemaValidationWarning
{
Path = $"{path}.purl",
Message = "Property 'purl' is recommended for package identification"
});
}
}
private void ValidateVulnerabilities(
JsonElement vulns,
List<SchemaValidationError> errors,
List<SchemaValidationWarning> warnings)
{
if (vulns.ValueKind != JsonValueKind.Array)
{
errors.Add(new SchemaValidationError
{
Path = "vulnerabilities",
Message = "Property 'vulnerabilities' must be an array",
Actual = vulns.ValueKind.ToString()
});
return;
}
if (vulns.GetArrayLength() == 0)
{
errors.Add(new SchemaValidationError
{
Path = "vulnerabilities",
Message = "Vulnerabilities array cannot be empty for VEX documents"
});
return;
}
var index = 0;
foreach (var vuln in vulns.EnumerateArray())
{
ValidateVulnerability(vuln, $"vulnerabilities[{index}]", errors, warnings);
index++;
}
}
private void ValidateVulnerability(
JsonElement vuln,
string path,
List<SchemaValidationError> errors,
List<SchemaValidationWarning> warnings)
{
if (vuln.ValueKind != JsonValueKind.Object)
{
errors.Add(new SchemaValidationError
{
Path = path,
Message = "Vulnerability must be an object",
Actual = vuln.ValueKind.ToString()
});
return;
}
// Validate id (required)
if (!vuln.TryGetProperty("id", out var idProp))
{
errors.Add(new SchemaValidationError
{
Path = $"{path}.id",
Message = "Required property 'id' is missing"
});
}
else
{
var id = idProp.GetString();
if (string.IsNullOrWhiteSpace(id))
{
errors.Add(new SchemaValidationError
{
Path = $"{path}.id",
Message = "Vulnerability ID cannot be empty"
});
}
}
// bom-ref is recommended
if (!vuln.TryGetProperty("bom-ref", out _))
{
warnings.Add(new SchemaValidationWarning
{
Path = $"{path}.bom-ref",
Message = "Property 'bom-ref' is recommended for referencing"
});
}
// Validate source (optional but recommended)
if (vuln.TryGetProperty("source", out var sourceProp))
{
ValidateVulnerabilitySource(sourceProp, $"{path}.source", errors);
}
// Validate affects (required for VEX - links to components)
if (!vuln.TryGetProperty("affects", out var affectsProp))
{
errors.Add(new SchemaValidationError
{
Path = $"{path}.affects",
Message = "Required property 'affects' is missing for VEX vulnerability"
});
}
else
{
ValidateAffects(affectsProp, $"{path}.affects", errors, warnings);
}
// Validate analysis (contains VEX state)
if (vuln.TryGetProperty("analysis", out var analysisProp))
{
ValidateAnalysis(analysisProp, $"{path}.analysis", errors, warnings);
}
else
{
warnings.Add(new SchemaValidationWarning
{
Path = $"{path}.analysis",
Message = "Property 'analysis' is recommended for VEX documents"
});
}
}
private static void ValidateVulnerabilitySource(
JsonElement source,
string path,
List<SchemaValidationError> errors)
{
if (source.ValueKind != JsonValueKind.Object)
{
errors.Add(new SchemaValidationError
{
Path = path,
Message = "Source must be an object",
Actual = source.ValueKind.ToString()
});
return;
}
// name is recommended
if (!source.TryGetProperty("name", out _) && !source.TryGetProperty("url", out _))
{
errors.Add(new SchemaValidationError
{
Path = path,
Message = "Source must have at least 'name' or 'url'"
});
}
}
private static void ValidateAffects(
JsonElement affects,
string path,
List<SchemaValidationError> errors,
List<SchemaValidationWarning> warnings)
{
if (affects.ValueKind != JsonValueKind.Array)
{
errors.Add(new SchemaValidationError
{
Path = path,
Message = "Property 'affects' must be an array",
Actual = affects.ValueKind.ToString()
});
return;
}
if (affects.GetArrayLength() == 0)
{
errors.Add(new SchemaValidationError
{
Path = path,
Message = "Property 'affects' cannot be empty"
});
return;
}
var index = 0;
foreach (var affect in affects.EnumerateArray())
{
var affectPath = $"{path}[{index}]";
if (affect.ValueKind != JsonValueKind.Object)
{
errors.Add(new SchemaValidationError
{
Path = affectPath,
Message = "Affect entry must be an object",
Actual = affect.ValueKind.ToString()
});
index++;
continue;
}
// ref is required - references a component bom-ref
if (!affect.TryGetProperty("ref", out _))
{
errors.Add(new SchemaValidationError
{
Path = $"{affectPath}.ref",
Message = "Required property 'ref' is missing"
});
}
// versions is optional but provides detail
if (affect.TryGetProperty("versions", out var versionsProp))
{
if (versionsProp.ValueKind != JsonValueKind.Array)
{
errors.Add(new SchemaValidationError
{
Path = $"{affectPath}.versions",
Message = "Property 'versions' must be an array",
Actual = versionsProp.ValueKind.ToString()
});
}
}
index++;
}
}
private void ValidateAnalysis(
JsonElement analysis,
string path,
List<SchemaValidationError> errors,
List<SchemaValidationWarning> warnings)
{
if (analysis.ValueKind != JsonValueKind.Object)
{
errors.Add(new SchemaValidationError
{
Path = path,
Message = "Property 'analysis' must be an object",
Actual = analysis.ValueKind.ToString()
});
return;
}
// Validate state (the core VEX status)
if (analysis.TryGetProperty("state", out var stateProp))
{
var state = stateProp.GetString();
if (state is null || !ValidStates.Contains(state))
{
errors.Add(new SchemaValidationError
{
Path = $"{path}.state",
Message = "Invalid analysis state",
Expected = string.Join(", ", ValidStates),
Actual = state
});
}
// If not_affected, justification is recommended
if (state == "not_affected")
{
if (!analysis.TryGetProperty("justification", out var justProp))
{
warnings.Add(new SchemaValidationWarning
{
Path = $"{path}.justification",
Message = "Property 'justification' is recommended when state is 'not_affected'"
});
}
else
{
var justification = justProp.GetString();
if (justification is not null && !ValidJustifications.Contains(justification))
{
warnings.Add(new SchemaValidationWarning
{
Path = $"{path}.justification",
Message = $"Unknown justification: {justification}"
});
}
}
}
}
else
{
errors.Add(new SchemaValidationError
{
Path = $"{path}.state",
Message = "Required property 'state' is missing for VEX analysis"
});
}
// Validate response (optional action items)
if (analysis.TryGetProperty("response", out var responseProp))
{
if (responseProp.ValueKind != JsonValueKind.Array)
{
errors.Add(new SchemaValidationError
{
Path = $"{path}.response",
Message = "Property 'response' must be an array",
Actual = responseProp.ValueKind.ToString()
});
}
}
// Validate detail (optional explanation)
if (analysis.TryGetProperty("detail", out var detailProp))
{
if (detailProp.ValueKind != JsonValueKind.String)
{
errors.Add(new SchemaValidationError
{
Path = $"{path}.detail",
Message = "Property 'detail' must be a string",
Actual = detailProp.ValueKind.ToString()
});
}
}
}
}

View File

@@ -0,0 +1,179 @@
using StellaOps.VexHub.Core.Models;
namespace StellaOps.VexHub.Core.Validation;
/// <summary>
/// Service for flagging untrusted or unverified VEX statements.
/// </summary>
public interface IStatementFlaggingService
{
/// <summary>
/// Evaluates a statement and returns whether it should be flagged.
/// </summary>
/// <param name="statement">The statement to evaluate.</param>
/// <param name="source">The source of the statement.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The flagging result.</returns>
Task<FlaggingResult> EvaluateAsync(
AggregatedVexStatement statement,
VexSource? source,
CancellationToken cancellationToken = default);
/// <summary>
/// Batch evaluates multiple statements.
/// </summary>
/// <param name="statements">The statements to evaluate.</param>
/// <param name="source">The source of the statements.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The flagging results keyed by statement ID.</returns>
Task<IReadOnlyDictionary<Guid, FlaggingResult>> EvaluateBatchAsync(
IEnumerable<AggregatedVexStatement> statements,
VexSource? source,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Result of statement flagging evaluation.
/// </summary>
public sealed record FlaggingResult
{
/// <summary>
/// Whether the statement should be flagged.
/// </summary>
public required bool ShouldFlag { get; init; }
/// <summary>
/// The reason for flagging, if flagged.
/// </summary>
public string? Reason { get; init; }
/// <summary>
/// The severity of the flag.
/// </summary>
public FlagSeverity Severity { get; init; }
/// <summary>
/// List of issues detected during evaluation.
/// </summary>
public IReadOnlyList<FlaggingIssue>? Issues { get; init; }
/// <summary>
/// Creates a result indicating no flag needed.
/// </summary>
public static FlaggingResult NoFlag() => new()
{
ShouldFlag = false
};
/// <summary>
/// Creates a result indicating the statement should be flagged.
/// </summary>
public static FlaggingResult Flag(
string reason,
FlagSeverity severity,
IReadOnlyList<FlaggingIssue>? issues = null) => new()
{
ShouldFlag = true,
Reason = reason,
Severity = severity,
Issues = issues
};
}
/// <summary>
/// Severity level of a flag.
/// </summary>
public enum FlagSeverity
{
/// <summary>
/// Low severity - advisory only.
/// </summary>
Low,
/// <summary>
/// Medium severity - should be reviewed.
/// </summary>
Medium,
/// <summary>
/// High severity - requires attention.
/// </summary>
High,
/// <summary>
/// Critical severity - should not be trusted.
/// </summary>
Critical
}
/// <summary>
/// An individual issue detected during flagging evaluation.
/// </summary>
public sealed record FlaggingIssue
{
/// <summary>
/// The type of issue.
/// </summary>
public required FlaggingIssueType Type { get; init; }
/// <summary>
/// Description of the issue.
/// </summary>
public required string Description { get; init; }
}
/// <summary>
/// Types of flagging issues.
/// </summary>
public enum FlaggingIssueType
{
/// <summary>
/// Signature verification failed.
/// </summary>
SignatureVerificationFailed,
/// <summary>
/// No signature present when expected.
/// </summary>
SignatureMissing,
/// <summary>
/// Signing key is not trusted.
/// </summary>
UntrustedSigningKey,
/// <summary>
/// Source is not trusted.
/// </summary>
UntrustedSource,
/// <summary>
/// Source trust tier is too low.
/// </summary>
LowTrustTier,
/// <summary>
/// Schema validation failed.
/// </summary>
SchemaValidationFailed,
/// <summary>
/// Statement conflicts with higher-trust sources.
/// </summary>
ConflictWithHigherTrust,
/// <summary>
/// Statement is missing required fields.
/// </summary>
MissingRequiredFields,
/// <summary>
/// Statement data is stale.
/// </summary>
StaleData,
/// <summary>
/// Other issue.
/// </summary>
Other
}

View File

@@ -0,0 +1,122 @@
using StellaOps.VexHub.Core.Models;
using StellaOps.VexLens.Models;
namespace StellaOps.VexHub.Core.Validation;
/// <summary>
/// Interface for validating VEX document schemas.
/// </summary>
public interface IVexSchemaValidator
{
/// <summary>
/// Gets the format this validator supports.
/// </summary>
VexSourceFormat SupportedFormat { get; }
/// <summary>
/// Validates a VEX document against the schema for this format.
/// </summary>
/// <param name="content">The document content to validate.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The validation result.</returns>
Task<SchemaValidationResult> ValidateAsync(
string content,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Result of schema validation.
/// </summary>
public sealed record SchemaValidationResult
{
/// <summary>
/// Whether the document is valid according to the schema.
/// </summary>
public required bool IsValid { get; init; }
/// <summary>
/// The format that was validated.
/// </summary>
public required VexSourceFormat Format { get; init; }
/// <summary>
/// Schema version detected in the document.
/// </summary>
public string? SchemaVersion { get; init; }
/// <summary>
/// List of validation errors if any.
/// </summary>
public IReadOnlyList<SchemaValidationError>? Errors { get; init; }
/// <summary>
/// List of validation warnings if any.
/// </summary>
public IReadOnlyList<SchemaValidationWarning>? Warnings { get; init; }
/// <summary>
/// Creates a successful validation result.
/// </summary>
public static SchemaValidationResult Success(VexSourceFormat format, string? schemaVersion = null) => new()
{
IsValid = true,
Format = format,
SchemaVersion = schemaVersion
};
/// <summary>
/// Creates a failed validation result.
/// </summary>
public static SchemaValidationResult Failure(
VexSourceFormat format,
IReadOnlyList<SchemaValidationError> errors,
string? schemaVersion = null) => new()
{
IsValid = false,
Format = format,
SchemaVersion = schemaVersion,
Errors = errors
};
}
/// <summary>
/// Represents a schema validation error.
/// </summary>
public sealed record SchemaValidationError
{
/// <summary>
/// The JSON path or location of the error.
/// </summary>
public required string Path { get; init; }
/// <summary>
/// The error message.
/// </summary>
public required string Message { get; init; }
/// <summary>
/// The expected value or type if applicable.
/// </summary>
public string? Expected { get; init; }
/// <summary>
/// The actual value or type found.
/// </summary>
public string? Actual { get; init; }
}
/// <summary>
/// Represents a schema validation warning.
/// </summary>
public sealed record SchemaValidationWarning
{
/// <summary>
/// The JSON path or location of the warning.
/// </summary>
public required string Path { get; init; }
/// <summary>
/// The warning message.
/// </summary>
public required string Message { get; init; }
}

View File

@@ -0,0 +1,28 @@
using StellaOps.VexHub.Core.Models;
namespace StellaOps.VexHub.Core.Validation;
/// <summary>
/// Interface for verifying VEX document signatures.
/// </summary>
public interface IVexSignatureVerifier
{
/// <summary>
/// Verifies the signature of a VEX document.
/// </summary>
Task<SignatureVerificationResult> VerifyAsync(
string content,
string expectedKeyFingerprint,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Result of signature verification.
/// </summary>
public sealed record SignatureVerificationResult
{
public required VerificationStatus Status { get; init; }
public DateTimeOffset? VerifiedAt { get; init; }
public string? KeyFingerprint { get; init; }
public string? ErrorMessage { get; init; }
}

View File

@@ -0,0 +1,490 @@
using System.Text.Json;
using Microsoft.Extensions.Logging;
using StellaOps.VexHub.Core.Models;
using StellaOps.VexLens.Models;
namespace StellaOps.VexHub.Core.Validation;
/// <summary>
/// Validates OpenVEX documents against the OpenVEX schema.
/// Reference: https://openvex.dev/spec/
/// </summary>
public sealed class OpenVexSchemaValidator : IVexSchemaValidator
{
private readonly ILogger<OpenVexSchemaValidator> _logger;
private static readonly HashSet<string> ValidStatuses = new(StringComparer.OrdinalIgnoreCase)
{
"not_affected",
"affected",
"fixed",
"under_investigation"
};
private static readonly HashSet<string> ValidJustifications = new(StringComparer.OrdinalIgnoreCase)
{
"component_not_present",
"vulnerable_code_not_present",
"vulnerable_code_not_in_execute_path",
"vulnerable_code_cannot_be_controlled_by_adversary",
"inline_mitigations_already_exist"
};
public VexSourceFormat SupportedFormat => VexSourceFormat.OpenVex;
public OpenVexSchemaValidator(ILogger<OpenVexSchemaValidator> logger)
{
_logger = logger;
}
public Task<SchemaValidationResult> ValidateAsync(
string content,
CancellationToken cancellationToken = default)
{
var errors = new List<SchemaValidationError>();
var warnings = new List<SchemaValidationWarning>();
string? schemaVersion = null;
try
{
using var doc = JsonDocument.Parse(content);
var root = doc.RootElement;
// Validate @context
if (!root.TryGetProperty("@context", out var contextProp))
{
errors.Add(new SchemaValidationError
{
Path = "@context",
Message = "Required property '@context' is missing",
Expected = "https://openvex.dev/ns or array containing it"
});
}
else
{
var context = contextProp.ValueKind == JsonValueKind.String
? contextProp.GetString()
: contextProp.ValueKind == JsonValueKind.Array && contextProp.GetArrayLength() > 0
? contextProp[0].GetString()
: null;
if (context is null || !context.Contains("openvex.dev"))
{
warnings.Add(new SchemaValidationWarning
{
Path = "@context",
Message = "Expected OpenVEX context URI"
});
}
schemaVersion = context;
}
// Validate @id
if (!root.TryGetProperty("@id", out _))
{
errors.Add(new SchemaValidationError
{
Path = "@id",
Message = "Required property '@id' is missing",
Expected = "Unique document identifier"
});
}
// Validate author
if (root.TryGetProperty("author", out var authorProp))
{
ValidateAuthor(authorProp, errors, warnings);
}
else
{
errors.Add(new SchemaValidationError
{
Path = "author",
Message = "Required property 'author' is missing"
});
}
// Validate timestamp
if (root.TryGetProperty("timestamp", out var timestampProp))
{
var timestamp = timestampProp.GetString();
if (!DateTimeOffset.TryParse(timestamp, out _))
{
errors.Add(new SchemaValidationError
{
Path = "timestamp",
Message = "Invalid timestamp format",
Expected = "ISO 8601 date-time string",
Actual = timestamp
});
}
}
else
{
errors.Add(new SchemaValidationError
{
Path = "timestamp",
Message = "Required property 'timestamp' is missing"
});
}
// Validate version
if (root.TryGetProperty("version", out var versionProp))
{
if (versionProp.ValueKind != JsonValueKind.Number)
{
errors.Add(new SchemaValidationError
{
Path = "version",
Message = "Property 'version' must be a number",
Actual = versionProp.ValueKind.ToString()
});
}
}
else
{
errors.Add(new SchemaValidationError
{
Path = "version",
Message = "Required property 'version' is missing"
});
}
// Validate statements array
if (root.TryGetProperty("statements", out var statementsProp))
{
if (statementsProp.ValueKind != JsonValueKind.Array)
{
errors.Add(new SchemaValidationError
{
Path = "statements",
Message = "Property 'statements' must be an array",
Actual = statementsProp.ValueKind.ToString()
});
}
else
{
var index = 0;
foreach (var statement in statementsProp.EnumerateArray())
{
ValidateStatement(statement, $"statements[{index}]", errors, warnings);
index++;
}
}
}
else
{
errors.Add(new SchemaValidationError
{
Path = "statements",
Message = "Required property 'statements' is missing"
});
}
}
catch (JsonException ex)
{
errors.Add(new SchemaValidationError
{
Path = "$",
Message = $"Invalid JSON: {ex.Message}"
});
}
var result = errors.Count == 0
? SchemaValidationResult.Success(SupportedFormat, schemaVersion)
: SchemaValidationResult.Failure(SupportedFormat, errors, schemaVersion);
if (warnings.Count > 0)
{
result = result with { Warnings = warnings };
}
return Task.FromResult(result);
}
private static void ValidateAuthor(
JsonElement author,
List<SchemaValidationError> errors,
List<SchemaValidationWarning> warnings)
{
if (author.ValueKind != JsonValueKind.Object)
{
errors.Add(new SchemaValidationError
{
Path = "author",
Message = "Property 'author' must be an object",
Actual = author.ValueKind.ToString()
});
return;
}
// Validate author @id (optional but recommended)
if (!author.TryGetProperty("@id", out _))
{
warnings.Add(new SchemaValidationWarning
{
Path = "author.@id",
Message = "Author should have an @id property for identification"
});
}
// Validate role if present
if (author.TryGetProperty("role", out var roleProp))
{
var role = roleProp.GetString();
if (role is not ("vendor" or "discoverer" or "coordinator" or "user" or "other"))
{
warnings.Add(new SchemaValidationWarning
{
Path = "author.role",
Message = $"Unknown author role: {role}"
});
}
}
}
private void ValidateStatement(
JsonElement statement,
string path,
List<SchemaValidationError> errors,
List<SchemaValidationWarning> warnings)
{
if (statement.ValueKind != JsonValueKind.Object)
{
errors.Add(new SchemaValidationError
{
Path = path,
Message = "Statement must be an object",
Actual = statement.ValueKind.ToString()
});
return;
}
// Validate vulnerability (required)
if (!statement.TryGetProperty("vulnerability", out var vulnProp))
{
errors.Add(new SchemaValidationError
{
Path = $"{path}.vulnerability",
Message = "Required property 'vulnerability' is missing"
});
}
else
{
ValidateVulnerability(vulnProp, $"{path}.vulnerability", errors);
}
// Validate products (required)
if (!statement.TryGetProperty("products", out var productsProp))
{
errors.Add(new SchemaValidationError
{
Path = $"{path}.products",
Message = "Required property 'products' is missing"
});
}
else
{
ValidateProducts(productsProp, $"{path}.products", errors, warnings);
}
// Validate status (required)
if (!statement.TryGetProperty("status", out var statusProp))
{
errors.Add(new SchemaValidationError
{
Path = $"{path}.status",
Message = "Required property 'status' is missing"
});
}
else
{
var status = statusProp.GetString();
if (status is null || !ValidStatuses.Contains(status))
{
errors.Add(new SchemaValidationError
{
Path = $"{path}.status",
Message = $"Invalid status value",
Expected = string.Join(", ", ValidStatuses),
Actual = status
});
}
// If not_affected, justification is required
if (status == "not_affected")
{
if (!statement.TryGetProperty("justification", out var justProp))
{
errors.Add(new SchemaValidationError
{
Path = $"{path}.justification",
Message = "Property 'justification' is required when status is 'not_affected'"
});
}
else
{
var justification = justProp.GetString();
if (justification is null || !ValidJustifications.Contains(justification))
{
errors.Add(new SchemaValidationError
{
Path = $"{path}.justification",
Message = "Invalid justification value",
Expected = string.Join(", ", ValidJustifications),
Actual = justification
});
}
}
}
// If affected, action_statement is recommended
if (status == "affected")
{
if (!statement.TryGetProperty("action_statement", out _))
{
warnings.Add(new SchemaValidationWarning
{
Path = $"{path}.action_statement",
Message = "Property 'action_statement' is recommended when status is 'affected'"
});
}
}
}
// Validate timestamp if present
if (statement.TryGetProperty("timestamp", out var tsProp))
{
var timestamp = tsProp.GetString();
if (!DateTimeOffset.TryParse(timestamp, out _))
{
errors.Add(new SchemaValidationError
{
Path = $"{path}.timestamp",
Message = "Invalid timestamp format",
Expected = "ISO 8601 date-time string",
Actual = timestamp
});
}
}
}
private static void ValidateVulnerability(
JsonElement vuln,
string path,
List<SchemaValidationError> errors)
{
if (vuln.ValueKind == JsonValueKind.String)
{
// Simple string reference (CVE ID)
var vulnId = vuln.GetString();
if (string.IsNullOrWhiteSpace(vulnId))
{
errors.Add(new SchemaValidationError
{
Path = path,
Message = "Vulnerability identifier cannot be empty"
});
}
}
else if (vuln.ValueKind == JsonValueKind.Object)
{
// Object with @id and optional properties
if (!vuln.TryGetProperty("@id", out var idProp) && !vuln.TryGetProperty("name", out _))
{
errors.Add(new SchemaValidationError
{
Path = path,
Message = "Vulnerability object must have '@id' or 'name' property"
});
}
}
else
{
errors.Add(new SchemaValidationError
{
Path = path,
Message = "Vulnerability must be a string or object",
Actual = vuln.ValueKind.ToString()
});
}
}
private static void ValidateProducts(
JsonElement products,
string path,
List<SchemaValidationError> errors,
List<SchemaValidationWarning> warnings)
{
if (products.ValueKind != JsonValueKind.Array)
{
errors.Add(new SchemaValidationError
{
Path = path,
Message = "Products must be an array",
Actual = products.ValueKind.ToString()
});
return;
}
if (products.GetArrayLength() == 0)
{
errors.Add(new SchemaValidationError
{
Path = path,
Message = "Products array cannot be empty"
});
return;
}
var index = 0;
foreach (var product in products.EnumerateArray())
{
var productPath = $"{path}[{index}]";
if (product.ValueKind == JsonValueKind.String)
{
var productId = product.GetString();
if (string.IsNullOrWhiteSpace(productId))
{
errors.Add(new SchemaValidationError
{
Path = productPath,
Message = "Product identifier cannot be empty"
});
}
else if (!productId.StartsWith("pkg:") && !productId.StartsWith("cpe:"))
{
warnings.Add(new SchemaValidationWarning
{
Path = productPath,
Message = "Product identifier should be a PURL (pkg:) or CPE (cpe:)"
});
}
}
else if (product.ValueKind == JsonValueKind.Object)
{
if (!product.TryGetProperty("@id", out _))
{
errors.Add(new SchemaValidationError
{
Path = productPath,
Message = "Product object must have '@id' property"
});
}
}
else
{
errors.Add(new SchemaValidationError
{
Path = productPath,
Message = "Product must be a string or object",
Actual = product.ValueKind.ToString()
});
}
index++;
}
}
}

View File

@@ -0,0 +1,272 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.VexHub.Core.Models;
using StellaOps.VexLens.Models;
namespace StellaOps.VexHub.Core.Validation;
/// <summary>
/// Default implementation of the statement flagging service.
/// </summary>
public sealed class StatementFlaggingService : IStatementFlaggingService
{
private readonly ILogger<StatementFlaggingService> _logger;
private readonly VexHubOptions _options;
public StatementFlaggingService(
IOptions<VexHubOptions> options,
ILogger<StatementFlaggingService> logger)
{
_options = options.Value;
_logger = logger;
}
public Task<FlaggingResult> EvaluateAsync(
AggregatedVexStatement statement,
VexSource? source,
CancellationToken cancellationToken = default)
{
var issues = new List<FlaggingIssue>();
// Check verification status
EvaluateVerificationStatus(statement, issues);
// Check source trust
EvaluateSourceTrust(source, issues);
// Check for missing required fields
EvaluateMissingFields(statement, issues);
// Check for stale data
EvaluateStaleness(statement, issues);
if (issues.Count == 0)
{
return Task.FromResult(FlaggingResult.NoFlag());
}
// Determine overall severity
var severity = DetermineOverallSeverity(issues);
var reason = BuildFlagReason(issues);
_logger.LogDebug(
"Statement {StatementId} flagged with {IssueCount} issues: {Reason}",
statement.Id,
issues.Count,
reason);
return Task.FromResult(FlaggingResult.Flag(reason, severity, issues));
}
public async Task<IReadOnlyDictionary<Guid, FlaggingResult>> EvaluateBatchAsync(
IEnumerable<AggregatedVexStatement> statements,
VexSource? source,
CancellationToken cancellationToken = default)
{
var results = new Dictionary<Guid, FlaggingResult>();
foreach (var statement in statements)
{
results[statement.Id] = await EvaluateAsync(statement, source, cancellationToken);
}
return results;
}
private void EvaluateVerificationStatus(
AggregatedVexStatement statement,
List<FlaggingIssue> issues)
{
switch (statement.VerificationStatus)
{
case VerificationStatus.Failed:
issues.Add(new FlaggingIssue
{
Type = FlaggingIssueType.SignatureVerificationFailed,
Description = "Signature verification failed for this statement"
});
break;
case VerificationStatus.Untrusted:
issues.Add(new FlaggingIssue
{
Type = FlaggingIssueType.UntrustedSigningKey,
Description = "The signing key is not in the trusted key set"
});
break;
case VerificationStatus.None:
// Check if we require signatures
if (_options.RequireSignedStatements)
{
issues.Add(new FlaggingIssue
{
Type = FlaggingIssueType.SignatureMissing,
Description = "Statement is not signed but signed statements are required"
});
}
break;
case VerificationStatus.Pending:
// Pending verification is not an error, but worth noting
_logger.LogDebug(
"Statement {StatementId} has pending signature verification",
statement.Id);
break;
case VerificationStatus.Verified:
// No issue
break;
}
}
private void EvaluateSourceTrust(VexSource? source, List<FlaggingIssue> issues)
{
if (source is null)
{
issues.Add(new FlaggingIssue
{
Type = FlaggingIssueType.UntrustedSource,
Description = "Source information is not available"
});
return;
}
// Check trust tier
switch (source.TrustTier)
{
case TrustTier.Unknown:
issues.Add(new FlaggingIssue
{
Type = FlaggingIssueType.LowTrustTier,
Description = "Source has unknown trust tier"
});
break;
case TrustTier.Untrusted:
// Untrusted sources may need higher scrutiny in strict mode
if (_options.RequireSignedStatements)
{
issues.Add(new FlaggingIssue
{
Type = FlaggingIssueType.LowTrustTier,
Description = "Source is untrusted and strict mode is enabled"
});
}
break;
case TrustTier.Trusted:
case TrustTier.Authoritative:
// No issue
break;
}
// Check if source is enabled
if (!source.IsEnabled)
{
issues.Add(new FlaggingIssue
{
Type = FlaggingIssueType.UntrustedSource,
Description = "Source is disabled"
});
}
}
private static void EvaluateMissingFields(
AggregatedVexStatement statement,
List<FlaggingIssue> issues)
{
// Check for missing vulnerability ID
if (string.IsNullOrWhiteSpace(statement.VulnerabilityId))
{
issues.Add(new FlaggingIssue
{
Type = FlaggingIssueType.MissingRequiredFields,
Description = "Statement is missing vulnerability ID"
});
}
// Check for missing product key
if (string.IsNullOrWhiteSpace(statement.ProductKey))
{
issues.Add(new FlaggingIssue
{
Type = FlaggingIssueType.MissingRequiredFields,
Description = "Statement is missing product key"
});
}
// Check for justification when status is not_affected
if (statement.Status == VexStatus.NotAffected && statement.Justification is null)
{
issues.Add(new FlaggingIssue
{
Type = FlaggingIssueType.MissingRequiredFields,
Description = "Statement with 'not_affected' status is missing justification"
});
}
}
private void EvaluateStaleness(
AggregatedVexStatement statement,
List<FlaggingIssue> issues)
{
if (_options.StaleDataThresholdDays <= 0)
return;
var age = DateTimeOffset.UtcNow - statement.IngestedAt;
if (age.TotalDays > _options.StaleDataThresholdDays)
{
issues.Add(new FlaggingIssue
{
Type = FlaggingIssueType.StaleData,
Description = $"Statement is {(int)age.TotalDays} days old, exceeds threshold of {_options.StaleDataThresholdDays} days"
});
}
}
private static FlagSeverity DetermineOverallSeverity(List<FlaggingIssue> issues)
{
// Critical issues
var criticalTypes = new[]
{
FlaggingIssueType.SignatureVerificationFailed,
FlaggingIssueType.UntrustedSigningKey
};
if (issues.Any(i => criticalTypes.Contains(i.Type)))
return FlagSeverity.Critical;
// High severity issues
var highTypes = new[]
{
FlaggingIssueType.UntrustedSource,
FlaggingIssueType.SchemaValidationFailed,
FlaggingIssueType.MissingRequiredFields
};
if (issues.Any(i => highTypes.Contains(i.Type)))
return FlagSeverity.High;
// Medium severity issues
var mediumTypes = new[]
{
FlaggingIssueType.SignatureMissing,
FlaggingIssueType.LowTrustTier,
FlaggingIssueType.ConflictWithHigherTrust
};
if (issues.Any(i => mediumTypes.Contains(i.Type)))
return FlagSeverity.Medium;
return FlagSeverity.Low;
}
private static string BuildFlagReason(List<FlaggingIssue> issues)
{
if (issues.Count == 1)
return issues[0].Description;
return $"{issues.Count} issues: {string.Join("; ", issues.Take(3).Select(i => i.Description))}";
}
}

View File

@@ -0,0 +1,145 @@
using Microsoft.Extensions.Logging;
using StellaOps.VexHub.Core.Models;
using StellaOps.VexLens.Models;
namespace StellaOps.VexHub.Core.Validation;
/// <summary>
/// Factory for creating VEX schema validators.
/// </summary>
public interface IVexSchemaValidatorFactory
{
/// <summary>
/// Gets the appropriate validator for a given VEX source format.
/// </summary>
/// <param name="format">The VEX source format.</param>
/// <returns>The validator for the format, or null if not supported.</returns>
IVexSchemaValidator? GetValidator(VexSourceFormat format);
/// <summary>
/// Gets all supported formats.
/// </summary>
IEnumerable<VexSourceFormat> SupportedFormats { get; }
/// <summary>
/// Validates a document, auto-detecting the format if not specified.
/// </summary>
/// <param name="content">The document content.</param>
/// <param name="format">The format, or null to auto-detect.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The validation result.</returns>
Task<SchemaValidationResult> ValidateAsync(
string content,
VexSourceFormat? format = null,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Default implementation of the VEX schema validator factory.
/// </summary>
public sealed class VexSchemaValidatorFactory : IVexSchemaValidatorFactory
{
private readonly Dictionary<VexSourceFormat, IVexSchemaValidator> _validators;
private readonly ILogger<VexSchemaValidatorFactory> _logger;
public VexSchemaValidatorFactory(
IEnumerable<IVexSchemaValidator> validators,
ILogger<VexSchemaValidatorFactory> logger)
{
_validators = validators.ToDictionary(v => v.SupportedFormat);
_logger = logger;
}
public IEnumerable<VexSourceFormat> SupportedFormats => _validators.Keys;
public IVexSchemaValidator? GetValidator(VexSourceFormat format)
{
return _validators.GetValueOrDefault(format);
}
public async Task<SchemaValidationResult> ValidateAsync(
string content,
VexSourceFormat? format = null,
CancellationToken cancellationToken = default)
{
var detectedFormat = format ?? DetectFormat(content);
if (detectedFormat == VexSourceFormat.Unknown)
{
return SchemaValidationResult.Failure(
VexSourceFormat.Unknown,
[new SchemaValidationError
{
Path = "$",
Message = "Unable to detect VEX format from content"
}]);
}
var validator = GetValidator(detectedFormat);
if (validator is null)
{
return SchemaValidationResult.Failure(
detectedFormat,
[new SchemaValidationError
{
Path = "$",
Message = $"No validator available for format: {detectedFormat}"
}]);
}
_logger.LogDebug("Validating document with {Format} validator", detectedFormat);
return await validator.ValidateAsync(content, cancellationToken);
}
/// <summary>
/// Attempts to detect the VEX format from content.
/// </summary>
private static VexSourceFormat DetectFormat(string content)
{
if (string.IsNullOrWhiteSpace(content))
return VexSourceFormat.Unknown;
// Try to detect based on key markers
var trimmed = content.TrimStart();
// JSON-based detection
if (trimmed.StartsWith('{'))
{
// Check for OpenVEX markers
if (content.Contains("\"@context\"") &&
(content.Contains("openvex.dev") || content.Contains("\"statements\"")))
{
return VexSourceFormat.OpenVex;
}
// Check for CycloneDX markers
if (content.Contains("\"bomFormat\"") && content.Contains("\"CycloneDX\""))
{
return VexSourceFormat.CycloneDxVex;
}
// Check for CSAF markers
if (content.Contains("\"document\"") &&
content.Contains("\"csaf_version\""))
{
return VexSourceFormat.CsafVex;
}
// Check for SPDX markers
if (content.Contains("\"spdxVersion\"") ||
content.Contains("\"SPDX-"))
{
return VexSourceFormat.SpdxVex;
}
// Check for StellaOps internal format
if (content.Contains("\"schemaVersion\"") &&
content.Contains("\"StellaOps\""))
{
return VexSourceFormat.StellaOps;
}
}
return VexSourceFormat.Unknown;
}
}

View File

@@ -0,0 +1,44 @@
using Microsoft.Extensions.Logging;
using StellaOps.VexHub.Core.Models;
namespace StellaOps.VexHub.Core.Validation;
/// <summary>
/// Default implementation of VEX signature verification.
/// </summary>
public sealed class VexSignatureVerifier : IVexSignatureVerifier
{
private readonly ILogger<VexSignatureVerifier> _logger;
public VexSignatureVerifier(ILogger<VexSignatureVerifier> logger)
{
_logger = logger;
}
public Task<SignatureVerificationResult> VerifyAsync(
string content,
string expectedKeyFingerprint,
CancellationToken cancellationToken = default)
{
// Placeholder implementation
// In production, this would:
// 1. Parse the DSSE envelope or JWS signature
// 2. Fetch the public key from a keystore or registry
// 3. Verify the signature cryptographically
// 4. Check key trust chain
_logger.LogDebug(
"Signature verification requested for key {KeyFingerprint}",
expectedKeyFingerprint);
// For now, return pending status as actual verification
// requires integration with the Cryptography module
return Task.FromResult(new SignatureVerificationResult
{
Status = VerificationStatus.Pending,
VerifiedAt = null,
KeyFingerprint = expectedKeyFingerprint,
ErrorMessage = null
});
}
}

View File

@@ -0,0 +1,148 @@
using StellaOps.VexHub.Core.Models;
namespace StellaOps.VexHub.Core.Webhooks;
/// <summary>
/// Service for managing and delivering webhooks.
/// </summary>
public interface IWebhookService
{
/// <summary>
/// Publishes an event to all matching webhook subscriptions.
/// </summary>
/// <param name="eventType">The type of event.</param>
/// <param name="data">The event data.</param>
/// <param name="vulnerabilityId">Optional vulnerability ID for filtering.</param>
/// <param name="productKey">Optional product key for filtering.</param>
/// <param name="sourceId">Optional source ID for filtering.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task PublishEventAsync(
WebhookEventType eventType,
object data,
string? vulnerabilityId = null,
string? productKey = null,
string? sourceId = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Delivers a webhook payload to a subscription.
/// </summary>
/// <param name="subscription">The subscription to deliver to.</param>
/// <param name="payload">The payload to deliver.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The delivery result.</returns>
Task<WebhookDeliveryResult> DeliverAsync(
WebhookSubscription subscription,
WebhookPayload payload,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Repository for webhook subscriptions.
/// </summary>
public interface IWebhookSubscriptionRepository
{
/// <summary>
/// Gets a subscription by ID.
/// </summary>
Task<WebhookSubscription?> GetByIdAsync(
Guid id,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets all enabled subscriptions.
/// </summary>
Task<IReadOnlyList<WebhookSubscription>> GetEnabledAsync(
CancellationToken cancellationToken = default);
/// <summary>
/// Gets subscriptions matching an event type and filters.
/// </summary>
Task<IReadOnlyList<WebhookSubscription>> GetMatchingAsync(
WebhookEventType eventType,
string? vulnerabilityId = null,
string? productKey = null,
string? sourceId = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Creates a new subscription.
/// </summary>
Task<WebhookSubscription> CreateAsync(
WebhookSubscription subscription,
CancellationToken cancellationToken = default);
/// <summary>
/// Updates a subscription.
/// </summary>
Task<WebhookSubscription> UpdateAsync(
WebhookSubscription subscription,
CancellationToken cancellationToken = default);
/// <summary>
/// Deletes a subscription.
/// </summary>
Task<bool> DeleteAsync(
Guid id,
CancellationToken cancellationToken = default);
/// <summary>
/// Records a successful delivery.
/// </summary>
Task RecordSuccessAsync(
Guid id,
CancellationToken cancellationToken = default);
/// <summary>
/// Records a failed delivery.
/// </summary>
Task RecordFailureAsync(
Guid id,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Result of a webhook delivery attempt.
/// </summary>
public sealed record WebhookDeliveryResult
{
/// <summary>
/// Whether the delivery was successful.
/// </summary>
public required bool Success { get; init; }
/// <summary>
/// HTTP status code returned.
/// </summary>
public int? StatusCode { get; init; }
/// <summary>
/// Error message if delivery failed.
/// </summary>
public string? ErrorMessage { get; init; }
/// <summary>
/// How long the delivery took.
/// </summary>
public TimeSpan? Duration { get; init; }
/// <summary>
/// Creates a successful result.
/// </summary>
public static WebhookDeliveryResult Ok(int statusCode, TimeSpan duration) => new()
{
Success = true,
StatusCode = statusCode,
Duration = duration
};
/// <summary>
/// Creates a failed result.
/// </summary>
public static WebhookDeliveryResult Fail(string error, int? statusCode = null) => new()
{
Success = false,
ErrorMessage = error,
StatusCode = statusCode
};
}

View File

@@ -0,0 +1,192 @@
using System.Diagnostics;
using System.Net.Http.Json;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.VexHub.Core.Models;
namespace StellaOps.VexHub.Core.Webhooks;
/// <summary>
/// Default implementation of the webhook service.
/// </summary>
public sealed class WebhookService : IWebhookService
{
private readonly IWebhookSubscriptionRepository _subscriptionRepository;
private readonly HttpClient _httpClient;
private readonly ILogger<WebhookService> _logger;
private readonly VexHubOptions _options;
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = false
};
public WebhookService(
IWebhookSubscriptionRepository subscriptionRepository,
HttpClient httpClient,
IOptions<VexHubOptions> options,
ILogger<WebhookService> logger)
{
_subscriptionRepository = subscriptionRepository;
_httpClient = httpClient;
_options = options.Value;
_logger = logger;
}
public async Task PublishEventAsync(
WebhookEventType eventType,
object data,
string? vulnerabilityId = null,
string? productKey = null,
string? sourceId = null,
CancellationToken cancellationToken = default)
{
if (!_options.Distribution.EnableWebhooks)
{
_logger.LogDebug("Webhooks are disabled, skipping event {EventType}", eventType);
return;
}
var subscriptions = await _subscriptionRepository.GetMatchingAsync(
eventType,
vulnerabilityId,
productKey,
sourceId,
cancellationToken);
if (subscriptions.Count == 0)
{
_logger.LogDebug("No matching subscriptions for event {EventType}", eventType);
return;
}
var payload = new WebhookPayload
{
EventId = Guid.NewGuid().ToString(),
EventType = eventType,
Timestamp = DateTimeOffset.UtcNow,
Data = data
};
_logger.LogInformation(
"Publishing event {EventType} to {SubscriptionCount} subscriptions",
eventType,
subscriptions.Count);
// Deliver to all matching subscriptions in parallel
var tasks = subscriptions.Select(s => DeliverAndRecordAsync(s, payload, cancellationToken));
await Task.WhenAll(tasks);
}
public async Task<WebhookDeliveryResult> DeliverAsync(
WebhookSubscription subscription,
WebhookPayload payload,
CancellationToken cancellationToken = default)
{
var stopwatch = Stopwatch.StartNew();
try
{
var jsonPayload = JsonSerializer.Serialize(payload, JsonOptions);
var content = new StringContent(jsonPayload, Encoding.UTF8, "application/json");
// Add signature header if secret is configured
if (!string.IsNullOrEmpty(subscription.Secret))
{
var signature = ComputeHmacSignature(jsonPayload, subscription.Secret);
content.Headers.Add("X-VexHub-Signature", $"sha256={signature}");
}
// Add event type header
content.Headers.Add("X-VexHub-Event", payload.EventType.ToString());
content.Headers.Add("X-VexHub-Delivery", payload.EventId);
var response = await _httpClient.PostAsync(
subscription.CallbackUrl,
content,
cancellationToken);
stopwatch.Stop();
if (response.IsSuccessStatusCode)
{
_logger.LogDebug(
"Webhook delivered successfully to {Url} in {Duration}ms",
subscription.CallbackUrl,
stopwatch.ElapsedMilliseconds);
return WebhookDeliveryResult.Ok((int)response.StatusCode, stopwatch.Elapsed);
}
_logger.LogWarning(
"Webhook delivery to {Url} failed with status {StatusCode}",
subscription.CallbackUrl,
(int)response.StatusCode);
return WebhookDeliveryResult.Fail(
$"HTTP {(int)response.StatusCode}: {response.ReasonPhrase}",
(int)response.StatusCode);
}
catch (HttpRequestException ex)
{
stopwatch.Stop();
_logger.LogError(ex,
"Webhook delivery to {Url} failed with network error",
subscription.CallbackUrl);
return WebhookDeliveryResult.Fail($"Network error: {ex.Message}");
}
catch (TaskCanceledException ex) when (ex.CancellationToken != cancellationToken)
{
stopwatch.Stop();
_logger.LogWarning(
"Webhook delivery to {Url} timed out",
subscription.CallbackUrl);
return WebhookDeliveryResult.Fail("Request timed out");
}
}
private async Task DeliverAndRecordAsync(
WebhookSubscription subscription,
WebhookPayload payload,
CancellationToken cancellationToken)
{
var result = await DeliverAsync(subscription, payload, cancellationToken);
try
{
if (result.Success)
{
await _subscriptionRepository.RecordSuccessAsync(subscription.Id, cancellationToken);
}
else
{
await _subscriptionRepository.RecordFailureAsync(subscription.Id, cancellationToken);
}
}
catch (Exception ex)
{
_logger.LogError(ex,
"Failed to record webhook delivery result for subscription {SubscriptionId}",
subscription.Id);
}
}
private static string ComputeHmacSignature(string payload, string secret)
{
var secretBytes = Encoding.UTF8.GetBytes(secret);
var payloadBytes = Encoding.UTF8.GetBytes(payload);
using var hmac = new HMACSHA256(secretBytes);
var hash = hmac.ComputeHash(payloadBytes);
return Convert.ToHexStringLower(hash);
}
}

View File

@@ -0,0 +1,29 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using StellaOps.Infrastructure.Postgres.Options;
using StellaOps.VexHub.Core;
using StellaOps.VexHub.Storage.Postgres.Repositories;
namespace StellaOps.VexHub.Storage.Postgres.Extensions;
/// <summary>
/// Service collection extensions for VexHub PostgreSQL storage.
/// </summary>
public static class VexHubPostgresServiceCollectionExtensions
{
/// <summary>
/// Adds VexHub PostgreSQL storage services to the service collection.
/// </summary>
public static IServiceCollection AddVexHubPostgres(
this IServiceCollection services,
IConfiguration configuration)
{
services.Configure<PostgresOptions>(configuration.GetSection("Postgres"));
services.AddSingleton<VexHubDataSource>();
services.AddScoped<IVexStatementRepository, PostgresVexStatementRepository>();
services.AddScoped<IVexProvenanceRepository, PostgresVexProvenanceRepository>();
return services;
}
}

View File

@@ -0,0 +1,213 @@
-- VexHub Schema Migration 001: Initial Schema
-- Creates the vexhub schema for VEX aggregation, storage, and distribution
-- Create schema
CREATE SCHEMA IF NOT EXISTS vexhub;
-- Enable extensions
CREATE EXTENSION IF NOT EXISTS pg_trgm;
-- VEX Sources table (configured VEX providers)
CREATE TABLE IF NOT EXISTS vexhub.sources (
source_id TEXT PRIMARY KEY,
name TEXT NOT NULL,
source_uri TEXT,
source_format TEXT NOT NULL CHECK (source_format IN ('OPENVEX', 'CSAF_VEX', 'CYCLONEDX_VEX', 'SPDX_VEX', 'STELLAOPS')),
issuer_category TEXT CHECK (issuer_category IN ('VENDOR', 'DISTRIBUTOR', 'COMMUNITY', 'INTERNAL', 'AGGREGATOR')),
trust_tier TEXT NOT NULL DEFAULT 'UNKNOWN' CHECK (trust_tier IN ('AUTHORITATIVE', 'TRUSTED', 'UNTRUSTED', 'UNKNOWN')),
is_enabled BOOLEAN NOT NULL DEFAULT TRUE,
polling_interval_seconds INT,
last_polled_at TIMESTAMPTZ,
last_error_message TEXT,
config JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ
);
CREATE INDEX idx_sources_enabled ON vexhub.sources(is_enabled, last_polled_at);
CREATE INDEX idx_sources_format ON vexhub.sources(source_format);
-- Aggregated VEX Statements table (main statement storage)
CREATE TABLE IF NOT EXISTS vexhub.statements (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
source_statement_id TEXT NOT NULL,
source_id TEXT NOT NULL REFERENCES vexhub.sources(source_id),
source_document_id TEXT NOT NULL,
vulnerability_id TEXT NOT NULL,
vulnerability_aliases TEXT[] DEFAULT '{}',
product_key TEXT NOT NULL,
status TEXT NOT NULL CHECK (status IN ('not_affected', 'affected', 'fixed', 'under_investigation')),
justification TEXT CHECK (justification IN (
'component_not_present',
'vulnerable_code_not_present',
'vulnerable_code_not_in_execute_path',
'vulnerable_code_cannot_be_controlled_by_adversary',
'inline_mitigations_already_exist'
)),
status_notes TEXT,
impact_statement TEXT,
action_statement TEXT,
versions JSONB,
issued_at TIMESTAMPTZ,
source_updated_at TIMESTAMPTZ,
verification_status TEXT NOT NULL DEFAULT 'none' CHECK (verification_status IN ('none', 'pending', 'verified', 'failed', 'untrusted')),
verified_at TIMESTAMPTZ,
signing_key_fingerprint TEXT,
is_flagged BOOLEAN NOT NULL DEFAULT FALSE,
flag_reason TEXT,
ingested_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ,
content_digest TEXT NOT NULL,
search_vector TSVECTOR,
UNIQUE(source_id, source_statement_id, vulnerability_id, product_key)
);
-- Indexes for statement queries
CREATE INDEX idx_statements_vulnerability ON vexhub.statements(vulnerability_id);
CREATE INDEX idx_statements_product ON vexhub.statements(product_key);
CREATE INDEX idx_statements_source ON vexhub.statements(source_id);
CREATE INDEX idx_statements_status ON vexhub.statements(status);
CREATE INDEX idx_statements_verification ON vexhub.statements(verification_status);
CREATE INDEX idx_statements_ingested ON vexhub.statements(ingested_at);
CREATE INDEX idx_statements_digest ON vexhub.statements(content_digest);
CREATE INDEX idx_statements_flagged ON vexhub.statements(is_flagged) WHERE is_flagged = TRUE;
CREATE INDEX idx_statements_search ON vexhub.statements USING GIN(search_vector);
CREATE INDEX idx_statements_vuln_product ON vexhub.statements(vulnerability_id, product_key);
CREATE INDEX idx_statements_product_trgm ON vexhub.statements USING GIN(product_key gin_trgm_ops);
CREATE INDEX idx_statements_aliases ON vexhub.statements USING GIN(vulnerability_aliases);
-- VEX Conflicts table
CREATE TABLE IF NOT EXISTS vexhub.conflicts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
vulnerability_id TEXT NOT NULL,
product_key TEXT NOT NULL,
conflicting_statement_ids UUID[] NOT NULL,
severity TEXT NOT NULL CHECK (severity IN ('low', 'medium', 'high', 'critical')),
description TEXT NOT NULL,
resolution_status TEXT NOT NULL DEFAULT 'open' CHECK (resolution_status IN ('open', 'auto_resolved', 'manually_resolved', 'suppressed')),
resolution_method TEXT,
winning_statement_id UUID REFERENCES vexhub.statements(id),
detected_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
resolved_at TIMESTAMPTZ
);
CREATE INDEX idx_conflicts_vuln_product ON vexhub.conflicts(vulnerability_id, product_key);
CREATE INDEX idx_conflicts_status ON vexhub.conflicts(resolution_status);
CREATE INDEX idx_conflicts_severity ON vexhub.conflicts(severity);
CREATE INDEX idx_conflicts_detected ON vexhub.conflicts(detected_at);
-- VEX Provenance table
CREATE TABLE IF NOT EXISTS vexhub.provenance (
statement_id UUID PRIMARY KEY REFERENCES vexhub.statements(id) ON DELETE CASCADE,
source_id TEXT NOT NULL,
document_uri TEXT,
document_digest TEXT,
source_revision TEXT,
issuer_id TEXT,
issuer_name TEXT,
fetched_at TIMESTAMPTZ NOT NULL,
transformation_rules TEXT[],
raw_statement_json JSONB
);
CREATE INDEX idx_provenance_source ON vexhub.provenance(source_id);
CREATE INDEX idx_provenance_issuer ON vexhub.provenance(issuer_id);
-- Ingestion Jobs table
CREATE TABLE IF NOT EXISTS vexhub.ingestion_jobs (
job_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
source_id TEXT NOT NULL REFERENCES vexhub.sources(source_id),
status TEXT NOT NULL DEFAULT 'queued' CHECK (status IN ('queued', 'running', 'completed', 'failed', 'cancelled', 'paused')),
started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
completed_at TIMESTAMPTZ,
documents_processed INT NOT NULL DEFAULT 0,
statements_ingested INT NOT NULL DEFAULT 0,
statements_deduplicated INT NOT NULL DEFAULT 0,
conflicts_detected INT NOT NULL DEFAULT 0,
error_count INT NOT NULL DEFAULT 0,
error_message TEXT,
checkpoint TEXT
);
CREATE INDEX idx_jobs_source ON vexhub.ingestion_jobs(source_id);
CREATE INDEX idx_jobs_status ON vexhub.ingestion_jobs(status);
CREATE INDEX idx_jobs_started ON vexhub.ingestion_jobs(started_at DESC);
-- Webhook Subscriptions table
CREATE TABLE IF NOT EXISTS vexhub.webhook_subscriptions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
callback_url TEXT NOT NULL,
secret TEXT,
event_types TEXT[] NOT NULL DEFAULT '{}',
filter_vulnerability_ids TEXT[],
filter_product_keys TEXT[],
filter_sources TEXT[],
is_enabled BOOLEAN NOT NULL DEFAULT TRUE,
last_triggered_at TIMESTAMPTZ,
failure_count INT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ
);
CREATE INDEX idx_webhooks_enabled ON vexhub.webhook_subscriptions(is_enabled);
-- Function to update search vector
CREATE OR REPLACE FUNCTION vexhub.update_statement_search_vector()
RETURNS TRIGGER AS $$
BEGIN
NEW.search_vector =
setweight(to_tsvector('english', COALESCE(NEW.vulnerability_id, '')), 'A') ||
setweight(to_tsvector('english', COALESCE(NEW.product_key, '')), 'A') ||
setweight(to_tsvector('english', COALESCE(NEW.status_notes, '')), 'B') ||
setweight(to_tsvector('english', COALESCE(NEW.impact_statement, '')), 'C') ||
setweight(to_tsvector('english', COALESCE(NEW.action_statement, '')), 'C');
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Trigger for search vector
CREATE TRIGGER trg_statements_search_vector
BEFORE INSERT OR UPDATE ON vexhub.statements
FOR EACH ROW EXECUTE FUNCTION vexhub.update_statement_search_vector();
-- Update timestamp function
CREATE OR REPLACE FUNCTION vexhub.update_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Triggers for updated_at
CREATE TRIGGER trg_sources_updated_at
BEFORE UPDATE ON vexhub.sources
FOR EACH ROW EXECUTE FUNCTION vexhub.update_updated_at();
CREATE TRIGGER trg_statements_updated_at
BEFORE UPDATE ON vexhub.statements
FOR EACH ROW EXECUTE FUNCTION vexhub.update_updated_at();
CREATE TRIGGER trg_webhooks_updated_at
BEFORE UPDATE ON vexhub.webhook_subscriptions
FOR EACH ROW EXECUTE FUNCTION vexhub.update_updated_at();
-- Statistics view for monitoring
CREATE OR REPLACE VIEW vexhub.statistics AS
SELECT
(SELECT COUNT(*) FROM vexhub.sources WHERE is_enabled = TRUE) AS enabled_sources,
(SELECT COUNT(*) FROM vexhub.statements) AS total_statements,
(SELECT COUNT(*) FROM vexhub.statements WHERE verification_status = 'verified') AS verified_statements,
(SELECT COUNT(*) FROM vexhub.statements WHERE is_flagged = TRUE) AS flagged_statements,
(SELECT COUNT(*) FROM vexhub.conflicts WHERE resolution_status = 'open') AS open_conflicts,
(SELECT COUNT(*) FROM vexhub.ingestion_jobs WHERE status = 'running') AS running_jobs,
(SELECT MAX(ingested_at) FROM vexhub.statements) AS last_ingestion_at;
-- Seed default sources
INSERT INTO vexhub.sources (source_id, name, source_format, issuer_category, trust_tier, is_enabled, polling_interval_seconds)
VALUES
('redhat-csaf', 'Red Hat CSAF', 'CSAF_VEX', 'VENDOR', 'AUTHORITATIVE', FALSE, 3600),
('cisco-csaf', 'Cisco CSAF', 'CSAF_VEX', 'VENDOR', 'AUTHORITATIVE', FALSE, 3600),
('openvex-community', 'OpenVEX Community', 'OPENVEX', 'COMMUNITY', 'TRUSTED', FALSE, 3600)
ON CONFLICT (source_id) DO NOTHING;

View File

@@ -0,0 +1,19 @@
namespace StellaOps.VexHub.Storage.Postgres.Models;
/// <summary>
/// Database entity for VEX conflicts.
/// </summary>
public sealed class VexConflictEntity
{
public Guid Id { get; set; }
public required string VulnerabilityId { get; set; }
public required string ProductKey { get; set; }
public Guid[]? ConflictingStatementIds { get; set; }
public required string Severity { get; set; }
public required string Description { get; set; }
public required string ResolutionStatus { get; set; }
public string? ResolutionMethod { get; set; }
public Guid? WinningStatementId { get; set; }
public required DateTimeOffset DetectedAt { get; set; }
public DateTimeOffset? ResolvedAt { get; set; }
}

View File

@@ -0,0 +1,20 @@
namespace StellaOps.VexHub.Storage.Postgres.Models;
/// <summary>
/// Database entity for VEX ingestion jobs.
/// </summary>
public sealed class VexIngestionJobEntity
{
public Guid JobId { get; set; }
public required string SourceId { get; set; }
public required string Status { get; set; }
public required DateTimeOffset StartedAt { get; set; }
public DateTimeOffset? CompletedAt { get; set; }
public int DocumentsProcessed { get; set; }
public int StatementsIngested { get; set; }
public int StatementsDeduplicated { get; set; }
public int ConflictsDetected { get; set; }
public int ErrorCount { get; set; }
public string? ErrorMessage { get; set; }
public string? Checkpoint { get; set; }
}

View File

@@ -0,0 +1,18 @@
namespace StellaOps.VexHub.Storage.Postgres.Models;
/// <summary>
/// Database entity for VEX provenance.
/// </summary>
public sealed class VexProvenanceEntity
{
public Guid StatementId { get; set; }
public required string SourceId { get; set; }
public string? DocumentUri { get; set; }
public string? DocumentDigest { get; set; }
public string? SourceRevision { get; set; }
public string? IssuerId { get; set; }
public string? IssuerName { get; set; }
public required DateTimeOffset FetchedAt { get; set; }
public string[]? TransformationRules { get; set; }
public string? RawStatementJson { get; set; }
}

View File

@@ -0,0 +1,21 @@
namespace StellaOps.VexHub.Storage.Postgres.Models;
/// <summary>
/// Database entity for VEX sources.
/// </summary>
public sealed class VexSourceEntity
{
public required string SourceId { get; set; }
public required string Name { get; set; }
public string? SourceUri { get; set; }
public required string SourceFormat { get; set; }
public string? IssuerCategory { get; set; }
public required string TrustTier { get; set; }
public bool IsEnabled { get; set; }
public int? PollingIntervalSeconds { get; set; }
public DateTimeOffset? LastPolledAt { get; set; }
public string? LastErrorMessage { get; set; }
public string? Config { get; set; }
public required DateTimeOffset CreatedAt { get; set; }
public DateTimeOffset? UpdatedAt { get; set; }
}

View File

@@ -0,0 +1,31 @@
namespace StellaOps.VexHub.Storage.Postgres.Models;
/// <summary>
/// Database entity for aggregated VEX statements.
/// </summary>
public sealed class VexStatementEntity
{
public Guid Id { get; set; }
public required string SourceStatementId { get; set; }
public required string SourceId { get; set; }
public required string SourceDocumentId { get; set; }
public required string VulnerabilityId { get; set; }
public string[]? VulnerabilityAliases { get; set; }
public required string ProductKey { get; set; }
public required string Status { get; set; }
public string? Justification { get; set; }
public string? StatusNotes { get; set; }
public string? ImpactStatement { get; set; }
public string? ActionStatement { get; set; }
public string? Versions { get; set; }
public DateTimeOffset? IssuedAt { get; set; }
public DateTimeOffset? SourceUpdatedAt { get; set; }
public required string VerificationStatus { get; set; }
public DateTimeOffset? VerifiedAt { get; set; }
public string? SigningKeyFingerprint { get; set; }
public bool IsFlagged { get; set; }
public string? FlagReason { get; set; }
public required DateTimeOffset IngestedAt { get; set; }
public DateTimeOffset? UpdatedAt { get; set; }
public required string ContentDigest { get; set; }
}

View File

@@ -0,0 +1,124 @@
using System.Text.Json;
using Dapper;
using Microsoft.Extensions.Logging;
using StellaOps.VexHub.Core;
using StellaOps.VexHub.Core.Models;
using StellaOps.VexHub.Storage.Postgres.Models;
namespace StellaOps.VexHub.Storage.Postgres.Repositories;
/// <summary>
/// PostgreSQL implementation of the VEX provenance repository.
/// </summary>
public sealed class PostgresVexProvenanceRepository : IVexProvenanceRepository
{
private readonly VexHubDataSource _dataSource;
private readonly ILogger<PostgresVexProvenanceRepository> _logger;
public PostgresVexProvenanceRepository(
VexHubDataSource dataSource,
ILogger<PostgresVexProvenanceRepository> logger)
{
_dataSource = dataSource;
_logger = logger;
}
public async Task<VexProvenance> AddAsync(
VexProvenance provenance,
CancellationToken cancellationToken = default)
{
const string sql = """
INSERT INTO vexhub.provenance (
statement_id, source_id, document_uri, document_digest,
source_revision, issuer_id, issuer_name, fetched_at,
transformation_rules, raw_statement_json
) VALUES (
@StatementId, @SourceId, @DocumentUri, @DocumentDigest,
@SourceRevision, @IssuerId, @IssuerName, @FetchedAt,
@TransformationRules, @RawStatementJson::jsonb
)
ON CONFLICT (statement_id) DO UPDATE SET
document_uri = EXCLUDED.document_uri,
document_digest = EXCLUDED.document_digest,
source_revision = EXCLUDED.source_revision,
issuer_id = EXCLUDED.issuer_id,
issuer_name = EXCLUDED.issuer_name,
transformation_rules = EXCLUDED.transformation_rules,
raw_statement_json = EXCLUDED.raw_statement_json
RETURNING statement_id
""";
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken);
var entity = ToEntity(provenance);
await connection.ExecuteScalarAsync<Guid>(sql, entity);
_logger.LogDebug("Added provenance for statement {StatementId}", provenance.StatementId);
return provenance;
}
public async Task<VexProvenance?> GetByStatementIdAsync(
Guid statementId,
CancellationToken cancellationToken = default)
{
const string sql = "SELECT * FROM vexhub.provenance WHERE statement_id = @StatementId";
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken);
var entity = await connection.QueryFirstOrDefaultAsync<VexProvenanceEntity>(
sql, new { StatementId = statementId });
return entity is null ? null : ToModel(entity);
}
public async Task<int> BulkAddAsync(
IEnumerable<VexProvenance> provenances,
CancellationToken cancellationToken = default)
{
var count = 0;
foreach (var provenance in provenances)
{
await AddAsync(provenance, cancellationToken);
count++;
}
return count;
}
public async Task<bool> DeleteByStatementIdAsync(
Guid statementId,
CancellationToken cancellationToken = default)
{
const string sql = "DELETE FROM vexhub.provenance WHERE statement_id = @StatementId";
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken);
var affected = await connection.ExecuteAsync(sql, new { StatementId = statementId });
return affected > 0;
}
private static VexProvenanceEntity ToEntity(VexProvenance model) => new()
{
StatementId = model.StatementId,
SourceId = model.SourceId,
DocumentUri = model.DocumentUri,
DocumentDigest = model.DocumentDigest,
SourceRevision = model.SourceRevision,
IssuerId = model.IssuerId,
IssuerName = model.IssuerName,
FetchedAt = model.FetchedAt,
TransformationRules = model.TransformationRules?.ToArray(),
RawStatementJson = model.RawStatementJson
};
private static VexProvenance ToModel(VexProvenanceEntity entity) => new()
{
StatementId = entity.StatementId,
SourceId = entity.SourceId,
DocumentUri = entity.DocumentUri,
DocumentDigest = entity.DocumentDigest,
SourceRevision = entity.SourceRevision,
IssuerId = entity.IssuerId,
IssuerName = entity.IssuerName,
FetchedAt = entity.FetchedAt,
TransformationRules = entity.TransformationRules?.ToList(),
RawStatementJson = entity.RawStatementJson
};
}

View File

@@ -0,0 +1,373 @@
using System.Text.Json;
using Dapper;
using Microsoft.Extensions.Logging;
using StellaOps.VexHub.Core;
using StellaOps.VexHub.Core.Models;
using StellaOps.VexHub.Storage.Postgres.Models;
using StellaOps.VexLens.Models;
namespace StellaOps.VexHub.Storage.Postgres.Repositories;
/// <summary>
/// PostgreSQL implementation of the VEX statement repository.
/// </summary>
public sealed class PostgresVexStatementRepository : IVexStatementRepository
{
private readonly VexHubDataSource _dataSource;
private readonly ILogger<PostgresVexStatementRepository> _logger;
public PostgresVexStatementRepository(
VexHubDataSource dataSource,
ILogger<PostgresVexStatementRepository> logger)
{
_dataSource = dataSource;
_logger = logger;
}
public async Task<AggregatedVexStatement> UpsertAsync(
AggregatedVexStatement statement,
CancellationToken cancellationToken = default)
{
const string sql = """
INSERT INTO vexhub.statements (
id, source_statement_id, source_id, source_document_id, vulnerability_id,
vulnerability_aliases, product_key, status, justification, status_notes,
impact_statement, action_statement, versions, issued_at, source_updated_at,
verification_status, verified_at, signing_key_fingerprint, is_flagged, flag_reason,
ingested_at, content_digest
) VALUES (
@Id, @SourceStatementId, @SourceId, @SourceDocumentId, @VulnerabilityId,
@VulnerabilityAliases, @ProductKey, @Status, @Justification, @StatusNotes,
@ImpactStatement, @ActionStatement, @Versions::jsonb, @IssuedAt, @SourceUpdatedAt,
@VerificationStatus, @VerifiedAt, @SigningKeyFingerprint, @IsFlagged, @FlagReason,
@IngestedAt, @ContentDigest
)
ON CONFLICT (source_id, source_statement_id, vulnerability_id, product_key)
DO UPDATE SET
source_document_id = EXCLUDED.source_document_id,
vulnerability_aliases = EXCLUDED.vulnerability_aliases,
status = EXCLUDED.status,
justification = EXCLUDED.justification,
status_notes = EXCLUDED.status_notes,
impact_statement = EXCLUDED.impact_statement,
action_statement = EXCLUDED.action_statement,
versions = EXCLUDED.versions,
issued_at = EXCLUDED.issued_at,
source_updated_at = EXCLUDED.source_updated_at,
verification_status = EXCLUDED.verification_status,
verified_at = EXCLUDED.verified_at,
signing_key_fingerprint = EXCLUDED.signing_key_fingerprint,
content_digest = EXCLUDED.content_digest
RETURNING id
""";
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken);
var entity = ToEntity(statement);
var id = await connection.ExecuteScalarAsync<Guid>(sql, entity);
return statement with { Id = id };
}
public async Task<int> BulkUpsertAsync(
IEnumerable<AggregatedVexStatement> statements,
CancellationToken cancellationToken = default)
{
var count = 0;
foreach (var statement in statements)
{
await UpsertAsync(statement, cancellationToken);
count++;
}
return count;
}
public async Task<AggregatedVexStatement?> GetByIdAsync(
Guid id,
CancellationToken cancellationToken = default)
{
const string sql = "SELECT * FROM vexhub.statements WHERE id = @Id";
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken);
var entity = await connection.QueryFirstOrDefaultAsync<VexStatementEntity>(sql, new { Id = id });
return entity is null ? null : ToModel(entity);
}
public async Task<IReadOnlyList<AggregatedVexStatement>> GetByCveAsync(
string cveId,
int? limit = null,
int? offset = null,
CancellationToken cancellationToken = default)
{
var sql = """
SELECT * FROM vexhub.statements
WHERE vulnerability_id = @CveId OR @CveId = ANY(vulnerability_aliases)
ORDER BY ingested_at DESC
""";
if (limit.HasValue)
sql += " LIMIT @Limit";
if (offset.HasValue)
sql += " OFFSET @Offset";
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken);
var entities = await connection.QueryAsync<VexStatementEntity>(
sql, new { CveId = cveId, Limit = limit, Offset = offset });
return entities.Select(ToModel).ToList();
}
public async Task<IReadOnlyList<AggregatedVexStatement>> GetByPackageAsync(
string purl,
int? limit = null,
int? offset = null,
CancellationToken cancellationToken = default)
{
var sql = """
SELECT * FROM vexhub.statements
WHERE product_key = @Purl
ORDER BY ingested_at DESC
""";
if (limit.HasValue)
sql += " LIMIT @Limit";
if (offset.HasValue)
sql += " OFFSET @Offset";
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken);
var entities = await connection.QueryAsync<VexStatementEntity>(
sql, new { Purl = purl, Limit = limit, Offset = offset });
return entities.Select(ToModel).ToList();
}
public async Task<IReadOnlyList<AggregatedVexStatement>> GetBySourceAsync(
string sourceId,
int? limit = null,
int? offset = null,
CancellationToken cancellationToken = default)
{
var sql = """
SELECT * FROM vexhub.statements
WHERE source_id = @SourceId
ORDER BY ingested_at DESC
""";
if (limit.HasValue)
sql += " LIMIT @Limit";
if (offset.HasValue)
sql += " OFFSET @Offset";
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken);
var entities = await connection.QueryAsync<VexStatementEntity>(
sql, new { SourceId = sourceId, Limit = limit, Offset = offset });
return entities.Select(ToModel).ToList();
}
public async Task<bool> ExistsByDigestAsync(
string contentDigest,
CancellationToken cancellationToken = default)
{
const string sql = "SELECT EXISTS(SELECT 1 FROM vexhub.statements WHERE content_digest = @Digest)";
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken);
return await connection.ExecuteScalarAsync<bool>(sql, new { Digest = contentDigest });
}
public async Task<long> GetCountAsync(
VexStatementFilter? filter = null,
CancellationToken cancellationToken = default)
{
var sql = "SELECT COUNT(*) FROM vexhub.statements WHERE 1=1";
var parameters = new DynamicParameters();
if (filter is not null)
sql = ApplyFilter(sql, filter, parameters);
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken);
return await connection.ExecuteScalarAsync<long>(sql, parameters);
}
public async Task<IReadOnlyList<AggregatedVexStatement>> SearchAsync(
VexStatementFilter filter,
int? limit = null,
int? offset = null,
CancellationToken cancellationToken = default)
{
var sql = "SELECT * FROM vexhub.statements WHERE 1=1";
var parameters = new DynamicParameters();
sql = ApplyFilter(sql, filter, parameters);
sql += " ORDER BY ingested_at DESC";
if (limit.HasValue)
{
sql += " LIMIT @Limit";
parameters.Add("Limit", limit);
}
if (offset.HasValue)
{
sql += " OFFSET @Offset";
parameters.Add("Offset", offset);
}
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken);
var entities = await connection.QueryAsync<VexStatementEntity>(sql, parameters);
return entities.Select(ToModel).ToList();
}
public async Task FlagStatementAsync(
Guid id,
string reason,
CancellationToken cancellationToken = default)
{
const string sql = """
UPDATE vexhub.statements
SET is_flagged = TRUE, flag_reason = @Reason
WHERE id = @Id
""";
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken);
await connection.ExecuteAsync(sql, new { Id = id, Reason = reason });
}
public async Task<int> DeleteBySourceAsync(
string sourceId,
CancellationToken cancellationToken = default)
{
const string sql = "DELETE FROM vexhub.statements WHERE source_id = @SourceId";
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken);
return await connection.ExecuteAsync(sql, new { SourceId = sourceId });
}
private static string ApplyFilter(string sql, VexStatementFilter filter, DynamicParameters parameters)
{
if (!string.IsNullOrEmpty(filter.SourceId))
{
sql += " AND source_id = @SourceId";
parameters.Add("SourceId", filter.SourceId);
}
if (!string.IsNullOrEmpty(filter.VulnerabilityId))
{
sql += " AND vulnerability_id = @VulnerabilityId";
parameters.Add("VulnerabilityId", filter.VulnerabilityId);
}
if (!string.IsNullOrEmpty(filter.ProductKey))
{
sql += " AND product_key = @ProductKey";
parameters.Add("ProductKey", filter.ProductKey);
}
if (filter.Status.HasValue)
{
sql += " AND status = @Status";
parameters.Add("Status", filter.Status.Value.ToString().ToLowerInvariant());
}
if (filter.VerificationStatus.HasValue)
{
sql += " AND verification_status = @VerificationStatus";
parameters.Add("VerificationStatus", filter.VerificationStatus.Value.ToString().ToLowerInvariant());
}
if (filter.IsFlagged.HasValue)
{
sql += " AND is_flagged = @IsFlagged";
parameters.Add("IsFlagged", filter.IsFlagged.Value);
}
if (filter.IngestedAfter.HasValue)
{
sql += " AND ingested_at >= @IngestedAfter";
parameters.Add("IngestedAfter", filter.IngestedAfter.Value);
}
if (filter.IngestedBefore.HasValue)
{
sql += " AND ingested_at <= @IngestedBefore";
parameters.Add("IngestedBefore", filter.IngestedBefore.Value);
}
if (filter.UpdatedAfter.HasValue)
{
sql += " AND source_updated_at >= @UpdatedAfter";
parameters.Add("UpdatedAfter", filter.UpdatedAfter.Value);
}
return sql;
}
private static VexStatementEntity ToEntity(AggregatedVexStatement model) => new()
{
Id = model.Id,
SourceStatementId = model.SourceStatementId,
SourceId = model.SourceId,
SourceDocumentId = model.SourceDocumentId,
VulnerabilityId = model.VulnerabilityId,
VulnerabilityAliases = model.VulnerabilityAliases?.ToArray(),
ProductKey = model.ProductKey,
Status = model.Status.ToString().ToLowerInvariant().Replace("notaffected", "not_affected").Replace("underinvestigation", "under_investigation"),
Justification = model.Justification?.ToString().ToLowerInvariant().Replace("componentnotpresent", "component_not_present")
.Replace("vulnerablecodenotpresent", "vulnerable_code_not_present")
.Replace("vulnerablecodenotinexecutepath", "vulnerable_code_not_in_execute_path")
.Replace("vulnerablecodecannotbecontrolledbyadversary", "vulnerable_code_cannot_be_controlled_by_adversary")
.Replace("inlinemitigationsalreadyexist", "inline_mitigations_already_exist"),
StatusNotes = model.StatusNotes,
ImpactStatement = model.ImpactStatement,
ActionStatement = model.ActionStatement,
Versions = model.Versions is not null ? JsonSerializer.Serialize(model.Versions) : null,
IssuedAt = model.IssuedAt,
SourceUpdatedAt = model.SourceUpdatedAt,
VerificationStatus = model.VerificationStatus.ToString().ToLowerInvariant(),
VerifiedAt = model.VerifiedAt,
SigningKeyFingerprint = model.SigningKeyFingerprint,
IsFlagged = model.IsFlagged,
FlagReason = model.FlagReason,
IngestedAt = model.IngestedAt,
UpdatedAt = model.UpdatedAt,
ContentDigest = model.ContentDigest
};
private static AggregatedVexStatement ToModel(VexStatementEntity entity) => new()
{
Id = entity.Id,
SourceStatementId = entity.SourceStatementId,
SourceId = entity.SourceId,
SourceDocumentId = entity.SourceDocumentId,
VulnerabilityId = entity.VulnerabilityId,
VulnerabilityAliases = entity.VulnerabilityAliases?.ToList(),
ProductKey = entity.ProductKey,
Status = ParseStatus(entity.Status),
Justification = entity.Justification is not null ? ParseJustification(entity.Justification) : null,
StatusNotes = entity.StatusNotes,
ImpactStatement = entity.ImpactStatement,
ActionStatement = entity.ActionStatement,
Versions = entity.Versions is not null ? JsonSerializer.Deserialize<VersionRange>(entity.Versions) : null,
IssuedAt = entity.IssuedAt,
SourceUpdatedAt = entity.SourceUpdatedAt,
VerificationStatus = Enum.Parse<VerificationStatus>(entity.VerificationStatus, ignoreCase: true),
VerifiedAt = entity.VerifiedAt,
SigningKeyFingerprint = entity.SigningKeyFingerprint,
IsFlagged = entity.IsFlagged,
FlagReason = entity.FlagReason,
IngestedAt = entity.IngestedAt,
UpdatedAt = entity.UpdatedAt,
ContentDigest = entity.ContentDigest
};
private static VexStatus ParseStatus(string status) => status switch
{
"not_affected" => VexStatus.NotAffected,
"affected" => VexStatus.Affected,
"fixed" => VexStatus.Fixed,
"under_investigation" => VexStatus.UnderInvestigation,
_ => throw new ArgumentException($"Unknown status: {status}")
};
private static VexJustification ParseJustification(string justification) => justification switch
{
"component_not_present" => VexJustification.ComponentNotPresent,
"vulnerable_code_not_present" => VexJustification.VulnerableCodeNotPresent,
"vulnerable_code_not_in_execute_path" => VexJustification.VulnerableCodeNotInExecutePath,
"vulnerable_code_cannot_be_controlled_by_adversary" => VexJustification.VulnerableCodeCannotBeControlledByAdversary,
"inline_mitigations_already_exist" => VexJustification.InlineMitigationsAlreadyExist,
_ => throw new ArgumentException($"Unknown justification: {justification}")
};
}

View File

@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="utf-8"?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
<RootNamespace>StellaOps.VexHub.Storage.Postgres</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Dapper" Version="2.1.35" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
</ItemGroup>
<ItemGroup>
<EmbeddedResource Include="Migrations\**\*.sql" LogicalName="%(RecursiveDir)%(Filename)%(Extension)" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Infrastructure.Postgres\StellaOps.Infrastructure.Postgres.csproj" />
<ProjectReference Include="..\..\..\__Libraries\StellaOps.DependencyInjection\StellaOps.DependencyInjection.csproj" />
<ProjectReference Include="..\StellaOps.VexHub.Core\StellaOps.VexHub.Core.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,50 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Npgsql;
using StellaOps.Infrastructure.Postgres.Connections;
using StellaOps.Infrastructure.Postgres.Options;
namespace StellaOps.VexHub.Storage.Postgres;
/// <summary>
/// PostgreSQL data source for the VexHub module.
/// Manages connections for VEX aggregation, storage, and distribution.
/// </summary>
/// <remarks>
/// The VexHub module stores global VEX statements that are not tenant-scoped.
/// VEX statements and their provenance are shared across all tenants.
/// </remarks>
public sealed class VexHubDataSource : DataSourceBase
{
/// <summary>
/// Default schema name for VexHub tables.
/// </summary>
public const string DefaultSchemaName = "vexhub";
/// <summary>
/// Creates a new VexHub data source.
/// </summary>
public VexHubDataSource(IOptions<PostgresOptions> options, ILogger<VexHubDataSource> logger)
: base(CreateOptions(options.Value), logger)
{
}
/// <inheritdoc />
protected override string ModuleName => "VexHub";
/// <inheritdoc />
protected override void ConfigureDataSourceBuilder(NpgsqlDataSourceBuilder builder)
{
base.ConfigureDataSourceBuilder(builder);
// Enable JSON support for JSONB columns
}
private static PostgresOptions CreateOptions(PostgresOptions baseOptions)
{
if (string.IsNullOrWhiteSpace(baseOptions.SchemaName))
{
baseOptions.SchemaName = DefaultSchemaName;
}
return baseOptions;
}
}

View File

@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<IsPackable>false</IsPackable>
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
<RootNamespace>StellaOps.VexHub.Core.Tests</RootNamespace>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../../__Libraries/StellaOps.VexHub.Core/StellaOps.VexHub.Core.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<IsPackable>false</IsPackable>
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
<RootNamespace>StellaOps.VexHub.Storage.Postgres.Tests</RootNamespace>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../../__Libraries/StellaOps.VexHub.Storage.Postgres/StellaOps.VexHub.Storage.Postgres.csproj" />
<ProjectReference Include="../../../__Libraries/StellaOps.Infrastructure.Postgres.Testing/StellaOps.Infrastructure.Postgres.Testing.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,256 @@
# VexHub Tool Compatibility Test Plan
## Overview
This document describes manual and automated tests for verifying VexHub compatibility with Trivy and Grype vulnerability scanners.
## Prerequisites
1. VexHub WebService running at `http://localhost:5200`
2. Trivy installed (`aquasecurity/trivy` or via package manager)
3. Grype installed (`anchore/grype` or via package manager)
4. Docker available for container image scanning
5. Test VEX data loaded into VexHub
## Test Environment Setup
```bash
# Start VexHub WebService
cd src/VexHub/StellaOps.VexHub.WebService
dotnet run
# Verify VexHub is running
curl http://localhost:5200/health
# Expected: {"status":"Healthy","service":"VexHub"}
```
## Test 1: Trivy VEX URL Integration (HUB-023)
### 1.1 Basic VEX Fetch
```bash
# Verify VexHub export endpoint returns valid OpenVEX
curl -H "Accept: application/vnd.openvex+json" \
http://localhost:5200/api/v1/vex/export | jq '.["@context"]'
# Expected: "https://openvex.dev/ns/v0.2.0"
```
### 1.2 Trivy with VEX URL
```bash
# Scan Alpine with VexHub VEX
trivy image --vex http://localhost:5200/api/v1/vex/export alpine:3.18 --format json > trivy-with-vex.json
# Scan same image without VEX
trivy image alpine:3.18 --format json > trivy-without-vex.json
# Compare vulnerability counts
jq '.Results[].Vulnerabilities | length' trivy-with-vex.json
jq '.Results[].Vulnerabilities | length' trivy-without-vex.json
```
### 1.3 Trivy VEX Matching
Load a test VEX statement for a known vulnerability, then verify Trivy respects it:
```bash
# Get list of vulnerabilities from a scan
trivy image alpine:3.18 --format json | jq '.Results[].Vulnerabilities[].VulnerabilityID' | head -5
# Add a VEX statement for one of those CVEs (via VexHub admin API or direct DB insert)
# Then rescan with VEX - the CVE should be filtered
trivy image --vex http://localhost:5200/api/v1/vex/export alpine:3.18 --format json | \
jq '[.Results[].Vulnerabilities[].VulnerabilityID] | contains(["CVE-XXXX-XXXX"])'
# Expected: false (CVE should be filtered by VEX)
```
### 1.4 Expected Results
| Test Case | Pass Criteria |
|-----------|---------------|
| VEX endpoint returns valid OpenVEX | `@context` is `https://openvex.dev/ns/v0.2.0` |
| Trivy accepts VEX URL | No errors fetching VEX |
| VEX filtering works | Vulnerability count reduced when VEX applied |
| VEX matching by CVE | Specific CVE with `not_affected` status is hidden |
## Test 2: Grype VEX Integration (HUB-024)
### 2.1 Download and Validate VEX
```bash
# Download VEX from VexHub
curl -H "Accept: application/vnd.openvex+json" \
http://localhost:5200/api/v1/vex/export > vexhub.openvex.json
# Validate OpenVEX structure
jq -e '."@context" and .statements' vexhub.openvex.json && echo "Valid OpenVEX"
```
### 2.2 Grype with VEX File
```bash
# Scan without VEX
grype alpine:3.18 --output json > grype-without-vex.json
# Scan with VEX
grype alpine:3.18 --vex vexhub.openvex.json --output json > grype-with-vex.json
# Compare vulnerability counts
jq '.matches | length' grype-without-vex.json
jq '.matches | length' grype-with-vex.json
```
### 2.3 Grype VEX Matching
```bash
# Check specific vulnerability is filtered
jq '[.matches[].vulnerability.id] | contains(["CVE-XXXX-XXXX"])' grype-with-vex.json
# Expected: false (if VEX marks it as not_affected)
```
### 2.4 Expected Results
| Test Case | Pass Criteria |
|-----------|---------------|
| VEX file is valid OpenVEX | No parse errors from Grype |
| Grype accepts VEX file | `--vex` flag works without errors |
| VEX filtering works | Vulnerability count reduced |
| VEX matching by PURL | Package-specific VEX is applied |
## Test 3: API Key Authentication
### 3.1 Anonymous Access
```bash
# Should work with default rate limit
for i in {1..10}; do
curl -s -o /dev/null -w "%{http_code}\n" http://localhost:5200/api/v1/vex/export
done
# Expected: All 200 (within rate limit)
```
### 3.2 Authenticated Access
```bash
# With API key, should have higher rate limit
API_KEY="test-api-key"
for i in {1..10}; do
curl -s -o /dev/null -w "%{http_code}\n" \
-H "X-Api-Key: $API_KEY" http://localhost:5200/api/v1/vex/export
done
```
### 3.3 Rate Limit Headers
```bash
curl -v http://localhost:5200/api/v1/vex/export 2>&1 | grep -E "X-RateLimit"
# Expected: X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset headers
```
## Test 4: Index Manifest
### 4.1 Verify Index Manifest
```bash
curl http://localhost:5200/api/v1/vex/index | jq .
# Expected structure:
# {
# "version": "1.0",
# "lastUpdated": "...",
# "sources": [...],
# "totalStatements": N,
# "endpoints": {...}
# }
```
## Automated Test Script
Save as `test-tool-compat.sh`:
```bash
#!/bin/bash
set -e
VEXHUB_URL="${VEXHUB_URL:-http://localhost:5200}"
FAILURES=0
echo "=== VexHub Tool Compatibility Tests ==="
echo "VexHub URL: $VEXHUB_URL"
# Test 1: Health check
echo -n "Health check... "
if curl -sf "$VEXHUB_URL/health" | grep -q "Healthy"; then
echo "PASS"
else
echo "FAIL"; ((FAILURES++))
fi
# Test 2: Index manifest
echo -n "Index manifest... "
if curl -sf "$VEXHUB_URL/api/v1/vex/index" | jq -e '.version' > /dev/null; then
echo "PASS"
else
echo "FAIL"; ((FAILURES++))
fi
# Test 3: Export endpoint
echo -n "Export endpoint... "
if curl -sf -H "Accept: application/vnd.openvex+json" "$VEXHUB_URL/api/v1/vex/export" | \
jq -e '.["@context"]' > /dev/null; then
echo "PASS"
else
echo "FAIL"; ((FAILURES++))
fi
# Test 4: Rate limit headers
echo -n "Rate limit headers... "
if curl -sI "$VEXHUB_URL/api/v1/vex/export" | grep -q "X-RateLimit-Limit"; then
echo "PASS"
else
echo "FAIL"; ((FAILURES++))
fi
# Test 5: Trivy integration (if available)
if command -v trivy &> /dev/null; then
echo -n "Trivy VEX integration... "
curl -sf -H "Accept: application/vnd.openvex+json" "$VEXHUB_URL/api/v1/vex/export" > /tmp/vexhub.openvex.json
if trivy image --vex /tmp/vexhub.openvex.json alpine:3.18 --quiet 2>/dev/null; then
echo "PASS"
else
echo "FAIL"; ((FAILURES++))
fi
else
echo "Trivy integration... SKIP (trivy not installed)"
fi
# Test 6: Grype integration (if available)
if command -v grype &> /dev/null; then
echo -n "Grype VEX integration... "
curl -sf -H "Accept: application/vnd.openvex+json" "$VEXHUB_URL/api/v1/vex/export" > /tmp/vexhub.openvex.json
if grype alpine:3.18 --vex /tmp/vexhub.openvex.json --quiet 2>/dev/null; then
echo "PASS"
else
echo "FAIL"; ((FAILURES++))
fi
else
echo "Grype integration... SKIP (grype not installed)"
fi
echo ""
if [ $FAILURES -eq 0 ]; then
echo "All tests passed!"
exit 0
else
echo "$FAILURES test(s) failed"
exit 1
fi
```
## Sign-off
| Test | Date | Tester | Result | Notes |
|------|------|--------|--------|-------|
| Trivy VEX URL (HUB-023) | | | | |
| Grype VEX File (HUB-024) | | | | |
| API Key Auth | | | | |
| Rate Limiting | | | | |

View File

@@ -0,0 +1,192 @@
using System.Net;
using System.Text.Json;
using FluentAssertions;
using Microsoft.AspNetCore.Mvc.Testing;
using Xunit;
namespace StellaOps.VexHub.WebService.Tests.Integration;
/// <summary>
/// Integration tests verifying VexHub API compatibility with Trivy and Grype.
/// These tests ensure the API endpoints return valid OpenVEX format that can be consumed by scanning tools.
/// </summary>
public sealed class VexExportCompatibilityTests : IClassFixture<WebApplicationFactory<Program>>
{
private readonly HttpClient _client;
public VexExportCompatibilityTests(WebApplicationFactory<Program> factory)
{
_client = factory.CreateClient();
}
[Fact]
public async Task HealthEndpoint_ReturnsHealthy()
{
// Act
var response = await _client.GetAsync("/health");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var content = await response.Content.ReadAsStringAsync();
content.Should().Contain("Healthy");
}
[Fact]
public async Task IndexEndpoint_ReturnsValidManifest()
{
// Act
var response = await _client.GetAsync("/api/v1/vex/index");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var content = await response.Content.ReadAsStringAsync();
var doc = JsonDocument.Parse(content);
doc.RootElement.TryGetProperty("version", out _).Should().BeTrue();
doc.RootElement.TryGetProperty("endpoints", out _).Should().BeTrue();
}
[Fact]
public async Task ExportEndpoint_ReturnsValidOpenVexFormat()
{
// Arrange
_client.DefaultRequestHeaders.Add("Accept", "application/vnd.openvex+json");
// Act
var response = await _client.GetAsync("/api/v1/vex/export");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var content = await response.Content.ReadAsStringAsync();
var doc = JsonDocument.Parse(content);
// Verify OpenVEX required fields
doc.RootElement.TryGetProperty("@context", out var context).Should().BeTrue();
context.GetString().Should().Contain("openvex");
doc.RootElement.TryGetProperty("statements", out var statements).Should().BeTrue();
statements.ValueKind.Should().Be(JsonValueKind.Array);
}
[Fact]
public async Task ExportEndpoint_IncludesRateLimitHeaders()
{
// Act
var response = await _client.GetAsync("/api/v1/vex/export");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
response.Headers.Should().ContainKey("X-RateLimit-Limit");
response.Headers.Should().ContainKey("X-RateLimit-Remaining");
response.Headers.Should().ContainKey("X-RateLimit-Reset");
}
[Fact]
public async Task CveEndpoint_ReturnsValidResponse()
{
// Act
var response = await _client.GetAsync("/api/v1/vex/cve/CVE-2024-0001");
// Assert
// May return 200 with empty results or 404 if no data
response.StatusCode.Should().BeOneOf(HttpStatusCode.OK, HttpStatusCode.NotFound);
}
[Fact]
public async Task PackageEndpoint_ReturnsValidResponse()
{
// Arrange - URL encode the PURL
var purl = Uri.EscapeDataString("pkg:npm/express@4.17.1");
// Act
var response = await _client.GetAsync($"/api/v1/vex/package/{purl}");
// Assert
response.StatusCode.Should().BeOneOf(HttpStatusCode.OK, HttpStatusCode.NotFound);
}
[Fact]
public async Task SourceEndpoint_ReturnsValidResponse()
{
// Act
var response = await _client.GetAsync("/api/v1/vex/source/redhat-csaf");
// Assert
response.StatusCode.Should().BeOneOf(HttpStatusCode.OK, HttpStatusCode.NotFound);
}
[Fact]
public async Task ExportEndpoint_SupportsPagination()
{
// Act
var response = await _client.GetAsync("/api/v1/vex/export?pageSize=10");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var content = await response.Content.ReadAsStringAsync();
var doc = JsonDocument.Parse(content);
// Should have statements array (may be empty)
doc.RootElement.TryGetProperty("statements", out var statements).Should().BeTrue();
statements.GetArrayLength().Should().BeLessThanOrEqualTo(10);
}
[Fact]
public async Task OpenVexFormat_HasRequiredTrivyFields()
{
// This test verifies the OpenVEX format contains all fields required by Trivy
// Reference: https://aquasecurity.github.io/trivy/latest/docs/supply-chain/vex/openvex/
// Arrange
_client.DefaultRequestHeaders.Add("Accept", "application/vnd.openvex+json");
// Act
var response = await _client.GetAsync("/api/v1/vex/export");
var content = await response.Content.ReadAsStringAsync();
var doc = JsonDocument.Parse(content);
// Assert - Required OpenVEX fields for Trivy compatibility
doc.RootElement.GetProperty("@context").GetString().Should().NotBeNullOrEmpty();
if (doc.RootElement.GetProperty("statements").GetArrayLength() > 0)
{
var statement = doc.RootElement.GetProperty("statements")[0];
// Trivy requires vulnerability identifier
statement.TryGetProperty("vulnerability", out _).Should().BeTrue();
// Trivy requires status
statement.TryGetProperty("status", out var status).Should().BeTrue();
var validStatuses = new[] { "not_affected", "affected", "fixed", "under_investigation" };
validStatuses.Should().Contain(status.GetString());
}
}
[Fact]
public async Task OpenVexFormat_HasRequiredGrypeFields()
{
// This test verifies the OpenVEX format contains all fields required by Grype
// Reference: https://github.com/anchore/grype#using-vex
// Arrange
_client.DefaultRequestHeaders.Add("Accept", "application/vnd.openvex+json");
// Act
var response = await _client.GetAsync("/api/v1/vex/export");
var content = await response.Content.ReadAsStringAsync();
var doc = JsonDocument.Parse(content);
// Assert - Required OpenVEX fields for Grype compatibility
doc.RootElement.GetProperty("@context").GetString().Should().Contain("openvex");
if (doc.RootElement.GetProperty("statements").GetArrayLength() > 0)
{
var statement = doc.RootElement.GetProperty("statements")[0];
// Grype matches on vulnerability ID and products
statement.TryGetProperty("vulnerability", out _).Should().BeTrue();
statement.TryGetProperty("products", out _).Should().BeTrue();
statement.TryGetProperty("status", out _).Should().BeTrue();
}
}
}

View File

@@ -0,0 +1,113 @@
# VexHub Tool Compatibility Test Script (PowerShell)
# Usage: .\test-tool-compat.ps1 [-VexHubUrl "http://localhost:5200"]
param(
[string]$VexHubUrl = "http://localhost:5200"
)
$ErrorActionPreference = "Stop"
$failures = 0
Write-Host "=== VexHub Tool Compatibility Tests ===" -ForegroundColor Cyan
Write-Host "VexHub URL: $VexHubUrl"
Write-Host ""
function Test-Endpoint {
param(
[string]$Name,
[scriptblock]$Test
)
Write-Host -NoNewline "$Name... "
try {
$result = & $Test
if ($result) {
Write-Host "PASS" -ForegroundColor Green
return $true
} else {
Write-Host "FAIL" -ForegroundColor Red
return $false
}
} catch {
Write-Host "FAIL ($_)" -ForegroundColor Red
return $false
}
}
# Test 1: Health check
if (-not (Test-Endpoint "Health check" {
$response = Invoke-RestMethod -Uri "$VexHubUrl/health" -Method Get
$response.status -eq "Healthy"
})) { $failures++ }
# Test 2: Index manifest
if (-not (Test-Endpoint "Index manifest" {
$response = Invoke-RestMethod -Uri "$VexHubUrl/api/v1/vex/index" -Method Get
$null -ne $response.version
})) { $failures++ }
# Test 3: Export endpoint (OpenVEX format)
if (-not (Test-Endpoint "Export endpoint" {
$headers = @{ "Accept" = "application/vnd.openvex+json" }
$response = Invoke-RestMethod -Uri "$VexHubUrl/api/v1/vex/export" -Method Get -Headers $headers
$response.'@context' -like "*openvex*"
})) { $failures++ }
# Test 4: Rate limit headers
if (-not (Test-Endpoint "Rate limit headers" {
$response = Invoke-WebRequest -Uri "$VexHubUrl/api/v1/vex/export" -Method Get
$response.Headers.ContainsKey("X-RateLimit-Limit")
})) { $failures++ }
# Test 5: CVE query endpoint
if (-not (Test-Endpoint "CVE query endpoint" {
try {
$response = Invoke-RestMethod -Uri "$VexHubUrl/api/v1/vex/cve/CVE-2024-0001" -Method Get
$true # Endpoint exists (may return empty results)
} catch {
if ($_.Exception.Response.StatusCode -eq 404) {
$true # 404 is OK - means endpoint works, no data
} else {
$false
}
}
})) { $failures++ }
# Test 6: Trivy integration (if available)
$trivyPath = Get-Command trivy -ErrorAction SilentlyContinue
if ($trivyPath) {
if (-not (Test-Endpoint "Trivy VEX integration" {
$headers = @{ "Accept" = "application/vnd.openvex+json" }
$vexContent = Invoke-RestMethod -Uri "$VexHubUrl/api/v1/vex/export" -Method Get -Headers $headers
$vexPath = Join-Path $env:TEMP "vexhub.openvex.json"
$vexContent | ConvertTo-Json -Depth 10 | Set-Content $vexPath
$trivyResult = & trivy image --vex $vexPath alpine:3.18 --quiet 2>&1
$LASTEXITCODE -eq 0
})) { $failures++ }
} else {
Write-Host "Trivy integration... SKIP (trivy not installed)" -ForegroundColor Yellow
}
# Test 7: Grype integration (if available)
$grypePath = Get-Command grype -ErrorAction SilentlyContinue
if ($grypePath) {
if (-not (Test-Endpoint "Grype VEX integration" {
$headers = @{ "Accept" = "application/vnd.openvex+json" }
$vexContent = Invoke-RestMethod -Uri "$VexHubUrl/api/v1/vex/export" -Method Get -Headers $headers
$vexPath = Join-Path $env:TEMP "vexhub.openvex.json"
$vexContent | ConvertTo-Json -Depth 10 | Set-Content $vexPath
$grypeResult = & grype alpine:3.18 --vex $vexPath --quiet 2>&1
$LASTEXITCODE -eq 0
})) { $failures++ }
} else {
Write-Host "Grype integration... SKIP (grype not installed)" -ForegroundColor Yellow
}
Write-Host ""
if ($failures -eq 0) {
Write-Host "All tests passed!" -ForegroundColor Green
exit 0
} else {
Write-Host "$failures test(s) failed" -ForegroundColor Red
exit 1
}

View File

@@ -0,0 +1,28 @@
<?xml version="1.0" encoding="utf-8"?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
<RootNamespace>StellaOps.VexHub.WebService.Tests</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Include="FluentAssertions" Version="7.0.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../../StellaOps.VexHub.WebService/StellaOps.VexHub.WebService.csproj" />
</ItemGroup>
<ItemGroup>
<None Update="Integration\ToolCompatibilityTestPlan.md">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="Integration\test-tool-compat.ps1">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>