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

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