wip - advisories and ui extensions
This commit is contained in:
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user