feat: Implement vulnerability token signing and verification utilities
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled

- Added VulnTokenSigner for signing JWT tokens with specified algorithms and keys.
- Introduced VulnTokenUtilities for resolving tenant and subject claims, and sanitizing context dictionaries.
- Created VulnTokenVerificationUtilities for parsing tokens, verifying signatures, and deserializing payloads.
- Developed VulnWorkflowAntiForgeryTokenIssuer for issuing anti-forgery tokens with configurable options.
- Implemented VulnWorkflowAntiForgeryTokenVerifier for verifying anti-forgery tokens and validating payloads.
- Added AuthorityVulnerabilityExplorerOptions to manage configuration for vulnerability explorer features.
- Included tests for FilesystemPackRunDispatcher to ensure proper job handling under egress policy restrictions.
This commit is contained in:
master
2025-11-03 10:02:29 +02:00
parent bf2bf4b395
commit b1e78fe412
215 changed files with 19441 additions and 12185 deletions

View File

@@ -1,173 +1,173 @@
using System.ComponentModel.DataAnnotations;
using System.IO;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Scheduler.WebService.Options;
namespace StellaOps.Scheduler.WebService.EventWebhooks;
public static class EventWebhookEndpointExtensions
{
public static void MapSchedulerEventWebhookEndpoints(this IEndpointRouteBuilder builder)
{
var group = builder.MapGroup("/events");
group.MapPost("/feedser-export", HandleFeedserExportAsync);
group.MapPost("/vexer-export", HandleVexerExportAsync);
}
private static async Task<IResult> HandleFeedserExportAsync(
HttpContext httpContext,
[FromServices] IOptionsMonitor<SchedulerEventsOptions> options,
[FromServices] IWebhookRequestAuthenticator authenticator,
[FromServices] IWebhookRateLimiter rateLimiter,
[FromServices] IInboundExportEventSink sink,
CancellationToken cancellationToken)
{
var webhookOptions = options.CurrentValue.Webhooks.Feedser;
if (!webhookOptions.Enabled)
{
return Results.StatusCode(StatusCodes.Status503ServiceUnavailable);
}
var readResult = await ReadPayloadAsync<FeedserExportEventRequest>(httpContext, cancellationToken).ConfigureAwait(false);
if (!readResult.Succeeded)
{
return readResult.ErrorResult!;
}
if (!rateLimiter.TryAcquire("feedser", webhookOptions.RateLimitRequests, webhookOptions.GetRateLimitWindow(), out var retryAfter))
{
var response = Results.StatusCode(StatusCodes.Status429TooManyRequests);
if (retryAfter > TimeSpan.Zero)
{
httpContext.Response.Headers.RetryAfter = ((int)Math.Ceiling(retryAfter.TotalSeconds)).ToString();
}
return response;
}
var authResult = await authenticator.AuthenticateAsync(httpContext, readResult.RawBody, webhookOptions, cancellationToken).ConfigureAwait(false);
if (!authResult.Succeeded)
{
return authResult.ToResult();
}
try
{
await sink.HandleFeedserAsync(readResult.Payload!, cancellationToken).ConfigureAwait(false);
return Results.Accepted(value: new { status = "accepted" });
}
catch (ValidationException ex)
{
return Results.BadRequest(new { error = ex.Message });
}
}
private static async Task<IResult> HandleVexerExportAsync(
HttpContext httpContext,
[FromServices] IOptionsMonitor<SchedulerEventsOptions> options,
[FromServices] IWebhookRequestAuthenticator authenticator,
[FromServices] IWebhookRateLimiter rateLimiter,
[FromServices] IInboundExportEventSink sink,
CancellationToken cancellationToken)
{
var webhookOptions = options.CurrentValue.Webhooks.Vexer;
if (!webhookOptions.Enabled)
{
return Results.StatusCode(StatusCodes.Status503ServiceUnavailable);
}
var readResult = await ReadPayloadAsync<VexerExportEventRequest>(httpContext, cancellationToken).ConfigureAwait(false);
if (!readResult.Succeeded)
{
return readResult.ErrorResult!;
}
if (!rateLimiter.TryAcquire("vexer", webhookOptions.RateLimitRequests, webhookOptions.GetRateLimitWindow(), out var retryAfter))
{
var response = Results.StatusCode(StatusCodes.Status429TooManyRequests);
if (retryAfter > TimeSpan.Zero)
{
httpContext.Response.Headers.RetryAfter = ((int)Math.Ceiling(retryAfter.TotalSeconds)).ToString();
}
return response;
}
var authResult = await authenticator.AuthenticateAsync(httpContext, readResult.RawBody, webhookOptions, cancellationToken).ConfigureAwait(false);
if (!authResult.Succeeded)
{
return authResult.ToResult();
}
try
{
await sink.HandleVexerAsync(readResult.Payload!, cancellationToken).ConfigureAwait(false);
return Results.Accepted(value: new { status = "accepted" });
}
catch (ValidationException ex)
{
return Results.BadRequest(new { error = ex.Message });
}
}
private static async Task<RequestPayload<T>> ReadPayloadAsync<T>(HttpContext context, CancellationToken cancellationToken)
{
context.Request.EnableBuffering();
await using var buffer = new MemoryStream();
await context.Request.Body.CopyToAsync(buffer, cancellationToken).ConfigureAwait(false);
var bodyBytes = buffer.ToArray();
context.Request.Body.Position = 0;
try
{
var payload = JsonSerializer.Deserialize<T>(bodyBytes, new JsonSerializerOptions(JsonSerializerDefaults.Web));
if (payload is null)
{
return RequestPayload<T>.Failed(Results.BadRequest(new { error = "Request payload cannot be empty." }));
}
return RequestPayload<T>.Success(payload, bodyBytes);
}
catch (JsonException ex)
{
return RequestPayload<T>.Failed(Results.BadRequest(new { error = ex.Message }));
}
catch (ValidationException ex)
{
return RequestPayload<T>.Failed(Results.BadRequest(new { error = ex.Message }));
}
}
private readonly struct RequestPayload<T>
{
private RequestPayload(T? payload, byte[] rawBody, IResult? error, bool succeeded)
{
Payload = payload;
RawBody = rawBody;
ErrorResult = error;
Succeeded = succeeded;
}
public T? Payload { get; }
public byte[] RawBody { get; }
public IResult? ErrorResult { get; }
public bool Succeeded { get; }
public static RequestPayload<T> Success(T payload, byte[] rawBody)
=> new(payload, rawBody, null, true);
public static RequestPayload<T> Failed(IResult error)
=> new(default, Array.Empty<byte>(), error, false);
}
}
using System.ComponentModel.DataAnnotations;
using System.IO;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Scheduler.WebService.Options;
namespace StellaOps.Scheduler.WebService.EventWebhooks;
public static class EventWebhookEndpointExtensions
{
public static void MapSchedulerEventWebhookEndpoints(this IEndpointRouteBuilder builder)
{
var group = builder.MapGroup("/events");
group.MapPost("/conselier-export", HandleConselierExportAsync);
group.MapPost("/excitor-export", HandleExcitorExportAsync);
}
private static async Task<IResult> HandleConselierExportAsync(
HttpContext httpContext,
[FromServices] IOptionsMonitor<SchedulerEventsOptions> options,
[FromServices] IWebhookRequestAuthenticator authenticator,
[FromServices] IWebhookRateLimiter rateLimiter,
[FromServices] IInboundExportEventSink sink,
CancellationToken cancellationToken)
{
var webhookOptions = options.CurrentValue.Webhooks.Conselier;
if (!webhookOptions.Enabled)
{
return Results.StatusCode(StatusCodes.Status503ServiceUnavailable);
}
var readResult = await ReadPayloadAsync<ConselierExportEventRequest>(httpContext, cancellationToken).ConfigureAwait(false);
if (!readResult.Succeeded)
{
return readResult.ErrorResult!;
}
if (!rateLimiter.TryAcquire("conselier", webhookOptions.RateLimitRequests, webhookOptions.GetRateLimitWindow(), out var retryAfter))
{
var response = Results.StatusCode(StatusCodes.Status429TooManyRequests);
if (retryAfter > TimeSpan.Zero)
{
httpContext.Response.Headers.RetryAfter = ((int)Math.Ceiling(retryAfter.TotalSeconds)).ToString();
}
return response;
}
var authResult = await authenticator.AuthenticateAsync(httpContext, readResult.RawBody, webhookOptions, cancellationToken).ConfigureAwait(false);
if (!authResult.Succeeded)
{
return authResult.ToResult();
}
try
{
await sink.HandleConselierAsync(readResult.Payload!, cancellationToken).ConfigureAwait(false);
return Results.Accepted(value: new { status = "accepted" });
}
catch (ValidationException ex)
{
return Results.BadRequest(new { error = ex.Message });
}
}
private static async Task<IResult> HandleExcitorExportAsync(
HttpContext httpContext,
[FromServices] IOptionsMonitor<SchedulerEventsOptions> options,
[FromServices] IWebhookRequestAuthenticator authenticator,
[FromServices] IWebhookRateLimiter rateLimiter,
[FromServices] IInboundExportEventSink sink,
CancellationToken cancellationToken)
{
var webhookOptions = options.CurrentValue.Webhooks.Excitor;
if (!webhookOptions.Enabled)
{
return Results.StatusCode(StatusCodes.Status503ServiceUnavailable);
}
var readResult = await ReadPayloadAsync<ExcitorExportEventRequest>(httpContext, cancellationToken).ConfigureAwait(false);
if (!readResult.Succeeded)
{
return readResult.ErrorResult!;
}
if (!rateLimiter.TryAcquire("excitor", webhookOptions.RateLimitRequests, webhookOptions.GetRateLimitWindow(), out var retryAfter))
{
var response = Results.StatusCode(StatusCodes.Status429TooManyRequests);
if (retryAfter > TimeSpan.Zero)
{
httpContext.Response.Headers.RetryAfter = ((int)Math.Ceiling(retryAfter.TotalSeconds)).ToString();
}
return response;
}
var authResult = await authenticator.AuthenticateAsync(httpContext, readResult.RawBody, webhookOptions, cancellationToken).ConfigureAwait(false);
if (!authResult.Succeeded)
{
return authResult.ToResult();
}
try
{
await sink.HandleExcitorAsync(readResult.Payload!, cancellationToken).ConfigureAwait(false);
return Results.Accepted(value: new { status = "accepted" });
}
catch (ValidationException ex)
{
return Results.BadRequest(new { error = ex.Message });
}
}
private static async Task<RequestPayload<T>> ReadPayloadAsync<T>(HttpContext context, CancellationToken cancellationToken)
{
context.Request.EnableBuffering();
await using var buffer = new MemoryStream();
await context.Request.Body.CopyToAsync(buffer, cancellationToken).ConfigureAwait(false);
var bodyBytes = buffer.ToArray();
context.Request.Body.Position = 0;
try
{
var payload = JsonSerializer.Deserialize<T>(bodyBytes, new JsonSerializerOptions(JsonSerializerDefaults.Web));
if (payload is null)
{
return RequestPayload<T>.Failed(Results.BadRequest(new { error = "Request payload cannot be empty." }));
}
return RequestPayload<T>.Success(payload, bodyBytes);
}
catch (JsonException ex)
{
return RequestPayload<T>.Failed(Results.BadRequest(new { error = ex.Message }));
}
catch (ValidationException ex)
{
return RequestPayload<T>.Failed(Results.BadRequest(new { error = ex.Message }));
}
}
private readonly struct RequestPayload<T>
{
private RequestPayload(T? payload, byte[] rawBody, IResult? error, bool succeeded)
{
Payload = payload;
RawBody = rawBody;
ErrorResult = error;
Succeeded = succeeded;
}
public T? Payload { get; }
public byte[] RawBody { get; }
public IResult? ErrorResult { get; }
public bool Succeeded { get; }
public static RequestPayload<T> Success(T payload, byte[] rawBody)
=> new(payload, rawBody, null, true);
public static RequestPayload<T> Failed(IResult error)
=> new(default, Array.Empty<byte>(), error, false);
}
}

View File

@@ -1,11 +1,11 @@
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Scheduler.WebService.EventWebhooks;
public interface IInboundExportEventSink
{
Task HandleFeedserAsync(FeedserExportEventRequest request, CancellationToken cancellationToken);
Task HandleVexerAsync(VexerExportEventRequest request, CancellationToken cancellationToken);
}
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Scheduler.WebService.EventWebhooks;
public interface IInboundExportEventSink
{
Task HandleConselierAsync(ConselierExportEventRequest request, CancellationToken cancellationToken);
Task HandleExcitorAsync(ExcitorExportEventRequest request, CancellationToken cancellationToken);
}

View File

@@ -1,33 +1,33 @@
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
namespace StellaOps.Scheduler.WebService.EventWebhooks;
internal sealed class LoggingExportEventSink : IInboundExportEventSink
{
private readonly ILogger<LoggingExportEventSink> _logger;
public LoggingExportEventSink(ILogger<LoggingExportEventSink> logger)
{
_logger = logger;
}
public Task HandleFeedserAsync(FeedserExportEventRequest request, CancellationToken cancellationToken)
{
_logger.LogInformation(
"Received Feedser export webhook {ExportId} with {ChangedProducts} product keys.",
request.ExportId,
request.ChangedProductKeys.Count);
return Task.CompletedTask;
}
public Task HandleVexerAsync(VexerExportEventRequest request, CancellationToken cancellationToken)
{
_logger.LogInformation(
"Received Vexer export webhook {ExportId} with {ChangedClaims} claim changes.",
request.ExportId,
request.ChangedClaims.Count);
return Task.CompletedTask;
}
}
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
namespace StellaOps.Scheduler.WebService.EventWebhooks;
internal sealed class LoggingExportEventSink : IInboundExportEventSink
{
private readonly ILogger<LoggingExportEventSink> _logger;
public LoggingExportEventSink(ILogger<LoggingExportEventSink> logger)
{
_logger = logger;
}
public Task HandleConselierAsync(ConselierExportEventRequest request, CancellationToken cancellationToken)
{
_logger.LogInformation(
"Received Conselier export webhook {ExportId} with {ChangedProducts} product keys.",
request.ExportId,
request.ChangedProductKeys.Count);
return Task.CompletedTask;
}
public Task HandleExcitorAsync(ExcitorExportEventRequest request, CancellationToken cancellationToken)
{
_logger.LogInformation(
"Received Excitor export webhook {ExportId} with {ChangedClaims} claim changes.",
request.ExportId,
request.ChangedClaims.Count);
return Task.CompletedTask;
}
}

View File

@@ -1,106 +1,106 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.ComponentModel.DataAnnotations;
using System.Linq;
namespace StellaOps.Scheduler.WebService.EventWebhooks;
public sealed record FeedserExportEventRequest(
string ExportId,
IReadOnlyList<string> ChangedProductKeys,
IReadOnlyList<string>? Kev,
WebhookEventWindow? Window)
{
public string ExportId { get; } = ExportId?.Trim() ?? throw new ArgumentNullException(nameof(ExportId));
public IReadOnlyList<string> ChangedProductKeys { get; } = NormalizeList(ChangedProductKeys, nameof(ChangedProductKeys));
public IReadOnlyList<string> Kev { get; } = NormalizeList(Kev, nameof(Kev), allowEmpty: true);
public WebhookEventWindow? Window { get; } = Window;
private static IReadOnlyList<string> NormalizeList(IReadOnlyList<string>? source, string propertyName, bool allowEmpty = false)
{
if (source is null)
{
if (allowEmpty)
{
return ImmutableArray<string>.Empty;
}
throw new ValidationException($"{propertyName} must be specified.");
}
var cleaned = source
.Where(item => !string.IsNullOrWhiteSpace(item))
.Select(item => item.Trim())
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
if (!allowEmpty && cleaned.Length == 0)
{
throw new ValidationException($"{propertyName} must contain at least one value.");
}
return cleaned;
}
}
public sealed record VexerExportEventRequest(
string ExportId,
IReadOnlyList<VexerClaimChange> ChangedClaims,
WebhookEventWindow? Window)
{
public string ExportId { get; } = ExportId?.Trim() ?? throw new ArgumentNullException(nameof(ExportId));
public IReadOnlyList<VexerClaimChange> ChangedClaims { get; } = NormalizeClaims(ChangedClaims);
public WebhookEventWindow? Window { get; } = Window;
private static IReadOnlyList<VexerClaimChange> NormalizeClaims(IReadOnlyList<VexerClaimChange>? claims)
{
if (claims is null || claims.Count == 0)
{
throw new ValidationException("changedClaims must contain at least one entry.");
}
foreach (var claim in claims)
{
claim.Validate();
}
return claims;
}
}
public sealed record VexerClaimChange(
string ProductKey,
string VulnerabilityId,
string Status)
{
public string ProductKey { get; } = Normalize(ProductKey, nameof(ProductKey));
public string VulnerabilityId { get; } = Normalize(VulnerabilityId, nameof(VulnerabilityId));
public string Status { get; } = Normalize(Status, nameof(Status));
internal void Validate()
{
_ = ProductKey;
_ = VulnerabilityId;
_ = Status;
}
private static string Normalize(string value, string propertyName)
{
if (string.IsNullOrWhiteSpace(value))
{
throw new ValidationException($"{propertyName} must be provided.");
}
return value.Trim();
}
}
public sealed record WebhookEventWindow(DateTimeOffset? From, DateTimeOffset? To);
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.ComponentModel.DataAnnotations;
using System.Linq;
namespace StellaOps.Scheduler.WebService.EventWebhooks;
public sealed record ConselierExportEventRequest(
string ExportId,
IReadOnlyList<string> ChangedProductKeys,
IReadOnlyList<string>? Kev,
WebhookEventWindow? Window)
{
public string ExportId { get; } = ExportId?.Trim() ?? throw new ArgumentNullException(nameof(ExportId));
public IReadOnlyList<string> ChangedProductKeys { get; } = NormalizeList(ChangedProductKeys, nameof(ChangedProductKeys));
public IReadOnlyList<string> Kev { get; } = NormalizeList(Kev, nameof(Kev), allowEmpty: true);
public WebhookEventWindow? Window { get; } = Window;
private static IReadOnlyList<string> NormalizeList(IReadOnlyList<string>? source, string propertyName, bool allowEmpty = false)
{
if (source is null)
{
if (allowEmpty)
{
return ImmutableArray<string>.Empty;
}
throw new ValidationException($"{propertyName} must be specified.");
}
var cleaned = source
.Where(item => !string.IsNullOrWhiteSpace(item))
.Select(item => item.Trim())
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
if (!allowEmpty && cleaned.Length == 0)
{
throw new ValidationException($"{propertyName} must contain at least one value.");
}
return cleaned;
}
}
public sealed record ExcitorExportEventRequest(
string ExportId,
IReadOnlyList<ExcitorClaimChange> ChangedClaims,
WebhookEventWindow? Window)
{
public string ExportId { get; } = ExportId?.Trim() ?? throw new ArgumentNullException(nameof(ExportId));
public IReadOnlyList<ExcitorClaimChange> ChangedClaims { get; } = NormalizeClaims(ChangedClaims);
public WebhookEventWindow? Window { get; } = Window;
private static IReadOnlyList<ExcitorClaimChange> NormalizeClaims(IReadOnlyList<ExcitorClaimChange>? claims)
{
if (claims is null || claims.Count == 0)
{
throw new ValidationException("changedClaims must contain at least one entry.");
}
foreach (var claim in claims)
{
claim.Validate();
}
return claims;
}
}
public sealed record ExcitorClaimChange(
string ProductKey,
string VulnerabilityId,
string Status)
{
public string ProductKey { get; } = Normalize(ProductKey, nameof(ProductKey));
public string VulnerabilityId { get; } = Normalize(VulnerabilityId, nameof(VulnerabilityId));
public string Status { get; } = Normalize(Status, nameof(Status));
internal void Validate()
{
_ = ProductKey;
_ = VulnerabilityId;
_ = Status;
}
private static string Normalize(string value, string propertyName)
{
if (string.IsNullOrWhiteSpace(value))
{
throw new ValidationException($"{propertyName} must be provided.");
}
return value.Trim();
}
}
public sealed record WebhookEventWindow(DateTimeOffset? From, DateTimeOffset? To);