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:
@@ -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"
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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>();
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
73
src/VexHub/StellaOps.VexHub.WebService/Program.cs
Normal file
73
src/VexHub/StellaOps.VexHub.WebService/Program.cs
Normal 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();
|
||||
@@ -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>
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Debug",
|
||||
"Microsoft.AspNetCore": "Information"
|
||||
}
|
||||
},
|
||||
"Serilog": {
|
||||
"MinimumLevel": {
|
||||
"Default": "Debug"
|
||||
}
|
||||
}
|
||||
}
|
||||
54
src/VexHub/StellaOps.VexHub.WebService/appsettings.json
Normal file
54
src/VexHub/StellaOps.VexHub.WebService/appsettings.json
Normal 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": "*"
|
||||
}
|
||||
61
src/VexHub/StellaOps.VexHub.sln
Normal file
61
src/VexHub/StellaOps.VexHub.sln
Normal 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
|
||||
@@ -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. |
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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++;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))}";
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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}")
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 | | | | |
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user