wip - advisories and ui extensions

This commit is contained in:
StellaOps Bot
2025-12-29 08:39:52 +02:00
parent c2b9cd8d1f
commit 1b61c72c90
56 changed files with 15187 additions and 24 deletions

View File

@@ -0,0 +1,738 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using StellaOps.Auth.Abstractions;
using StellaOps.Scanner.Sources.Contracts;
using StellaOps.Scanner.Sources.Domain;
using StellaOps.Scanner.Sources.Services;
using StellaOps.Scanner.WebService.Constants;
using StellaOps.Scanner.WebService.Infrastructure;
using StellaOps.Scanner.WebService.Security;
namespace StellaOps.Scanner.WebService.Endpoints;
/// <summary>
/// Endpoints for managing SBOM sources (Zastava, Docker, CLI, Git).
/// </summary>
internal static class SourcesEndpoints
{
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
{
Converters = { new JsonStringEnumConverter() },
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
public static void MapSourcesEndpoints(this RouteGroupBuilder apiGroup, string sourcesSegment = "/sources")
{
ArgumentNullException.ThrowIfNull(apiGroup);
var sources = apiGroup.MapGroup(sourcesSegment);
// List sources
sources.MapGet("/", HandleListAsync)
.WithName("scanner.sources.list")
.Produces<PagedResponse<SourceResponse>>(StatusCodes.Status200OK)
.RequireAuthorization(ScannerPolicies.SourcesRead);
// Get source by ID
sources.MapGet("/{sourceId:guid}", HandleGetAsync)
.WithName("scanner.sources.get")
.Produces<SourceResponse>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound)
.RequireAuthorization(ScannerPolicies.SourcesRead);
// Get source by name
sources.MapGet("/by-name/{name}", HandleGetByNameAsync)
.WithName("scanner.sources.getByName")
.Produces<SourceResponse>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound)
.RequireAuthorization(ScannerPolicies.SourcesRead);
// Create source
sources.MapPost("/", HandleCreateAsync)
.WithName("scanner.sources.create")
.Produces<SourceResponse>(StatusCodes.Status201Created)
.Produces(StatusCodes.Status400BadRequest)
.Produces(StatusCodes.Status409Conflict)
.RequireAuthorization(ScannerPolicies.SourcesWrite);
// Update source
sources.MapPut("/{sourceId:guid}", HandleUpdateAsync)
.WithName("scanner.sources.update")
.Produces<SourceResponse>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status400BadRequest)
.Produces(StatusCodes.Status404NotFound)
.RequireAuthorization(ScannerPolicies.SourcesWrite);
// Delete source
sources.MapDelete("/{sourceId:guid}", HandleDeleteAsync)
.WithName("scanner.sources.delete")
.Produces(StatusCodes.Status204NoContent)
.Produces(StatusCodes.Status404NotFound)
.RequireAuthorization(ScannerPolicies.SourcesAdmin);
// Test connection (existing source)
sources.MapPost("/{sourceId:guid}/test", HandleTestConnectionAsync)
.WithName("scanner.sources.test")
.Produces<ConnectionTestResult>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound)
.RequireAuthorization(ScannerPolicies.SourcesRead);
// Test connection (new configuration)
sources.MapPost("/test", HandleTestNewConnectionAsync)
.WithName("scanner.sources.testNew")
.Produces<ConnectionTestResult>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status400BadRequest)
.RequireAuthorization(ScannerPolicies.SourcesWrite);
// Pause source
sources.MapPost("/{sourceId:guid}/pause", HandlePauseAsync)
.WithName("scanner.sources.pause")
.Produces<SourceResponse>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound)
.RequireAuthorization(ScannerPolicies.SourcesWrite);
// Resume source
sources.MapPost("/{sourceId:guid}/resume", HandleResumeAsync)
.WithName("scanner.sources.resume")
.Produces<SourceResponse>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound)
.RequireAuthorization(ScannerPolicies.SourcesWrite);
// Activate source (Draft -> Active)
sources.MapPost("/{sourceId:guid}/activate", HandleActivateAsync)
.WithName("scanner.sources.activate")
.Produces<SourceResponse>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound)
.RequireAuthorization(ScannerPolicies.SourcesWrite);
// Trigger scan
sources.MapPost("/{sourceId:guid}/scan", HandleTriggerScanAsync)
.WithName("scanner.sources.trigger")
.Produces<TriggerScanResult>(StatusCodes.Status202Accepted)
.Produces(StatusCodes.Status400BadRequest)
.Produces(StatusCodes.Status404NotFound)
.RequireAuthorization(ScannerPolicies.SourcesWrite);
// List runs for a source
sources.MapGet("/{sourceId:guid}/runs", HandleListRunsAsync)
.WithName("scanner.sources.runs.list")
.Produces<PagedResponse<SourceRunResponse>>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound)
.RequireAuthorization(ScannerPolicies.SourcesRead);
// Get specific run
sources.MapGet("/{sourceId:guid}/runs/{runId:guid}", HandleGetRunAsync)
.WithName("scanner.sources.runs.get")
.Produces<SourceRunResponse>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound)
.RequireAuthorization(ScannerPolicies.SourcesRead);
// Get source types metadata
sources.MapGet("/types", HandleGetTypesAsync)
.WithName("scanner.sources.types")
.Produces<SourceTypesResponse>(StatusCodes.Status200OK)
.RequireAuthorization(ScannerPolicies.SourcesRead);
}
private static async Task<IResult> HandleListAsync(
[AsParameters] ListSourcesQueryParams queryParams,
ISbomSourceService sourceService,
ITenantContext tenantContext,
HttpContext context,
CancellationToken ct)
{
var tenantId = tenantContext.TenantId;
if (string.IsNullOrEmpty(tenantId))
{
return ProblemResultFactory.Create(
context,
ProblemTypes.Validation,
"Tenant context required",
StatusCodes.Status400BadRequest);
}
var request = new ListSourcesRequest
{
SourceType = queryParams.Type,
Status = queryParams.Status,
NameContains = queryParams.Search,
Cursor = queryParams.Cursor,
Limit = queryParams.Limit ?? 50
};
var result = await sourceService.ListAsync(tenantId, request, ct);
return Json(result, StatusCodes.Status200OK);
}
private static async Task<IResult> HandleGetAsync(
Guid sourceId,
ISbomSourceService sourceService,
ITenantContext tenantContext,
HttpContext context,
CancellationToken ct)
{
var tenantId = tenantContext.TenantId;
if (string.IsNullOrEmpty(tenantId))
{
return ProblemResultFactory.Create(
context,
ProblemTypes.Validation,
"Tenant context required",
StatusCodes.Status400BadRequest);
}
var source = await sourceService.GetAsync(tenantId, sourceId, ct);
if (source == null)
{
return ProblemResultFactory.Create(
context,
ProblemTypes.NotFound,
"Source not found",
StatusCodes.Status404NotFound,
detail: $"Source {sourceId} not found");
}
return Json(source, StatusCodes.Status200OK);
}
private static async Task<IResult> HandleGetByNameAsync(
string name,
ISbomSourceService sourceService,
ITenantContext tenantContext,
HttpContext context,
CancellationToken ct)
{
var tenantId = tenantContext.TenantId;
if (string.IsNullOrEmpty(tenantId))
{
return ProblemResultFactory.Create(
context,
ProblemTypes.Validation,
"Tenant context required",
StatusCodes.Status400BadRequest);
}
var source = await sourceService.GetByNameAsync(tenantId, name, ct);
if (source == null)
{
return ProblemResultFactory.Create(
context,
ProblemTypes.NotFound,
"Source not found",
StatusCodes.Status404NotFound,
detail: $"Source '{name}' not found");
}
return Json(source, StatusCodes.Status200OK);
}
private static async Task<IResult> HandleCreateAsync(
CreateSourceRequest request,
ISbomSourceService sourceService,
ITenantContext tenantContext,
IUserContext userContext,
LinkGenerator links,
HttpContext context,
CancellationToken ct)
{
var tenantId = tenantContext.TenantId;
if (string.IsNullOrEmpty(tenantId))
{
return ProblemResultFactory.Create(
context,
ProblemTypes.Validation,
"Tenant context required",
StatusCodes.Status400BadRequest);
}
var userId = userContext.UserId ?? "system";
try
{
var source = await sourceService.CreateAsync(tenantId, request, userId, ct);
var location = links.GetPathByName(
httpContext: context,
endpointName: "scanner.sources.get",
values: new { sourceId = source.SourceId });
if (!string.IsNullOrWhiteSpace(location))
{
context.Response.Headers.Location = location;
}
return Json(source, StatusCodes.Status201Created);
}
catch (InvalidOperationException ex)
{
return ProblemResultFactory.Create(
context,
ProblemTypes.Conflict,
"Source already exists",
StatusCodes.Status409Conflict,
detail: ex.Message);
}
catch (ArgumentException ex)
{
return ProblemResultFactory.Create(
context,
ProblemTypes.Validation,
"Invalid request",
StatusCodes.Status400BadRequest,
detail: ex.Message);
}
}
private static async Task<IResult> HandleUpdateAsync(
Guid sourceId,
UpdateSourceRequest request,
ISbomSourceService sourceService,
ITenantContext tenantContext,
IUserContext userContext,
HttpContext context,
CancellationToken ct)
{
var tenantId = tenantContext.TenantId;
if (string.IsNullOrEmpty(tenantId))
{
return ProblemResultFactory.Create(
context,
ProblemTypes.Validation,
"Tenant context required",
StatusCodes.Status400BadRequest);
}
var userId = userContext.UserId ?? "system";
try
{
var source = await sourceService.UpdateAsync(tenantId, sourceId, request, userId, ct);
return Json(source, StatusCodes.Status200OK);
}
catch (KeyNotFoundException)
{
return ProblemResultFactory.Create(
context,
ProblemTypes.NotFound,
"Source not found",
StatusCodes.Status404NotFound);
}
catch (InvalidOperationException ex)
{
return ProblemResultFactory.Create(
context,
ProblemTypes.Conflict,
"Update conflict",
StatusCodes.Status409Conflict,
detail: ex.Message);
}
catch (ArgumentException ex)
{
return ProblemResultFactory.Create(
context,
ProblemTypes.Validation,
"Invalid request",
StatusCodes.Status400BadRequest,
detail: ex.Message);
}
}
private static async Task<IResult> HandleDeleteAsync(
Guid sourceId,
ISbomSourceService sourceService,
ITenantContext tenantContext,
HttpContext context,
CancellationToken ct)
{
var tenantId = tenantContext.TenantId;
if (string.IsNullOrEmpty(tenantId))
{
return ProblemResultFactory.Create(
context,
ProblemTypes.Validation,
"Tenant context required",
StatusCodes.Status400BadRequest);
}
try
{
await sourceService.DeleteAsync(tenantId, sourceId, ct);
return Results.NoContent();
}
catch (KeyNotFoundException)
{
return ProblemResultFactory.Create(
context,
ProblemTypes.NotFound,
"Source not found",
StatusCodes.Status404NotFound);
}
}
private static async Task<IResult> HandleTestConnectionAsync(
Guid sourceId,
ISbomSourceService sourceService,
ITenantContext tenantContext,
HttpContext context,
CancellationToken ct)
{
var tenantId = tenantContext.TenantId;
if (string.IsNullOrEmpty(tenantId))
{
return ProblemResultFactory.Create(
context,
ProblemTypes.Validation,
"Tenant context required",
StatusCodes.Status400BadRequest);
}
try
{
var result = await sourceService.TestConnectionAsync(tenantId, sourceId, ct);
return Json(result, StatusCodes.Status200OK);
}
catch (KeyNotFoundException)
{
return ProblemResultFactory.Create(
context,
ProblemTypes.NotFound,
"Source not found",
StatusCodes.Status404NotFound);
}
}
private static async Task<IResult> HandleTestNewConnectionAsync(
TestConnectionRequest request,
ISbomSourceService sourceService,
ITenantContext tenantContext,
HttpContext context,
CancellationToken ct)
{
var tenantId = tenantContext.TenantId;
if (string.IsNullOrEmpty(tenantId))
{
return ProblemResultFactory.Create(
context,
ProblemTypes.Validation,
"Tenant context required",
StatusCodes.Status400BadRequest);
}
var result = await sourceService.TestNewConnectionAsync(tenantId, request, ct);
return Json(result, StatusCodes.Status200OK);
}
private static async Task<IResult> HandlePauseAsync(
Guid sourceId,
PauseSourceRequest request,
ISbomSourceService sourceService,
ITenantContext tenantContext,
IUserContext userContext,
HttpContext context,
CancellationToken ct)
{
var tenantId = tenantContext.TenantId;
if (string.IsNullOrEmpty(tenantId))
{
return ProblemResultFactory.Create(
context,
ProblemTypes.Validation,
"Tenant context required",
StatusCodes.Status400BadRequest);
}
var userId = userContext.UserId ?? "system";
try
{
var source = await sourceService.PauseAsync(tenantId, sourceId, request, userId, ct);
return Json(source, StatusCodes.Status200OK);
}
catch (KeyNotFoundException)
{
return ProblemResultFactory.Create(
context,
ProblemTypes.NotFound,
"Source not found",
StatusCodes.Status404NotFound);
}
}
private static async Task<IResult> HandleResumeAsync(
Guid sourceId,
ISbomSourceService sourceService,
ITenantContext tenantContext,
IUserContext userContext,
HttpContext context,
CancellationToken ct)
{
var tenantId = tenantContext.TenantId;
if (string.IsNullOrEmpty(tenantId))
{
return ProblemResultFactory.Create(
context,
ProblemTypes.Validation,
"Tenant context required",
StatusCodes.Status400BadRequest);
}
var userId = userContext.UserId ?? "system";
try
{
var source = await sourceService.ResumeAsync(tenantId, sourceId, userId, ct);
return Json(source, StatusCodes.Status200OK);
}
catch (KeyNotFoundException)
{
return ProblemResultFactory.Create(
context,
ProblemTypes.NotFound,
"Source not found",
StatusCodes.Status404NotFound);
}
}
private static async Task<IResult> HandleActivateAsync(
Guid sourceId,
ISbomSourceService sourceService,
ITenantContext tenantContext,
IUserContext userContext,
HttpContext context,
CancellationToken ct)
{
var tenantId = tenantContext.TenantId;
if (string.IsNullOrEmpty(tenantId))
{
return ProblemResultFactory.Create(
context,
ProblemTypes.Validation,
"Tenant context required",
StatusCodes.Status400BadRequest);
}
var userId = userContext.UserId ?? "system";
try
{
var source = await sourceService.ActivateAsync(tenantId, sourceId, userId, ct);
return Json(source, StatusCodes.Status200OK);
}
catch (KeyNotFoundException)
{
return ProblemResultFactory.Create(
context,
ProblemTypes.NotFound,
"Source not found",
StatusCodes.Status404NotFound);
}
}
private static async Task<IResult> HandleTriggerScanAsync(
Guid sourceId,
TriggerScanRequest? request,
ISbomSourceService sourceService,
ITenantContext tenantContext,
IUserContext userContext,
HttpContext context,
CancellationToken ct)
{
var tenantId = tenantContext.TenantId;
if (string.IsNullOrEmpty(tenantId))
{
return ProblemResultFactory.Create(
context,
ProblemTypes.Validation,
"Tenant context required",
StatusCodes.Status400BadRequest);
}
var userId = userContext.UserId ?? "system";
try
{
var result = await sourceService.TriggerScanAsync(tenantId, sourceId, request, userId, ct);
return Json(result, StatusCodes.Status202Accepted);
}
catch (KeyNotFoundException)
{
return ProblemResultFactory.Create(
context,
ProblemTypes.NotFound,
"Source not found",
StatusCodes.Status404NotFound);
}
catch (InvalidOperationException ex)
{
return ProblemResultFactory.Create(
context,
ProblemTypes.Validation,
"Cannot trigger scan",
StatusCodes.Status400BadRequest,
detail: ex.Message);
}
}
private static async Task<IResult> HandleListRunsAsync(
Guid sourceId,
[AsParameters] ListRunsQueryParams queryParams,
ISbomSourceService sourceService,
ITenantContext tenantContext,
HttpContext context,
CancellationToken ct)
{
var tenantId = tenantContext.TenantId;
if (string.IsNullOrEmpty(tenantId))
{
return ProblemResultFactory.Create(
context,
ProblemTypes.Validation,
"Tenant context required",
StatusCodes.Status400BadRequest);
}
var request = new ListSourceRunsRequest
{
Status = queryParams.Status,
Trigger = queryParams.Trigger,
Cursor = queryParams.Cursor,
Limit = queryParams.Limit ?? 50
};
try
{
var result = await sourceService.GetRunsAsync(tenantId, sourceId, request, ct);
return Json(result, StatusCodes.Status200OK);
}
catch (KeyNotFoundException)
{
return ProblemResultFactory.Create(
context,
ProblemTypes.NotFound,
"Source not found",
StatusCodes.Status404NotFound);
}
}
private static async Task<IResult> HandleGetRunAsync(
Guid sourceId,
Guid runId,
ISbomSourceService sourceService,
ITenantContext tenantContext,
HttpContext context,
CancellationToken ct)
{
var tenantId = tenantContext.TenantId;
if (string.IsNullOrEmpty(tenantId))
{
return ProblemResultFactory.Create(
context,
ProblemTypes.Validation,
"Tenant context required",
StatusCodes.Status400BadRequest);
}
try
{
var run = await sourceService.GetRunAsync(tenantId, sourceId, runId, ct);
if (run == null)
{
return ProblemResultFactory.Create(
context,
ProblemTypes.NotFound,
"Run not found",
StatusCodes.Status404NotFound);
}
return Json(run, StatusCodes.Status200OK);
}
catch (KeyNotFoundException)
{
return ProblemResultFactory.Create(
context,
ProblemTypes.NotFound,
"Source not found",
StatusCodes.Status404NotFound);
}
}
private static Task<IResult> HandleGetTypesAsync(
ISourceConfigValidator configValidator,
HttpContext context,
CancellationToken ct)
{
var types = new SourceTypesResponse
{
Types = Enum.GetValues<SbomSourceType>()
.Select(t => new SourceTypeInfo
{
Type = t,
Name = t.ToString(),
Description = GetSourceTypeDescription(t),
ConfigurationSchema = configValidator.GetConfigurationSchema(t)
})
.ToList()
};
return Task.FromResult(Json(types, StatusCodes.Status200OK));
}
private static string GetSourceTypeDescription(SbomSourceType type) => type switch
{
SbomSourceType.Zastava => "Container registry webhook - receives push events from Docker Hub, Harbor, ECR, etc.",
SbomSourceType.Docker => "Docker image scanner - scans images on schedule or on-demand",
SbomSourceType.Cli => "CLI submission endpoint - receives SBOMs from external tools via API",
SbomSourceType.Git => "Git repository scanner - scans source code from GitHub, GitLab, Bitbucket, etc.",
_ => "Unknown source type"
};
private static IResult Json<T>(T value, int statusCode)
{
var payload = JsonSerializer.Serialize(value, SerializerOptions);
return Results.Content(payload, "application/json", System.Text.Encoding.UTF8, statusCode);
}
}
/// <summary>
/// Query parameters for listing sources.
/// </summary>
public record ListSourcesQueryParams
{
public SbomSourceType? Type { get; init; }
public SbomSourceStatus? Status { get; init; }
public string? Search { get; init; }
public string? Cursor { get; init; }
public int? Limit { get; init; }
}
/// <summary>
/// Query parameters for listing runs.
/// </summary>
public record ListRunsQueryParams
{
public SbomSourceRunStatus? Status { get; init; }
public SbomSourceRunTrigger? Trigger { get; init; }
public string? Cursor { get; init; }
public int? Limit { get; init; }
}
/// <summary>
/// Response containing source type information.
/// </summary>
public record SourceTypesResponse
{
public required List<SourceTypeInfo> Types { get; init; }
}
/// <summary>
/// Information about a source type.
/// </summary>
public record SourceTypeInfo
{
public required SbomSourceType Type { get; init; }
public required string Name { get; init; }
public required string Description { get; init; }
public string? ConfigurationSchema { get; init; }
}

View File

@@ -0,0 +1,584 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.Logging;
using StellaOps.Scanner.Sources.Domain;
using StellaOps.Scanner.Sources.Handlers;
using StellaOps.Scanner.Sources.Persistence;
using StellaOps.Scanner.Sources.Services;
using StellaOps.Scanner.Sources.Triggers;
using StellaOps.Scanner.WebService.Constants;
using StellaOps.Scanner.WebService.Infrastructure;
namespace StellaOps.Scanner.WebService.Endpoints;
/// <summary>
/// Endpoints for receiving webhooks from container registries and Git providers.
/// </summary>
internal static class WebhookEndpoints
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true
};
/// <summary>
/// Maps webhook endpoints for receiving push events.
/// </summary>
public static void MapWebhookEndpoints(this RouteGroupBuilder apiGroup, string webhookSegment = "/webhooks")
{
ArgumentNullException.ThrowIfNull(apiGroup);
var webhooks = apiGroup.MapGroup(webhookSegment);
// Generic webhook endpoint (uses sourceId in path)
webhooks.MapPost("/{sourceId:guid}", HandleWebhookAsync)
.WithName("scanner.webhooks.receive")
.Produces(StatusCodes.Status200OK)
.Produces(StatusCodes.Status202Accepted)
.Produces(StatusCodes.Status400BadRequest)
.Produces(StatusCodes.Status401Unauthorized)
.Produces(StatusCodes.Status404NotFound)
.AllowAnonymous();
// Docker Hub webhook (uses source name for friendlier URLs)
webhooks.MapPost("/docker/{sourceName}", HandleDockerHubWebhookAsync)
.WithName("scanner.webhooks.dockerhub")
.Produces(StatusCodes.Status200OK)
.Produces(StatusCodes.Status202Accepted)
.Produces(StatusCodes.Status400BadRequest)
.Produces(StatusCodes.Status401Unauthorized)
.Produces(StatusCodes.Status404NotFound)
.AllowAnonymous();
// GitHub webhook
webhooks.MapPost("/github/{sourceName}", HandleGitHubWebhookAsync)
.WithName("scanner.webhooks.github")
.Produces(StatusCodes.Status200OK)
.Produces(StatusCodes.Status202Accepted)
.Produces(StatusCodes.Status400BadRequest)
.Produces(StatusCodes.Status401Unauthorized)
.Produces(StatusCodes.Status404NotFound)
.AllowAnonymous();
// GitLab webhook
webhooks.MapPost("/gitlab/{sourceName}", HandleGitLabWebhookAsync)
.WithName("scanner.webhooks.gitlab")
.Produces(StatusCodes.Status200OK)
.Produces(StatusCodes.Status202Accepted)
.Produces(StatusCodes.Status400BadRequest)
.Produces(StatusCodes.Status401Unauthorized)
.Produces(StatusCodes.Status404NotFound)
.AllowAnonymous();
// Harbor webhook
webhooks.MapPost("/harbor/{sourceName}", HandleHarborWebhookAsync)
.WithName("scanner.webhooks.harbor")
.Produces(StatusCodes.Status200OK)
.Produces(StatusCodes.Status202Accepted)
.Produces(StatusCodes.Status400BadRequest)
.Produces(StatusCodes.Status401Unauthorized)
.Produces(StatusCodes.Status404NotFound)
.AllowAnonymous();
}
/// <summary>
/// Handle generic webhook by source ID.
/// </summary>
private static async Task<IResult> HandleWebhookAsync(
Guid sourceId,
[FromHeader(Name = "X-Hub-Signature-256")] string? signatureSha256,
[FromHeader(Name = "X-Hub-Signature")] string? signatureSha1,
[FromHeader(Name = "X-Gitlab-Token")] string? gitlabToken,
[FromHeader(Name = "Authorization")] string? authorization,
ISbomSourceRepository sourceRepository,
IEnumerable<ISourceTypeHandler> handlers,
ISourceTriggerDispatcher dispatcher,
ICredentialResolver credentialResolver,
ILogger<WebhookEndpoints> logger,
HttpContext context,
CancellationToken ct)
{
// Read the raw payload
using var reader = new StreamReader(context.Request.Body);
var payloadString = await reader.ReadToEndAsync(ct);
var payloadBytes = Encoding.UTF8.GetBytes(payloadString);
// Get the source
var source = await sourceRepository.GetByIdAsync(null!, sourceId, ct);
if (source == null)
{
return ProblemResultFactory.Create(
context,
ProblemTypes.NotFound,
"Source not found",
StatusCodes.Status404NotFound);
}
// Get the handler
var handler = handlers.FirstOrDefault(h => h.SourceType == source.SourceType);
if (handler == null || handler is not IWebhookCapableHandler webhookHandler)
{
return ProblemResultFactory.Create(
context,
ProblemTypes.Validation,
"Source does not support webhooks",
StatusCodes.Status400BadRequest);
}
// Determine signature to use
var signature = signatureSha256 ?? signatureSha1 ?? gitlabToken ?? ExtractBearerToken(authorization);
// Verify signature if source has a webhook secret reference
if (!string.IsNullOrEmpty(source.WebhookSecretRef))
{
if (string.IsNullOrEmpty(signature))
{
logger.LogWarning("Webhook received without signature for source {SourceId}", sourceId);
return ProblemResultFactory.Create(
context,
ProblemTypes.Unauthorized,
"Missing webhook signature",
StatusCodes.Status401Unauthorized);
}
// Resolve the webhook secret from the credential store
var secretCredential = await credentialResolver.ResolveAsync(source.WebhookSecretRef, ct);
var webhookSecret = secretCredential?.Token ?? secretCredential?.Password;
if (string.IsNullOrEmpty(webhookSecret))
{
logger.LogWarning("Failed to resolve webhook secret for source {SourceId}", sourceId);
return ProblemResultFactory.Create(
context,
ProblemTypes.InternalError,
"Failed to resolve webhook secret",
StatusCodes.Status500InternalServerError);
}
if (!webhookHandler.VerifyWebhookSignature(payloadBytes, signature, webhookSecret))
{
logger.LogWarning("Invalid webhook signature for source {SourceId}", sourceId);
return ProblemResultFactory.Create(
context,
ProblemTypes.Unauthorized,
"Invalid webhook signature",
StatusCodes.Status401Unauthorized);
}
}
// Parse the payload
JsonDocument payload;
try
{
payload = JsonDocument.Parse(payloadString, new JsonDocumentOptions
{
AllowTrailingCommas = true
});
}
catch (JsonException ex)
{
logger.LogWarning(ex, "Invalid JSON payload for source {SourceId}", sourceId);
return ProblemResultFactory.Create(
context,
ProblemTypes.Validation,
"Invalid JSON payload",
StatusCodes.Status400BadRequest);
}
// Create trigger context
var triggerContext = new TriggerContext
{
Trigger = SbomSourceRunTrigger.Webhook,
TriggerDetails = $"Webhook from {context.Request.Headers["User-Agent"]}",
CorrelationId = context.TraceIdentifier,
WebhookPayload = payload
};
// Dispatch the trigger
try
{
var result = await dispatcher.DispatchAsync(sourceId, triggerContext, ct);
if (!result.Success)
{
logger.LogWarning(
"Webhook dispatch failed for source {SourceId}: {Error}",
sourceId, result.Error);
// Return 200 even on dispatch failure to prevent retries
// The error is logged and tracked in the run record
return Results.Ok(new WebhookResponse
{
Accepted = false,
Message = result.Error,
RunId = result.Run?.RunId
});
}
logger.LogInformation(
"Webhook processed for source {SourceId}, run {RunId}, {JobCount} jobs queued",
sourceId, result.Run?.RunId, result.JobsQueued);
return Results.Accepted(value: new WebhookResponse
{
Accepted = true,
Message = $"Queued {result.JobsQueued} scan jobs",
RunId = result.Run?.RunId,
JobsQueued = result.JobsQueued
});
}
catch (Exception ex)
{
logger.LogError(ex, "Webhook processing failed for source {SourceId}", sourceId);
return ProblemResultFactory.Create(
context,
ProblemTypes.InternalError,
"Webhook processing failed",
StatusCodes.Status500InternalServerError,
detail: ex.Message);
}
}
/// <summary>
/// Handle Docker Hub webhook by source name.
/// </summary>
private static async Task<IResult> HandleDockerHubWebhookAsync(
string sourceName,
ISbomSourceRepository sourceRepository,
IEnumerable<ISourceTypeHandler> handlers,
ISourceTriggerDispatcher dispatcher,
ICredentialResolver credentialResolver,
ILogger<WebhookEndpoints> logger,
HttpContext context,
CancellationToken ct)
{
// Docker Hub uses callback_url for validation
// and sends signature in body.callback_url when configured
var source = await FindSourceByNameAsync(sourceRepository, sourceName, SbomSourceType.Zastava, ct);
if (source == null)
{
return ProblemResultFactory.Create(
context,
ProblemTypes.NotFound,
"Source not found",
StatusCodes.Status404NotFound);
}
return await ProcessWebhookAsync(
source,
handlers,
dispatcher,
credentialResolver,
logger,
context,
signatureHeader: "X-Hub-Signature",
ct);
}
/// <summary>
/// Handle GitHub webhook by source name.
/// </summary>
private static async Task<IResult> HandleGitHubWebhookAsync(
string sourceName,
[FromHeader(Name = "X-GitHub-Event")] string? eventType,
ISbomSourceRepository sourceRepository,
IEnumerable<ISourceTypeHandler> handlers,
ISourceTriggerDispatcher dispatcher,
ICredentialResolver credentialResolver,
ILogger<WebhookEndpoints> logger,
HttpContext context,
CancellationToken ct)
{
// GitHub can send ping events for webhook validation
if (eventType == "ping")
{
return Results.Ok(new { message = "pong" });
}
// Only process push and pull_request events
if (eventType != "push" && eventType != "pull_request" && eventType != "create")
{
return Results.Ok(new { message = $"Event type '{eventType}' ignored" });
}
var source = await FindSourceByNameAsync(sourceRepository, sourceName, SbomSourceType.Git, ct);
if (source == null)
{
return ProblemResultFactory.Create(
context,
ProblemTypes.NotFound,
"Source not found",
StatusCodes.Status404NotFound);
}
return await ProcessWebhookAsync(
source,
handlers,
dispatcher,
credentialResolver,
logger,
context,
signatureHeader: "X-Hub-Signature-256",
ct);
}
/// <summary>
/// Handle GitLab webhook by source name.
/// </summary>
private static async Task<IResult> HandleGitLabWebhookAsync(
string sourceName,
[FromHeader(Name = "X-Gitlab-Event")] string? eventType,
ISbomSourceRepository sourceRepository,
IEnumerable<ISourceTypeHandler> handlers,
ISourceTriggerDispatcher dispatcher,
ICredentialResolver credentialResolver,
ILogger<WebhookEndpoints> logger,
HttpContext context,
CancellationToken ct)
{
// Only process push and merge request events
if (eventType != "Push Hook" && eventType != "Merge Request Hook" && eventType != "Tag Push Hook")
{
return Results.Ok(new { message = $"Event type '{eventType}' ignored" });
}
var source = await FindSourceByNameAsync(sourceRepository, sourceName, SbomSourceType.Git, ct);
if (source == null)
{
return ProblemResultFactory.Create(
context,
ProblemTypes.NotFound,
"Source not found",
StatusCodes.Status404NotFound);
}
return await ProcessWebhookAsync(
source,
handlers,
dispatcher,
credentialResolver,
logger,
context,
signatureHeader: "X-Gitlab-Token",
ct);
}
/// <summary>
/// Handle Harbor webhook by source name.
/// </summary>
private static async Task<IResult> HandleHarborWebhookAsync(
string sourceName,
ISbomSourceRepository sourceRepository,
IEnumerable<ISourceTypeHandler> handlers,
ISourceTriggerDispatcher dispatcher,
ICredentialResolver credentialResolver,
ILogger<WebhookEndpoints> logger,
HttpContext context,
CancellationToken ct)
{
var source = await FindSourceByNameAsync(sourceRepository, sourceName, SbomSourceType.Zastava, ct);
if (source == null)
{
return ProblemResultFactory.Create(
context,
ProblemTypes.NotFound,
"Source not found",
StatusCodes.Status404NotFound);
}
return await ProcessWebhookAsync(
source,
handlers,
dispatcher,
credentialResolver,
logger,
context,
signatureHeader: "Authorization",
ct);
}
private static async Task<SbomSource?> FindSourceByNameAsync(
ISbomSourceRepository repository,
string name,
SbomSourceType expectedType,
CancellationToken ct)
{
// Search across all tenants for the source by name
// Note: In production, this should be scoped to a specific tenant
// extracted from the webhook URL or a custom header
var sources = await repository.SearchByNameAsync(name, ct);
return sources.FirstOrDefault(s => s.SourceType == expectedType);
}
private static async Task<IResult> ProcessWebhookAsync(
SbomSource source,
IEnumerable<ISourceTypeHandler> handlers,
ISourceTriggerDispatcher dispatcher,
ICredentialResolver credentialResolver,
ILogger<WebhookEndpoints> logger,
HttpContext context,
string signatureHeader,
CancellationToken ct)
{
// Read the raw payload
using var reader = new StreamReader(context.Request.Body);
var payloadString = await reader.ReadToEndAsync(ct);
var payloadBytes = Encoding.UTF8.GetBytes(payloadString);
// Get the handler
var handler = handlers.FirstOrDefault(h => h.SourceType == source.SourceType);
if (handler == null || handler is not IWebhookCapableHandler webhookHandler)
{
return ProblemResultFactory.Create(
context,
ProblemTypes.Validation,
"Source does not support webhooks",
StatusCodes.Status400BadRequest);
}
// Get signature from header
string? signature = signatureHeader switch
{
"X-Hub-Signature-256" => context.Request.Headers["X-Hub-Signature-256"].FirstOrDefault(),
"X-Hub-Signature" => context.Request.Headers["X-Hub-Signature"].FirstOrDefault(),
"X-Gitlab-Token" => context.Request.Headers["X-Gitlab-Token"].FirstOrDefault(),
"Authorization" => ExtractBearerToken(context.Request.Headers.Authorization.FirstOrDefault()),
_ => null
};
// Verify signature if source has a webhook secret reference
if (!string.IsNullOrEmpty(source.WebhookSecretRef))
{
if (string.IsNullOrEmpty(signature))
{
logger.LogWarning("Webhook received without signature for source {SourceId}", source.SourceId);
return ProblemResultFactory.Create(
context,
ProblemTypes.Unauthorized,
"Missing webhook signature",
StatusCodes.Status401Unauthorized);
}
// Resolve the webhook secret from the credential store
var secretCredential = await credentialResolver.ResolveAsync(source.WebhookSecretRef, ct);
var webhookSecret = secretCredential?.Token ?? secretCredential?.Password;
if (string.IsNullOrEmpty(webhookSecret))
{
logger.LogWarning("Failed to resolve webhook secret for source {SourceId}", source.SourceId);
return ProblemResultFactory.Create(
context,
ProblemTypes.InternalError,
"Failed to resolve webhook secret",
StatusCodes.Status500InternalServerError);
}
if (!webhookHandler.VerifyWebhookSignature(payloadBytes, signature, webhookSecret))
{
logger.LogWarning("Invalid webhook signature for source {SourceId}", source.SourceId);
return ProblemResultFactory.Create(
context,
ProblemTypes.Unauthorized,
"Invalid webhook signature",
StatusCodes.Status401Unauthorized);
}
}
// Parse the payload
JsonDocument payload;
try
{
payload = JsonDocument.Parse(payloadString, new JsonDocumentOptions
{
AllowTrailingCommas = true
});
}
catch (JsonException ex)
{
logger.LogWarning(ex, "Invalid JSON payload for source {SourceId}", source.SourceId);
return ProblemResultFactory.Create(
context,
ProblemTypes.Validation,
"Invalid JSON payload",
StatusCodes.Status400BadRequest);
}
// Create trigger context
var triggerContext = new TriggerContext
{
Trigger = SbomSourceRunTrigger.Webhook,
TriggerDetails = $"Webhook from {context.Request.Headers["User-Agent"]}",
CorrelationId = context.TraceIdentifier,
WebhookPayload = payload
};
// Dispatch the trigger
try
{
var result = await dispatcher.DispatchAsync(source.SourceId, triggerContext, ct);
if (!result.Success)
{
logger.LogWarning(
"Webhook dispatch failed for source {SourceId}: {Error}",
source.SourceId, result.Error);
return Results.Ok(new WebhookResponse
{
Accepted = false,
Message = result.Error,
RunId = result.Run?.RunId
});
}
logger.LogInformation(
"Webhook processed for source {SourceId}, run {RunId}, {JobCount} jobs queued",
source.SourceId, result.Run?.RunId, result.JobsQueued);
return Results.Accepted(value: new WebhookResponse
{
Accepted = true,
Message = $"Queued {result.JobsQueued} scan jobs",
RunId = result.Run?.RunId,
JobsQueued = result.JobsQueued
});
}
catch (Exception ex)
{
logger.LogError(ex, "Webhook processing failed for source {SourceId}", source.SourceId);
return ProblemResultFactory.Create(
context,
ProblemTypes.InternalError,
"Webhook processing failed",
StatusCodes.Status500InternalServerError,
detail: ex.Message);
}
}
private static string? ExtractBearerToken(string? authHeader)
{
if (string.IsNullOrEmpty(authHeader))
return null;
if (authHeader.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
return authHeader[7..];
return authHeader;
}
}
/// <summary>
/// Response for webhook processing.
/// </summary>
public record WebhookResponse
{
public bool Accepted { get; init; }
public string? Message { get; init; }
public Guid? RunId { get; init; }
public int JobsQueued { get; init; }
}

View File

@@ -19,4 +19,9 @@ internal static class ScannerPolicies
// Admin policies
public const string Admin = "scanner.admin";
// Sources policies
public const string SourcesRead = "scanner.sources.read";
public const string SourcesWrite = "scanner.sources.write";
public const string SourcesAdmin = "scanner.sources.admin";
}

View File

@@ -46,6 +46,7 @@
<ProjectReference Include="../../Concelier/__Libraries/StellaOps.Concelier.Connector.Common/StellaOps.Concelier.Connector.Common.csproj" />
<ProjectReference Include="../../Router/__Libraries/StellaOps.Messaging/StellaOps.Messaging.csproj" />
<ProjectReference Include="../__Libraries/StellaOps.Scanner.Orchestration/StellaOps.Scanner.Orchestration.csproj" />
<ProjectReference Include="../__Libraries/StellaOps.Scanner.Sources/StellaOps.Scanner.Sources.csproj" />
<ProjectReference Include="../../Router/__Libraries/StellaOps.Router.AspNet/StellaOps.Router.AspNet.csproj" />
</ItemGroup>