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>
|
||||
|
||||
|
||||
@@ -59,6 +59,18 @@ public enum RegistryType
|
||||
/// <summary>JFrog Artifactory.</summary>
|
||||
Artifactory,
|
||||
|
||||
/// <summary>GitLab Container Registry.</summary>
|
||||
GitLab,
|
||||
|
||||
/// <summary>Sonatype Nexus Registry.</summary>
|
||||
Nexus,
|
||||
|
||||
/// <summary>JFrog Container Registry (standalone).</summary>
|
||||
JFrog,
|
||||
|
||||
/// <summary>Custom/self-hosted OCI registry.</summary>
|
||||
Custom,
|
||||
|
||||
/// <summary>Generic registry with configurable payload mapping.</summary>
|
||||
Generic
|
||||
}
|
||||
@@ -83,6 +95,25 @@ public sealed record ZastavaFilters
|
||||
/// <summary>Tag patterns to exclude (glob patterns).</summary>
|
||||
[JsonPropertyName("excludeTags")]
|
||||
public string[]? ExcludeTags { get; init; }
|
||||
|
||||
// Computed properties for handler compatibility
|
||||
[JsonIgnore]
|
||||
public IReadOnlyList<string> RepositoryPatterns => Repositories;
|
||||
|
||||
[JsonIgnore]
|
||||
public IReadOnlyList<string> TagPatterns => Tags;
|
||||
|
||||
[JsonIgnore]
|
||||
public IReadOnlyList<string>? ExcludePatterns
|
||||
{
|
||||
get
|
||||
{
|
||||
var combined = new List<string>();
|
||||
if (ExcludeRepositories != null) combined.AddRange(ExcludeRepositories);
|
||||
if (ExcludeTags != null) combined.AddRange(ExcludeTags);
|
||||
return combined.Count > 0 ? combined : null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Scanner.Sources.Configuration;
|
||||
using StellaOps.Scanner.Sources.Contracts;
|
||||
using StellaOps.Scanner.Sources.Domain;
|
||||
using StellaOps.Scanner.Sources.Services;
|
||||
|
||||
namespace StellaOps.Scanner.Sources.ConnectionTesters;
|
||||
|
||||
/// <summary>
|
||||
/// Connection tester for CLI sources.
|
||||
/// CLI sources are passive endpoints - they receive SBOMs from external tools.
|
||||
/// This tester validates the configuration rather than testing a connection.
|
||||
/// </summary>
|
||||
public sealed class CliConnectionTester : ISourceTypeConnectionTester
|
||||
{
|
||||
private readonly ICredentialResolver _credentialResolver;
|
||||
private readonly ILogger<CliConnectionTester> _logger;
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
};
|
||||
|
||||
public SbomSourceType SourceType => SbomSourceType.Cli;
|
||||
|
||||
public CliConnectionTester(
|
||||
ICredentialResolver credentialResolver,
|
||||
ILogger<CliConnectionTester> logger)
|
||||
{
|
||||
_credentialResolver = credentialResolver;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<ConnectionTestResult> TestAsync(
|
||||
SbomSource source,
|
||||
JsonDocument? overrideCredentials,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var config = source.Configuration.Deserialize<CliSourceConfig>(JsonOptions);
|
||||
if (config == null)
|
||||
{
|
||||
return new ConnectionTestResult
|
||||
{
|
||||
Success = false,
|
||||
Message = "Invalid configuration format",
|
||||
TestedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
}
|
||||
|
||||
var details = new Dictionary<string, object>
|
||||
{
|
||||
["sourceType"] = "CLI",
|
||||
["endpointType"] = "passive"
|
||||
};
|
||||
|
||||
// CLI sources are passive - validate configuration instead
|
||||
var validationIssues = new List<string>();
|
||||
|
||||
// Check accepted formats
|
||||
if (config.Validation.AllowedFormats is { Length: > 0 })
|
||||
{
|
||||
details["acceptedFormats"] = config.Validation.AllowedFormats.Select(f => f.ToString()).ToList();
|
||||
}
|
||||
else
|
||||
{
|
||||
details["acceptedFormats"] = "all";
|
||||
}
|
||||
|
||||
// Check validation rules
|
||||
if (config.Validation.RequireSignedSbom)
|
||||
{
|
||||
details["requiresSignature"] = true;
|
||||
}
|
||||
|
||||
if (config.Validation.MaxSbomSizeBytes > 0)
|
||||
{
|
||||
details["maxFileSizeBytes"] = config.Validation.MaxSbomSizeBytes;
|
||||
}
|
||||
|
||||
// Check if auth reference is valid (if provided)
|
||||
if (!string.IsNullOrEmpty(source.AuthRef))
|
||||
{
|
||||
var authValid = await _credentialResolver.ValidateRefAsync(source.AuthRef, ct);
|
||||
if (!authValid)
|
||||
{
|
||||
validationIssues.Add("AuthRef credential not found or inaccessible");
|
||||
}
|
||||
else
|
||||
{
|
||||
details["authConfigured"] = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Generate webhook URL info
|
||||
details["note"] = "CLI sources receive SBOMs via API endpoint";
|
||||
details["submissionEndpoint"] = $"/api/v1/sources/{source.SourceId}/sbom";
|
||||
|
||||
if (validationIssues.Count > 0)
|
||||
{
|
||||
return new ConnectionTestResult
|
||||
{
|
||||
Success = false,
|
||||
Message = $"Configuration issues: {string.Join("; ", validationIssues)}",
|
||||
TestedAt = DateTimeOffset.UtcNow,
|
||||
Details = details
|
||||
};
|
||||
}
|
||||
|
||||
return new ConnectionTestResult
|
||||
{
|
||||
Success = true,
|
||||
Message = "CLI source configuration is valid - ready to receive SBOMs",
|
||||
TestedAt = DateTimeOffset.UtcNow,
|
||||
Details = details
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
using System.Net;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Scanner.Sources.Configuration;
|
||||
using StellaOps.Scanner.Sources.Contracts;
|
||||
using StellaOps.Scanner.Sources.Domain;
|
||||
using StellaOps.Scanner.Sources.Services;
|
||||
|
||||
namespace StellaOps.Scanner.Sources.ConnectionTesters;
|
||||
|
||||
/// <summary>
|
||||
/// Tests connection to Docker registries for scheduled image scanning.
|
||||
/// </summary>
|
||||
public sealed class DockerConnectionTester : ISourceTypeConnectionTester
|
||||
{
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
private readonly ICredentialResolver _credentialResolver;
|
||||
private readonly ILogger<DockerConnectionTester> _logger;
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
};
|
||||
|
||||
public SbomSourceType SourceType => SbomSourceType.Docker;
|
||||
|
||||
public DockerConnectionTester(
|
||||
IHttpClientFactory httpClientFactory,
|
||||
ICredentialResolver credentialResolver,
|
||||
ILogger<DockerConnectionTester> logger)
|
||||
{
|
||||
_httpClientFactory = httpClientFactory;
|
||||
_credentialResolver = credentialResolver;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<ConnectionTestResult> TestAsync(
|
||||
SbomSource source,
|
||||
JsonDocument? overrideCredentials,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var config = source.Configuration.Deserialize<DockerSourceConfig>(JsonOptions);
|
||||
if (config == null)
|
||||
{
|
||||
return new ConnectionTestResult
|
||||
{
|
||||
Success = false,
|
||||
Message = "Invalid configuration format",
|
||||
TestedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
}
|
||||
|
||||
var client = _httpClientFactory.CreateClient("SourceConnectionTest");
|
||||
client.Timeout = TimeSpan.FromSeconds(30);
|
||||
|
||||
// Get credentials
|
||||
string? authHeader = null;
|
||||
if (overrideCredentials != null)
|
||||
{
|
||||
authHeader = ExtractAuthFromTestCredentials(overrideCredentials);
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(source.AuthRef))
|
||||
{
|
||||
var creds = await _credentialResolver.ResolveAsync(source.AuthRef, ct);
|
||||
authHeader = BuildAuthHeader(creds);
|
||||
}
|
||||
|
||||
if (authHeader != null)
|
||||
{
|
||||
client.DefaultRequestHeaders.TryAddWithoutValidation("Authorization", authHeader);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Determine registry URL
|
||||
var registryUrl = GetRegistryUrl(config);
|
||||
var testUrl = $"{registryUrl}/v2/";
|
||||
|
||||
var response = await client.GetAsync(testUrl, ct);
|
||||
|
||||
var details = new Dictionary<string, object>
|
||||
{
|
||||
["registryUrl"] = registryUrl,
|
||||
["statusCode"] = (int)response.StatusCode
|
||||
};
|
||||
|
||||
// Test image access if we have specific images configured
|
||||
if (response.IsSuccessStatusCode && config.Images.Length > 0)
|
||||
{
|
||||
var firstImage = config.Images[0];
|
||||
var imageTestResult = await TestImageAccess(
|
||||
client, registryUrl, firstImage, ct);
|
||||
|
||||
details["imageTest"] = imageTestResult;
|
||||
|
||||
if (!imageTestResult.Success)
|
||||
{
|
||||
return new ConnectionTestResult
|
||||
{
|
||||
Success = false,
|
||||
Message = $"Registry accessible but image test failed: {imageTestResult.Message}",
|
||||
TestedAt = DateTimeOffset.UtcNow,
|
||||
Details = details
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
return new ConnectionTestResult
|
||||
{
|
||||
Success = true,
|
||||
Message = "Successfully connected to Docker registry",
|
||||
TestedAt = DateTimeOffset.UtcNow,
|
||||
Details = details
|
||||
};
|
||||
}
|
||||
|
||||
details["responseBody"] = await TruncateResponseBody(response, ct);
|
||||
|
||||
return response.StatusCode switch
|
||||
{
|
||||
HttpStatusCode.Unauthorized => new ConnectionTestResult
|
||||
{
|
||||
Success = false,
|
||||
Message = "Authentication required - configure credentials",
|
||||
TestedAt = DateTimeOffset.UtcNow,
|
||||
Details = details
|
||||
},
|
||||
HttpStatusCode.Forbidden => new ConnectionTestResult
|
||||
{
|
||||
Success = false,
|
||||
Message = "Access denied - check permissions",
|
||||
TestedAt = DateTimeOffset.UtcNow,
|
||||
Details = details
|
||||
},
|
||||
_ => new ConnectionTestResult
|
||||
{
|
||||
Success = false,
|
||||
Message = $"Registry returned {response.StatusCode}",
|
||||
TestedAt = DateTimeOffset.UtcNow,
|
||||
Details = details
|
||||
}
|
||||
};
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "HTTP error testing Docker connection");
|
||||
return new ConnectionTestResult
|
||||
{
|
||||
Success = false,
|
||||
Message = $"Connection failed: {ex.Message}",
|
||||
TestedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
}
|
||||
catch (TaskCanceledException) when (!ct.IsCancellationRequested)
|
||||
{
|
||||
return new ConnectionTestResult
|
||||
{
|
||||
Success = false,
|
||||
Message = "Connection timed out",
|
||||
TestedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetRegistryUrl(DockerSourceConfig config)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(config.RegistryUrl))
|
||||
{
|
||||
return config.RegistryUrl.TrimEnd('/');
|
||||
}
|
||||
|
||||
// Default to Docker Hub
|
||||
return "https://registry-1.docker.io";
|
||||
}
|
||||
|
||||
private async Task<ImageTestResult> TestImageAccess(
|
||||
HttpClient client,
|
||||
string registryUrl,
|
||||
ImageSpec image,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var repository = GetRepositoryFromReference(image.Reference);
|
||||
|
||||
try
|
||||
{
|
||||
// Try to fetch image manifest tags
|
||||
var tagsUrl = $"{registryUrl}/v2/{repository}/tags/list";
|
||||
var response = await client.GetAsync(tagsUrl, ct);
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
var content = await response.Content.ReadAsStringAsync(ct);
|
||||
return new ImageTestResult
|
||||
{
|
||||
Success = true,
|
||||
Message = "Image repository accessible",
|
||||
Repository = repository
|
||||
};
|
||||
}
|
||||
|
||||
return new ImageTestResult
|
||||
{
|
||||
Success = false,
|
||||
Message = $"Cannot access repository: {response.StatusCode}",
|
||||
Repository = repository
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new ImageTestResult
|
||||
{
|
||||
Success = false,
|
||||
Message = $"Error accessing repository: {ex.Message}",
|
||||
Repository = repository
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetRepositoryFromReference(string reference)
|
||||
{
|
||||
// Reference format: [registry/]repo[/subpath]:tag or [registry/]repo[/subpath]@sha256:digest
|
||||
// Strip the tag or digest
|
||||
var atIdx = reference.IndexOf('@');
|
||||
var colonIdx = reference.LastIndexOf(':');
|
||||
|
||||
string repoWithRegistry;
|
||||
if (atIdx > 0)
|
||||
{
|
||||
repoWithRegistry = reference[..atIdx];
|
||||
}
|
||||
else if (colonIdx > 0 && !reference[..colonIdx].Contains('/'))
|
||||
{
|
||||
// Simple format like "nginx:latest" - no registry prefix
|
||||
repoWithRegistry = reference[..colonIdx];
|
||||
}
|
||||
else if (colonIdx > 0)
|
||||
{
|
||||
repoWithRegistry = reference[..colonIdx];
|
||||
}
|
||||
else
|
||||
{
|
||||
repoWithRegistry = reference;
|
||||
}
|
||||
|
||||
// For Docker Hub, prepend "library/" for official images
|
||||
if (!repoWithRegistry.Contains('/'))
|
||||
{
|
||||
return $"library/{repoWithRegistry}";
|
||||
}
|
||||
|
||||
return repoWithRegistry;
|
||||
}
|
||||
|
||||
private static string? ExtractAuthFromTestCredentials(JsonDocument credentials)
|
||||
{
|
||||
var root = credentials.RootElement;
|
||||
|
||||
if (root.TryGetProperty("token", out var token))
|
||||
{
|
||||
return $"Bearer {token.GetString()}";
|
||||
}
|
||||
|
||||
if (root.TryGetProperty("username", out var username) &&
|
||||
root.TryGetProperty("password", out var password))
|
||||
{
|
||||
var encoded = Convert.ToBase64String(
|
||||
System.Text.Encoding.UTF8.GetBytes(
|
||||
$"{username.GetString()}:{password.GetString()}"));
|
||||
return $"Basic {encoded}";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string? BuildAuthHeader(ResolvedCredential? credential)
|
||||
{
|
||||
if (credential == null) return null;
|
||||
|
||||
return credential.Type switch
|
||||
{
|
||||
CredentialType.BearerToken => $"Bearer {credential.Token}",
|
||||
CredentialType.BasicAuth => $"Basic {Convert.ToBase64String(
|
||||
System.Text.Encoding.UTF8.GetBytes($"{credential.Username}:{credential.Password}"))}",
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
private static async Task<string> TruncateResponseBody(HttpResponseMessage response, CancellationToken ct)
|
||||
{
|
||||
var body = await response.Content.ReadAsStringAsync(ct);
|
||||
return body.Length > 500 ? body[..500] + "..." : body;
|
||||
}
|
||||
|
||||
private sealed record ImageTestResult
|
||||
{
|
||||
public bool Success { get; init; }
|
||||
public string Message { get; init; } = "";
|
||||
public string Repository { get; init; } = "";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,389 @@
|
||||
using System.Net;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Scanner.Sources.Configuration;
|
||||
using StellaOps.Scanner.Sources.Contracts;
|
||||
using StellaOps.Scanner.Sources.Domain;
|
||||
using StellaOps.Scanner.Sources.Services;
|
||||
|
||||
namespace StellaOps.Scanner.Sources.ConnectionTesters;
|
||||
|
||||
/// <summary>
|
||||
/// Tests connection to Git repositories for source scanning.
|
||||
/// </summary>
|
||||
public sealed class GitConnectionTester : ISourceTypeConnectionTester
|
||||
{
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
private readonly ICredentialResolver _credentialResolver;
|
||||
private readonly ILogger<GitConnectionTester> _logger;
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
};
|
||||
|
||||
public SbomSourceType SourceType => SbomSourceType.Git;
|
||||
|
||||
public GitConnectionTester(
|
||||
IHttpClientFactory httpClientFactory,
|
||||
ICredentialResolver credentialResolver,
|
||||
ILogger<GitConnectionTester> logger)
|
||||
{
|
||||
_httpClientFactory = httpClientFactory;
|
||||
_credentialResolver = credentialResolver;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<ConnectionTestResult> TestAsync(
|
||||
SbomSource source,
|
||||
JsonDocument? overrideCredentials,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var config = source.Configuration.Deserialize<GitSourceConfig>(JsonOptions);
|
||||
if (config == null)
|
||||
{
|
||||
return new ConnectionTestResult
|
||||
{
|
||||
Success = false,
|
||||
Message = "Invalid configuration format",
|
||||
TestedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
}
|
||||
|
||||
// Determine the test approach based on URL type
|
||||
var repoUrl = config.RepositoryUrl;
|
||||
|
||||
if (IsSshUrl(repoUrl))
|
||||
{
|
||||
// SSH URLs require different testing approach
|
||||
return await TestSshConnection(source, config, overrideCredentials, ct);
|
||||
}
|
||||
|
||||
// HTTPS URLs can be tested via API
|
||||
return await TestHttpsConnection(source, config, overrideCredentials, ct);
|
||||
}
|
||||
|
||||
private async Task<ConnectionTestResult> TestHttpsConnection(
|
||||
SbomSource source,
|
||||
GitSourceConfig config,
|
||||
JsonDocument? overrideCredentials,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var client = _httpClientFactory.CreateClient("SourceConnectionTest");
|
||||
client.Timeout = TimeSpan.FromSeconds(30);
|
||||
|
||||
// Build auth header
|
||||
string? authHeader = null;
|
||||
if (overrideCredentials != null)
|
||||
{
|
||||
authHeader = ExtractAuthFromTestCredentials(overrideCredentials);
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(source.AuthRef))
|
||||
{
|
||||
var creds = await _credentialResolver.ResolveAsync(source.AuthRef, ct);
|
||||
authHeader = BuildAuthHeader(creds);
|
||||
}
|
||||
|
||||
if (authHeader != null)
|
||||
{
|
||||
client.DefaultRequestHeaders.TryAddWithoutValidation("Authorization", authHeader);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var testUrl = BuildApiTestUrl(config);
|
||||
if (testUrl == null)
|
||||
{
|
||||
// Fall back to git info/refs
|
||||
testUrl = GetGitInfoRefsUrl(config.RepositoryUrl);
|
||||
}
|
||||
|
||||
_logger.LogDebug("Testing Git connection to {Url}", testUrl);
|
||||
|
||||
var response = await client.GetAsync(testUrl, ct);
|
||||
|
||||
var details = new Dictionary<string, object>
|
||||
{
|
||||
["repositoryUrl"] = config.RepositoryUrl,
|
||||
["provider"] = config.Provider.ToString(),
|
||||
["statusCode"] = (int)response.StatusCode
|
||||
};
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
// Try to extract additional info
|
||||
{
|
||||
var repoInfo = await ExtractRepoInfo(response, config.Provider, ct);
|
||||
if (repoInfo != null)
|
||||
{
|
||||
details["defaultBranch"] = repoInfo.DefaultBranch;
|
||||
details["visibility"] = repoInfo.IsPrivate ? "private" : "public";
|
||||
}
|
||||
}
|
||||
|
||||
return new ConnectionTestResult
|
||||
{
|
||||
Success = true,
|
||||
Message = "Successfully connected to Git repository",
|
||||
TestedAt = DateTimeOffset.UtcNow,
|
||||
Details = details
|
||||
};
|
||||
}
|
||||
|
||||
details["responseBody"] = await TruncateResponseBody(response, ct);
|
||||
|
||||
return response.StatusCode switch
|
||||
{
|
||||
HttpStatusCode.Unauthorized => new ConnectionTestResult
|
||||
{
|
||||
Success = false,
|
||||
Message = "Authentication required - configure credentials",
|
||||
TestedAt = DateTimeOffset.UtcNow,
|
||||
Details = details
|
||||
},
|
||||
HttpStatusCode.Forbidden => new ConnectionTestResult
|
||||
{
|
||||
Success = false,
|
||||
Message = "Access denied - check token permissions",
|
||||
TestedAt = DateTimeOffset.UtcNow,
|
||||
Details = details
|
||||
},
|
||||
HttpStatusCode.NotFound => new ConnectionTestResult
|
||||
{
|
||||
Success = false,
|
||||
Message = "Repository not found - check URL and access",
|
||||
TestedAt = DateTimeOffset.UtcNow,
|
||||
Details = details
|
||||
},
|
||||
_ => new ConnectionTestResult
|
||||
{
|
||||
Success = false,
|
||||
Message = $"Server returned {response.StatusCode}",
|
||||
TestedAt = DateTimeOffset.UtcNow,
|
||||
Details = details
|
||||
}
|
||||
};
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "HTTP error testing Git connection");
|
||||
return new ConnectionTestResult
|
||||
{
|
||||
Success = false,
|
||||
Message = $"Connection failed: {ex.Message}",
|
||||
TestedAt = DateTimeOffset.UtcNow,
|
||||
Details = new Dictionary<string, object>
|
||||
{
|
||||
["repositoryUrl"] = config.RepositoryUrl
|
||||
}
|
||||
};
|
||||
}
|
||||
catch (TaskCanceledException) when (!ct.IsCancellationRequested)
|
||||
{
|
||||
return new ConnectionTestResult
|
||||
{
|
||||
Success = false,
|
||||
Message = "Connection timed out",
|
||||
TestedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private Task<ConnectionTestResult> TestSshConnection(
|
||||
SbomSource source,
|
||||
GitSourceConfig config,
|
||||
JsonDocument? overrideCredentials,
|
||||
CancellationToken ct)
|
||||
{
|
||||
// SSH connection testing requires actual SSH client
|
||||
// For now, return a message that SSH will be validated on first scan
|
||||
return Task.FromResult(new ConnectionTestResult
|
||||
{
|
||||
Success = true,
|
||||
Message = "SSH configuration accepted - connection will be validated on first scan",
|
||||
TestedAt = DateTimeOffset.UtcNow,
|
||||
Details = new Dictionary<string, object>
|
||||
{
|
||||
["repositoryUrl"] = config.RepositoryUrl,
|
||||
["authMethod"] = config.AuthMethod.ToString(),
|
||||
["note"] = "Full SSH validation requires runtime execution"
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static bool IsSshUrl(string url)
|
||||
{
|
||||
return url.StartsWith("git@", StringComparison.OrdinalIgnoreCase) ||
|
||||
url.StartsWith("ssh://", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static string? BuildApiTestUrl(GitSourceConfig config)
|
||||
{
|
||||
// Parse owner/repo from URL
|
||||
var (owner, repo) = ParseRepoPath(config.RepositoryUrl);
|
||||
if (owner == null || repo == null)
|
||||
return null;
|
||||
|
||||
return config.Provider switch
|
||||
{
|
||||
GitProvider.GitHub => $"https://api.github.com/repos/{owner}/{repo}",
|
||||
GitProvider.GitLab => BuildGitLabApiUrl(config.RepositoryUrl, owner, repo),
|
||||
GitProvider.Bitbucket => $"https://api.bitbucket.org/2.0/repositories/{owner}/{repo}",
|
||||
GitProvider.AzureDevOps => null, // Azure DevOps requires different approach
|
||||
GitProvider.Gitea => BuildGiteaApiUrl(config.RepositoryUrl, owner, repo),
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
private static string GetGitInfoRefsUrl(string repoUrl)
|
||||
{
|
||||
var baseUrl = repoUrl.TrimEnd('/');
|
||||
if (!baseUrl.EndsWith(".git"))
|
||||
{
|
||||
baseUrl += ".git";
|
||||
}
|
||||
return $"{baseUrl}/info/refs?service=git-upload-pack";
|
||||
}
|
||||
|
||||
private static string BuildGitLabApiUrl(string repoUrl, string owner, string repo)
|
||||
{
|
||||
// Extract GitLab host from URL
|
||||
var uri = new Uri(repoUrl.Replace("git@", "https://").Replace(":", "/"));
|
||||
var host = uri.Host;
|
||||
var encodedPath = Uri.EscapeDataString($"{owner}/{repo}");
|
||||
return $"https://{host}/api/v4/projects/{encodedPath}";
|
||||
}
|
||||
|
||||
private static string BuildGiteaApiUrl(string repoUrl, string owner, string repo)
|
||||
{
|
||||
var uri = new Uri(repoUrl.Replace("git@", "https://").Replace(":", "/"));
|
||||
var host = uri.Host;
|
||||
return $"https://{host}/api/v1/repos/{owner}/{repo}";
|
||||
}
|
||||
|
||||
private static (string? Owner, string? Repo) ParseRepoPath(string url)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Handle SSH URLs: git@github.com:owner/repo.git
|
||||
if (url.StartsWith("git@"))
|
||||
{
|
||||
var colonIdx = url.IndexOf(':');
|
||||
if (colonIdx > 0)
|
||||
{
|
||||
var path = url[(colonIdx + 1)..].TrimEnd('/');
|
||||
if (path.EndsWith(".git"))
|
||||
path = path[..^4];
|
||||
var parts = path.Split('/');
|
||||
if (parts.Length >= 2)
|
||||
return (parts[0], parts[1]);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle HTTPS URLs
|
||||
var uri = new Uri(url);
|
||||
var segments = uri.AbsolutePath.Trim('/').Split('/');
|
||||
if (segments.Length >= 2)
|
||||
{
|
||||
var repo = segments[1];
|
||||
if (repo.EndsWith(".git"))
|
||||
repo = repo[..^4];
|
||||
return (segments[0], repo);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// URL parsing failed
|
||||
}
|
||||
|
||||
return (null, null);
|
||||
}
|
||||
|
||||
private static string? ExtractAuthFromTestCredentials(JsonDocument credentials)
|
||||
{
|
||||
var root = credentials.RootElement;
|
||||
|
||||
if (root.TryGetProperty("token", out var token))
|
||||
{
|
||||
var tokenStr = token.GetString();
|
||||
// GitHub tokens are prefixed with ghp_, gho_, etc.
|
||||
// GitLab tokens are prefixed with glpat-
|
||||
// For most providers, use Bearer auth
|
||||
return $"Bearer {tokenStr}";
|
||||
}
|
||||
|
||||
if (root.TryGetProperty("username", out var username) &&
|
||||
root.TryGetProperty("password", out var password))
|
||||
{
|
||||
var encoded = Convert.ToBase64String(
|
||||
System.Text.Encoding.UTF8.GetBytes(
|
||||
$"{username.GetString()}:{password.GetString()}"));
|
||||
return $"Basic {encoded}";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string? BuildAuthHeader(ResolvedCredential? credential)
|
||||
{
|
||||
if (credential == null) return null;
|
||||
|
||||
return credential.Type switch
|
||||
{
|
||||
CredentialType.BearerToken => $"Bearer {credential.Token}",
|
||||
CredentialType.BasicAuth => $"Basic {Convert.ToBase64String(
|
||||
System.Text.Encoding.UTF8.GetBytes($"{credential.Username}:{credential.Password}"))}",
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
private static async Task<string> TruncateResponseBody(HttpResponseMessage response, CancellationToken ct)
|
||||
{
|
||||
var body = await response.Content.ReadAsStringAsync(ct);
|
||||
return body.Length > 500 ? body[..500] + "..." : body;
|
||||
}
|
||||
|
||||
private async Task<RepoInfo?> ExtractRepoInfo(
|
||||
HttpResponseMessage response,
|
||||
GitProvider provider,
|
||||
CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var json = await response.Content.ReadAsStringAsync(ct);
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
var root = doc.RootElement;
|
||||
|
||||
return provider switch
|
||||
{
|
||||
GitProvider.GitHub => new RepoInfo
|
||||
{
|
||||
DefaultBranch = root.TryGetProperty("default_branch", out var db)
|
||||
? db.GetString() ?? "main"
|
||||
: "main",
|
||||
IsPrivate = root.TryGetProperty("private", out var priv) && priv.GetBoolean()
|
||||
},
|
||||
GitProvider.GitLab => new RepoInfo
|
||||
{
|
||||
DefaultBranch = root.TryGetProperty("default_branch", out var db)
|
||||
? db.GetString() ?? "main"
|
||||
: "main",
|
||||
IsPrivate = root.TryGetProperty("visibility", out var vis)
|
||||
&& vis.GetString() == "private"
|
||||
},
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record RepoInfo
|
||||
{
|
||||
public string DefaultBranch { get; init; } = "main";
|
||||
public bool IsPrivate { get; init; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Scanner.Sources.Configuration;
|
||||
using StellaOps.Scanner.Sources.Contracts;
|
||||
using StellaOps.Scanner.Sources.Domain;
|
||||
using StellaOps.Scanner.Sources.Services;
|
||||
|
||||
namespace StellaOps.Scanner.Sources.ConnectionTesters;
|
||||
|
||||
/// <summary>
|
||||
/// Tests connection to container registries for Zastava webhook sources.
|
||||
/// </summary>
|
||||
public sealed class ZastavaConnectionTester : ISourceTypeConnectionTester
|
||||
{
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
private readonly ICredentialResolver _credentialResolver;
|
||||
private readonly ILogger<ZastavaConnectionTester> _logger;
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
};
|
||||
|
||||
public SbomSourceType SourceType => SbomSourceType.Zastava;
|
||||
|
||||
public ZastavaConnectionTester(
|
||||
IHttpClientFactory httpClientFactory,
|
||||
ICredentialResolver credentialResolver,
|
||||
ILogger<ZastavaConnectionTester> logger)
|
||||
{
|
||||
_httpClientFactory = httpClientFactory;
|
||||
_credentialResolver = credentialResolver;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<ConnectionTestResult> TestAsync(
|
||||
SbomSource source,
|
||||
JsonDocument? overrideCredentials,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var config = source.Configuration.Deserialize<ZastavaSourceConfig>(JsonOptions);
|
||||
if (config == null)
|
||||
{
|
||||
return new ConnectionTestResult
|
||||
{
|
||||
Success = false,
|
||||
Message = "Invalid configuration format",
|
||||
TestedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
}
|
||||
|
||||
var client = _httpClientFactory.CreateClient("SourceConnectionTest");
|
||||
client.Timeout = TimeSpan.FromSeconds(30);
|
||||
|
||||
// Get credentials
|
||||
string? authHeader = null;
|
||||
if (overrideCredentials != null)
|
||||
{
|
||||
authHeader = ExtractAuthFromTestCredentials(overrideCredentials);
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(source.AuthRef))
|
||||
{
|
||||
var creds = await _credentialResolver.ResolveAsync(source.AuthRef, ct);
|
||||
authHeader = BuildAuthHeader(creds);
|
||||
}
|
||||
|
||||
if (authHeader != null)
|
||||
{
|
||||
client.DefaultRequestHeaders.TryAddWithoutValidation("Authorization", authHeader);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var testUrl = BuildRegistryTestUrl(config);
|
||||
var response = await client.GetAsync(testUrl, ct);
|
||||
|
||||
var details = new Dictionary<string, object>
|
||||
{
|
||||
["registryType"] = config.RegistryType.ToString(),
|
||||
["registryUrl"] = config.RegistryUrl,
|
||||
["statusCode"] = (int)response.StatusCode
|
||||
};
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
return new ConnectionTestResult
|
||||
{
|
||||
Success = true,
|
||||
Message = "Successfully connected to registry",
|
||||
TestedAt = DateTimeOffset.UtcNow,
|
||||
Details = details
|
||||
};
|
||||
}
|
||||
|
||||
// Handle specific error codes
|
||||
details["responseBody"] = await TruncateResponseBody(response, ct);
|
||||
|
||||
return response.StatusCode switch
|
||||
{
|
||||
HttpStatusCode.Unauthorized => new ConnectionTestResult
|
||||
{
|
||||
Success = false,
|
||||
Message = "Authentication failed - check credentials",
|
||||
TestedAt = DateTimeOffset.UtcNow,
|
||||
Details = details
|
||||
},
|
||||
HttpStatusCode.Forbidden => new ConnectionTestResult
|
||||
{
|
||||
Success = false,
|
||||
Message = "Access denied - insufficient permissions",
|
||||
TestedAt = DateTimeOffset.UtcNow,
|
||||
Details = details
|
||||
},
|
||||
HttpStatusCode.NotFound => new ConnectionTestResult
|
||||
{
|
||||
Success = false,
|
||||
Message = "Registry endpoint not found - check URL",
|
||||
TestedAt = DateTimeOffset.UtcNow,
|
||||
Details = details
|
||||
},
|
||||
_ => new ConnectionTestResult
|
||||
{
|
||||
Success = false,
|
||||
Message = $"Registry returned {response.StatusCode}",
|
||||
TestedAt = DateTimeOffset.UtcNow,
|
||||
Details = details
|
||||
}
|
||||
};
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "HTTP error testing Zastava connection to {Url}", config.RegistryUrl);
|
||||
return new ConnectionTestResult
|
||||
{
|
||||
Success = false,
|
||||
Message = $"Connection failed: {ex.Message}",
|
||||
TestedAt = DateTimeOffset.UtcNow,
|
||||
Details = new Dictionary<string, object>
|
||||
{
|
||||
["registryUrl"] = config.RegistryUrl,
|
||||
["errorType"] = "HttpRequestException"
|
||||
}
|
||||
};
|
||||
}
|
||||
catch (TaskCanceledException) when (!ct.IsCancellationRequested)
|
||||
{
|
||||
return new ConnectionTestResult
|
||||
{
|
||||
Success = false,
|
||||
Message = "Connection timed out",
|
||||
TestedAt = DateTimeOffset.UtcNow,
|
||||
Details = new Dictionary<string, object>
|
||||
{
|
||||
["registryUrl"] = config.RegistryUrl,
|
||||
["errorType"] = "Timeout"
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private static string BuildRegistryTestUrl(ZastavaSourceConfig config)
|
||||
{
|
||||
var baseUrl = config.RegistryUrl.TrimEnd('/');
|
||||
|
||||
return config.RegistryType switch
|
||||
{
|
||||
// Docker Registry V2 API
|
||||
RegistryType.DockerHub => "https://registry-1.docker.io/v2/",
|
||||
RegistryType.Harbor or
|
||||
RegistryType.Quay or
|
||||
RegistryType.Nexus or
|
||||
RegistryType.JFrog or
|
||||
RegistryType.Custom => $"{baseUrl}/v2/",
|
||||
|
||||
// Cloud provider registries
|
||||
RegistryType.Ecr => $"{baseUrl}/v2/", // ECR uses standard V2 API
|
||||
RegistryType.Gcr => $"{baseUrl}/v2/",
|
||||
RegistryType.Acr => $"{baseUrl}/v2/",
|
||||
RegistryType.Ghcr => "https://ghcr.io/v2/",
|
||||
|
||||
// GitLab container registry
|
||||
RegistryType.GitLab => $"{baseUrl}/v2/",
|
||||
|
||||
_ => $"{baseUrl}/v2/"
|
||||
};
|
||||
}
|
||||
|
||||
private static string? ExtractAuthFromTestCredentials(JsonDocument credentials)
|
||||
{
|
||||
var root = credentials.RootElement;
|
||||
|
||||
// Support various credential formats
|
||||
if (root.TryGetProperty("token", out var token))
|
||||
{
|
||||
return $"Bearer {token.GetString()}";
|
||||
}
|
||||
|
||||
if (root.TryGetProperty("username", out var username) &&
|
||||
root.TryGetProperty("password", out var password))
|
||||
{
|
||||
var encoded = Convert.ToBase64String(
|
||||
System.Text.Encoding.UTF8.GetBytes(
|
||||
$"{username.GetString()}:{password.GetString()}"));
|
||||
return $"Basic {encoded}";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string? BuildAuthHeader(ResolvedCredential? credential)
|
||||
{
|
||||
if (credential == null) return null;
|
||||
|
||||
return credential.Type switch
|
||||
{
|
||||
CredentialType.BearerToken => $"Bearer {credential.Token}",
|
||||
CredentialType.BasicAuth => $"Basic {Convert.ToBase64String(
|
||||
System.Text.Encoding.UTF8.GetBytes($"{credential.Username}:{credential.Password}"))}",
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
private static async Task<string> TruncateResponseBody(HttpResponseMessage response, CancellationToken ct)
|
||||
{
|
||||
var body = await response.Content.ReadAsStringAsync(ct);
|
||||
return body.Length > 500 ? body[..500] + "..." : body;
|
||||
}
|
||||
}
|
||||
@@ -105,6 +105,9 @@ public sealed record ListSourcesRequest
|
||||
/// <summary>Search term (matches name, description).</summary>
|
||||
public string? Search { get; init; }
|
||||
|
||||
/// <summary>Filter by name contains (case-insensitive).</summary>
|
||||
public string? NameContains { get; init; }
|
||||
|
||||
/// <summary>Page size.</summary>
|
||||
public int Limit { get; init; } = 25;
|
||||
|
||||
@@ -163,22 +166,7 @@ public sealed record TestConnectionRequest
|
||||
public string? AuthRef { get; init; }
|
||||
|
||||
/// <summary>Inline credentials for testing (not stored).</summary>
|
||||
public TestCredentials? TestCredentials { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Inline credentials for connection testing.
|
||||
/// </summary>
|
||||
public sealed record TestCredentials
|
||||
{
|
||||
/// <summary>Username (registry auth, git).</summary>
|
||||
public string? Username { get; init; }
|
||||
|
||||
/// <summary>Password or token.</summary>
|
||||
public string? Password { get; init; }
|
||||
|
||||
/// <summary>SSH private key (git).</summary>
|
||||
public string? SshKey { get; init; }
|
||||
public JsonDocument? TestCredentials { get; init; }
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
@@ -310,19 +298,23 @@ public sealed record ConnectionTestResult
|
||||
public required bool Success { get; init; }
|
||||
public string? Message { get; init; }
|
||||
public string? ErrorCode { get; init; }
|
||||
public DateTimeOffset TestedAt { get; init; } = DateTimeOffset.UtcNow;
|
||||
public List<ConnectionTestCheck> Checks { get; init; } = [];
|
||||
public Dictionary<string, object>? Details { get; init; }
|
||||
|
||||
public static ConnectionTestResult Succeeded(string? message = null) => new()
|
||||
{
|
||||
Success = true,
|
||||
Message = message ?? "Connection successful"
|
||||
Message = message ?? "Connection successful",
|
||||
TestedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
public static ConnectionTestResult Failed(string message, string? errorCode = null) => new()
|
||||
{
|
||||
Success = false,
|
||||
Message = message,
|
||||
ErrorCode = errorCode
|
||||
ErrorCode = errorCode,
|
||||
TestedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using StellaOps.Scanner.Sources.Configuration;
|
||||
using StellaOps.Scanner.Sources.ConnectionTesters;
|
||||
using StellaOps.Scanner.Sources.Handlers;
|
||||
using StellaOps.Scanner.Sources.Handlers.Cli;
|
||||
using StellaOps.Scanner.Sources.Handlers.Docker;
|
||||
using StellaOps.Scanner.Sources.Handlers.Git;
|
||||
using StellaOps.Scanner.Sources.Handlers.Zastava;
|
||||
using StellaOps.Scanner.Sources.Persistence;
|
||||
using StellaOps.Scanner.Sources.Scheduling;
|
||||
using StellaOps.Scanner.Sources.Services;
|
||||
using StellaOps.Scanner.Sources.Triggers;
|
||||
|
||||
namespace StellaOps.Scanner.Sources.DependencyInjection;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for registering Scanner.Sources services.
|
||||
/// </summary>
|
||||
public static class ServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds SBOM source management services to the service collection.
|
||||
/// </summary>
|
||||
public static IServiceCollection AddSbomSources(
|
||||
this IServiceCollection services,
|
||||
Action<SbomSourcesOptions>? configure = null)
|
||||
{
|
||||
var options = new SbomSourcesOptions();
|
||||
configure?.Invoke(options);
|
||||
|
||||
// Register options
|
||||
services.AddSingleton(options);
|
||||
|
||||
// Register core services
|
||||
services.AddScoped<ISbomSourceService, SbomSourceService>();
|
||||
services.AddScoped<ISourceConfigValidator, SourceConfigValidator>();
|
||||
services.AddScoped<ISourceConnectionTester, SourceConnectionTester>();
|
||||
|
||||
// Register repositories
|
||||
services.AddScoped<ISbomSourceRepository, SbomSourceRepository>();
|
||||
services.AddScoped<ISbomSourceRunRepository, SbomSourceRunRepository>();
|
||||
|
||||
// Register connection testers
|
||||
services.AddScoped<ISourceTypeConnectionTester, ZastavaConnectionTester>();
|
||||
services.AddScoped<ISourceTypeConnectionTester, DockerConnectionTester>();
|
||||
services.AddScoped<ISourceTypeConnectionTester, GitConnectionTester>();
|
||||
services.AddScoped<ISourceTypeConnectionTester, CliConnectionTester>();
|
||||
|
||||
// Register source type handlers
|
||||
services.AddScoped<ISourceTypeHandler, ZastavaSourceHandler>();
|
||||
services.AddScoped<ISourceTypeHandler, DockerSourceHandler>();
|
||||
services.AddScoped<ISourceTypeHandler, GitSourceHandler>();
|
||||
services.AddScoped<ISourceTypeHandler, CliSourceHandler>();
|
||||
|
||||
// Register trigger dispatcher
|
||||
services.AddScoped<ISourceTriggerDispatcher, SourceTriggerDispatcher>();
|
||||
|
||||
// Register image discovery service
|
||||
services.AddSingleton<IImageDiscoveryService, ImageDiscoveryService>();
|
||||
|
||||
// Register HTTP client for connection testing
|
||||
services.AddHttpClient("SourceConnectionTest", client =>
|
||||
{
|
||||
client.DefaultRequestHeaders.Add("User-Agent", "StellaOps-SourceConnectionTester/1.0");
|
||||
client.Timeout = TimeSpan.FromSeconds(30);
|
||||
});
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds the source scheduler background service.
|
||||
/// </summary>
|
||||
public static IServiceCollection AddSbomSourceScheduler(
|
||||
this IServiceCollection services,
|
||||
Action<SourceSchedulerOptions>? configure = null)
|
||||
{
|
||||
services.Configure<SourceSchedulerOptions>(opt =>
|
||||
{
|
||||
configure?.Invoke(opt);
|
||||
});
|
||||
|
||||
services.TryAddSingleton(TimeProvider.System);
|
||||
services.AddHostedService<SourceSchedulerHostedService>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a custom credential resolver for SBOM sources.
|
||||
/// </summary>
|
||||
public static IServiceCollection AddSbomSourceCredentialResolver<TResolver>(
|
||||
this IServiceCollection services)
|
||||
where TResolver : class, ICredentialResolver
|
||||
{
|
||||
services.AddScoped<ICredentialResolver, TResolver>();
|
||||
return services;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Options for SBOM source management.
|
||||
/// </summary>
|
||||
public sealed class SbomSourcesOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Default timeout for connection tests in seconds.
|
||||
/// </summary>
|
||||
public int ConnectionTestTimeoutSeconds { get; set; } = 30;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum number of runs to retain per source.
|
||||
/// </summary>
|
||||
public int MaxRunsPerSource { get; set; } = 1000;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to enable connection test caching.
|
||||
/// </summary>
|
||||
public bool EnableConnectionTestCaching { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Connection test cache duration in minutes.
|
||||
/// </summary>
|
||||
public int ConnectionTestCacheMinutes { get; set; } = 5;
|
||||
}
|
||||
@@ -0,0 +1,358 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Scanner.Sources.Configuration;
|
||||
using StellaOps.Scanner.Sources.Contracts;
|
||||
using StellaOps.Scanner.Sources.Domain;
|
||||
using StellaOps.Scanner.Sources.Services;
|
||||
using StellaOps.Scanner.Sources.Triggers;
|
||||
|
||||
namespace StellaOps.Scanner.Sources.Handlers.Cli;
|
||||
|
||||
/// <summary>
|
||||
/// Handler for CLI (external submission) sources.
|
||||
/// Receives SBOM uploads from CI/CD pipelines via the CLI tool.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// CLI sources are passive - they don't discover targets but receive
|
||||
/// submissions from external systems. The handler validates submissions
|
||||
/// against the configured rules.
|
||||
/// </remarks>
|
||||
public sealed class CliSourceHandler : ISourceTypeHandler
|
||||
{
|
||||
private readonly ISourceConfigValidator _configValidator;
|
||||
private readonly ILogger<CliSourceHandler> _logger;
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
};
|
||||
|
||||
public SbomSourceType SourceType => SbomSourceType.Cli;
|
||||
public bool SupportsWebhooks => false;
|
||||
public bool SupportsScheduling => false;
|
||||
public int MaxConcurrentTargets => 100;
|
||||
|
||||
public CliSourceHandler(
|
||||
ISourceConfigValidator configValidator,
|
||||
ILogger<CliSourceHandler> logger)
|
||||
{
|
||||
_configValidator = configValidator;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// CLI sources don't discover targets - submissions come via API.
|
||||
/// This method returns an empty list for scheduled/manual triggers.
|
||||
/// For submissions, the target is created from the submission metadata.
|
||||
/// </summary>
|
||||
public Task<IReadOnlyList<ScanTarget>> DiscoverTargetsAsync(
|
||||
SbomSource source,
|
||||
TriggerContext context,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var config = source.Configuration.Deserialize<CliSourceConfig>(JsonOptions);
|
||||
if (config == null)
|
||||
{
|
||||
_logger.LogWarning("Invalid configuration for source {SourceId}", source.SourceId);
|
||||
return Task.FromResult<IReadOnlyList<ScanTarget>>([]);
|
||||
}
|
||||
|
||||
// CLI sources only process submissions via the SubmissionContext
|
||||
if (context.Metadata.TryGetValue("submissionId", out var submissionId))
|
||||
{
|
||||
// Create target from submission metadata
|
||||
var target = new ScanTarget
|
||||
{
|
||||
Reference = context.Metadata.TryGetValue("reference", out var refValue) ? refValue : submissionId,
|
||||
Metadata = new Dictionary<string, string>(context.Metadata)
|
||||
};
|
||||
|
||||
_logger.LogInformation(
|
||||
"Created target from CLI submission {SubmissionId} for source {SourceId}",
|
||||
submissionId, source.SourceId);
|
||||
|
||||
return Task.FromResult<IReadOnlyList<ScanTarget>>([target]);
|
||||
}
|
||||
|
||||
// For scheduled/manual triggers, CLI sources have nothing to discover
|
||||
_logger.LogDebug(
|
||||
"CLI source {SourceId} has no targets to discover for trigger {Trigger}",
|
||||
source.SourceId, context.Trigger);
|
||||
|
||||
return Task.FromResult<IReadOnlyList<ScanTarget>>([]);
|
||||
}
|
||||
|
||||
public ConfigValidationResult ValidateConfiguration(JsonDocument configuration)
|
||||
{
|
||||
return _configValidator.Validate(SbomSourceType.Cli, configuration);
|
||||
}
|
||||
|
||||
public Task<ConnectionTestResult> TestConnectionAsync(
|
||||
SbomSource source,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var config = source.Configuration.Deserialize<CliSourceConfig>(JsonOptions);
|
||||
if (config == null)
|
||||
{
|
||||
return Task.FromResult(new ConnectionTestResult
|
||||
{
|
||||
Success = false,
|
||||
Message = "Invalid configuration",
|
||||
TestedAt = DateTimeOffset.UtcNow
|
||||
});
|
||||
}
|
||||
|
||||
// CLI sources don't have external connections to test
|
||||
// We just validate the configuration
|
||||
return Task.FromResult(new ConnectionTestResult
|
||||
{
|
||||
Success = true,
|
||||
Message = "CLI source configuration is valid",
|
||||
TestedAt = DateTimeOffset.UtcNow,
|
||||
Details = new Dictionary<string, object>
|
||||
{
|
||||
["allowedTools"] = config.AllowedTools,
|
||||
["allowedFormats"] = config.Validation.AllowedFormats.Select(f => f.ToString()).ToArray(),
|
||||
["requireSignedSbom"] = config.Validation.RequireSignedSbom,
|
||||
["maxSbomSizeMb"] = config.Validation.MaxSbomSizeBytes / (1024 * 1024)
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validate an SBOM submission against the source configuration.
|
||||
/// </summary>
|
||||
public SubmissionValidationResult ValidateSubmission(
|
||||
SbomSource source,
|
||||
CliSubmissionRequest submission)
|
||||
{
|
||||
var config = source.Configuration.Deserialize<CliSourceConfig>(JsonOptions);
|
||||
if (config == null)
|
||||
{
|
||||
return SubmissionValidationResult.Failed("Invalid source configuration");
|
||||
}
|
||||
|
||||
var errors = new List<string>();
|
||||
|
||||
// Validate tool
|
||||
if (!config.AllowedTools.Contains(submission.Tool, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
errors.Add($"Tool '{submission.Tool}' is not allowed. Allowed tools: {string.Join(", ", config.AllowedTools)}");
|
||||
}
|
||||
|
||||
// Validate CI system if specified
|
||||
if (config.AllowedCiSystems is { Length: > 0 } && submission.CiSystem != null)
|
||||
{
|
||||
if (!config.AllowedCiSystems.Contains(submission.CiSystem, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
errors.Add($"CI system '{submission.CiSystem}' is not allowed. Allowed systems: {string.Join(", ", config.AllowedCiSystems)}");
|
||||
}
|
||||
}
|
||||
|
||||
// Validate format
|
||||
if (!config.Validation.AllowedFormats.Contains(submission.Format))
|
||||
{
|
||||
errors.Add($"Format '{submission.Format}' is not allowed. Allowed formats: {string.Join(", ", config.Validation.AllowedFormats)}");
|
||||
}
|
||||
|
||||
// Validate size
|
||||
if (submission.SbomSizeBytes > config.Validation.MaxSbomSizeBytes)
|
||||
{
|
||||
var maxMb = config.Validation.MaxSbomSizeBytes / (1024 * 1024);
|
||||
var actualMb = submission.SbomSizeBytes / (1024 * 1024);
|
||||
errors.Add($"SBOM size ({actualMb} MB) exceeds maximum allowed size ({maxMb} MB)");
|
||||
}
|
||||
|
||||
// Validate signature if required
|
||||
if (config.Validation.RequireSignedSbom && string.IsNullOrEmpty(submission.Signature))
|
||||
{
|
||||
errors.Add("Signed SBOM is required but no signature was provided");
|
||||
}
|
||||
|
||||
// Validate signer if signature is present
|
||||
if (!string.IsNullOrEmpty(submission.Signature) &&
|
||||
config.Validation.AllowedSigners is { Length: > 0 })
|
||||
{
|
||||
if (!config.Validation.AllowedSigners.Contains(submission.SignerFingerprint, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
errors.Add($"Signer fingerprint '{submission.SignerFingerprint}' is not in the allowed list");
|
||||
}
|
||||
}
|
||||
|
||||
// Validate attribution requirements
|
||||
if (config.Attribution.RequireBuildId && string.IsNullOrEmpty(submission.BuildId))
|
||||
{
|
||||
errors.Add("Build ID is required");
|
||||
}
|
||||
|
||||
if (config.Attribution.RequireRepository && string.IsNullOrEmpty(submission.Repository))
|
||||
{
|
||||
errors.Add("Repository reference is required");
|
||||
}
|
||||
|
||||
if (config.Attribution.RequireCommitSha && string.IsNullOrEmpty(submission.CommitSha))
|
||||
{
|
||||
errors.Add("Commit SHA is required");
|
||||
}
|
||||
|
||||
if (config.Attribution.RequirePipelineId && string.IsNullOrEmpty(submission.PipelineId))
|
||||
{
|
||||
errors.Add("Pipeline ID is required");
|
||||
}
|
||||
|
||||
// Validate repository against allowed patterns
|
||||
if (!string.IsNullOrEmpty(submission.Repository) &&
|
||||
config.Attribution.AllowedRepositories is { Length: > 0 })
|
||||
{
|
||||
var repoAllowed = config.Attribution.AllowedRepositories
|
||||
.Any(p => MatchesPattern(submission.Repository, p));
|
||||
|
||||
if (!repoAllowed)
|
||||
{
|
||||
errors.Add($"Repository '{submission.Repository}' is not in the allowed list");
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.Count > 0)
|
||||
{
|
||||
return SubmissionValidationResult.Failed(errors);
|
||||
}
|
||||
|
||||
return SubmissionValidationResult.Valid();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generate a token for CLI authentication to this source.
|
||||
/// </summary>
|
||||
public CliAuthToken GenerateAuthToken(SbomSource source, TimeSpan validity)
|
||||
{
|
||||
var tokenBytes = new byte[32];
|
||||
RandomNumberGenerator.Fill(tokenBytes);
|
||||
var token = Convert.ToBase64String(tokenBytes);
|
||||
|
||||
// Create token hash for storage
|
||||
var tokenHash = SHA256.HashData(Encoding.UTF8.GetBytes(token));
|
||||
|
||||
return new CliAuthToken
|
||||
{
|
||||
Token = token,
|
||||
TokenHash = Convert.ToHexString(tokenHash).ToLowerInvariant(),
|
||||
SourceId = source.SourceId,
|
||||
ExpiresAt = DateTimeOffset.UtcNow.Add(validity),
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
}
|
||||
|
||||
private static bool MatchesPattern(string value, string pattern)
|
||||
{
|
||||
var regexPattern = "^" + Regex.Escape(pattern)
|
||||
.Replace("\\*\\*", ".*")
|
||||
.Replace("\\*", "[^/]*")
|
||||
.Replace("\\?", ".") + "$";
|
||||
|
||||
return Regex.IsMatch(value, regexPattern, RegexOptions.IgnoreCase);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request for CLI SBOM submission.
|
||||
/// </summary>
|
||||
public sealed record CliSubmissionRequest
|
||||
{
|
||||
/// <summary>Scanner/tool that generated the SBOM.</summary>
|
||||
public required string Tool { get; init; }
|
||||
|
||||
/// <summary>Tool version.</summary>
|
||||
public string? ToolVersion { get; init; }
|
||||
|
||||
/// <summary>CI system (e.g., "github-actions", "gitlab-ci").</summary>
|
||||
public string? CiSystem { get; init; }
|
||||
|
||||
/// <summary>SBOM format.</summary>
|
||||
public required SbomFormat Format { get; init; }
|
||||
|
||||
/// <summary>SBOM format version.</summary>
|
||||
public string? FormatVersion { get; init; }
|
||||
|
||||
/// <summary>SBOM size in bytes.</summary>
|
||||
public long SbomSizeBytes { get; init; }
|
||||
|
||||
/// <summary>SBOM content hash (for verification).</summary>
|
||||
public string? ContentHash { get; init; }
|
||||
|
||||
/// <summary>SBOM signature (if signed).</summary>
|
||||
public string? Signature { get; init; }
|
||||
|
||||
/// <summary>Signer key fingerprint.</summary>
|
||||
public string? SignerFingerprint { get; init; }
|
||||
|
||||
/// <summary>Build ID.</summary>
|
||||
public string? BuildId { get; init; }
|
||||
|
||||
/// <summary>Repository URL.</summary>
|
||||
public string? Repository { get; init; }
|
||||
|
||||
/// <summary>Commit SHA.</summary>
|
||||
public string? CommitSha { get; init; }
|
||||
|
||||
/// <summary>Branch name.</summary>
|
||||
public string? Branch { get; init; }
|
||||
|
||||
/// <summary>Pipeline/workflow ID.</summary>
|
||||
public string? PipelineId { get; init; }
|
||||
|
||||
/// <summary>Pipeline/workflow name.</summary>
|
||||
public string? PipelineName { get; init; }
|
||||
|
||||
/// <summary>Subject reference (what was scanned).</summary>
|
||||
public required string Subject { get; init; }
|
||||
|
||||
/// <summary>Subject digest.</summary>
|
||||
public string? SubjectDigest { get; init; }
|
||||
|
||||
/// <summary>Additional metadata.</summary>
|
||||
public Dictionary<string, string> Metadata { get; init; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of submission validation.
|
||||
/// </summary>
|
||||
public sealed record SubmissionValidationResult
|
||||
{
|
||||
public bool IsValid { get; init; }
|
||||
public IReadOnlyList<string> Errors { get; init; } = [];
|
||||
|
||||
public static SubmissionValidationResult Valid() =>
|
||||
new() { IsValid = true };
|
||||
|
||||
public static SubmissionValidationResult Failed(string error) =>
|
||||
new() { IsValid = false, Errors = [error] };
|
||||
|
||||
public static SubmissionValidationResult Failed(IReadOnlyList<string> errors) =>
|
||||
new() { IsValid = false, Errors = errors };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// CLI authentication token.
|
||||
/// </summary>
|
||||
public sealed record CliAuthToken
|
||||
{
|
||||
/// <summary>The raw token (only returned once on creation).</summary>
|
||||
public required string Token { get; init; }
|
||||
|
||||
/// <summary>Hash of the token (stored in database).</summary>
|
||||
public required string TokenHash { get; init; }
|
||||
|
||||
/// <summary>Source this token is for.</summary>
|
||||
public Guid SourceId { get; init; }
|
||||
|
||||
/// <summary>When the token expires.</summary>
|
||||
public DateTimeOffset ExpiresAt { get; init; }
|
||||
|
||||
/// <summary>When the token was created.</summary>
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Scanner.Sources.Configuration;
|
||||
using StellaOps.Scanner.Sources.Contracts;
|
||||
using StellaOps.Scanner.Sources.Domain;
|
||||
using StellaOps.Scanner.Sources.Handlers.Zastava;
|
||||
using StellaOps.Scanner.Sources.Services;
|
||||
using StellaOps.Scanner.Sources.Triggers;
|
||||
|
||||
namespace StellaOps.Scanner.Sources.Handlers.Docker;
|
||||
|
||||
/// <summary>
|
||||
/// Handler for Docker (direct image scan) sources.
|
||||
/// Scans specific images from container registries on schedule or on-demand.
|
||||
/// </summary>
|
||||
public sealed class DockerSourceHandler : ISourceTypeHandler
|
||||
{
|
||||
private readonly IRegistryClientFactory _clientFactory;
|
||||
private readonly ICredentialResolver _credentialResolver;
|
||||
private readonly ISourceConfigValidator _configValidator;
|
||||
private readonly IImageDiscoveryService _discoveryService;
|
||||
private readonly ILogger<DockerSourceHandler> _logger;
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
};
|
||||
|
||||
public SbomSourceType SourceType => SbomSourceType.Docker;
|
||||
public bool SupportsWebhooks => false;
|
||||
public bool SupportsScheduling => true;
|
||||
public int MaxConcurrentTargets => 50;
|
||||
|
||||
public DockerSourceHandler(
|
||||
IRegistryClientFactory clientFactory,
|
||||
ICredentialResolver credentialResolver,
|
||||
ISourceConfigValidator configValidator,
|
||||
IImageDiscoveryService discoveryService,
|
||||
ILogger<DockerSourceHandler> logger)
|
||||
{
|
||||
_clientFactory = clientFactory;
|
||||
_credentialResolver = credentialResolver;
|
||||
_configValidator = configValidator;
|
||||
_discoveryService = discoveryService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<ScanTarget>> DiscoverTargetsAsync(
|
||||
SbomSource source,
|
||||
TriggerContext context,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var config = source.Configuration.Deserialize<DockerSourceConfig>(JsonOptions);
|
||||
if (config == null)
|
||||
{
|
||||
_logger.LogWarning("Invalid configuration for source {SourceId}", source.SourceId);
|
||||
return [];
|
||||
}
|
||||
|
||||
var credentials = await GetCredentialsAsync(source.AuthRef, ct);
|
||||
var registryType = InferRegistryType(config.RegistryUrl);
|
||||
|
||||
using var client = _clientFactory.Create(registryType, config.RegistryUrl, credentials);
|
||||
|
||||
var targets = new List<ScanTarget>();
|
||||
|
||||
foreach (var imageSpec in config.Images)
|
||||
{
|
||||
try
|
||||
{
|
||||
var discovered = await DiscoverImageTargetsAsync(
|
||||
client, config, imageSpec, ct);
|
||||
targets.AddRange(discovered);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex,
|
||||
"Failed to discover targets for image {Reference}",
|
||||
imageSpec.Reference);
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Discovered {Count} targets from {ImageCount} image specs for source {SourceId}",
|
||||
targets.Count, config.Images.Length, source.SourceId);
|
||||
|
||||
return targets;
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<ScanTarget>> DiscoverImageTargetsAsync(
|
||||
IRegistryClient client,
|
||||
DockerSourceConfig config,
|
||||
ImageSpec imageSpec,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var targets = new List<ScanTarget>();
|
||||
|
||||
// Parse the reference to get repository and optional tag
|
||||
var (repository, tag) = ParseReference(imageSpec.Reference);
|
||||
|
||||
// If the reference has a specific tag and no patterns, just scan that image
|
||||
if (tag != null && (imageSpec.TagPatterns == null || imageSpec.TagPatterns.Length == 0))
|
||||
{
|
||||
var digest = await client.GetDigestAsync(repository, tag, ct);
|
||||
targets.Add(new ScanTarget
|
||||
{
|
||||
Reference = BuildFullReference(config.RegistryUrl, repository, tag),
|
||||
Digest = digest,
|
||||
Priority = config.ScanOptions.Priority,
|
||||
Metadata = new Dictionary<string, string>
|
||||
{
|
||||
["repository"] = repository,
|
||||
["tag"] = tag,
|
||||
["registryUrl"] = config.RegistryUrl
|
||||
}
|
||||
});
|
||||
return targets;
|
||||
}
|
||||
|
||||
// Discover tags based on patterns
|
||||
var tagPatterns = imageSpec.TagPatterns ?? ["*"];
|
||||
var allTags = await client.ListTagsAsync(repository, tagPatterns, imageSpec.MaxTags * 2, ct);
|
||||
|
||||
// Filter and sort tags
|
||||
var filteredTags = _discoveryService.FilterTags(
|
||||
allTags,
|
||||
config.Discovery?.ExcludePatterns,
|
||||
config.Discovery?.IncludePreRelease ?? false);
|
||||
|
||||
var sortedTags = _discoveryService.SortTags(
|
||||
filteredTags,
|
||||
config.Discovery?.SortOrder ?? TagSortOrder.SemVerDescending);
|
||||
|
||||
// Apply age filter if specified
|
||||
if (imageSpec.MaxAgeHours.HasValue)
|
||||
{
|
||||
var cutoff = DateTimeOffset.UtcNow.AddHours(-imageSpec.MaxAgeHours.Value);
|
||||
sortedTags = sortedTags
|
||||
.Where(t => t.LastUpdated == null || t.LastUpdated >= cutoff)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
// Take the configured number of tags
|
||||
var tagsToScan = sortedTags.Take(imageSpec.MaxTags).ToList();
|
||||
|
||||
foreach (var tagInfo in tagsToScan)
|
||||
{
|
||||
targets.Add(new ScanTarget
|
||||
{
|
||||
Reference = BuildFullReference(config.RegistryUrl, repository, tagInfo.Name),
|
||||
Digest = tagInfo.Digest,
|
||||
Priority = config.ScanOptions.Priority,
|
||||
Metadata = new Dictionary<string, string>
|
||||
{
|
||||
["repository"] = repository,
|
||||
["tag"] = tagInfo.Name,
|
||||
["registryUrl"] = config.RegistryUrl,
|
||||
["digestPin"] = imageSpec.DigestPin.ToString().ToLowerInvariant()
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return targets;
|
||||
}
|
||||
|
||||
public ConfigValidationResult ValidateConfiguration(JsonDocument configuration)
|
||||
{
|
||||
return _configValidator.Validate(SbomSourceType.Docker, configuration);
|
||||
}
|
||||
|
||||
public async Task<ConnectionTestResult> TestConnectionAsync(
|
||||
SbomSource source,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var config = source.Configuration.Deserialize<DockerSourceConfig>(JsonOptions);
|
||||
if (config == null)
|
||||
{
|
||||
return new ConnectionTestResult
|
||||
{
|
||||
Success = false,
|
||||
Message = "Invalid configuration",
|
||||
TestedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var credentials = await GetCredentialsAsync(source.AuthRef, ct);
|
||||
var registryType = InferRegistryType(config.RegistryUrl);
|
||||
using var client = _clientFactory.Create(registryType, config.RegistryUrl, credentials);
|
||||
|
||||
var pingSuccess = await client.PingAsync(ct);
|
||||
if (!pingSuccess)
|
||||
{
|
||||
return new ConnectionTestResult
|
||||
{
|
||||
Success = false,
|
||||
Message = "Registry ping failed",
|
||||
TestedAt = DateTimeOffset.UtcNow,
|
||||
Details = new Dictionary<string, object>
|
||||
{
|
||||
["registryUrl"] = config.RegistryUrl
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Try to get digest for the first image to verify access
|
||||
if (config.Images.Length > 0)
|
||||
{
|
||||
var (repo, tag) = ParseReference(config.Images[0].Reference);
|
||||
var digest = await client.GetDigestAsync(repo, tag ?? "latest", ct);
|
||||
|
||||
return new ConnectionTestResult
|
||||
{
|
||||
Success = true,
|
||||
Message = "Successfully connected to registry",
|
||||
TestedAt = DateTimeOffset.UtcNow,
|
||||
Details = new Dictionary<string, object>
|
||||
{
|
||||
["registryUrl"] = config.RegistryUrl,
|
||||
["testImage"] = config.Images[0].Reference,
|
||||
["imageAccessible"] = digest != null
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return new ConnectionTestResult
|
||||
{
|
||||
Success = true,
|
||||
Message = "Successfully connected to registry",
|
||||
TestedAt = DateTimeOffset.UtcNow,
|
||||
Details = new Dictionary<string, object>
|
||||
{
|
||||
["registryUrl"] = config.RegistryUrl
|
||||
}
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Connection test failed for source {SourceId}", source.SourceId);
|
||||
return new ConnectionTestResult
|
||||
{
|
||||
Success = false,
|
||||
Message = $"Connection failed: {ex.Message}",
|
||||
TestedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<RegistryCredentials?> GetCredentialsAsync(string? authRef, CancellationToken ct)
|
||||
{
|
||||
if (string.IsNullOrEmpty(authRef))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var resolved = await _credentialResolver.ResolveAsync(authRef, ct);
|
||||
if (resolved == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return resolved.Type switch
|
||||
{
|
||||
CredentialType.BasicAuth => new RegistryCredentials
|
||||
{
|
||||
AuthType = RegistryAuthType.Basic,
|
||||
Username = resolved.Username,
|
||||
Password = resolved.Password
|
||||
},
|
||||
CredentialType.BearerToken => new RegistryCredentials
|
||||
{
|
||||
AuthType = RegistryAuthType.Token,
|
||||
Token = resolved.Token
|
||||
},
|
||||
CredentialType.AwsCredentials => new RegistryCredentials
|
||||
{
|
||||
AuthType = RegistryAuthType.AwsEcr,
|
||||
AwsAccessKey = resolved.Properties?.GetValueOrDefault("accessKey"),
|
||||
AwsSecretKey = resolved.Properties?.GetValueOrDefault("secretKey"),
|
||||
AwsRegion = resolved.Properties?.GetValueOrDefault("region")
|
||||
},
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
private static RegistryType InferRegistryType(string registryUrl)
|
||||
{
|
||||
var host = new Uri(registryUrl).Host.ToLowerInvariant();
|
||||
|
||||
return host switch
|
||||
{
|
||||
_ when host.Contains("docker.io") || host.Contains("docker.com") => RegistryType.DockerHub,
|
||||
_ when host.Contains("ecr.") && host.Contains("amazonaws.com") => RegistryType.Ecr,
|
||||
_ when host.Contains("gcr.io") || host.Contains("pkg.dev") => RegistryType.Gcr,
|
||||
_ when host.Contains("azurecr.io") => RegistryType.Acr,
|
||||
_ when host.Contains("ghcr.io") => RegistryType.Ghcr,
|
||||
_ when host.Contains("quay.io") => RegistryType.Quay,
|
||||
_ when host.Contains("jfrog.io") || host.Contains("artifactory") => RegistryType.Artifactory,
|
||||
_ => RegistryType.Generic
|
||||
};
|
||||
}
|
||||
|
||||
private static (string Repository, string? Tag) ParseReference(string reference)
|
||||
{
|
||||
// Handle digest references
|
||||
if (reference.Contains('@'))
|
||||
{
|
||||
var parts = reference.Split('@', 2);
|
||||
return (parts[0], null);
|
||||
}
|
||||
|
||||
// Handle tag references
|
||||
if (reference.Contains(':'))
|
||||
{
|
||||
var lastColon = reference.LastIndexOf(':');
|
||||
return (reference[..lastColon], reference[(lastColon + 1)..]);
|
||||
}
|
||||
|
||||
return (reference, null);
|
||||
}
|
||||
|
||||
private static string BuildFullReference(string registryUrl, string repository, string tag)
|
||||
{
|
||||
var host = new Uri(registryUrl).Host;
|
||||
|
||||
// Docker Hub special case
|
||||
if (host.Contains("docker.io") || host.Contains("docker.com"))
|
||||
{
|
||||
if (!repository.Contains('/'))
|
||||
{
|
||||
repository = $"library/{repository}";
|
||||
}
|
||||
return $"{repository}:{tag}";
|
||||
}
|
||||
|
||||
return $"{host}/{repository}:{tag}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using StellaOps.Scanner.Sources.Configuration;
|
||||
using StellaOps.Scanner.Sources.Handlers.Zastava;
|
||||
|
||||
namespace StellaOps.Scanner.Sources.Handlers.Docker;
|
||||
|
||||
/// <summary>
|
||||
/// Service for discovering and filtering container image tags.
|
||||
/// </summary>
|
||||
public interface IImageDiscoveryService
|
||||
{
|
||||
/// <summary>
|
||||
/// Filter tags based on exclusion patterns and pre-release settings.
|
||||
/// </summary>
|
||||
IReadOnlyList<RegistryTag> FilterTags(
|
||||
IReadOnlyList<RegistryTag> tags,
|
||||
string[]? excludePatterns,
|
||||
bool includePreRelease);
|
||||
|
||||
/// <summary>
|
||||
/// Sort tags according to the specified sort order.
|
||||
/// </summary>
|
||||
IReadOnlyList<RegistryTag> SortTags(
|
||||
IReadOnlyList<RegistryTag> tags,
|
||||
TagSortOrder sortOrder);
|
||||
|
||||
/// <summary>
|
||||
/// Parse a semantic version from a tag name.
|
||||
/// </summary>
|
||||
SemVer? ParseSemVer(string tag);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of tag discovery and filtering.
|
||||
/// </summary>
|
||||
public sealed class ImageDiscoveryService : IImageDiscoveryService
|
||||
{
|
||||
private static readonly Regex SemVerRegex = new(
|
||||
@"^v?(?<major>\d+)\.(?<minor>\d+)\.(?<patch>\d+)" +
|
||||
@"(?:-(?<prerelease>[a-zA-Z0-9.-]+))?" +
|
||||
@"(?:\+(?<metadata>[a-zA-Z0-9.-]+))?$",
|
||||
RegexOptions.Compiled | RegexOptions.IgnoreCase);
|
||||
|
||||
private static readonly Regex PreReleasePattern = new(
|
||||
@"(?:alpha|beta|rc|pre|preview|dev|snapshot|canary|nightly)",
|
||||
RegexOptions.Compiled | RegexOptions.IgnoreCase);
|
||||
|
||||
public IReadOnlyList<RegistryTag> FilterTags(
|
||||
IReadOnlyList<RegistryTag> tags,
|
||||
string[]? excludePatterns,
|
||||
bool includePreRelease)
|
||||
{
|
||||
var filtered = tags.AsEnumerable();
|
||||
|
||||
// Apply exclusion patterns
|
||||
if (excludePatterns is { Length: > 0 })
|
||||
{
|
||||
var regexPatterns = excludePatterns
|
||||
.Select(p => new Regex(
|
||||
"^" + Regex.Escape(p).Replace("\\*", ".*").Replace("\\?", ".") + "$",
|
||||
RegexOptions.IgnoreCase))
|
||||
.ToList();
|
||||
|
||||
filtered = filtered.Where(t =>
|
||||
!regexPatterns.Any(r => r.IsMatch(t.Name)));
|
||||
}
|
||||
|
||||
// Filter pre-release tags if not included
|
||||
if (!includePreRelease)
|
||||
{
|
||||
filtered = filtered.Where(t => !IsPreRelease(t.Name));
|
||||
}
|
||||
|
||||
return filtered.ToList();
|
||||
}
|
||||
|
||||
public IReadOnlyList<RegistryTag> SortTags(
|
||||
IReadOnlyList<RegistryTag> tags,
|
||||
TagSortOrder sortOrder)
|
||||
{
|
||||
return sortOrder switch
|
||||
{
|
||||
TagSortOrder.SemVerDescending => tags
|
||||
.Select(t => (Tag: t, SemVer: ParseSemVer(t.Name)))
|
||||
.OrderByDescending(x => x.SemVer?.Major ?? 0)
|
||||
.ThenByDescending(x => x.SemVer?.Minor ?? 0)
|
||||
.ThenByDescending(x => x.SemVer?.Patch ?? 0)
|
||||
.ThenBy(x => x.SemVer?.PreRelease ?? "")
|
||||
.ThenByDescending(x => x.Tag.Name)
|
||||
.Select(x => x.Tag)
|
||||
.ToList(),
|
||||
|
||||
TagSortOrder.SemVerAscending => tags
|
||||
.Select(t => (Tag: t, SemVer: ParseSemVer(t.Name)))
|
||||
.OrderBy(x => x.SemVer?.Major ?? int.MaxValue)
|
||||
.ThenBy(x => x.SemVer?.Minor ?? int.MaxValue)
|
||||
.ThenBy(x => x.SemVer?.Patch ?? int.MaxValue)
|
||||
.ThenByDescending(x => x.SemVer?.PreRelease ?? "")
|
||||
.ThenBy(x => x.Tag.Name)
|
||||
.Select(x => x.Tag)
|
||||
.ToList(),
|
||||
|
||||
TagSortOrder.AlphaDescending => tags
|
||||
.OrderByDescending(t => t.Name)
|
||||
.ToList(),
|
||||
|
||||
TagSortOrder.AlphaAscending => tags
|
||||
.OrderBy(t => t.Name)
|
||||
.ToList(),
|
||||
|
||||
TagSortOrder.DateDescending => tags
|
||||
.OrderByDescending(t => t.LastUpdated ?? DateTimeOffset.MinValue)
|
||||
.ThenByDescending(t => t.Name)
|
||||
.ToList(),
|
||||
|
||||
TagSortOrder.DateAscending => tags
|
||||
.OrderBy(t => t.LastUpdated ?? DateTimeOffset.MaxValue)
|
||||
.ThenBy(t => t.Name)
|
||||
.ToList(),
|
||||
|
||||
_ => tags.ToList()
|
||||
};
|
||||
}
|
||||
|
||||
public SemVer? ParseSemVer(string tag)
|
||||
{
|
||||
var match = SemVerRegex.Match(tag);
|
||||
if (!match.Success)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new SemVer
|
||||
{
|
||||
Major = int.Parse(match.Groups["major"].Value),
|
||||
Minor = int.Parse(match.Groups["minor"].Value),
|
||||
Patch = int.Parse(match.Groups["patch"].Value),
|
||||
PreRelease = match.Groups["prerelease"].Success
|
||||
? match.Groups["prerelease"].Value
|
||||
: null,
|
||||
Metadata = match.Groups["metadata"].Success
|
||||
? match.Groups["metadata"].Value
|
||||
: null
|
||||
};
|
||||
}
|
||||
|
||||
private static bool IsPreRelease(string tagName)
|
||||
{
|
||||
// Check common pre-release indicators
|
||||
if (PreReleasePattern.IsMatch(tagName))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// Also check parsed semver
|
||||
var semver = new ImageDiscoveryService().ParseSemVer(tagName);
|
||||
return semver?.PreRelease != null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a parsed semantic version.
|
||||
/// </summary>
|
||||
public sealed record SemVer : IComparable<SemVer>
|
||||
{
|
||||
public int Major { get; init; }
|
||||
public int Minor { get; init; }
|
||||
public int Patch { get; init; }
|
||||
public string? PreRelease { get; init; }
|
||||
public string? Metadata { get; init; }
|
||||
|
||||
public int CompareTo(SemVer? other)
|
||||
{
|
||||
if (other is null) return 1;
|
||||
|
||||
var majorCompare = Major.CompareTo(other.Major);
|
||||
if (majorCompare != 0) return majorCompare;
|
||||
|
||||
var minorCompare = Minor.CompareTo(other.Minor);
|
||||
if (minorCompare != 0) return minorCompare;
|
||||
|
||||
var patchCompare = Patch.CompareTo(other.Patch);
|
||||
if (patchCompare != 0) return patchCompare;
|
||||
|
||||
// Pre-release versions have lower precedence than release versions
|
||||
if (PreRelease is null && other.PreRelease is not null) return 1;
|
||||
if (PreRelease is not null && other.PreRelease is null) return -1;
|
||||
if (PreRelease is null && other.PreRelease is null) return 0;
|
||||
|
||||
return string.Compare(PreRelease, other.PreRelease, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
public override string ToString()
|
||||
{
|
||||
var result = $"{Major}.{Minor}.{Patch}";
|
||||
if (!string.IsNullOrEmpty(PreRelease))
|
||||
{
|
||||
result += $"-{PreRelease}";
|
||||
}
|
||||
if (!string.IsNullOrEmpty(Metadata))
|
||||
{
|
||||
result += $"+{Metadata}";
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,511 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Scanner.Sources.Configuration;
|
||||
using StellaOps.Scanner.Sources.Contracts;
|
||||
using StellaOps.Scanner.Sources.Domain;
|
||||
using StellaOps.Scanner.Sources.Services;
|
||||
using StellaOps.Scanner.Sources.Triggers;
|
||||
|
||||
namespace StellaOps.Scanner.Sources.Handlers.Git;
|
||||
|
||||
/// <summary>
|
||||
/// Handler for Git (repository) sources.
|
||||
/// Scans source code repositories for dependencies and vulnerabilities.
|
||||
/// </summary>
|
||||
public sealed class GitSourceHandler : ISourceTypeHandler, IWebhookCapableHandler
|
||||
{
|
||||
private readonly IGitClientFactory _gitClientFactory;
|
||||
private readonly ICredentialResolver _credentialResolver;
|
||||
private readonly ISourceConfigValidator _configValidator;
|
||||
private readonly ILogger<GitSourceHandler> _logger;
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
};
|
||||
|
||||
public SbomSourceType SourceType => SbomSourceType.Git;
|
||||
public bool SupportsWebhooks => true;
|
||||
public bool SupportsScheduling => true;
|
||||
public int MaxConcurrentTargets => 10;
|
||||
|
||||
public GitSourceHandler(
|
||||
IGitClientFactory gitClientFactory,
|
||||
ICredentialResolver credentialResolver,
|
||||
ISourceConfigValidator configValidator,
|
||||
ILogger<GitSourceHandler> logger)
|
||||
{
|
||||
_gitClientFactory = gitClientFactory;
|
||||
_credentialResolver = credentialResolver;
|
||||
_configValidator = configValidator;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<ScanTarget>> DiscoverTargetsAsync(
|
||||
SbomSource source,
|
||||
TriggerContext context,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var config = source.Configuration.Deserialize<GitSourceConfig>(JsonOptions);
|
||||
if (config == null)
|
||||
{
|
||||
_logger.LogWarning("Invalid configuration for source {SourceId}", source.SourceId);
|
||||
return [];
|
||||
}
|
||||
|
||||
// For webhook triggers, extract target from payload
|
||||
if (context.Trigger == SbomSourceRunTrigger.Webhook)
|
||||
{
|
||||
if (context.WebhookPayload != null)
|
||||
{
|
||||
var payloadInfo = ParseWebhookPayload(context.WebhookPayload);
|
||||
|
||||
// Check if it matches configured triggers and branch filters
|
||||
if (!ShouldTrigger(payloadInfo, config))
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Webhook payload does not match triggers for source {SourceId}",
|
||||
source.SourceId);
|
||||
return [];
|
||||
}
|
||||
|
||||
return
|
||||
[
|
||||
new ScanTarget
|
||||
{
|
||||
Reference = BuildReference(config.RepositoryUrl, payloadInfo.Branch ?? payloadInfo.Reference),
|
||||
Metadata = new Dictionary<string, string>
|
||||
{
|
||||
["repository"] = config.RepositoryUrl,
|
||||
["branch"] = payloadInfo.Branch ?? "",
|
||||
["commit"] = payloadInfo.CommitSha ?? "",
|
||||
["eventType"] = payloadInfo.EventType,
|
||||
["actor"] = payloadInfo.Actor ?? "unknown"
|
||||
}
|
||||
}
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// For scheduled/manual triggers, discover branches to scan
|
||||
return await DiscoverBranchTargetsAsync(source, config, ct);
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<ScanTarget>> DiscoverBranchTargetsAsync(
|
||||
SbomSource source,
|
||||
GitSourceConfig config,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var credentials = await GetCredentialsAsync(source.AuthRef, config.AuthMethod, ct);
|
||||
using var client = _gitClientFactory.Create(config.Provider, config.RepositoryUrl, credentials);
|
||||
|
||||
var branches = await client.ListBranchesAsync(ct);
|
||||
var targets = new List<ScanTarget>();
|
||||
|
||||
foreach (var branch in branches)
|
||||
{
|
||||
// Check inclusion patterns
|
||||
var included = config.Branches.Include
|
||||
.Any(p => MatchesPattern(branch.Name, p));
|
||||
|
||||
if (!included)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check exclusion patterns
|
||||
var excluded = config.Branches.Exclude?
|
||||
.Any(p => MatchesPattern(branch.Name, p)) ?? false;
|
||||
|
||||
if (excluded)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
targets.Add(new ScanTarget
|
||||
{
|
||||
Reference = BuildReference(config.RepositoryUrl, branch.Name),
|
||||
Metadata = new Dictionary<string, string>
|
||||
{
|
||||
["repository"] = config.RepositoryUrl,
|
||||
["branch"] = branch.Name,
|
||||
["commit"] = branch.HeadCommit ?? "",
|
||||
["eventType"] = "scheduled"
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Discovered {Count} branch targets for source {SourceId}",
|
||||
targets.Count, source.SourceId);
|
||||
|
||||
return targets;
|
||||
}
|
||||
|
||||
public ConfigValidationResult ValidateConfiguration(JsonDocument configuration)
|
||||
{
|
||||
return _configValidator.Validate(SbomSourceType.Git, configuration);
|
||||
}
|
||||
|
||||
public async Task<ConnectionTestResult> TestConnectionAsync(
|
||||
SbomSource source,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var config = source.Configuration.Deserialize<GitSourceConfig>(JsonOptions);
|
||||
if (config == null)
|
||||
{
|
||||
return new ConnectionTestResult
|
||||
{
|
||||
Success = false,
|
||||
Message = "Invalid configuration",
|
||||
TestedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var credentials = await GetCredentialsAsync(source.AuthRef, config.AuthMethod, ct);
|
||||
using var client = _gitClientFactory.Create(config.Provider, config.RepositoryUrl, credentials);
|
||||
|
||||
var repoInfo = await client.GetRepositoryInfoAsync(ct);
|
||||
if (repoInfo == null)
|
||||
{
|
||||
return new ConnectionTestResult
|
||||
{
|
||||
Success = false,
|
||||
Message = "Repository not found or inaccessible",
|
||||
TestedAt = DateTimeOffset.UtcNow,
|
||||
Details = new Dictionary<string, object>
|
||||
{
|
||||
["repositoryUrl"] = config.RepositoryUrl,
|
||||
["provider"] = config.Provider.ToString()
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return new ConnectionTestResult
|
||||
{
|
||||
Success = true,
|
||||
Message = "Successfully connected to repository",
|
||||
TestedAt = DateTimeOffset.UtcNow,
|
||||
Details = new Dictionary<string, object>
|
||||
{
|
||||
["repositoryUrl"] = config.RepositoryUrl,
|
||||
["provider"] = config.Provider.ToString(),
|
||||
["defaultBranch"] = repoInfo.DefaultBranch ?? "",
|
||||
["sizeKb"] = repoInfo.SizeKb
|
||||
}
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Connection test failed for source {SourceId}", source.SourceId);
|
||||
return new ConnectionTestResult
|
||||
{
|
||||
Success = false,
|
||||
Message = $"Connection failed: {ex.Message}",
|
||||
TestedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public bool VerifyWebhookSignature(byte[] payload, string signature, string secret)
|
||||
{
|
||||
if (string.IsNullOrEmpty(signature) || string.IsNullOrEmpty(secret))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// GitHub uses HMAC-SHA256 with "sha256=" prefix
|
||||
if (signature.StartsWith("sha256=", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return VerifyHmacSha256(payload, signature[7..], secret);
|
||||
}
|
||||
|
||||
// GitHub legacy uses HMAC-SHA1 with "sha1=" prefix
|
||||
if (signature.StartsWith("sha1=", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return VerifyHmacSha1(payload, signature[5..], secret);
|
||||
}
|
||||
|
||||
// GitLab uses X-Gitlab-Token header (direct secret comparison)
|
||||
if (!signature.Contains('='))
|
||||
{
|
||||
return string.Equals(signature, secret, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public WebhookPayloadInfo ParseWebhookPayload(JsonDocument payload)
|
||||
{
|
||||
var root = payload.RootElement;
|
||||
|
||||
// GitHub push event
|
||||
if (root.TryGetProperty("ref", out var refProp) &&
|
||||
root.TryGetProperty("repository", out var ghRepo))
|
||||
{
|
||||
var refValue = refProp.GetString() ?? "";
|
||||
var branch = refValue.StartsWith("refs/heads/")
|
||||
? refValue[11..]
|
||||
: refValue.StartsWith("refs/tags/")
|
||||
? refValue[10..]
|
||||
: refValue;
|
||||
|
||||
var isTag = refValue.StartsWith("refs/tags/");
|
||||
|
||||
return new WebhookPayloadInfo
|
||||
{
|
||||
EventType = isTag ? "tag" : "push",
|
||||
Reference = ghRepo.TryGetProperty("full_name", out var fullName)
|
||||
? fullName.GetString()!
|
||||
: "",
|
||||
Branch = branch,
|
||||
CommitSha = root.TryGetProperty("after", out var after)
|
||||
? after.GetString()
|
||||
: null,
|
||||
Actor = root.TryGetProperty("sender", out var sender) &&
|
||||
sender.TryGetProperty("login", out var login)
|
||||
? login.GetString()
|
||||
: null,
|
||||
Timestamp = DateTimeOffset.UtcNow
|
||||
};
|
||||
}
|
||||
|
||||
// GitHub pull request event
|
||||
if (root.TryGetProperty("action", out var action) &&
|
||||
root.TryGetProperty("pull_request", out var pr))
|
||||
{
|
||||
return new WebhookPayloadInfo
|
||||
{
|
||||
EventType = "pull_request",
|
||||
Reference = root.TryGetProperty("repository", out var prRepo) &&
|
||||
prRepo.TryGetProperty("full_name", out var prFullName)
|
||||
? prFullName.GetString()!
|
||||
: "",
|
||||
Branch = pr.TryGetProperty("head", out var head) &&
|
||||
head.TryGetProperty("ref", out var headRef)
|
||||
? headRef.GetString()
|
||||
: null,
|
||||
CommitSha = head.TryGetProperty("sha", out var sha)
|
||||
? sha.GetString()
|
||||
: null,
|
||||
Actor = pr.TryGetProperty("user", out var user) &&
|
||||
user.TryGetProperty("login", out var prLogin)
|
||||
? prLogin.GetString()
|
||||
: null,
|
||||
Metadata = new Dictionary<string, string>
|
||||
{
|
||||
["action"] = action.GetString() ?? "",
|
||||
["prNumber"] = pr.TryGetProperty("number", out var num)
|
||||
? num.GetInt32().ToString()
|
||||
: ""
|
||||
},
|
||||
Timestamp = DateTimeOffset.UtcNow
|
||||
};
|
||||
}
|
||||
|
||||
// GitLab push event
|
||||
if (root.TryGetProperty("object_kind", out var objectKind))
|
||||
{
|
||||
var kind = objectKind.GetString();
|
||||
|
||||
if (kind == "push")
|
||||
{
|
||||
return new WebhookPayloadInfo
|
||||
{
|
||||
EventType = "push",
|
||||
Reference = root.TryGetProperty("project", out var project) &&
|
||||
project.TryGetProperty("path_with_namespace", out var path)
|
||||
? path.GetString()!
|
||||
: "",
|
||||
Branch = root.TryGetProperty("ref", out var glRef)
|
||||
? glRef.GetString()?.Replace("refs/heads/", "") ?? ""
|
||||
: null,
|
||||
CommitSha = root.TryGetProperty("after", out var glAfter)
|
||||
? glAfter.GetString()
|
||||
: null,
|
||||
Actor = root.TryGetProperty("user_name", out var userName)
|
||||
? userName.GetString()
|
||||
: null,
|
||||
Timestamp = DateTimeOffset.UtcNow
|
||||
};
|
||||
}
|
||||
|
||||
if (kind == "merge_request")
|
||||
{
|
||||
var mrAttrs = root.TryGetProperty("object_attributes", out var oa) ? oa : default;
|
||||
return new WebhookPayloadInfo
|
||||
{
|
||||
EventType = "pull_request",
|
||||
Reference = root.TryGetProperty("project", out var mrProject) &&
|
||||
mrProject.TryGetProperty("path_with_namespace", out var mrPath)
|
||||
? mrPath.GetString()!
|
||||
: "",
|
||||
Branch = mrAttrs.TryGetProperty("source_branch", out var srcBranch)
|
||||
? srcBranch.GetString()
|
||||
: null,
|
||||
CommitSha = mrAttrs.TryGetProperty("last_commit", out var lastCommit) &&
|
||||
lastCommit.TryGetProperty("id", out var commitId)
|
||||
? commitId.GetString()
|
||||
: null,
|
||||
Actor = root.TryGetProperty("user", out var glUser) &&
|
||||
glUser.TryGetProperty("username", out var glUsername)
|
||||
? glUsername.GetString()
|
||||
: null,
|
||||
Metadata = new Dictionary<string, string>
|
||||
{
|
||||
["action"] = mrAttrs.TryGetProperty("action", out var mrAction)
|
||||
? mrAction.GetString() ?? ""
|
||||
: ""
|
||||
},
|
||||
Timestamp = DateTimeOffset.UtcNow
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogWarning("Unable to parse Git webhook payload format");
|
||||
return new WebhookPayloadInfo
|
||||
{
|
||||
EventType = "unknown",
|
||||
Reference = "",
|
||||
Timestamp = DateTimeOffset.UtcNow
|
||||
};
|
||||
}
|
||||
|
||||
private bool ShouldTrigger(WebhookPayloadInfo payload, GitSourceConfig config)
|
||||
{
|
||||
// Check event type against configured triggers
|
||||
switch (payload.EventType)
|
||||
{
|
||||
case "push":
|
||||
if (!config.Triggers.OnPush)
|
||||
return false;
|
||||
break;
|
||||
|
||||
case "tag":
|
||||
if (!config.Triggers.OnTag)
|
||||
return false;
|
||||
// Check tag patterns if specified
|
||||
if (config.Triggers.TagPatterns is { Length: > 0 })
|
||||
{
|
||||
if (!config.Triggers.TagPatterns.Any(p => MatchesPattern(payload.Branch ?? "", p)))
|
||||
return false;
|
||||
}
|
||||
break;
|
||||
|
||||
case "pull_request":
|
||||
if (!config.Triggers.OnPullRequest)
|
||||
return false;
|
||||
// Check PR action if specified
|
||||
if (config.Triggers.PrActions is { Length: > 0 })
|
||||
{
|
||||
var actionStr = payload.Metadata.GetValueOrDefault("action", "");
|
||||
var matchedAction = Enum.TryParse<PullRequestAction>(actionStr, ignoreCase: true, out var action)
|
||||
&& config.Triggers.PrActions.Contains(action);
|
||||
if (!matchedAction)
|
||||
return false;
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check branch filters (only for push and PR, not tags)
|
||||
if (payload.EventType != "tag" && !string.IsNullOrEmpty(payload.Branch))
|
||||
{
|
||||
var included = config.Branches.Include.Any(p => MatchesPattern(payload.Branch, p));
|
||||
if (!included)
|
||||
return false;
|
||||
|
||||
var excluded = config.Branches.Exclude?.Any(p => MatchesPattern(payload.Branch, p)) ?? false;
|
||||
if (excluded)
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private async Task<GitCredentials?> GetCredentialsAsync(
|
||||
string? authRef,
|
||||
GitAuthMethod authMethod,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (string.IsNullOrEmpty(authRef))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var resolved = await _credentialResolver.ResolveAsync(authRef, ct);
|
||||
if (resolved == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return authMethod switch
|
||||
{
|
||||
GitAuthMethod.Token => new GitCredentials
|
||||
{
|
||||
AuthType = GitAuthType.Token,
|
||||
Token = resolved.Token ?? resolved.Password
|
||||
},
|
||||
GitAuthMethod.Ssh => new GitCredentials
|
||||
{
|
||||
AuthType = GitAuthType.Ssh,
|
||||
SshPrivateKey = resolved.Properties?.GetValueOrDefault("privateKey"),
|
||||
SshPassphrase = resolved.Properties?.GetValueOrDefault("passphrase")
|
||||
},
|
||||
GitAuthMethod.OAuth => new GitCredentials
|
||||
{
|
||||
AuthType = GitAuthType.OAuth,
|
||||
Token = resolved.Token
|
||||
},
|
||||
GitAuthMethod.GitHubApp => new GitCredentials
|
||||
{
|
||||
AuthType = GitAuthType.GitHubApp,
|
||||
AppId = resolved.Properties?.GetValueOrDefault("appId"),
|
||||
PrivateKey = resolved.Properties?.GetValueOrDefault("privateKey"),
|
||||
InstallationId = resolved.Properties?.GetValueOrDefault("installationId")
|
||||
},
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
private static bool MatchesPattern(string value, string pattern)
|
||||
{
|
||||
var regexPattern = "^" + Regex.Escape(pattern)
|
||||
.Replace("\\*\\*", ".*")
|
||||
.Replace("\\*", "[^/]*")
|
||||
.Replace("\\?", ".") + "$";
|
||||
|
||||
return Regex.IsMatch(value, regexPattern, RegexOptions.IgnoreCase);
|
||||
}
|
||||
|
||||
private static string BuildReference(string repositoryUrl, string branchOrRef)
|
||||
{
|
||||
return $"{repositoryUrl}@{branchOrRef}";
|
||||
}
|
||||
|
||||
private static bool VerifyHmacSha256(byte[] payload, string expected, string secret)
|
||||
{
|
||||
using var hmac = new System.Security.Cryptography.HMACSHA256(
|
||||
System.Text.Encoding.UTF8.GetBytes(secret));
|
||||
var computed = Convert.ToHexString(hmac.ComputeHash(payload)).ToLowerInvariant();
|
||||
return System.Security.Cryptography.CryptographicOperations.FixedTimeEquals(
|
||||
System.Text.Encoding.UTF8.GetBytes(computed),
|
||||
System.Text.Encoding.UTF8.GetBytes(expected.ToLowerInvariant()));
|
||||
}
|
||||
|
||||
private static bool VerifyHmacSha1(byte[] payload, string expected, string secret)
|
||||
{
|
||||
using var hmac = new System.Security.Cryptography.HMACSHA1(
|
||||
System.Text.Encoding.UTF8.GetBytes(secret));
|
||||
var computed = Convert.ToHexString(hmac.ComputeHash(payload)).ToLowerInvariant();
|
||||
return System.Security.Cryptography.CryptographicOperations.FixedTimeEquals(
|
||||
System.Text.Encoding.UTF8.GetBytes(computed),
|
||||
System.Text.Encoding.UTF8.GetBytes(expected.ToLowerInvariant()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
using StellaOps.Scanner.Sources.Configuration;
|
||||
|
||||
namespace StellaOps.Scanner.Sources.Handlers.Git;
|
||||
|
||||
/// <summary>
|
||||
/// Interface for interacting with Git repositories via API.
|
||||
/// </summary>
|
||||
public interface IGitClient : IDisposable
|
||||
{
|
||||
/// <summary>
|
||||
/// Get repository information.
|
||||
/// </summary>
|
||||
Task<RepositoryInfo?> GetRepositoryInfoAsync(CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// List branches in the repository.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<BranchInfo>> ListBranchesAsync(CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// List tags in the repository.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<TagInfo>> ListTagsAsync(CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Get commit information.
|
||||
/// </summary>
|
||||
Task<CommitInfo?> GetCommitAsync(string sha, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Factory for creating Git clients.
|
||||
/// </summary>
|
||||
public interface IGitClientFactory
|
||||
{
|
||||
/// <summary>
|
||||
/// Create a Git client for the specified provider.
|
||||
/// </summary>
|
||||
IGitClient Create(
|
||||
GitProvider provider,
|
||||
string repositoryUrl,
|
||||
GitCredentials? credentials = null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Credentials for Git repository authentication.
|
||||
/// </summary>
|
||||
public sealed record GitCredentials
|
||||
{
|
||||
/// <summary>Type of authentication.</summary>
|
||||
public required GitAuthType AuthType { get; init; }
|
||||
|
||||
/// <summary>Personal access token or OAuth token.</summary>
|
||||
public string? Token { get; init; }
|
||||
|
||||
/// <summary>SSH private key content.</summary>
|
||||
public string? SshPrivateKey { get; init; }
|
||||
|
||||
/// <summary>SSH key passphrase.</summary>
|
||||
public string? SshPassphrase { get; init; }
|
||||
|
||||
/// <summary>GitHub App ID.</summary>
|
||||
public string? AppId { get; init; }
|
||||
|
||||
/// <summary>GitHub App private key.</summary>
|
||||
public string? PrivateKey { get; init; }
|
||||
|
||||
/// <summary>GitHub App installation ID.</summary>
|
||||
public string? InstallationId { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Git authentication types.
|
||||
/// </summary>
|
||||
public enum GitAuthType
|
||||
{
|
||||
None,
|
||||
Token,
|
||||
Ssh,
|
||||
OAuth,
|
||||
GitHubApp
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Repository information.
|
||||
/// </summary>
|
||||
public sealed record RepositoryInfo
|
||||
{
|
||||
/// <summary>Repository name.</summary>
|
||||
public required string Name { get; init; }
|
||||
|
||||
/// <summary>Full path or full name.</summary>
|
||||
public required string FullName { get; init; }
|
||||
|
||||
/// <summary>Default branch name.</summary>
|
||||
public string? DefaultBranch { get; init; }
|
||||
|
||||
/// <summary>Repository size in KB.</summary>
|
||||
public long SizeKb { get; init; }
|
||||
|
||||
/// <summary>Whether the repository is private.</summary>
|
||||
public bool IsPrivate { get; init; }
|
||||
|
||||
/// <summary>Repository description.</summary>
|
||||
public string? Description { get; init; }
|
||||
|
||||
/// <summary>Clone URL (HTTPS).</summary>
|
||||
public string? CloneUrl { get; init; }
|
||||
|
||||
/// <summary>SSH clone URL.</summary>
|
||||
public string? SshUrl { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Branch information.
|
||||
/// </summary>
|
||||
public sealed record BranchInfo
|
||||
{
|
||||
/// <summary>Branch name.</summary>
|
||||
public required string Name { get; init; }
|
||||
|
||||
/// <summary>HEAD commit SHA.</summary>
|
||||
public string? HeadCommit { get; init; }
|
||||
|
||||
/// <summary>Whether this is the default branch.</summary>
|
||||
public bool IsDefault { get; init; }
|
||||
|
||||
/// <summary>Whether the branch is protected.</summary>
|
||||
public bool IsProtected { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tag information.
|
||||
/// </summary>
|
||||
public sealed record TagInfo
|
||||
{
|
||||
/// <summary>Tag name.</summary>
|
||||
public required string Name { get; init; }
|
||||
|
||||
/// <summary>Commit SHA the tag points to.</summary>
|
||||
public string? CommitSha { get; init; }
|
||||
|
||||
/// <summary>Tag message (for annotated tags).</summary>
|
||||
public string? Message { get; init; }
|
||||
|
||||
/// <summary>When the tag was created.</summary>
|
||||
public DateTimeOffset? CreatedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Commit information.
|
||||
/// </summary>
|
||||
public sealed record CommitInfo
|
||||
{
|
||||
/// <summary>Commit SHA.</summary>
|
||||
public required string Sha { get; init; }
|
||||
|
||||
/// <summary>Commit message.</summary>
|
||||
public string? Message { get; init; }
|
||||
|
||||
/// <summary>Author name.</summary>
|
||||
public string? AuthorName { get; init; }
|
||||
|
||||
/// <summary>Author email.</summary>
|
||||
public string? AuthorEmail { get; init; }
|
||||
|
||||
/// <summary>When the commit was authored.</summary>
|
||||
public DateTimeOffset? AuthoredAt { get; init; }
|
||||
|
||||
/// <summary>Parent commit SHAs.</summary>
|
||||
public IReadOnlyList<string> Parents { get; init; } = [];
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
using System.Text.Json;
|
||||
using StellaOps.Scanner.Sources.Configuration;
|
||||
using StellaOps.Scanner.Sources.Contracts;
|
||||
using StellaOps.Scanner.Sources.Domain;
|
||||
using StellaOps.Scanner.Sources.Triggers;
|
||||
|
||||
namespace StellaOps.Scanner.Sources.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// Interface for source type-specific handlers.
|
||||
/// Each source type (Zastava, Docker, CLI, Git) has its own handler.
|
||||
/// </summary>
|
||||
public interface ISourceTypeHandler
|
||||
{
|
||||
/// <summary>The source type this handler manages.</summary>
|
||||
SbomSourceType SourceType { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Discover targets to scan based on source configuration and trigger context.
|
||||
/// </summary>
|
||||
/// <param name="source">The source configuration.</param>
|
||||
/// <param name="context">The trigger context.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>List of targets to scan.</returns>
|
||||
Task<IReadOnlyList<ScanTarget>> DiscoverTargetsAsync(
|
||||
SbomSource source,
|
||||
TriggerContext context,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Validate source configuration.
|
||||
/// </summary>
|
||||
/// <param name="configuration">The configuration to validate.</param>
|
||||
/// <returns>Validation result.</returns>
|
||||
ConfigValidationResult ValidateConfiguration(JsonDocument configuration);
|
||||
|
||||
/// <summary>
|
||||
/// Test connection to the source.
|
||||
/// </summary>
|
||||
/// <param name="source">The source to test.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Connection test result.</returns>
|
||||
Task<ConnectionTestResult> TestConnectionAsync(
|
||||
SbomSource source,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the maximum number of concurrent targets this handler supports.
|
||||
/// </summary>
|
||||
int MaxConcurrentTargets => 10;
|
||||
|
||||
/// <summary>
|
||||
/// Whether this handler supports webhook triggers.
|
||||
/// </summary>
|
||||
bool SupportsWebhooks => false;
|
||||
|
||||
/// <summary>
|
||||
/// Whether this handler supports scheduled triggers.
|
||||
/// </summary>
|
||||
bool SupportsScheduling => true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extended interface for handlers that can process webhooks.
|
||||
/// </summary>
|
||||
public interface IWebhookCapableHandler : ISourceTypeHandler
|
||||
{
|
||||
/// <summary>
|
||||
/// Verify webhook signature.
|
||||
/// </summary>
|
||||
bool VerifyWebhookSignature(
|
||||
byte[] payload,
|
||||
string signature,
|
||||
string secret);
|
||||
|
||||
/// <summary>
|
||||
/// Parse webhook payload to extract trigger information.
|
||||
/// </summary>
|
||||
WebhookPayloadInfo ParseWebhookPayload(JsonDocument payload);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parsed webhook payload information.
|
||||
/// </summary>
|
||||
public sealed record WebhookPayloadInfo
|
||||
{
|
||||
/// <summary>Type of event (push, tag, delete, etc.).</summary>
|
||||
public required string EventType { get; init; }
|
||||
|
||||
/// <summary>Repository or image reference.</summary>
|
||||
public required string Reference { get; init; }
|
||||
|
||||
/// <summary>Tag if applicable.</summary>
|
||||
public string? Tag { get; init; }
|
||||
|
||||
/// <summary>Digest if applicable.</summary>
|
||||
public string? Digest { get; init; }
|
||||
|
||||
/// <summary>Branch if applicable (git webhooks).</summary>
|
||||
public string? Branch { get; init; }
|
||||
|
||||
/// <summary>Commit SHA if applicable (git webhooks).</summary>
|
||||
public string? CommitSha { get; init; }
|
||||
|
||||
/// <summary>User who triggered the event.</summary>
|
||||
public string? Actor { get; init; }
|
||||
|
||||
/// <summary>Timestamp of the event.</summary>
|
||||
public DateTimeOffset Timestamp { get; init; } = DateTimeOffset.UtcNow;
|
||||
|
||||
/// <summary>Additional metadata from the payload.</summary>
|
||||
public Dictionary<string, string> Metadata { get; init; } = [];
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
namespace StellaOps.Scanner.Sources.Handlers.Zastava;
|
||||
|
||||
/// <summary>
|
||||
/// Interface for interacting with container registries.
|
||||
/// </summary>
|
||||
public interface IRegistryClient : IDisposable
|
||||
{
|
||||
/// <summary>
|
||||
/// Test connectivity to the registry.
|
||||
/// </summary>
|
||||
Task<bool> PingAsync(CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// List repositories matching a pattern.
|
||||
/// </summary>
|
||||
/// <param name="pattern">Glob pattern (e.g., "library/*").</param>
|
||||
/// <param name="limit">Maximum number of repositories to return.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
Task<IReadOnlyList<string>> ListRepositoriesAsync(
|
||||
string? pattern = null,
|
||||
int limit = 100,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// List tags for a repository.
|
||||
/// </summary>
|
||||
/// <param name="repository">Repository name.</param>
|
||||
/// <param name="patterns">Tag patterns to match (null = all).</param>
|
||||
/// <param name="limit">Maximum number of tags to return.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
Task<IReadOnlyList<RegistryTag>> ListTagsAsync(
|
||||
string repository,
|
||||
IReadOnlyList<string>? patterns = null,
|
||||
int limit = 100,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Get manifest digest for an image reference.
|
||||
/// </summary>
|
||||
Task<string?> GetDigestAsync(
|
||||
string repository,
|
||||
string tag,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a tag in a container registry.
|
||||
/// </summary>
|
||||
public sealed record RegistryTag
|
||||
{
|
||||
/// <summary>The tag name.</summary>
|
||||
public required string Name { get; init; }
|
||||
|
||||
/// <summary>The manifest digest.</summary>
|
||||
public string? Digest { get; init; }
|
||||
|
||||
/// <summary>When the tag was last updated.</summary>
|
||||
public DateTimeOffset? LastUpdated { get; init; }
|
||||
|
||||
/// <summary>Size of the image in bytes.</summary>
|
||||
public long? SizeBytes { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Factory for creating registry clients.
|
||||
/// </summary>
|
||||
public interface IRegistryClientFactory
|
||||
{
|
||||
/// <summary>
|
||||
/// Create a registry client for the specified registry.
|
||||
/// </summary>
|
||||
IRegistryClient Create(
|
||||
Configuration.RegistryType registryType,
|
||||
string registryUrl,
|
||||
RegistryCredentials? credentials = null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Credentials for registry authentication.
|
||||
/// </summary>
|
||||
public sealed record RegistryCredentials
|
||||
{
|
||||
/// <summary>Type of authentication.</summary>
|
||||
public required RegistryAuthType AuthType { get; init; }
|
||||
|
||||
/// <summary>Username for basic auth.</summary>
|
||||
public string? Username { get; init; }
|
||||
|
||||
/// <summary>Password or token for basic auth.</summary>
|
||||
public string? Password { get; init; }
|
||||
|
||||
/// <summary>Bearer token for token auth.</summary>
|
||||
public string? Token { get; init; }
|
||||
|
||||
/// <summary>AWS access key for ECR.</summary>
|
||||
public string? AwsAccessKey { get; init; }
|
||||
|
||||
/// <summary>AWS secret key for ECR.</summary>
|
||||
public string? AwsSecretKey { get; init; }
|
||||
|
||||
/// <summary>AWS region for ECR.</summary>
|
||||
public string? AwsRegion { get; init; }
|
||||
|
||||
/// <summary>GCP service account JSON for GCR.</summary>
|
||||
public string? GcpServiceAccountJson { get; init; }
|
||||
|
||||
/// <summary>Azure client ID for ACR.</summary>
|
||||
public string? AzureClientId { get; init; }
|
||||
|
||||
/// <summary>Azure client secret for ACR.</summary>
|
||||
public string? AzureClientSecret { get; init; }
|
||||
|
||||
/// <summary>Azure tenant ID for ACR.</summary>
|
||||
public string? AzureTenantId { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registry authentication types.
|
||||
/// </summary>
|
||||
public enum RegistryAuthType
|
||||
{
|
||||
None,
|
||||
Basic,
|
||||
Token,
|
||||
AwsEcr,
|
||||
GcpGcr,
|
||||
AzureAcr
|
||||
}
|
||||
@@ -0,0 +1,456 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Scanner.Sources.Configuration;
|
||||
using StellaOps.Scanner.Sources.Contracts;
|
||||
using StellaOps.Scanner.Sources.Domain;
|
||||
using StellaOps.Scanner.Sources.Services;
|
||||
using StellaOps.Scanner.Sources.Triggers;
|
||||
|
||||
namespace StellaOps.Scanner.Sources.Handlers.Zastava;
|
||||
|
||||
/// <summary>
|
||||
/// Handler for Zastava (container registry webhook) sources.
|
||||
/// </summary>
|
||||
public sealed class ZastavaSourceHandler : ISourceTypeHandler, IWebhookCapableHandler
|
||||
{
|
||||
private readonly IRegistryClientFactory _clientFactory;
|
||||
private readonly ICredentialResolver _credentialResolver;
|
||||
private readonly ISourceConfigValidator _configValidator;
|
||||
private readonly ILogger<ZastavaSourceHandler> _logger;
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
};
|
||||
|
||||
public SbomSourceType SourceType => SbomSourceType.Zastava;
|
||||
public bool SupportsWebhooks => true;
|
||||
public bool SupportsScheduling => true;
|
||||
public int MaxConcurrentTargets => 20;
|
||||
|
||||
public ZastavaSourceHandler(
|
||||
IRegistryClientFactory clientFactory,
|
||||
ICredentialResolver credentialResolver,
|
||||
ISourceConfigValidator configValidator,
|
||||
ILogger<ZastavaSourceHandler> logger)
|
||||
{
|
||||
_clientFactory = clientFactory;
|
||||
_credentialResolver = credentialResolver;
|
||||
_configValidator = configValidator;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<ScanTarget>> DiscoverTargetsAsync(
|
||||
SbomSource source,
|
||||
TriggerContext context,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var config = source.Configuration.Deserialize<ZastavaSourceConfig>(JsonOptions);
|
||||
if (config == null)
|
||||
{
|
||||
_logger.LogWarning("Invalid configuration for source {SourceId}", source.SourceId);
|
||||
return [];
|
||||
}
|
||||
|
||||
// For webhook triggers, extract target from payload
|
||||
if (context.Trigger == SbomSourceRunTrigger.Webhook)
|
||||
{
|
||||
if (context.WebhookPayload != null)
|
||||
{
|
||||
var payloadInfo = ParseWebhookPayload(context.WebhookPayload);
|
||||
|
||||
// Check if it matches filters
|
||||
if (!MatchesFilters(payloadInfo, config.Filters))
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Webhook payload does not match filters for source {SourceId}",
|
||||
source.SourceId);
|
||||
return [];
|
||||
}
|
||||
|
||||
var reference = BuildReference(config.RegistryUrl, payloadInfo.Reference, payloadInfo.Tag);
|
||||
return
|
||||
[
|
||||
new ScanTarget
|
||||
{
|
||||
Reference = reference,
|
||||
Digest = payloadInfo.Digest,
|
||||
Metadata = new Dictionary<string, string>
|
||||
{
|
||||
["repository"] = payloadInfo.Reference,
|
||||
["tag"] = payloadInfo.Tag ?? "latest",
|
||||
["pushedBy"] = payloadInfo.Actor ?? "unknown",
|
||||
["eventType"] = payloadInfo.EventType
|
||||
}
|
||||
}
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// For scheduled/manual triggers, discover from registry
|
||||
return await DiscoverFromRegistryAsync(source, config, ct);
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<ScanTarget>> DiscoverFromRegistryAsync(
|
||||
SbomSource source,
|
||||
ZastavaSourceConfig config,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var credentials = await GetCredentialsAsync(source.AuthRef, ct);
|
||||
using var client = _clientFactory.Create(config.RegistryType, config.RegistryUrl, credentials);
|
||||
|
||||
var targets = new List<ScanTarget>();
|
||||
var repoPatterns = config.Filters?.RepositoryPatterns ?? ["*"];
|
||||
|
||||
foreach (var pattern in repoPatterns)
|
||||
{
|
||||
var repos = await client.ListRepositoriesAsync(pattern, 100, ct);
|
||||
|
||||
foreach (var repo in repos)
|
||||
{
|
||||
// Check exclusions
|
||||
if (config.Filters?.ExcludePatterns?.Any(ex => MatchesPattern(repo, ex)) == true)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var tagPatterns = config.Filters?.TagPatterns ?? ["*"];
|
||||
var tags = await client.ListTagsAsync(repo, tagPatterns, 50, ct);
|
||||
|
||||
foreach (var tag in tags)
|
||||
{
|
||||
// Check tag exclusions
|
||||
if (config.Filters?.ExcludePatterns?.Any(ex => MatchesPattern(tag.Name, ex)) == true)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var reference = BuildReference(config.RegistryUrl, repo, tag.Name);
|
||||
targets.Add(new ScanTarget
|
||||
{
|
||||
Reference = reference,
|
||||
Digest = tag.Digest,
|
||||
Metadata = new Dictionary<string, string>
|
||||
{
|
||||
["repository"] = repo,
|
||||
["tag"] = tag.Name
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Discovered {Count} targets from registry for source {SourceId}",
|
||||
targets.Count, source.SourceId);
|
||||
|
||||
return targets;
|
||||
}
|
||||
|
||||
public ConfigValidationResult ValidateConfiguration(JsonDocument configuration)
|
||||
{
|
||||
return _configValidator.Validate(SbomSourceType.Zastava, configuration);
|
||||
}
|
||||
|
||||
public async Task<ConnectionTestResult> TestConnectionAsync(
|
||||
SbomSource source,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var config = source.Configuration.Deserialize<ZastavaSourceConfig>(JsonOptions);
|
||||
if (config == null)
|
||||
{
|
||||
return new ConnectionTestResult
|
||||
{
|
||||
Success = false,
|
||||
Message = "Invalid configuration",
|
||||
TestedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var credentials = await GetCredentialsAsync(source.AuthRef, ct);
|
||||
using var client = _clientFactory.Create(config.RegistryType, config.RegistryUrl, credentials);
|
||||
|
||||
var pingSuccess = await client.PingAsync(ct);
|
||||
if (!pingSuccess)
|
||||
{
|
||||
return new ConnectionTestResult
|
||||
{
|
||||
Success = false,
|
||||
Message = "Registry ping failed",
|
||||
TestedAt = DateTimeOffset.UtcNow,
|
||||
Details = new Dictionary<string, object>
|
||||
{
|
||||
["registryUrl"] = config.RegistryUrl,
|
||||
["registryType"] = config.RegistryType.ToString()
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Try to list repositories to verify access
|
||||
var repos = await client.ListRepositoriesAsync(limit: 1, ct: ct);
|
||||
|
||||
return new ConnectionTestResult
|
||||
{
|
||||
Success = true,
|
||||
Message = "Successfully connected to registry",
|
||||
TestedAt = DateTimeOffset.UtcNow,
|
||||
Details = new Dictionary<string, object>
|
||||
{
|
||||
["registryUrl"] = config.RegistryUrl,
|
||||
["registryType"] = config.RegistryType.ToString(),
|
||||
["repositoriesAccessible"] = repos.Count > 0
|
||||
}
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Connection test failed for source {SourceId}", source.SourceId);
|
||||
return new ConnectionTestResult
|
||||
{
|
||||
Success = false,
|
||||
Message = $"Connection failed: {ex.Message}",
|
||||
TestedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public bool VerifyWebhookSignature(byte[] payload, string signature, string secret)
|
||||
{
|
||||
// Support multiple signature formats
|
||||
// Docker Hub: X-Hub-Signature (SHA1)
|
||||
// Harbor: Authorization header with shared secret
|
||||
// Generic: HMAC-SHA256
|
||||
|
||||
if (string.IsNullOrEmpty(signature) || string.IsNullOrEmpty(secret))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Try HMAC-SHA256 first (most common)
|
||||
var secretBytes = Encoding.UTF8.GetBytes(secret);
|
||||
using var hmac256 = new HMACSHA256(secretBytes);
|
||||
var computed256 = Convert.ToHexString(hmac256.ComputeHash(payload)).ToLowerInvariant();
|
||||
|
||||
if (signature.StartsWith("sha256=", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var expected = signature[7..].ToLowerInvariant();
|
||||
return CryptographicOperations.FixedTimeEquals(
|
||||
Encoding.UTF8.GetBytes(computed256),
|
||||
Encoding.UTF8.GetBytes(expected));
|
||||
}
|
||||
|
||||
// Try SHA1 (Docker Hub legacy)
|
||||
using var hmac1 = new HMACSHA1(secretBytes);
|
||||
var computed1 = Convert.ToHexString(hmac1.ComputeHash(payload)).ToLowerInvariant();
|
||||
|
||||
if (signature.StartsWith("sha1=", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var expected = signature[5..].ToLowerInvariant();
|
||||
return CryptographicOperations.FixedTimeEquals(
|
||||
Encoding.UTF8.GetBytes(computed1),
|
||||
Encoding.UTF8.GetBytes(expected));
|
||||
}
|
||||
|
||||
// Plain comparison (Harbor style)
|
||||
return CryptographicOperations.FixedTimeEquals(
|
||||
Encoding.UTF8.GetBytes(signature),
|
||||
Encoding.UTF8.GetBytes(secret));
|
||||
}
|
||||
|
||||
public WebhookPayloadInfo ParseWebhookPayload(JsonDocument payload)
|
||||
{
|
||||
var root = payload.RootElement;
|
||||
|
||||
// Try different webhook formats
|
||||
|
||||
// Docker Hub format
|
||||
if (root.TryGetProperty("push_data", out var pushData) &&
|
||||
root.TryGetProperty("repository", out var repository))
|
||||
{
|
||||
return new WebhookPayloadInfo
|
||||
{
|
||||
EventType = "push",
|
||||
Reference = repository.TryGetProperty("repo_name", out var repoName)
|
||||
? repoName.GetString()!
|
||||
: repository.GetProperty("name").GetString()!,
|
||||
Tag = pushData.TryGetProperty("tag", out var tag) ? tag.GetString() : "latest",
|
||||
Actor = pushData.TryGetProperty("pusher", out var pusher) ? pusher.GetString() : null,
|
||||
Timestamp = DateTimeOffset.UtcNow
|
||||
};
|
||||
}
|
||||
|
||||
// Harbor format
|
||||
if (root.TryGetProperty("type", out var eventType) &&
|
||||
root.TryGetProperty("event_data", out var eventData))
|
||||
{
|
||||
var resources = eventData.TryGetProperty("resources", out var res) ? res : default;
|
||||
var firstResource = resources.ValueKind == JsonValueKind.Array && resources.GetArrayLength() > 0
|
||||
? resources[0]
|
||||
: default;
|
||||
|
||||
return new WebhookPayloadInfo
|
||||
{
|
||||
EventType = eventType.GetString() ?? "push",
|
||||
Reference = eventData.TryGetProperty("repository", out var repo)
|
||||
? (repo.TryGetProperty("repo_full_name", out var fullName)
|
||||
? fullName.GetString()!
|
||||
: repo.GetProperty("name").GetString()!)
|
||||
: "",
|
||||
Tag = firstResource.TryGetProperty("tag", out var harborTag)
|
||||
? harborTag.GetString()
|
||||
: null,
|
||||
Digest = firstResource.TryGetProperty("digest", out var digest)
|
||||
? digest.GetString()
|
||||
: null,
|
||||
Actor = eventData.TryGetProperty("operator", out var op) ? op.GetString() : null,
|
||||
Timestamp = DateTimeOffset.UtcNow
|
||||
};
|
||||
}
|
||||
|
||||
// Generic OCI distribution format
|
||||
if (root.TryGetProperty("events", out var events) &&
|
||||
events.ValueKind == JsonValueKind.Array &&
|
||||
events.GetArrayLength() > 0)
|
||||
{
|
||||
var firstEvent = events[0];
|
||||
return new WebhookPayloadInfo
|
||||
{
|
||||
EventType = firstEvent.TryGetProperty("action", out var action)
|
||||
? action.GetString() ?? "push"
|
||||
: "push",
|
||||
Reference = firstEvent.TryGetProperty("target", out var target) &&
|
||||
target.TryGetProperty("repository", out var targetRepo)
|
||||
? targetRepo.GetString()!
|
||||
: "",
|
||||
Tag = target.TryGetProperty("tag", out var ociTag)
|
||||
? ociTag.GetString()
|
||||
: null,
|
||||
Digest = target.TryGetProperty("digest", out var ociDigest)
|
||||
? ociDigest.GetString()
|
||||
: null,
|
||||
Actor = firstEvent.TryGetProperty("actor", out var actor) &&
|
||||
actor.TryGetProperty("name", out var actorName)
|
||||
? actorName.GetString()
|
||||
: null,
|
||||
Timestamp = DateTimeOffset.UtcNow
|
||||
};
|
||||
}
|
||||
|
||||
_logger.LogWarning("Unable to parse webhook payload format");
|
||||
return new WebhookPayloadInfo
|
||||
{
|
||||
EventType = "unknown",
|
||||
Reference = "",
|
||||
Timestamp = DateTimeOffset.UtcNow
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<RegistryCredentials?> GetCredentialsAsync(string? authRef, CancellationToken ct)
|
||||
{
|
||||
if (string.IsNullOrEmpty(authRef))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var resolved = await _credentialResolver.ResolveAsync(authRef, ct);
|
||||
if (resolved == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return resolved.Type switch
|
||||
{
|
||||
CredentialType.BasicAuth => new RegistryCredentials
|
||||
{
|
||||
AuthType = RegistryAuthType.Basic,
|
||||
Username = resolved.Username,
|
||||
Password = resolved.Password
|
||||
},
|
||||
CredentialType.BearerToken => new RegistryCredentials
|
||||
{
|
||||
AuthType = RegistryAuthType.Token,
|
||||
Token = resolved.Token
|
||||
},
|
||||
CredentialType.AwsCredentials => new RegistryCredentials
|
||||
{
|
||||
AuthType = RegistryAuthType.AwsEcr,
|
||||
AwsAccessKey = resolved.Properties?.GetValueOrDefault("accessKey"),
|
||||
AwsSecretKey = resolved.Properties?.GetValueOrDefault("secretKey"),
|
||||
AwsRegion = resolved.Properties?.GetValueOrDefault("region")
|
||||
},
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
private static bool MatchesFilters(WebhookPayloadInfo payload, ZastavaFilters? filters)
|
||||
{
|
||||
if (filters == null)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check repository patterns
|
||||
if (filters.RepositoryPatterns?.Count > 0)
|
||||
{
|
||||
if (!filters.RepositoryPatterns.Any(p => MatchesPattern(payload.Reference, p)))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Check tag patterns
|
||||
if (filters.TagPatterns?.Count > 0 && payload.Tag != null)
|
||||
{
|
||||
if (!filters.TagPatterns.Any(p => MatchesPattern(payload.Tag, p)))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Check exclusions
|
||||
if (filters.ExcludePatterns?.Count > 0)
|
||||
{
|
||||
if (filters.ExcludePatterns.Any(p =>
|
||||
MatchesPattern(payload.Reference, p) ||
|
||||
(payload.Tag != null && MatchesPattern(payload.Tag, p))))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool MatchesPattern(string value, string pattern)
|
||||
{
|
||||
// Convert glob pattern to regex
|
||||
var regexPattern = "^" + Regex.Escape(pattern)
|
||||
.Replace("\\*", ".*")
|
||||
.Replace("\\?", ".") + "$";
|
||||
|
||||
return Regex.IsMatch(value, regexPattern, RegexOptions.IgnoreCase);
|
||||
}
|
||||
|
||||
private static string BuildReference(string registryUrl, string repository, string? tag)
|
||||
{
|
||||
var host = new Uri(registryUrl).Host;
|
||||
|
||||
// Docker Hub special case
|
||||
if (host.Contains("docker.io") || host.Contains("docker.com"))
|
||||
{
|
||||
if (!repository.Contains('/'))
|
||||
{
|
||||
repository = $"library/{repository}";
|
||||
}
|
||||
return $"{repository}:{tag ?? "latest"}";
|
||||
}
|
||||
|
||||
return $"{host}/{repository}:{tag ?? "latest"}";
|
||||
}
|
||||
}
|
||||
@@ -53,6 +53,18 @@ public interface ISbomSourceRepository
|
||||
/// Check if a source name exists in the tenant.
|
||||
/// </summary>
|
||||
Task<bool> NameExistsAsync(string tenantId, string name, Guid? excludeSourceId = null, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Search for sources by name across all tenants.
|
||||
/// Used for webhook routing where tenant is not known upfront.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<SbomSource>> SearchByNameAsync(string name, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Get sources that are due for scheduled execution.
|
||||
/// Alias for GetDueScheduledSourcesAsync for dispatcher compatibility.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<SbomSource>> GetDueForScheduledRunAsync(CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -122,11 +122,11 @@ public sealed class SbomSourceRepository : RepositoryBase<ScannerSourcesDataSour
|
||||
MapSource,
|
||||
ct);
|
||||
|
||||
var totalCount = await ExecuteScalarAsync<long>(
|
||||
var totalCount = (await ExecuteScalarAsync<long>(
|
||||
tenantId,
|
||||
countSb.ToString(),
|
||||
AddFilters,
|
||||
ct) ?? 0;
|
||||
ct)).Value;
|
||||
|
||||
string? nextCursor = null;
|
||||
if (items.Count > request.Limit)
|
||||
@@ -296,6 +296,30 @@ public sealed class SbomSourceRepository : RepositoryBase<ScannerSourcesDataSour
|
||||
ct);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<SbomSource>> SearchByNameAsync(
|
||||
string name,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
const string sql = $"""
|
||||
SELECT * FROM {FullTable}
|
||||
WHERE name = @name
|
||||
LIMIT 10
|
||||
""";
|
||||
|
||||
// Cross-tenant search, use system context
|
||||
return await QueryAsync(
|
||||
"__system__",
|
||||
sql,
|
||||
cmd => AddParameter(cmd, "name", name),
|
||||
MapSource,
|
||||
ct);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<SbomSource>> GetDueForScheduledRunAsync(CancellationToken ct = default)
|
||||
{
|
||||
return GetDueScheduledSourcesAsync(DateTimeOffset.UtcNow, 100, ct);
|
||||
}
|
||||
|
||||
private void ConfigureSourceParams(NpgsqlCommand cmd, SbomSource source)
|
||||
{
|
||||
AddParameter(cmd, "sourceId", source.SourceId);
|
||||
|
||||
@@ -98,11 +98,12 @@ public sealed class SbomSourceRunRepository : RepositoryBase<ScannerSourcesDataS
|
||||
MapRun,
|
||||
ct);
|
||||
|
||||
var totalCount = await ExecuteScalarAsync<long>(
|
||||
var totalCountResult = await ExecuteScalarAsync<long>(
|
||||
"__system__",
|
||||
countSb.ToString(),
|
||||
AddFilters,
|
||||
ct) ?? 0;
|
||||
ct);
|
||||
var totalCount = totalCountResult.GetValueOrDefault();
|
||||
|
||||
string? nextCursor = null;
|
||||
if (items.Count > request.Limit)
|
||||
|
||||
@@ -10,13 +10,21 @@ namespace StellaOps.Scanner.Sources.Persistence;
|
||||
/// </summary>
|
||||
public sealed class ScannerSourcesDataSource : DataSourceBase
|
||||
{
|
||||
/// <summary>
|
||||
/// Default schema name for Scanner Sources tables.
|
||||
/// </summary>
|
||||
public const string DefaultSchemaName = "sources";
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new Scanner Sources data source.
|
||||
/// </summary>
|
||||
public ScannerSourcesDataSource(
|
||||
IOptions<PostgresOptions> options,
|
||||
ILogger<ScannerSourcesDataSource> logger)
|
||||
: base(options, logger)
|
||||
: base(options.Value, logger)
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override string ModuleName => "ScannerSources";
|
||||
}
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Scanner.Sources.Triggers;
|
||||
|
||||
namespace StellaOps.Scanner.Sources.Scheduling;
|
||||
|
||||
/// <summary>
|
||||
/// Background service that processes scheduled SBOM sources.
|
||||
/// </summary>
|
||||
public sealed partial class SourceSchedulerHostedService : BackgroundService
|
||||
{
|
||||
private readonly ISourceTriggerDispatcher _dispatcher;
|
||||
private readonly IOptionsMonitor<SourceSchedulerOptions> _options;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<SourceSchedulerHostedService> _logger;
|
||||
|
||||
public SourceSchedulerHostedService(
|
||||
ISourceTriggerDispatcher dispatcher,
|
||||
IOptionsMonitor<SourceSchedulerOptions> options,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<SourceSchedulerHostedService> logger)
|
||||
{
|
||||
_dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher));
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
_logger.LogInformation("Source scheduler started");
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
await ProcessScheduledSourcesAsync(stoppingToken);
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Source scheduler encountered an error");
|
||||
}
|
||||
|
||||
var options = _options.CurrentValue;
|
||||
await Task.Delay(options.CheckInterval, _timeProvider, stoppingToken);
|
||||
}
|
||||
|
||||
_logger.LogInformation("Source scheduler stopping");
|
||||
}
|
||||
|
||||
private async Task ProcessScheduledSourcesAsync(CancellationToken ct)
|
||||
{
|
||||
var options = _options.CurrentValue;
|
||||
|
||||
if (!options.Enabled)
|
||||
{
|
||||
_logger.LogDebug("Source scheduler is disabled");
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var processed = await _dispatcher.ProcessScheduledSourcesAsync(ct);
|
||||
|
||||
if (processed > 0)
|
||||
{
|
||||
_logger.LogInformation("Processed {Count} scheduled sources", processed);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogDebug("No scheduled sources due for processing");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to process scheduled sources");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for the source scheduler.
|
||||
/// </summary>
|
||||
public sealed class SourceSchedulerOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether the scheduler is enabled.
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// How often to check for due scheduled sources.
|
||||
/// </summary>
|
||||
public TimeSpan CheckInterval { get; set; } = TimeSpan.FromMinutes(1);
|
||||
|
||||
/// <summary>
|
||||
/// Maximum number of sources to process in a single batch.
|
||||
/// </summary>
|
||||
public int MaxBatchSize { get; set; } = 50;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to allow scheduling sources that have never run.
|
||||
/// </summary>
|
||||
public bool AllowFirstRun { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Minimum interval between runs for the same source (to prevent rapid re-triggering).
|
||||
/// </summary>
|
||||
public TimeSpan MinRunInterval { get; set; } = TimeSpan.FromMinutes(5);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
namespace StellaOps.Scanner.Sources.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Credential types supported by the resolver.
|
||||
/// </summary>
|
||||
public enum CredentialType
|
||||
{
|
||||
None,
|
||||
BearerToken,
|
||||
BasicAuth,
|
||||
SshKey,
|
||||
AwsCredentials,
|
||||
GcpServiceAccount,
|
||||
AzureServicePrincipal,
|
||||
GitHubApp
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolved credential from the credential store.
|
||||
/// </summary>
|
||||
public sealed record ResolvedCredential
|
||||
{
|
||||
public required CredentialType Type { get; init; }
|
||||
public string? Token { get; init; }
|
||||
public string? Username { get; init; }
|
||||
public string? Password { get; init; }
|
||||
public string? PrivateKey { get; init; }
|
||||
public string? Passphrase { get; init; }
|
||||
public IReadOnlyDictionary<string, string>? Properties { get; init; }
|
||||
public DateTimeOffset? ExpiresAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interface for resolving credentials from the credential store.
|
||||
/// Credentials are stored externally and referenced by AuthRef.
|
||||
/// </summary>
|
||||
public interface ICredentialResolver
|
||||
{
|
||||
/// <summary>
|
||||
/// Resolves credentials by AuthRef.
|
||||
/// </summary>
|
||||
/// <param name="authRef">Reference to the credential in the store (e.g., "vault://secrets/registry-auth")</param>
|
||||
/// <param name="ct">Cancellation token</param>
|
||||
/// <returns>Resolved credential or null if not found</returns>
|
||||
Task<ResolvedCredential?> ResolveAsync(string authRef, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Checks if a credential reference is valid (exists and is accessible).
|
||||
/// </summary>
|
||||
Task<bool> ValidateRefAsync(string authRef, CancellationToken ct = default);
|
||||
}
|
||||
@@ -12,12 +12,15 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" />
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" />
|
||||
<PackageReference Include="Cronos" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Infrastructure.Postgres/StellaOps.Infrastructure.Postgres.csproj" />
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.Infrastructure.Postgres/StellaOps.Infrastructure.Postgres.csproj" />
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.Cryptography/StellaOps.Cryptography.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
using StellaOps.Scanner.Sources.Domain;
|
||||
|
||||
namespace StellaOps.Scanner.Sources.Triggers;
|
||||
|
||||
/// <summary>
|
||||
/// Interface for dispatching source triggers and creating scan jobs.
|
||||
/// </summary>
|
||||
public interface ISourceTriggerDispatcher
|
||||
{
|
||||
/// <summary>
|
||||
/// Dispatch a trigger for a source, discovering targets and creating scan jobs.
|
||||
/// </summary>
|
||||
/// <param name="sourceId">The source ID to trigger.</param>
|
||||
/// <param name="context">Trigger context with details.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Result containing the run and queued jobs.</returns>
|
||||
Task<TriggerDispatchResult> DispatchAsync(
|
||||
Guid sourceId,
|
||||
TriggerContext context,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Dispatch a trigger by source ID with simple trigger type.
|
||||
/// </summary>
|
||||
Task<TriggerDispatchResult> DispatchAsync(
|
||||
Guid sourceId,
|
||||
SbomSourceRunTrigger trigger,
|
||||
string? triggerDetails = null,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Process all scheduled sources that are due for execution.
|
||||
/// Called by the scheduler worker.
|
||||
/// </summary>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Number of sources processed.</returns>
|
||||
Task<int> ProcessScheduledSourcesAsync(CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Retry a failed run for a source.
|
||||
/// </summary>
|
||||
/// <param name="sourceId">The source ID.</param>
|
||||
/// <param name="originalRunId">The original run that failed.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>The new retry run.</returns>
|
||||
Task<TriggerDispatchResult> RetryAsync(
|
||||
Guid sourceId,
|
||||
Guid originalRunId,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Scanner.Sources.Domain;
|
||||
using StellaOps.Scanner.Sources.Handlers;
|
||||
using StellaOps.Scanner.Sources.Persistence;
|
||||
|
||||
namespace StellaOps.Scanner.Sources.Triggers;
|
||||
|
||||
/// <summary>
|
||||
/// Dispatches source triggers, discovering targets and creating scan jobs.
|
||||
/// </summary>
|
||||
public sealed class SourceTriggerDispatcher : ISourceTriggerDispatcher
|
||||
{
|
||||
private readonly ISbomSourceRepository _sourceRepository;
|
||||
private readonly ISbomSourceRunRepository _runRepository;
|
||||
private readonly IEnumerable<ISourceTypeHandler> _handlers;
|
||||
private readonly IScanJobQueue _scanJobQueue;
|
||||
private readonly ILogger<SourceTriggerDispatcher> _logger;
|
||||
|
||||
public SourceTriggerDispatcher(
|
||||
ISbomSourceRepository sourceRepository,
|
||||
ISbomSourceRunRepository runRepository,
|
||||
IEnumerable<ISourceTypeHandler> handlers,
|
||||
IScanJobQueue scanJobQueue,
|
||||
ILogger<SourceTriggerDispatcher> logger)
|
||||
{
|
||||
_sourceRepository = sourceRepository;
|
||||
_runRepository = runRepository;
|
||||
_handlers = handlers;
|
||||
_scanJobQueue = scanJobQueue;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public Task<TriggerDispatchResult> DispatchAsync(
|
||||
Guid sourceId,
|
||||
SbomSourceRunTrigger trigger,
|
||||
string? triggerDetails = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var context = new TriggerContext
|
||||
{
|
||||
Trigger = trigger,
|
||||
TriggerDetails = triggerDetails,
|
||||
CorrelationId = Guid.NewGuid().ToString("N")
|
||||
};
|
||||
|
||||
return DispatchAsync(sourceId, context, ct);
|
||||
}
|
||||
|
||||
public async Task<TriggerDispatchResult> DispatchAsync(
|
||||
Guid sourceId,
|
||||
TriggerContext context,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Dispatching {Trigger} for source {SourceId}, correlationId={CorrelationId}",
|
||||
context.Trigger, sourceId, context.CorrelationId);
|
||||
|
||||
// 1. Get the source
|
||||
var source = await _sourceRepository.GetByIdAsync(null!, sourceId, ct);
|
||||
if (source == null)
|
||||
{
|
||||
_logger.LogWarning("Source {SourceId} not found", sourceId);
|
||||
throw new KeyNotFoundException($"Source {sourceId} not found");
|
||||
}
|
||||
|
||||
// 2. Check if source can be triggered
|
||||
var canTrigger = CanTrigger(source, context);
|
||||
if (!canTrigger.Success)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Source {SourceId} cannot be triggered: {Reason}",
|
||||
sourceId, canTrigger.Error);
|
||||
|
||||
// Create a failed run for tracking
|
||||
var failedRun = SbomSourceRun.Create(
|
||||
sourceId,
|
||||
source.TenantId,
|
||||
context.Trigger,
|
||||
context.CorrelationId,
|
||||
context.TriggerDetails);
|
||||
failedRun.Fail(canTrigger.Error!);
|
||||
await _runRepository.CreateAsync(failedRun, ct);
|
||||
|
||||
return new TriggerDispatchResult
|
||||
{
|
||||
Run = failedRun,
|
||||
Success = false,
|
||||
Error = canTrigger.Error
|
||||
};
|
||||
}
|
||||
|
||||
// 3. Create the run record
|
||||
var run = SbomSourceRun.Create(
|
||||
sourceId,
|
||||
source.TenantId,
|
||||
context.Trigger,
|
||||
context.CorrelationId,
|
||||
context.TriggerDetails);
|
||||
|
||||
await _runRepository.CreateAsync(run, ct);
|
||||
|
||||
try
|
||||
{
|
||||
// 4. Get the appropriate handler
|
||||
var handler = GetHandler(source.SourceType);
|
||||
if (handler == null)
|
||||
{
|
||||
run.Fail($"No handler registered for source type {source.SourceType}");
|
||||
await _runRepository.UpdateAsync(run, ct);
|
||||
return new TriggerDispatchResult
|
||||
{
|
||||
Run = run,
|
||||
Success = false,
|
||||
Error = run.ErrorMessage
|
||||
};
|
||||
}
|
||||
|
||||
// 5. Discover targets
|
||||
var targets = await handler.DiscoverTargetsAsync(source, context, ct);
|
||||
run.SetDiscoveredItems(targets.Count);
|
||||
await _runRepository.UpdateAsync(run, ct);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Discovered {Count} targets for source {SourceId}",
|
||||
targets.Count, sourceId);
|
||||
|
||||
if (targets.Count == 0)
|
||||
{
|
||||
run.Complete();
|
||||
await _runRepository.UpdateAsync(run, ct);
|
||||
source.RecordSuccessfulRun(DateTimeOffset.UtcNow);
|
||||
await _sourceRepository.UpdateAsync(source, ct);
|
||||
|
||||
return new TriggerDispatchResult
|
||||
{
|
||||
Run = run,
|
||||
Targets = targets,
|
||||
JobsQueued = 0
|
||||
};
|
||||
}
|
||||
|
||||
// 6. Queue scan jobs
|
||||
var jobsQueued = 0;
|
||||
foreach (var target in targets)
|
||||
{
|
||||
try
|
||||
{
|
||||
var jobId = await _scanJobQueue.EnqueueAsync(new ScanJobRequest
|
||||
{
|
||||
SourceId = sourceId,
|
||||
RunId = run.RunId,
|
||||
TenantId = source.TenantId,
|
||||
Reference = target.Reference,
|
||||
Digest = target.Digest,
|
||||
CorrelationId = context.CorrelationId,
|
||||
Metadata = target.Metadata
|
||||
}, ct);
|
||||
|
||||
run.RecordItemSuccess(jobId);
|
||||
jobsQueued++;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to queue scan for target {Reference}", target.Reference);
|
||||
run.RecordItemFailure();
|
||||
}
|
||||
}
|
||||
|
||||
// 7. Complete or fail based on results
|
||||
if (run.ItemsFailed == run.ItemsDiscovered)
|
||||
{
|
||||
run.Fail("All targets failed to queue");
|
||||
source.RecordFailedRun(DateTimeOffset.UtcNow, run.ErrorMessage!);
|
||||
}
|
||||
else
|
||||
{
|
||||
run.Complete();
|
||||
source.RecordSuccessfulRun(DateTimeOffset.UtcNow);
|
||||
}
|
||||
|
||||
await _runRepository.UpdateAsync(run, ct);
|
||||
await _sourceRepository.UpdateAsync(source, ct);
|
||||
|
||||
return new TriggerDispatchResult
|
||||
{
|
||||
Run = run,
|
||||
Targets = targets,
|
||||
JobsQueued = jobsQueued
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Dispatch failed for source {SourceId}", sourceId);
|
||||
|
||||
run.Fail(ex.Message);
|
||||
await _runRepository.UpdateAsync(run, ct);
|
||||
|
||||
source.RecordFailedRun(DateTimeOffset.UtcNow, ex.Message);
|
||||
await _sourceRepository.UpdateAsync(source, ct);
|
||||
|
||||
return new TriggerDispatchResult
|
||||
{
|
||||
Run = run,
|
||||
Success = false,
|
||||
Error = ex.Message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<int> ProcessScheduledSourcesAsync(CancellationToken ct = default)
|
||||
{
|
||||
_logger.LogDebug("Processing scheduled sources");
|
||||
|
||||
var dueSources = await _sourceRepository.GetDueForScheduledRunAsync(ct);
|
||||
var processed = 0;
|
||||
|
||||
foreach (var source in dueSources)
|
||||
{
|
||||
try
|
||||
{
|
||||
var context = TriggerContext.Scheduled(source.CronSchedule!);
|
||||
await DispatchAsync(source.SourceId, context, ct);
|
||||
processed++;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to process scheduled source {SourceId}", source.SourceId);
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation("Processed {Count} scheduled sources", processed);
|
||||
return processed;
|
||||
}
|
||||
|
||||
public async Task<TriggerDispatchResult> RetryAsync(
|
||||
Guid sourceId,
|
||||
Guid originalRunId,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var originalRun = await _runRepository.GetByIdAsync(originalRunId, ct);
|
||||
if (originalRun == null)
|
||||
{
|
||||
throw new KeyNotFoundException($"Run {originalRunId} not found");
|
||||
}
|
||||
|
||||
var context = new TriggerContext
|
||||
{
|
||||
Trigger = originalRun.Trigger,
|
||||
TriggerDetails = $"Retry of run {originalRunId}",
|
||||
CorrelationId = Guid.NewGuid().ToString("N"),
|
||||
Metadata = new() { ["originalRunId"] = originalRunId.ToString() }
|
||||
};
|
||||
|
||||
return await DispatchAsync(sourceId, context, ct);
|
||||
}
|
||||
|
||||
private ISourceTypeHandler? GetHandler(SbomSourceType sourceType)
|
||||
{
|
||||
return _handlers.FirstOrDefault(h => h.SourceType == sourceType);
|
||||
}
|
||||
|
||||
private static (bool Success, string? Error) CanTrigger(SbomSource source, TriggerContext context)
|
||||
{
|
||||
if (source.Status == SbomSourceStatus.Disabled)
|
||||
{
|
||||
return (false, "Source is disabled");
|
||||
}
|
||||
|
||||
if (source.Status == SbomSourceStatus.Pending)
|
||||
{
|
||||
return (false, "Source has not been activated");
|
||||
}
|
||||
|
||||
if (source.Paused)
|
||||
{
|
||||
return (false, $"Source is paused: {source.PauseReason}");
|
||||
}
|
||||
|
||||
if (source.Status == SbomSourceStatus.Error)
|
||||
{
|
||||
// Allow manual triggers for error state to allow recovery
|
||||
if (context.Trigger != SbomSourceRunTrigger.Manual)
|
||||
{
|
||||
return (false, "Source is in error state. Use manual trigger to recover.");
|
||||
}
|
||||
}
|
||||
|
||||
if (source.IsRateLimited())
|
||||
{
|
||||
return (false, "Source is rate limited");
|
||||
}
|
||||
|
||||
return (true, null);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interface for the scan job queue.
|
||||
/// </summary>
|
||||
public interface IScanJobQueue
|
||||
{
|
||||
/// <summary>
|
||||
/// Enqueue a scan job.
|
||||
/// </summary>
|
||||
Task<Guid> EnqueueAsync(ScanJobRequest request, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to create a scan job.
|
||||
/// </summary>
|
||||
public sealed record ScanJobRequest
|
||||
{
|
||||
public required Guid SourceId { get; init; }
|
||||
public required Guid RunId { get; init; }
|
||||
public required string TenantId { get; init; }
|
||||
public required string Reference { get; init; }
|
||||
public string? Digest { get; init; }
|
||||
public required string CorrelationId { get; init; }
|
||||
public Dictionary<string, string> Metadata { get; init; } = [];
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
using System.Text.Json;
|
||||
using StellaOps.Scanner.Sources.Domain;
|
||||
|
||||
namespace StellaOps.Scanner.Sources.Triggers;
|
||||
|
||||
/// <summary>
|
||||
/// Context information for a source trigger.
|
||||
/// </summary>
|
||||
public sealed record TriggerContext
|
||||
{
|
||||
/// <summary>Type of trigger that initiated this run.</summary>
|
||||
public required SbomSourceRunTrigger Trigger { get; init; }
|
||||
|
||||
/// <summary>Details about the trigger (e.g., webhook event type, cron expression).</summary>
|
||||
public string? TriggerDetails { get; init; }
|
||||
|
||||
/// <summary>Correlation ID for distributed tracing.</summary>
|
||||
public required string CorrelationId { get; init; }
|
||||
|
||||
/// <summary>Webhook payload for webhook-triggered runs.</summary>
|
||||
public JsonDocument? WebhookPayload { get; init; }
|
||||
|
||||
/// <summary>Additional metadata from the trigger source.</summary>
|
||||
public Dictionary<string, string> Metadata { get; init; } = [];
|
||||
|
||||
/// <summary>Creates a context for a manual trigger.</summary>
|
||||
public static TriggerContext Manual(string triggeredBy, string? correlationId = null) => new()
|
||||
{
|
||||
Trigger = SbomSourceRunTrigger.Manual,
|
||||
TriggerDetails = $"Triggered by {triggeredBy}",
|
||||
CorrelationId = correlationId ?? Guid.NewGuid().ToString("N"),
|
||||
Metadata = new() { ["triggeredBy"] = triggeredBy }
|
||||
};
|
||||
|
||||
/// <summary>Creates a context for a scheduled trigger.</summary>
|
||||
public static TriggerContext Scheduled(string cronExpression, string? correlationId = null) => new()
|
||||
{
|
||||
Trigger = SbomSourceRunTrigger.Scheduled,
|
||||
TriggerDetails = $"Cron: {cronExpression}",
|
||||
CorrelationId = correlationId ?? Guid.NewGuid().ToString("N")
|
||||
};
|
||||
|
||||
/// <summary>Creates a context for a webhook trigger.</summary>
|
||||
public static TriggerContext Webhook(
|
||||
string eventDetails,
|
||||
JsonDocument payload,
|
||||
string? correlationId = null) => new()
|
||||
{
|
||||
Trigger = SbomSourceRunTrigger.Webhook,
|
||||
TriggerDetails = eventDetails,
|
||||
CorrelationId = correlationId ?? Guid.NewGuid().ToString("N"),
|
||||
WebhookPayload = payload
|
||||
};
|
||||
|
||||
/// <summary>Creates a context for a push event trigger (registry/git push via webhook).</summary>
|
||||
public static TriggerContext Push(
|
||||
string eventDetails,
|
||||
JsonDocument payload,
|
||||
string? correlationId = null) => new()
|
||||
{
|
||||
Trigger = SbomSourceRunTrigger.Webhook,
|
||||
TriggerDetails = $"Push: {eventDetails}",
|
||||
CorrelationId = correlationId ?? Guid.NewGuid().ToString("N"),
|
||||
WebhookPayload = payload
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Target to be scanned, discovered by a source handler.
|
||||
/// </summary>
|
||||
public sealed record ScanTarget
|
||||
{
|
||||
/// <summary>Reference to the target (image ref, repo URL, etc.).</summary>
|
||||
public required string Reference { get; init; }
|
||||
|
||||
/// <summary>Optional pinned digest for container images.</summary>
|
||||
public string? Digest { get; init; }
|
||||
|
||||
/// <summary>Metadata about the target.</summary>
|
||||
public Dictionary<string, string> Metadata { get; init; } = [];
|
||||
|
||||
/// <summary>Priority of this target (higher = scan first).</summary>
|
||||
public int Priority { get; init; } = 0;
|
||||
|
||||
/// <summary>Creates a container image target.</summary>
|
||||
public static ScanTarget Image(string reference, string? digest = null) => new()
|
||||
{
|
||||
Reference = reference,
|
||||
Digest = digest
|
||||
};
|
||||
|
||||
/// <summary>Creates a git repository target.</summary>
|
||||
public static ScanTarget Repository(string repoUrl, string branch, string? commitSha = null) => new()
|
||||
{
|
||||
Reference = repoUrl,
|
||||
Metadata = new()
|
||||
{
|
||||
["branch"] = branch,
|
||||
["commitSha"] = commitSha ?? "",
|
||||
["ref"] = $"refs/heads/{branch}"
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of dispatching a trigger.
|
||||
/// </summary>
|
||||
public sealed record TriggerDispatchResult
|
||||
{
|
||||
/// <summary>The run created for this trigger.</summary>
|
||||
public required SbomSourceRun Run { get; init; }
|
||||
|
||||
/// <summary>Targets discovered and queued for scanning.</summary>
|
||||
public IReadOnlyList<ScanTarget> Targets { get; init; } = [];
|
||||
|
||||
/// <summary>Number of scan jobs created.</summary>
|
||||
public int JobsQueued { get; init; }
|
||||
|
||||
/// <summary>Whether the dispatch was successful.</summary>
|
||||
public bool Success { get; init; } = true;
|
||||
|
||||
/// <summary>Error message if dispatch failed.</summary>
|
||||
public string? Error { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
-- ============================================================================
|
||||
-- SCANNER STORAGE - SBOM SOURCES SCHEMA
|
||||
-- ============================================================================
|
||||
-- Migration: 020_sbom_sources.sql
|
||||
-- Description: Creates tables for managing SBOM ingestion sources
|
||||
-- Supports: Zastava (registry webhooks), Docker (image scanning),
|
||||
-- CLI (external submissions), Git (source code scanning)
|
||||
-- ============================================================================
|
||||
|
||||
-- ============================================================================
|
||||
-- ENUMS
|
||||
-- ============================================================================
|
||||
|
||||
DO $$ BEGIN
|
||||
CREATE TYPE scanner.sbom_source_type AS ENUM (
|
||||
'zastava', -- Registry webhook (Docker Hub, Harbor, ECR, etc.)
|
||||
'docker', -- Direct image scanning
|
||||
'cli', -- External SBOM submissions
|
||||
'git' -- Source code scanning
|
||||
);
|
||||
EXCEPTION WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
|
||||
DO $$ BEGIN
|
||||
CREATE TYPE scanner.sbom_source_status AS ENUM (
|
||||
'draft', -- Initial state, not yet activated
|
||||
'active', -- Ready to process
|
||||
'disabled', -- Administratively disabled
|
||||
'error' -- In error state (consecutive failures)
|
||||
);
|
||||
EXCEPTION WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
|
||||
DO $$ BEGIN
|
||||
CREATE TYPE scanner.sbom_source_run_status AS ENUM (
|
||||
'pending', -- Queued
|
||||
'running', -- In progress
|
||||
'succeeded', -- Completed successfully
|
||||
'failed', -- Completed with errors
|
||||
'cancelled' -- Cancelled by user
|
||||
);
|
||||
EXCEPTION WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
|
||||
DO $$ BEGIN
|
||||
CREATE TYPE scanner.sbom_source_run_trigger AS ENUM (
|
||||
'manual', -- User-triggered
|
||||
'scheduled', -- Cron-triggered
|
||||
'webhook', -- External webhook event
|
||||
'push' -- Registry push event
|
||||
);
|
||||
EXCEPTION WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
|
||||
-- ============================================================================
|
||||
-- SBOM SOURCES TABLE
|
||||
-- ============================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS scanner.sbom_sources (
|
||||
-- Identity
|
||||
source_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
|
||||
-- Type and configuration
|
||||
source_type scanner.sbom_source_type NOT NULL,
|
||||
configuration JSONB NOT NULL,
|
||||
|
||||
-- Status
|
||||
status scanner.sbom_source_status NOT NULL DEFAULT 'draft',
|
||||
|
||||
-- Authentication
|
||||
auth_ref TEXT, -- Reference to credentials in vault (e.g., "vault://secrets/registry-auth")
|
||||
|
||||
-- Webhook (for Zastava type)
|
||||
webhook_secret TEXT,
|
||||
webhook_endpoint TEXT,
|
||||
|
||||
-- Scheduling (for scheduled sources)
|
||||
cron_schedule TEXT,
|
||||
cron_timezone TEXT DEFAULT 'UTC',
|
||||
next_scheduled_run TIMESTAMPTZ,
|
||||
|
||||
-- Run tracking
|
||||
last_run_at TIMESTAMPTZ,
|
||||
last_run_status scanner.sbom_source_run_status,
|
||||
last_run_error TEXT,
|
||||
consecutive_failures INT NOT NULL DEFAULT 0,
|
||||
|
||||
-- Pause state
|
||||
paused BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
pause_reason TEXT,
|
||||
pause_ticket TEXT,
|
||||
paused_at TIMESTAMPTZ,
|
||||
paused_by TEXT,
|
||||
|
||||
-- Rate limiting
|
||||
max_scans_per_hour INT,
|
||||
last_rate_limit_reset TIMESTAMPTZ,
|
||||
scans_in_current_hour INT NOT NULL DEFAULT 0,
|
||||
|
||||
-- Metadata
|
||||
tags JSONB NOT NULL DEFAULT '[]',
|
||||
metadata JSONB NOT NULL DEFAULT '{}',
|
||||
|
||||
-- Audit
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
created_by TEXT NOT NULL,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_by TEXT NOT NULL,
|
||||
|
||||
-- Constraints
|
||||
CONSTRAINT uq_sbom_sources_tenant_name UNIQUE (tenant_id, name)
|
||||
);
|
||||
|
||||
-- Indexes for common queries
|
||||
CREATE INDEX IF NOT EXISTS ix_sbom_sources_tenant
|
||||
ON scanner.sbom_sources (tenant_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_sbom_sources_type
|
||||
ON scanner.sbom_sources (source_type);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_sbom_sources_status
|
||||
ON scanner.sbom_sources (status);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_sbom_sources_next_scheduled
|
||||
ON scanner.sbom_sources (next_scheduled_run)
|
||||
WHERE next_scheduled_run IS NOT NULL AND status = 'active' AND NOT paused;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_sbom_sources_webhook_endpoint
|
||||
ON scanner.sbom_sources (webhook_endpoint)
|
||||
WHERE webhook_endpoint IS NOT NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_sbom_sources_tags
|
||||
ON scanner.sbom_sources USING GIN (tags);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_sbom_sources_name_search
|
||||
ON scanner.sbom_sources USING gin (name gin_trgm_ops);
|
||||
|
||||
-- ============================================================================
|
||||
-- SBOM SOURCE RUNS TABLE
|
||||
-- ============================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS scanner.sbom_source_runs (
|
||||
-- Identity
|
||||
run_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
source_id UUID NOT NULL REFERENCES scanner.sbom_sources(source_id) ON DELETE CASCADE,
|
||||
tenant_id TEXT NOT NULL,
|
||||
|
||||
-- Trigger info
|
||||
trigger scanner.sbom_source_run_trigger NOT NULL,
|
||||
trigger_details TEXT,
|
||||
correlation_id TEXT NOT NULL,
|
||||
|
||||
-- Status
|
||||
status scanner.sbom_source_run_status NOT NULL DEFAULT 'pending',
|
||||
|
||||
-- Timing
|
||||
started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
completed_at TIMESTAMPTZ,
|
||||
duration_ms BIGINT NOT NULL DEFAULT 0,
|
||||
|
||||
-- Progress counters
|
||||
items_discovered INT NOT NULL DEFAULT 0,
|
||||
items_scanned INT NOT NULL DEFAULT 0,
|
||||
items_succeeded INT NOT NULL DEFAULT 0,
|
||||
items_failed INT NOT NULL DEFAULT 0,
|
||||
items_skipped INT NOT NULL DEFAULT 0,
|
||||
|
||||
-- Results
|
||||
scan_job_ids JSONB NOT NULL DEFAULT '[]',
|
||||
error_message TEXT,
|
||||
error_details JSONB,
|
||||
|
||||
-- Metadata
|
||||
metadata JSONB NOT NULL DEFAULT '{}'
|
||||
);
|
||||
|
||||
-- Indexes for run queries
|
||||
CREATE INDEX IF NOT EXISTS ix_sbom_source_runs_source
|
||||
ON scanner.sbom_source_runs (source_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_sbom_source_runs_tenant
|
||||
ON scanner.sbom_source_runs (tenant_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_sbom_source_runs_status
|
||||
ON scanner.sbom_source_runs (status);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_sbom_source_runs_started
|
||||
ON scanner.sbom_source_runs (started_at DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_sbom_source_runs_correlation
|
||||
ON scanner.sbom_source_runs (correlation_id);
|
||||
|
||||
-- Partial index for active runs
|
||||
CREATE INDEX IF NOT EXISTS ix_sbom_source_runs_active
|
||||
ON scanner.sbom_source_runs (source_id, started_at DESC)
|
||||
WHERE status IN ('pending', 'running');
|
||||
|
||||
-- ============================================================================
|
||||
-- FUNCTIONS
|
||||
-- ============================================================================
|
||||
|
||||
-- Function to update source statistics after a run completes
|
||||
CREATE OR REPLACE FUNCTION scanner.update_source_after_run()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
IF NEW.status IN ('succeeded', 'failed', 'cancelled') AND
|
||||
(OLD.status IS NULL OR OLD.status IN ('pending', 'running')) THEN
|
||||
|
||||
UPDATE scanner.sbom_sources SET
|
||||
last_run_at = NEW.completed_at,
|
||||
last_run_status = NEW.status,
|
||||
last_run_error = CASE WHEN NEW.status = 'failed' THEN NEW.error_message ELSE NULL END,
|
||||
consecutive_failures = CASE
|
||||
WHEN NEW.status = 'succeeded' THEN 0
|
||||
WHEN NEW.status = 'failed' THEN consecutive_failures + 1
|
||||
ELSE consecutive_failures
|
||||
END,
|
||||
status = CASE
|
||||
WHEN NEW.status = 'failed' AND consecutive_failures >= 4 THEN 'error'::scanner.sbom_source_status
|
||||
ELSE status
|
||||
END,
|
||||
updated_at = NOW()
|
||||
WHERE source_id = NEW.source_id;
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Trigger to update source after run completion
|
||||
DROP TRIGGER IF EXISTS trg_update_source_after_run ON scanner.sbom_source_runs;
|
||||
CREATE TRIGGER trg_update_source_after_run
|
||||
AFTER UPDATE ON scanner.sbom_source_runs
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION scanner.update_source_after_run();
|
||||
|
||||
-- Function to reset rate limit counters
|
||||
CREATE OR REPLACE FUNCTION scanner.reset_rate_limit_if_needed(p_source_id UUID)
|
||||
RETURNS VOID AS $$
|
||||
BEGIN
|
||||
UPDATE scanner.sbom_sources SET
|
||||
scans_in_current_hour = 0,
|
||||
last_rate_limit_reset = NOW()
|
||||
WHERE source_id = p_source_id
|
||||
AND (last_rate_limit_reset IS NULL
|
||||
OR last_rate_limit_reset < NOW() - INTERVAL '1 hour');
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Function to calculate next scheduled run
|
||||
CREATE OR REPLACE FUNCTION scanner.calculate_next_scheduled_run(
|
||||
p_cron_schedule TEXT,
|
||||
p_timezone TEXT DEFAULT 'UTC'
|
||||
)
|
||||
RETURNS TIMESTAMPTZ AS $$
|
||||
DECLARE
|
||||
v_next TIMESTAMPTZ;
|
||||
BEGIN
|
||||
-- Note: This is a placeholder. In practice, cron parsing is done in application code.
|
||||
-- The application should call UPDATE to set next_scheduled_run after calculating it.
|
||||
RETURN NULL;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql IMMUTABLE;
|
||||
|
||||
-- ============================================================================
|
||||
-- ENABLE TRIGRAM EXTENSION (if not exists)
|
||||
-- ============================================================================
|
||||
CREATE EXTENSION IF NOT EXISTS pg_trgm;
|
||||
|
||||
-- ============================================================================
|
||||
-- COMMENTS
|
||||
-- ============================================================================
|
||||
|
||||
COMMENT ON TABLE scanner.sbom_sources IS
|
||||
'Registry of SBOM ingestion sources (Zastava webhooks, Docker scanning, CLI submissions, Git repos)';
|
||||
|
||||
COMMENT ON TABLE scanner.sbom_source_runs IS
|
||||
'Execution history for SBOM source scan runs';
|
||||
|
||||
COMMENT ON COLUMN scanner.sbom_sources.auth_ref IS
|
||||
'Reference to credentials in external vault (e.g., vault://secrets/registry-auth)';
|
||||
|
||||
COMMENT ON COLUMN scanner.sbom_sources.configuration IS
|
||||
'Type-specific configuration as JSON (ZastavaSourceConfig, DockerSourceConfig, etc.)';
|
||||
|
||||
COMMENT ON COLUMN scanner.sbom_source_runs.correlation_id IS
|
||||
'Correlation ID for tracing across services';
|
||||
|
||||
COMMENT ON COLUMN scanner.sbom_source_runs.scan_job_ids IS
|
||||
'Array of scan job IDs created by this run';
|
||||
@@ -0,0 +1,380 @@
|
||||
using System.Text.Json;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Scanner.Sources.Configuration;
|
||||
using StellaOps.Scanner.Sources.Domain;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.Sources.Tests.Configuration;
|
||||
|
||||
public class SourceConfigValidatorTests
|
||||
{
|
||||
private readonly SourceConfigValidator _validator = new(NullLogger<SourceConfigValidator>.Instance);
|
||||
|
||||
#region Zastava Configuration Tests
|
||||
|
||||
[Fact]
|
||||
public void Validate_ValidZastavaConfig_ReturnsSuccess()
|
||||
{
|
||||
// Arrange
|
||||
var config = JsonDocument.Parse("""
|
||||
{
|
||||
"registryType": "Harbor",
|
||||
"registryUrl": "https://harbor.example.com",
|
||||
"filters": {
|
||||
"repositoryPatterns": ["library/*"]
|
||||
}
|
||||
}
|
||||
""");
|
||||
|
||||
// Act
|
||||
var result = _validator.Validate(SbomSourceType.Zastava, config);
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeTrue();
|
||||
result.Errors.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_ZastavaConfig_MissingRegistryType_ReturnsFalure()
|
||||
{
|
||||
// Arrange
|
||||
var config = JsonDocument.Parse("""
|
||||
{
|
||||
"registryUrl": "https://harbor.example.com"
|
||||
}
|
||||
""");
|
||||
|
||||
// Act
|
||||
var result = _validator.Validate(SbomSourceType.Zastava, config);
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Errors.Should().Contain(e => e.Contains("registryType"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_ZastavaConfig_InvalidRegistryType_ReturnsFalure()
|
||||
{
|
||||
// Arrange
|
||||
var config = JsonDocument.Parse("""
|
||||
{
|
||||
"registryType": "InvalidRegistry",
|
||||
"registryUrl": "https://harbor.example.com"
|
||||
}
|
||||
""");
|
||||
|
||||
// Act
|
||||
var result = _validator.Validate(SbomSourceType.Zastava, config);
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Errors.Should().Contain(e => e.Contains("Invalid registryType"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_ZastavaConfig_MissingRegistryUrl_ReturnsFalure()
|
||||
{
|
||||
// Arrange
|
||||
var config = JsonDocument.Parse("""
|
||||
{
|
||||
"registryType": "Harbor"
|
||||
}
|
||||
""");
|
||||
|
||||
// Act
|
||||
var result = _validator.Validate(SbomSourceType.Zastava, config);
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Errors.Should().Contain(e => e.Contains("registryUrl"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_ZastavaConfig_NoFilters_ReturnsWarning()
|
||||
{
|
||||
// Arrange
|
||||
var config = JsonDocument.Parse("""
|
||||
{
|
||||
"registryType": "Harbor",
|
||||
"registryUrl": "https://harbor.example.com"
|
||||
}
|
||||
""");
|
||||
|
||||
// Act
|
||||
var result = _validator.Validate(SbomSourceType.Zastava, config);
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeTrue();
|
||||
result.Warnings.Should().Contain(w => w.Contains("No filters"));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Docker Configuration Tests
|
||||
|
||||
[Fact]
|
||||
public void Validate_ValidDockerConfig_WithImages_ReturnsSuccess()
|
||||
{
|
||||
// Arrange
|
||||
var config = JsonDocument.Parse("""
|
||||
{
|
||||
"registryUrl": "https://registry.example.com",
|
||||
"images": [
|
||||
{
|
||||
"repository": "library/nginx",
|
||||
"tag": "latest"
|
||||
}
|
||||
]
|
||||
}
|
||||
""");
|
||||
|
||||
// Act
|
||||
var result = _validator.Validate(SbomSourceType.Docker, config);
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_ValidDockerConfig_WithDiscovery_ReturnsSuccess()
|
||||
{
|
||||
// Arrange
|
||||
var config = JsonDocument.Parse("""
|
||||
{
|
||||
"registryUrl": "https://registry.example.com",
|
||||
"discoveryOptions": {
|
||||
"repositoryPattern": "library/*",
|
||||
"maxTagsPerRepo": 5
|
||||
}
|
||||
}
|
||||
""");
|
||||
|
||||
// Act
|
||||
var result = _validator.Validate(SbomSourceType.Docker, config);
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_DockerConfig_NoImagesOrDiscovery_ReturnsFalure()
|
||||
{
|
||||
// Arrange
|
||||
var config = JsonDocument.Parse("""
|
||||
{
|
||||
"registryUrl": "https://registry.example.com"
|
||||
}
|
||||
""");
|
||||
|
||||
// Act
|
||||
var result = _validator.Validate(SbomSourceType.Docker, config);
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Errors.Should().Contain(e => e.Contains("images") || e.Contains("discoveryOptions"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_DockerConfig_ImageMissingRepository_ReturnsFalure()
|
||||
{
|
||||
// Arrange
|
||||
var config = JsonDocument.Parse("""
|
||||
{
|
||||
"images": [
|
||||
{
|
||||
"tag": "latest"
|
||||
}
|
||||
]
|
||||
}
|
||||
""");
|
||||
|
||||
// Act
|
||||
var result = _validator.Validate(SbomSourceType.Docker, config);
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Errors.Should().Contain(e => e.Contains("repository"));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region CLI Configuration Tests
|
||||
|
||||
[Fact]
|
||||
public void Validate_ValidCliConfig_ReturnsSuccess()
|
||||
{
|
||||
// Arrange
|
||||
var config = JsonDocument.Parse("""
|
||||
{
|
||||
"acceptedFormats": ["CycloneDX", "SPDX"],
|
||||
"validationRules": {
|
||||
"requireSignature": false,
|
||||
"maxFileSizeBytes": 10485760
|
||||
}
|
||||
}
|
||||
""");
|
||||
|
||||
// Act
|
||||
var result = _validator.Validate(SbomSourceType.Cli, config);
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_CliConfig_InvalidFormat_ReturnsFalure()
|
||||
{
|
||||
// Arrange
|
||||
var config = JsonDocument.Parse("""
|
||||
{
|
||||
"acceptedFormats": ["InvalidFormat"]
|
||||
}
|
||||
""");
|
||||
|
||||
// Act
|
||||
var result = _validator.Validate(SbomSourceType.Cli, config);
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Errors.Should().Contain(e => e.Contains("Invalid SBOM format"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_CliConfig_Empty_ReturnsWarning()
|
||||
{
|
||||
// Arrange
|
||||
var config = JsonDocument.Parse("{}");
|
||||
|
||||
// Act
|
||||
var result = _validator.Validate(SbomSourceType.Cli, config);
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeTrue();
|
||||
result.Warnings.Should().Contain(w => w.Contains("validation rules"));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Git Configuration Tests
|
||||
|
||||
[Fact]
|
||||
public void Validate_ValidGitConfig_HttpsUrl_ReturnsSuccess()
|
||||
{
|
||||
// Arrange
|
||||
var config = JsonDocument.Parse("""
|
||||
{
|
||||
"repositoryUrl": "https://github.com/example/repo",
|
||||
"provider": "GitHub",
|
||||
"authMethod": "Token",
|
||||
"branchConfig": {
|
||||
"defaultBranch": "main"
|
||||
}
|
||||
}
|
||||
""");
|
||||
|
||||
// Act
|
||||
var result = _validator.Validate(SbomSourceType.Git, config);
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_ValidGitConfig_SshUrl_ReturnsSuccess()
|
||||
{
|
||||
// Arrange
|
||||
var config = JsonDocument.Parse("""
|
||||
{
|
||||
"repositoryUrl": "git@github.com:example/repo.git",
|
||||
"provider": "GitHub",
|
||||
"authMethod": "SshKey"
|
||||
}
|
||||
""");
|
||||
|
||||
// Act
|
||||
var result = _validator.Validate(SbomSourceType.Git, config);
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_GitConfig_MissingRepositoryUrl_ReturnsFalure()
|
||||
{
|
||||
// Arrange
|
||||
var config = JsonDocument.Parse("""
|
||||
{
|
||||
"provider": "GitHub"
|
||||
}
|
||||
""");
|
||||
|
||||
// Act
|
||||
var result = _validator.Validate(SbomSourceType.Git, config);
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Errors.Should().Contain(e => e.Contains("repositoryUrl"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_GitConfig_InvalidProvider_ReturnsFalure()
|
||||
{
|
||||
// Arrange
|
||||
var config = JsonDocument.Parse("""
|
||||
{
|
||||
"repositoryUrl": "https://github.com/example/repo",
|
||||
"provider": "InvalidProvider"
|
||||
}
|
||||
""");
|
||||
|
||||
// Act
|
||||
var result = _validator.Validate(SbomSourceType.Git, config);
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Errors.Should().Contain(e => e.Contains("Invalid provider"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_GitConfig_NoBranchConfig_ReturnsWarning()
|
||||
{
|
||||
// Arrange
|
||||
var config = JsonDocument.Parse("""
|
||||
{
|
||||
"repositoryUrl": "https://github.com/example/repo",
|
||||
"provider": "GitHub"
|
||||
}
|
||||
""");
|
||||
|
||||
// Act
|
||||
var result = _validator.Validate(SbomSourceType.Git, config);
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeTrue();
|
||||
result.Warnings.Should().Contain(w => w.Contains("branch configuration"));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Schema Tests
|
||||
|
||||
[Theory]
|
||||
[InlineData(SbomSourceType.Zastava)]
|
||||
[InlineData(SbomSourceType.Docker)]
|
||||
[InlineData(SbomSourceType.Cli)]
|
||||
[InlineData(SbomSourceType.Git)]
|
||||
public void GetConfigurationSchema_ReturnsValidJsonSchema(SbomSourceType sourceType)
|
||||
{
|
||||
// Act
|
||||
var schema = _validator.GetConfigurationSchema(sourceType);
|
||||
|
||||
// Assert
|
||||
schema.Should().NotBeNullOrEmpty();
|
||||
var parsed = JsonDocument.Parse(schema);
|
||||
parsed.RootElement.GetProperty("$schema").GetString()
|
||||
.Should().Contain("json-schema.org");
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
using FluentAssertions;
|
||||
using StellaOps.Scanner.Sources.Domain;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.Sources.Tests.Domain;
|
||||
|
||||
public class SbomSourceRunTests
|
||||
{
|
||||
[Fact]
|
||||
public void Create_WithValidInputs_CreatesRunInPendingStatus()
|
||||
{
|
||||
// Arrange
|
||||
var sourceId = Guid.NewGuid();
|
||||
var correlationId = Guid.NewGuid().ToString("N");
|
||||
|
||||
// Act
|
||||
var run = SbomSourceRun.Create(
|
||||
sourceId: sourceId,
|
||||
tenantId: "tenant-1",
|
||||
trigger: SbomSourceRunTrigger.Manual,
|
||||
correlationId: correlationId,
|
||||
triggerDetails: "Triggered by user");
|
||||
|
||||
// Assert
|
||||
run.RunId.Should().NotBeEmpty();
|
||||
run.SourceId.Should().Be(sourceId);
|
||||
run.TenantId.Should().Be("tenant-1");
|
||||
run.Trigger.Should().Be(SbomSourceRunTrigger.Manual);
|
||||
run.CorrelationId.Should().Be(correlationId);
|
||||
run.TriggerDetails.Should().Be("Triggered by user");
|
||||
run.Status.Should().Be(SbomSourceRunStatus.Pending);
|
||||
run.ItemsDiscovered.Should().Be(0);
|
||||
run.ItemsScanned.Should().Be(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Start_SetsStatusToRunning()
|
||||
{
|
||||
// Arrange
|
||||
var run = CreateTestRun();
|
||||
|
||||
// Act
|
||||
run.Start();
|
||||
|
||||
// Assert
|
||||
run.Status.Should().Be(SbomSourceRunStatus.Running);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SetDiscoveredItems_UpdatesDiscoveryCount()
|
||||
{
|
||||
// Arrange
|
||||
var run = CreateTestRun();
|
||||
run.Start();
|
||||
|
||||
// Act
|
||||
run.SetDiscoveredItems(10);
|
||||
|
||||
// Assert
|
||||
run.ItemsDiscovered.Should().Be(10);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RecordItemSuccess_IncrementsCounts()
|
||||
{
|
||||
// Arrange
|
||||
var run = CreateTestRun();
|
||||
run.Start();
|
||||
run.SetDiscoveredItems(5);
|
||||
|
||||
// Act
|
||||
var scanJobId = Guid.NewGuid();
|
||||
run.RecordItemSuccess(scanJobId);
|
||||
run.RecordItemSuccess(Guid.NewGuid());
|
||||
|
||||
// Assert
|
||||
run.ItemsScanned.Should().Be(2);
|
||||
run.ItemsSucceeded.Should().Be(2);
|
||||
run.ScanJobIds.Should().Contain(scanJobId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RecordItemFailure_IncrementsCounts()
|
||||
{
|
||||
// Arrange
|
||||
var run = CreateTestRun();
|
||||
run.Start();
|
||||
run.SetDiscoveredItems(5);
|
||||
|
||||
// Act
|
||||
run.RecordItemFailure();
|
||||
run.RecordItemFailure();
|
||||
|
||||
// Assert
|
||||
run.ItemsScanned.Should().Be(2);
|
||||
run.ItemsFailed.Should().Be(2);
|
||||
run.ItemsSucceeded.Should().Be(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RecordItemSkipped_IncrementsCounts()
|
||||
{
|
||||
// Arrange
|
||||
var run = CreateTestRun();
|
||||
run.Start();
|
||||
run.SetDiscoveredItems(5);
|
||||
|
||||
// Act
|
||||
run.RecordItemSkipped();
|
||||
|
||||
// Assert
|
||||
run.ItemsScanned.Should().Be(1);
|
||||
run.ItemsSkipped.Should().Be(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Complete_SetsSuccessStatusAndDuration()
|
||||
{
|
||||
// Arrange
|
||||
var run = CreateTestRun();
|
||||
run.Start();
|
||||
run.SetDiscoveredItems(3);
|
||||
run.RecordItemSuccess(Guid.NewGuid());
|
||||
run.RecordItemSuccess(Guid.NewGuid());
|
||||
run.RecordItemSuccess(Guid.NewGuid());
|
||||
|
||||
// Act
|
||||
run.Complete();
|
||||
|
||||
// Assert
|
||||
run.Status.Should().Be(SbomSourceRunStatus.Succeeded);
|
||||
run.CompletedAt.Should().NotBeNull();
|
||||
run.DurationMs.Should().BeGreaterOrEqualTo(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Fail_SetsFailedStatusAndErrorMessage()
|
||||
{
|
||||
// Arrange
|
||||
var run = CreateTestRun();
|
||||
run.Start();
|
||||
|
||||
// Act
|
||||
run.Fail("Connection timeout", new { retries = 3 });
|
||||
|
||||
// Assert
|
||||
run.Status.Should().Be(SbomSourceRunStatus.Failed);
|
||||
run.ErrorMessage.Should().Be("Connection timeout");
|
||||
run.ErrorDetails.Should().NotBeNull();
|
||||
run.CompletedAt.Should().NotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Cancel_SetsCancelledStatus()
|
||||
{
|
||||
// Arrange
|
||||
var run = CreateTestRun();
|
||||
run.Start();
|
||||
|
||||
// Act
|
||||
run.Cancel();
|
||||
|
||||
// Assert
|
||||
run.Status.Should().Be(SbomSourceRunStatus.Cancelled);
|
||||
run.CompletedAt.Should().NotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MixedResults_TracksAllCountsCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var run = CreateTestRun();
|
||||
run.Start();
|
||||
run.SetDiscoveredItems(10);
|
||||
|
||||
// Act
|
||||
run.RecordItemSuccess(Guid.NewGuid()); // 1 success
|
||||
run.RecordItemSuccess(Guid.NewGuid()); // 2 successes
|
||||
run.RecordItemFailure(); // 1 failure
|
||||
run.RecordItemSkipped(); // 1 skipped
|
||||
run.RecordItemSuccess(Guid.NewGuid()); // 3 successes
|
||||
run.RecordItemFailure(); // 2 failures
|
||||
|
||||
// Assert
|
||||
run.ItemsScanned.Should().Be(6);
|
||||
run.ItemsSucceeded.Should().Be(3);
|
||||
run.ItemsFailed.Should().Be(2);
|
||||
run.ItemsSkipped.Should().Be(1);
|
||||
run.ScanJobIds.Should().HaveCount(3);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(SbomSourceRunTrigger.Manual, "Manual trigger")]
|
||||
[InlineData(SbomSourceRunTrigger.Scheduled, "Cron: 0 * * * *")]
|
||||
[InlineData(SbomSourceRunTrigger.Webhook, "Harbor push event")]
|
||||
[InlineData(SbomSourceRunTrigger.Push, "Registry push event")]
|
||||
public void Create_WithDifferentTriggers_StoresTriggerInfo(
|
||||
SbomSourceRunTrigger trigger,
|
||||
string details)
|
||||
{
|
||||
// Arrange & Act
|
||||
var run = SbomSourceRun.Create(
|
||||
sourceId: Guid.NewGuid(),
|
||||
tenantId: "tenant-1",
|
||||
trigger: trigger,
|
||||
correlationId: Guid.NewGuid().ToString("N"),
|
||||
triggerDetails: details);
|
||||
|
||||
// Assert
|
||||
run.Trigger.Should().Be(trigger);
|
||||
run.TriggerDetails.Should().Be(details);
|
||||
}
|
||||
|
||||
private static SbomSourceRun CreateTestRun()
|
||||
{
|
||||
return SbomSourceRun.Create(
|
||||
sourceId: Guid.NewGuid(),
|
||||
tenantId: "tenant-1",
|
||||
trigger: SbomSourceRunTrigger.Manual,
|
||||
correlationId: Guid.NewGuid().ToString("N"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
using System.Text.Json;
|
||||
using FluentAssertions;
|
||||
using StellaOps.Scanner.Sources.Domain;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.Sources.Tests.Domain;
|
||||
|
||||
public class SbomSourceTests
|
||||
{
|
||||
private static readonly JsonDocument SampleConfig = JsonDocument.Parse("""
|
||||
{
|
||||
"registryType": "Harbor",
|
||||
"registryUrl": "https://harbor.example.com"
|
||||
}
|
||||
""");
|
||||
|
||||
[Fact]
|
||||
public void Create_WithValidInputs_CreatesSourceInDraftStatus()
|
||||
{
|
||||
// Arrange & Act
|
||||
var source = SbomSource.Create(
|
||||
tenantId: "tenant-1",
|
||||
name: "test-source",
|
||||
sourceType: SbomSourceType.Zastava,
|
||||
configuration: SampleConfig,
|
||||
createdBy: "user-1");
|
||||
|
||||
// Assert
|
||||
source.SourceId.Should().NotBeEmpty();
|
||||
source.TenantId.Should().Be("tenant-1");
|
||||
source.Name.Should().Be("test-source");
|
||||
source.SourceType.Should().Be(SbomSourceType.Zastava);
|
||||
source.Status.Should().Be(SbomSourceStatus.Draft);
|
||||
source.CreatedBy.Should().Be("user-1");
|
||||
source.Paused.Should().BeFalse();
|
||||
source.ConsecutiveFailures.Should().Be(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Create_WithCronSchedule_CalculatesNextScheduledRun()
|
||||
{
|
||||
// Arrange & Act
|
||||
var source = SbomSource.Create(
|
||||
tenantId: "tenant-1",
|
||||
name: "scheduled-source",
|
||||
sourceType: SbomSourceType.Docker,
|
||||
configuration: SampleConfig,
|
||||
createdBy: "user-1",
|
||||
cronSchedule: "0 * * * *"); // Every hour
|
||||
|
||||
// Assert
|
||||
source.CronSchedule.Should().Be("0 * * * *");
|
||||
source.NextScheduledRun.Should().NotBeNull();
|
||||
source.NextScheduledRun.Should().BeAfter(DateTimeOffset.UtcNow);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Create_WithZastavaType_GeneratesWebhookEndpointAndSecret()
|
||||
{
|
||||
// Arrange & Act
|
||||
var source = SbomSource.Create(
|
||||
tenantId: "tenant-1",
|
||||
name: "webhook-source",
|
||||
sourceType: SbomSourceType.Zastava,
|
||||
configuration: SampleConfig,
|
||||
createdBy: "user-1");
|
||||
|
||||
// Assert
|
||||
source.WebhookEndpoint.Should().NotBeNullOrEmpty();
|
||||
source.WebhookSecret.Should().NotBeNullOrEmpty();
|
||||
source.WebhookSecret!.Length.Should().BeGreaterOrEqualTo(32);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Activate_FromDraft_ChangesStatusToActive()
|
||||
{
|
||||
// Arrange
|
||||
var source = SbomSource.Create(
|
||||
tenantId: "tenant-1",
|
||||
name: "test-source",
|
||||
sourceType: SbomSourceType.Docker,
|
||||
configuration: SampleConfig,
|
||||
createdBy: "user-1");
|
||||
|
||||
// Act
|
||||
source.Activate("activator");
|
||||
|
||||
// Assert
|
||||
source.Status.Should().Be(SbomSourceStatus.Active);
|
||||
source.UpdatedBy.Should().Be("activator");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Pause_WhenActive_PausesSource()
|
||||
{
|
||||
// Arrange
|
||||
var source = SbomSource.Create(
|
||||
tenantId: "tenant-1",
|
||||
name: "test-source",
|
||||
sourceType: SbomSourceType.Docker,
|
||||
configuration: SampleConfig,
|
||||
createdBy: "user-1");
|
||||
source.Activate("activator");
|
||||
|
||||
// Act
|
||||
source.Pause("Maintenance window", "TICKET-123", "operator");
|
||||
|
||||
// Assert
|
||||
source.Paused.Should().BeTrue();
|
||||
source.PauseReason.Should().Be("Maintenance window");
|
||||
source.PauseTicket.Should().Be("TICKET-123");
|
||||
source.PausedAt.Should().NotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resume_WhenPaused_UnpausesSource()
|
||||
{
|
||||
// Arrange
|
||||
var source = SbomSource.Create(
|
||||
tenantId: "tenant-1",
|
||||
name: "test-source",
|
||||
sourceType: SbomSourceType.Docker,
|
||||
configuration: SampleConfig,
|
||||
createdBy: "user-1");
|
||||
source.Activate("activator");
|
||||
source.Pause("Maintenance", null, "operator");
|
||||
|
||||
// Act
|
||||
source.Resume("operator");
|
||||
|
||||
// Assert
|
||||
source.Paused.Should().BeFalse();
|
||||
source.PauseReason.Should().BeNull();
|
||||
source.PausedAt.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RecordSuccessfulRun_ResetsConsecutiveFailures()
|
||||
{
|
||||
// Arrange
|
||||
var source = SbomSource.Create(
|
||||
tenantId: "tenant-1",
|
||||
name: "test-source",
|
||||
sourceType: SbomSourceType.Docker,
|
||||
configuration: SampleConfig,
|
||||
createdBy: "user-1");
|
||||
source.Activate("activator");
|
||||
|
||||
// Simulate some failures
|
||||
source.RecordFailedRun("Error 1");
|
||||
source.RecordFailedRun("Error 2");
|
||||
source.ConsecutiveFailures.Should().Be(2);
|
||||
|
||||
// Act
|
||||
source.RecordSuccessfulRun();
|
||||
|
||||
// Assert
|
||||
source.ConsecutiveFailures.Should().Be(0);
|
||||
source.LastRunStatus.Should().Be(SbomSourceRunStatus.Succeeded);
|
||||
source.LastRunError.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RecordFailedRun_MultipleTimes_MovesToErrorStatus()
|
||||
{
|
||||
// Arrange
|
||||
var source = SbomSource.Create(
|
||||
tenantId: "tenant-1",
|
||||
name: "test-source",
|
||||
sourceType: SbomSourceType.Docker,
|
||||
configuration: SampleConfig,
|
||||
createdBy: "user-1");
|
||||
source.Activate("activator");
|
||||
|
||||
// Act - fail 5 times (threshold is 5)
|
||||
for (var i = 0; i < 5; i++)
|
||||
{
|
||||
source.RecordFailedRun($"Error {i + 1}");
|
||||
}
|
||||
|
||||
// Assert
|
||||
source.Status.Should().Be(SbomSourceStatus.Error);
|
||||
source.ConsecutiveFailures.Should().Be(5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsRateLimited_WhenUnderLimit_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var source = SbomSource.Create(
|
||||
tenantId: "tenant-1",
|
||||
name: "test-source",
|
||||
sourceType: SbomSourceType.Docker,
|
||||
configuration: SampleConfig,
|
||||
createdBy: "user-1");
|
||||
source.MaxScansPerHour = 10;
|
||||
source.Activate("activator");
|
||||
|
||||
// Act
|
||||
var isLimited = source.IsRateLimited();
|
||||
|
||||
// Assert
|
||||
isLimited.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpdateConfiguration_ChangesConfigAndUpdatesTimestamp()
|
||||
{
|
||||
// Arrange
|
||||
var source = SbomSource.Create(
|
||||
tenantId: "tenant-1",
|
||||
name: "test-source",
|
||||
sourceType: SbomSourceType.Docker,
|
||||
configuration: SampleConfig,
|
||||
createdBy: "user-1");
|
||||
|
||||
var newConfig = JsonDocument.Parse("""
|
||||
{
|
||||
"registryType": "DockerHub",
|
||||
"registryUrl": "https://registry-1.docker.io"
|
||||
}
|
||||
""");
|
||||
|
||||
// Act
|
||||
source.UpdateConfiguration(newConfig, "updater");
|
||||
|
||||
// Assert
|
||||
source.Configuration.RootElement.GetProperty("registryType").GetString()
|
||||
.Should().Be("DockerHub");
|
||||
source.UpdatedBy.Should().Be("updater");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Scanner.Sources/StellaOps.Scanner.Sources.csproj" />
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.TestKit/StellaOps.TestKit.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||
<PackageReference Include="xunit" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" />
|
||||
<PackageReference Include="FluentAssertions" />
|
||||
<PackageReference Include="Moq" />
|
||||
<PackageReference Include="coverlet.collector" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
Reference in New Issue
Block a user