Files
git.stella-ops.org/docs/implplan/SPRINT_1229_002_BE_sbom-sources-triggers.md

23 KiB

SPRINT_1229_002_BE: SBOM Sources Trigger Service

Executive Summary

This sprint implements the trigger service that dispatches scans based on source configurations. It handles scheduled (cron) triggers, webhook handlers (Zastava registry, Git), manual triggers, and retry logic.

Working Directory: src/Scanner/, src/Scheduler/ Module: BE (Backend) Dependencies: SPRINT_1229_001_BE (Sources Foundation)


Architecture

┌─────────────────────────────────────────────────────────────────────────────┐
│                         Source Trigger Service                              │
│                                                                             │
│  ┌───────────────────────────────────────────────────────────────────────┐ │
│  │                       Trigger Dispatcher                               │ │
│  │                                                                        │ │
│  │   ┌─────────┐    ┌─────────┐    ┌─────────┐    ┌─────────┐           │ │
│  │   │Schedule │    │ Webhook │    │ Manual  │    │  Retry  │           │ │
│  │   │ (Cron)  │    │ Handler │    │ Trigger │    │ Handler │           │ │
│  │   └────┬────┘    └────┬────┘    └────┬────┘    └────┬────┘           │ │
│  │        │              │              │              │                 │ │
│  │        └──────────────┴──────────────┴──────────────┘                 │ │
│  │                              │                                        │ │
│  │                    ┌─────────▼─────────┐                              │ │
│  │                    │   Source Context  │                              │ │
│  │                    │    Resolver       │                              │ │
│  │                    └─────────┬─────────┘                              │ │
│  │                              │                                        │ │
│  └──────────────────────────────┼────────────────────────────────────────┘ │
│                                 │                                          │
│  ┌──────────────────────────────▼────────────────────────────────────────┐ │
│  │                    Source Type Handlers                               │ │
│  │                                                                        │ │
│  │  ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────────┐  │ │
│  │  │  Zastava    │ │   Docker    │ │    CLI      │ │      Git        │  │ │
│  │  │  Handler    │ │   Handler   │ │   Handler   │ │    Handler      │  │ │
│  │  └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ └────────┬────────┘  │ │
│  │         │               │               │                  │          │ │
│  └─────────┼───────────────┼───────────────┼──────────────────┼──────────┘ │
│            │               │               │                  │            │
└────────────┼───────────────┼───────────────┼──────────────────┼────────────┘
             │               │               │                  │
             ▼               ▼               ▼                  ▼
     ┌───────────────────────────────────────────────────────────────────┐
     │                    Scanner Job Queue                              │
     │                                                                   │
     │   ScanJob { imageRef, sourceId, correlationId, metadata }        │
     │                                                                   │
     └───────────────────────────────────────────────────────────────────┘

Component Design

1. Trigger Dispatcher

Centralized coordinator for all trigger types:

public interface ISourceTriggerDispatcher
{
    /// <summary>
    /// Dispatch a trigger for a source, creating scan jobs as appropriate.
    /// </summary>
    Task<SbomSourceRun> DispatchAsync(
        Guid sourceId,
        SbomSourceRunTrigger trigger,
        string? triggerDetails = null,
        CancellationToken ct = default);

    /// <summary>
    /// Process scheduled sources that are due.
    /// Called by scheduler worker.
    /// </summary>
    Task ProcessScheduledSourcesAsync(CancellationToken ct);
}

2. Source Type Handlers

Each source type has a dedicated handler:

public interface ISourceTypeHandler
{
    SbomSourceType SourceType { get; }

    /// <summary>
    /// Discover items to scan based on source configuration.
    /// </summary>
    Task<IReadOnlyList<ScanTarget>> DiscoverTargetsAsync(
        SbomSource source,
        TriggerContext context,
        CancellationToken ct);

    /// <summary>
    /// Validate source configuration.
    /// </summary>
    ValidationResult ValidateConfiguration(JsonDocument configuration);

    /// <summary>
    /// Test source connection with credentials.
    /// </summary>
    Task<ConnectionTestResult> TestConnectionAsync(
        SbomSource source,
        CancellationToken ct);
}

public record ScanTarget(
    string Reference,           // Image ref, repo URL, etc.
    string? Digest,            // Optional pinned digest
    Dictionary<string, string> Metadata
);

public record TriggerContext(
    SbomSourceRunTrigger Trigger,
    string? TriggerDetails,
    string CorrelationId,
    JsonDocument? WebhookPayload
);

Webhook Handlers

Zastava Registry Webhook

Endpoint: POST /api/v1/webhooks/zastava/{sourceId}

Supported Registry Types:

  • Docker Hub
  • Harbor
  • Quay
  • AWS ECR
  • Google GCR
  • Azure ACR
  • GitHub Container Registry
  • Generic (configurable payload mapping)
public class ZastavaWebhookHandler
{
    /// <summary>
    /// Handle registry push webhook.
    /// </summary>
    public async Task<WebhookResult> HandleAsync(
        Guid sourceId,
        HttpRequest request,
        CancellationToken ct)
    {
        // 1. Verify webhook signature
        var source = await _sourceRepo.GetAsync(sourceId, ct);
        if (!VerifySignature(request, source.WebhookSecretRef))
            return WebhookResult.Unauthorized();

        // 2. Parse payload based on registry type
        var config = source.GetConfiguration<ZastavaSourceConfig>();
        var payload = await ParsePayload(request, config.RegistryType);

        // 3. Check filters (repo patterns, tag patterns)
        if (!MatchesFilters(payload, config.Filters))
            return WebhookResult.Skipped("Does not match filters");

        // 4. Dispatch trigger
        var run = await _dispatcher.DispatchAsync(
            sourceId,
            SbomSourceRunTrigger.Webhook,
            $"push:{payload.Repository}:{payload.Tag}");

        return WebhookResult.Accepted(run.RunId);
    }
}

Payload Normalization:

public record RegistryPushPayload(
    string Repository,
    string Tag,
    string? Digest,
    string? PushedBy,
    DateTimeOffset Timestamp,
    Dictionary<string, string> RawHeaders
);

public interface IRegistryPayloadParser
{
    RegistryPushPayload Parse(HttpRequest request);
}

// Implementations:
// - DockerHubPayloadParser
// - HarborPayloadParser
// - QuayPayloadParser
// - EcrPayloadParser
// - GcrPayloadParser
// - AcrPayloadParser
// - GhcrPayloadParser
// - GenericPayloadParser (JSONPath-based configuration)

Git Webhook

Endpoint: POST /api/v1/webhooks/git/{sourceId}

Supported Providers:

  • GitHub
  • GitLab
  • Bitbucket
  • Azure DevOps
  • Gitea
public class GitWebhookHandler
{
    public async Task<WebhookResult> HandleAsync(
        Guid sourceId,
        HttpRequest request,
        CancellationToken ct)
    {
        var source = await _sourceRepo.GetAsync(sourceId, ct);
        var config = source.GetConfiguration<GitSourceConfig>();

        // 1. Verify webhook signature
        if (!VerifySignature(request, config.Provider, source.WebhookSecretRef))
            return WebhookResult.Unauthorized();

        // 2. Parse event type
        var eventType = DetectEventType(request, config.Provider);

        // 3. Check if event matches triggers
        if (!ShouldTrigger(eventType, config.Triggers))
            return WebhookResult.Skipped("Event type not configured for trigger");

        // 4. Parse payload
        var payload = await ParsePayload(request, config.Provider, eventType);

        // 5. Check branch/tag filters
        if (!MatchesBranchFilters(payload, config.Branches))
            return WebhookResult.Skipped("Branch does not match filters");

        // 6. Dispatch
        var run = await _dispatcher.DispatchAsync(
            sourceId,
            SbomSourceRunTrigger.Webhook,
            $"{eventType}:{payload.Ref}@{payload.CommitSha}");

        return WebhookResult.Accepted(run.RunId);
    }
}

Scheduled Trigger Integration

Scheduler Job Type

Register a new job type with the Scheduler service:

public class SourceSchedulerJob : IScheduledJob
{
    public string JobType => "sbom-source-scheduled";

    public async Task ExecuteAsync(JobContext context, CancellationToken ct)
    {
        var sourceId = Guid.Parse(context.Payload["sourceId"]);

        await _dispatcher.DispatchAsync(
            sourceId,
            SbomSourceRunTrigger.Scheduled,
            context.Payload["cronExpression"]);
    }
}

Schedule Registration

When a source with cron schedule is created/updated:

public async Task RegisterScheduleAsync(SbomSource source)
{
    if (string.IsNullOrEmpty(source.CronSchedule))
        return;

    await _schedulerClient.UpsertScheduleAsync(new ScheduleRequest
    {
        ScheduleId = $"sbom-source-{source.SourceId}",
        JobType = "sbom-source-scheduled",
        Cron = source.CronSchedule,
        Timezone = source.CronTimezone ?? "UTC",
        Payload = new Dictionary<string, string>
        {
            ["sourceId"] = source.SourceId.ToString(),
            ["cronExpression"] = source.CronSchedule
        },
        Enabled = source.Status == SbomSourceStatus.Active && !source.Paused
    });
}

Source Type Handler Implementations

Zastava Handler

public class ZastavaSourceHandler : ISourceTypeHandler
{
    public SbomSourceType SourceType => SbomSourceType.Zastava;

    public async Task<IReadOnlyList<ScanTarget>> DiscoverTargetsAsync(
        SbomSource source,
        TriggerContext context,
        CancellationToken ct)
    {
        // For webhook triggers, target is in the payload
        if (context.Trigger == SbomSourceRunTrigger.Webhook &&
            context.WebhookPayload != null)
        {
            var payload = ParseWebhookPayload(context.WebhookPayload);
            return [new ScanTarget(
                $"{payload.Repository}:{payload.Tag}",
                payload.Digest,
                new() { ["pushedBy"] = payload.PushedBy ?? "unknown" }
            )];
        }

        // For scheduled/manual, discover from registry
        var config = source.GetConfiguration<ZastavaSourceConfig>();
        var credentials = await _credentialStore.GetAsync(source.AuthRef!);

        var client = _registryClientFactory.Create(config.RegistryType, config.RegistryUrl, credentials);
        var targets = new List<ScanTarget>();

        foreach (var repoPattern in config.Filters.Repositories)
        {
            var repos = await client.ListRepositoriesAsync(repoPattern, ct);
            foreach (var repo in repos)
            {
                var tags = await client.ListTagsAsync(repo, config.Filters.Tags, ct);
                foreach (var tag in tags)
                {
                    if (ShouldExclude(repo, tag, config.Filters))
                        continue;

                    targets.Add(new ScanTarget($"{repo}:{tag}", null, new()));
                }
            }
        }

        return targets;
    }

    public async Task<ConnectionTestResult> TestConnectionAsync(
        SbomSource source,
        CancellationToken ct)
    {
        var config = source.GetConfiguration<ZastavaSourceConfig>();
        var credentials = await _credentialStore.GetAsync(source.AuthRef!);

        try
        {
            var client = _registryClientFactory.Create(
                config.RegistryType,
                config.RegistryUrl,
                credentials);

            await client.PingAsync(ct);
            return ConnectionTestResult.Success();
        }
        catch (Exception ex)
        {
            return ConnectionTestResult.Failure(ex.Message);
        }
    }
}

Docker Handler

public class DockerSourceHandler : ISourceTypeHandler
{
    public SbomSourceType SourceType => SbomSourceType.Docker;

    public async Task<IReadOnlyList<ScanTarget>> DiscoverTargetsAsync(
        SbomSource source,
        TriggerContext context,
        CancellationToken ct)
    {
        var config = source.GetConfiguration<DockerSourceConfig>();
        var targets = new List<ScanTarget>();

        foreach (var imageSpec in config.Images)
        {
            if (imageSpec.TagPatterns?.Any() == true)
            {
                // Discover matching tags
                var credentials = await _credentialStore.GetAsync(source.AuthRef!);
                var client = _registryClientFactory.Create(config.RegistryUrl, credentials);

                var (repo, _) = ParseImageReference(imageSpec.Reference);
                var tags = await client.ListTagsAsync(repo, imageSpec.TagPatterns, ct);

                foreach (var tag in tags)
                {
                    targets.Add(new ScanTarget($"{repo}:{tag}", null, new()));
                }
            }
            else
            {
                // Scan specific reference
                targets.Add(new ScanTarget(imageSpec.Reference, null, new()));
            }
        }

        return targets;
    }
}

Git Handler

public class GitSourceHandler : ISourceTypeHandler
{
    public SbomSourceType SourceType => SbomSourceType.Git;

    public async Task<IReadOnlyList<ScanTarget>> DiscoverTargetsAsync(
        SbomSource source,
        TriggerContext context,
        CancellationToken ct)
    {
        var config = source.GetConfiguration<GitSourceConfig>();

        // For webhook triggers, use the payload
        if (context.Trigger == SbomSourceRunTrigger.Webhook &&
            context.WebhookPayload != null)
        {
            var payload = ParseGitPayload(context.WebhookPayload, config.Provider);
            return [new ScanTarget(
                config.RepositoryUrl,
                null,
                new()
                {
                    ["ref"] = payload.Ref,
                    ["commitSha"] = payload.CommitSha,
                    ["branch"] = payload.Branch ?? "",
                    ["tag"] = payload.Tag ?? ""
                }
            )];
        }

        // For scheduled/manual, scan default branch or configured branches
        var credentials = await _credentialStore.GetAsync(source.AuthRef!);
        var gitClient = _gitClientFactory.Create(config.Provider, credentials);

        var branches = await gitClient.ListBranchesAsync(config.RepositoryUrl, ct);
        var matchingBranches = branches
            .Where(b => MatchesBranchPattern(b, config.Branches.Include))
            .Where(b => !MatchesBranchPattern(b, config.Branches.Exclude ?? []))
            .ToList();

        return matchingBranches.Select(b => new ScanTarget(
            config.RepositoryUrl,
            null,
            new() { ["branch"] = b, ["ref"] = $"refs/heads/{b}" }
        )).ToList();
    }
}

CLI Handler

public class CliSourceHandler : ISourceTypeHandler
{
    public SbomSourceType SourceType => SbomSourceType.Cli;

    public Task<IReadOnlyList<ScanTarget>> DiscoverTargetsAsync(
        SbomSource source,
        TriggerContext context,
        CancellationToken ct)
    {
        // CLI sources don't "discover" targets - they receive submissions
        // This handler validates incoming submissions against source config
        return Task.FromResult<IReadOnlyList<ScanTarget>>([]);
    }

    /// <summary>
    /// Validate an incoming CLI submission against source configuration.
    /// </summary>
    public ValidationResult ValidateSubmission(
        SbomSource source,
        CliSubmissionRequest submission)
    {
        var config = source.GetConfiguration<CliSourceConfig>();
        var errors = new List<string>();

        // Check tool allowlist
        if (!config.AllowedTools.Contains(submission.Tool))
            errors.Add($"Tool '{submission.Tool}' not in allowed list");

        // Check CI system
        if (config.AllowedCiSystems?.Any() == true &&
            !config.AllowedCiSystems.Contains(submission.CiSystem ?? ""))
            errors.Add($"CI system '{submission.CiSystem}' not allowed");

        // Check format
        if (!config.Validation.AllowedFormats.Contains(submission.Format))
            errors.Add($"Format '{submission.Format}' not allowed");

        // Check size
        if (submission.SbomSizeBytes > config.Validation.MaxSbomSizeBytes)
            errors.Add($"SBOM size {submission.SbomSizeBytes} exceeds max {config.Validation.MaxSbomSizeBytes}");

        // Check 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 is required");
        if (config.Attribution.RequireCommitSha && string.IsNullOrEmpty(submission.CommitSha))
            errors.Add("Commit SHA is required");

        return errors.Any()
            ? ValidationResult.Failure(errors)
            : ValidationResult.Success();
    }
}

Task Breakdown

T1: Trigger Dispatcher Service (TODO)

Files to Create:

  • src/Scanner/__Libraries/StellaOps.Scanner.Sources/Triggers/ISourceTriggerDispatcher.cs
  • src/Scanner/__Libraries/StellaOps.Scanner.Sources/Triggers/SourceTriggerDispatcher.cs
  • src/Scanner/__Libraries/StellaOps.Scanner.Sources/Triggers/TriggerContext.cs

T2: Source Type Handler Interface & Base (TODO)

Files to Create:

  • src/Scanner/__Libraries/StellaOps.Scanner.Sources/Handlers/ISourceTypeHandler.cs
  • src/Scanner/__Libraries/StellaOps.Scanner.Sources/Handlers/SourceTypeHandlerBase.cs
  • src/Scanner/__Libraries/StellaOps.Scanner.Sources/Handlers/ScanTarget.cs

T3: Zastava Handler Implementation (TODO)

Files to Create:

  • src/Scanner/__Libraries/StellaOps.Scanner.Sources/Handlers/Zastava/ZastavaSourceHandler.cs
  • src/Scanner/__Libraries/StellaOps.Scanner.Sources/Handlers/Zastava/RegistryPayloadParsers.cs
  • src/Scanner/__Libraries/StellaOps.Scanner.Sources/Handlers/Zastava/IRegistryClient.cs

T4: Docker Handler Implementation (TODO)

Files to Create:

  • src/Scanner/__Libraries/StellaOps.Scanner.Sources/Handlers/Docker/DockerSourceHandler.cs
  • src/Scanner/__Libraries/StellaOps.Scanner.Sources/Handlers/Docker/ImageDiscovery.cs

T5: Git Handler Implementation (TODO)

Files to Create:

  • src/Scanner/__Libraries/StellaOps.Scanner.Sources/Handlers/Git/GitSourceHandler.cs
  • src/Scanner/__Libraries/StellaOps.Scanner.Sources/Handlers/Git/GitPayloadParsers.cs
  • src/Scanner/__Libraries/StellaOps.Scanner.Sources/Handlers/Git/IGitClient.cs

T6: CLI Handler Implementation (TODO)

Files to Create:

  • src/Scanner/__Libraries/StellaOps.Scanner.Sources/Handlers/Cli/CliSourceHandler.cs
  • src/Scanner/__Libraries/StellaOps.Scanner.Sources/Handlers/Cli/CliSubmissionValidator.cs

T7: Webhook Endpoints (TODO)

Files to Create:

  • src/Scanner/StellaOps.Scanner.WebService/Endpoints/WebhookEndpoints.cs
  • src/Scanner/StellaOps.Scanner.WebService/Webhooks/WebhookSignatureValidator.cs

T8: Scheduler Integration (TODO)

Files to Create:

  • src/Scanner/__Libraries/StellaOps.Scanner.Sources/Scheduling/SourceSchedulerJob.cs
  • src/Scanner/__Libraries/StellaOps.Scanner.Sources/Scheduling/ScheduleRegistration.cs

T9: Retry Handler (TODO)

Files to Create:

  • src/Scanner/__Libraries/StellaOps.Scanner.Sources/Triggers/RetryHandler.cs
  • src/Scanner/__Libraries/StellaOps.Scanner.Sources/Triggers/RetryPolicy.cs

T10: Unit & Integration Tests (TODO)

Files to Create:

  • src/Scanner/__Tests/StellaOps.Scanner.Sources.Tests/Triggers/SourceTriggerDispatcherTests.cs
  • src/Scanner/__Tests/StellaOps.Scanner.Sources.Tests/Handlers/*Tests.cs
  • src/Scanner/__Tests/StellaOps.Scanner.Sources.Tests/Webhooks/WebhookEndpointsTests.cs

Delivery Tracker

Task Status Notes
T1: Trigger Dispatcher TODO
T2: Handler Interface TODO
T3: Zastava Handler TODO
T4: Docker Handler TODO
T5: Git Handler TODO
T6: CLI Handler TODO
T7: Webhook Endpoints TODO
T8: Scheduler Integration TODO
T9: Retry Handler TODO
T10: Tests TODO

Next Sprint

SPRINT_1229_003_FE_sbom-sources-ui - Frontend Sources Manager:

  • Sources list page with status indicators
  • Add/Edit source wizard per type
  • Connection test UI
  • Source detail page with run history