wip - advisories and ui extensions

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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>

View File

@@ -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
};
}
}

View File

@@ -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; } = "";
}
}

View File

@@ -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; }
}
}

View File

@@ -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;
}
}

View File

@@ -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
};
}

View File

@@ -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;
}

View File

@@ -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; }
}

View File

@@ -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}";
}
}

View File

@@ -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;
}
}

View File

@@ -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()));
}
}

View File

@@ -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; } = [];
}

View File

@@ -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; } = [];
}

View File

@@ -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
}

View File

@@ -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"}";
}
}

View File

@@ -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>

View File

@@ -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);

View File

@@ -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)

View File

@@ -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";
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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>

View File

@@ -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);
}

View File

@@ -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; } = [];
}

View File

@@ -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; }
}

View File

@@ -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';

View File

@@ -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
}

View File

@@ -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"));
}
}

View File

@@ -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");
}
}

View File

@@ -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>