Fix build and code structure improvements. New but essential UI functionality. CI improvements. Documentation improvements. AI module improvements.

This commit is contained in:
StellaOps Bot
2025-12-26 21:54:17 +02:00
parent 335ff7da16
commit c2b9cd8d1f
3717 changed files with 264714 additions and 48202 deletions

View File

@@ -0,0 +1,124 @@
using System.Text.Json.Serialization;
namespace StellaOps.Scanner.Sources.Configuration;
/// <summary>
/// Configuration for a CLI (external submission) source.
/// Receives SBOM uploads from CI/CD pipelines via the CLI.
/// </summary>
public sealed record CliSourceConfig
{
/// <summary>Allowed scanner/tools that can submit to this source.</summary>
[JsonPropertyName("allowedTools")]
public required string[] AllowedTools { get; init; }
/// <summary>Allowed CI systems (optional filter).</summary>
[JsonPropertyName("allowedCiSystems")]
public string[]? AllowedCiSystems { get; init; }
/// <summary>Validation rules for incoming SBOMs.</summary>
[JsonPropertyName("validation")]
public required CliValidationRules Validation { get; init; }
/// <summary>Required attribution fields.</summary>
[JsonPropertyName("attribution")]
public required CliAttributionRules Attribution { get; init; }
/// <summary>Post-processing options.</summary>
[JsonPropertyName("postProcessing")]
public CliPostProcessing? PostProcessing { get; init; }
}
/// <summary>
/// Validation rules for CLI SBOM submissions.
/// </summary>
public sealed record CliValidationRules
{
/// <summary>Require signed SBOMs.</summary>
[JsonPropertyName("requireSignedSbom")]
public bool RequireSignedSbom { get; init; }
/// <summary>Allowed signer public key fingerprints.</summary>
[JsonPropertyName("allowedSigners")]
public string[]? AllowedSigners { get; init; }
/// <summary>Maximum SBOM size in bytes.</summary>
[JsonPropertyName("maxSbomSizeBytes")]
public long MaxSbomSizeBytes { get; init; } = 50 * 1024 * 1024; // 50 MB default
/// <summary>Allowed SBOM formats.</summary>
[JsonPropertyName("allowedFormats")]
public required SbomFormat[] AllowedFormats { get; init; }
/// <summary>Minimum SBOM spec version (e.g., "2.3" for CycloneDX).</summary>
[JsonPropertyName("minSpecVersion")]
public string? MinSpecVersion { get; init; }
/// <summary>Require specific fields in the SBOM.</summary>
[JsonPropertyName("requiredFields")]
public string[]? RequiredFields { get; init; }
}
/// <summary>
/// Supported SBOM formats.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum SbomFormat
{
/// <summary>SPDX JSON format.</summary>
SpdxJson,
/// <summary>CycloneDX JSON format.</summary>
CycloneDxJson,
/// <summary>CycloneDX XML format.</summary>
CycloneDxXml
}
/// <summary>
/// Attribution rules for CLI submissions.
/// </summary>
public sealed record CliAttributionRules
{
/// <summary>Require build ID.</summary>
[JsonPropertyName("requireBuildId")]
public bool RequireBuildId { get; init; }
/// <summary>Require repository reference.</summary>
[JsonPropertyName("requireRepository")]
public bool RequireRepository { get; init; }
/// <summary>Require commit SHA.</summary>
[JsonPropertyName("requireCommitSha")]
public bool RequireCommitSha { get; init; }
/// <summary>Require pipeline/workflow ID.</summary>
[JsonPropertyName("requirePipelineId")]
public bool RequirePipelineId { get; init; }
/// <summary>Allowed repository URL patterns.</summary>
[JsonPropertyName("allowedRepositories")]
public string[]? AllowedRepositories { get; init; }
}
/// <summary>
/// Post-processing options for CLI submissions.
/// </summary>
public sealed record CliPostProcessing
{
/// <summary>Run vulnerability matching after upload.</summary>
[JsonPropertyName("runVulnMatching")]
public bool RunVulnMatching { get; init; } = true;
/// <summary>Run reachability analysis after upload.</summary>
[JsonPropertyName("runReachability")]
public bool RunReachability { get; init; }
/// <summary>Apply VEX suppression after upload.</summary>
[JsonPropertyName("applyVex")]
public bool ApplyVex { get; init; } = true;
/// <summary>Generate attestation for the SBOM.</summary>
[JsonPropertyName("generateAttestation")]
public bool GenerateAttestation { get; init; }
}

View File

@@ -0,0 +1,95 @@
using System.Text.Json.Serialization;
namespace StellaOps.Scanner.Sources.Configuration;
/// <summary>
/// Configuration for a Docker (direct image scan) source.
/// Scans specific images on a schedule or on-demand.
/// </summary>
public sealed record DockerSourceConfig
{
/// <summary>Registry URL (e.g., https://registry-1.docker.io).</summary>
[JsonPropertyName("registryUrl")]
public required string RegistryUrl { get; init; }
/// <summary>Images to scan.</summary>
[JsonPropertyName("images")]
public required ImageSpec[] Images { get; init; }
/// <summary>Scan options.</summary>
[JsonPropertyName("scanOptions")]
public required ScanOptions ScanOptions { get; init; }
/// <summary>Discovery options for tag enumeration.</summary>
[JsonPropertyName("discovery")]
public DiscoveryOptions? Discovery { get; init; }
}
/// <summary>
/// Specification for an image to scan.
/// </summary>
public sealed record ImageSpec
{
/// <summary>Image reference (e.g., "nginx:latest", "myrepo/app:v1.2.3").</summary>
[JsonPropertyName("reference")]
public required string Reference { get; init; }
/// <summary>Tag patterns to scan (if discovering tags).</summary>
[JsonPropertyName("tagPatterns")]
public string[]? TagPatterns { get; init; }
/// <summary>Pin to specific digest after first scan.</summary>
[JsonPropertyName("digestPin")]
public bool DigestPin { get; init; }
/// <summary>Maximum number of tags to scan per discovery.</summary>
[JsonPropertyName("maxTags")]
public int MaxTags { get; init; } = 10;
/// <summary>Only scan tags newer than this age (hours).</summary>
[JsonPropertyName("maxAgeHours")]
public int? MaxAgeHours { get; init; }
}
/// <summary>
/// Options for tag discovery.
/// </summary>
public sealed record DiscoveryOptions
{
/// <summary>Include pre-release tags (e.g., alpha, beta, rc).</summary>
[JsonPropertyName("includePreRelease")]
public bool IncludePreRelease { get; init; }
/// <summary>Sort order for tag selection.</summary>
[JsonPropertyName("sortOrder")]
public TagSortOrder SortOrder { get; init; } = TagSortOrder.SemVerDescending;
/// <summary>Skip tags that match these patterns.</summary>
[JsonPropertyName("excludePatterns")]
public string[]? ExcludePatterns { get; init; }
}
/// <summary>
/// Sort order for tag selection during discovery.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum TagSortOrder
{
/// <summary>Semantic version descending (newest first).</summary>
SemVerDescending,
/// <summary>Semantic version ascending (oldest first).</summary>
SemVerAscending,
/// <summary>Alphabetical descending.</summary>
AlphaDescending,
/// <summary>Alphabetical ascending.</summary>
AlphaAscending,
/// <summary>By creation date descending (newest first).</summary>
DateDescending,
/// <summary>By creation date ascending (oldest first).</summary>
DateAscending
}

View File

@@ -0,0 +1,183 @@
using System.Text.Json.Serialization;
namespace StellaOps.Scanner.Sources.Configuration;
/// <summary>
/// Configuration for a Git (repository) source.
/// Scans source code repositories for dependencies.
/// </summary>
public sealed record GitSourceConfig
{
/// <summary>Git provider type.</summary>
[JsonPropertyName("provider")]
public required GitProvider Provider { get; init; }
/// <summary>Repository URL.</summary>
[JsonPropertyName("repositoryUrl")]
public required string RepositoryUrl { get; init; }
/// <summary>Branch configuration.</summary>
[JsonPropertyName("branches")]
public required GitBranchConfig Branches { get; init; }
/// <summary>Trigger configuration.</summary>
[JsonPropertyName("triggers")]
public required GitTriggerConfig Triggers { get; init; }
/// <summary>Scan options.</summary>
[JsonPropertyName("scanOptions")]
public required GitScanOptions ScanOptions { get; init; }
/// <summary>Authentication method.</summary>
[JsonPropertyName("authMethod")]
public GitAuthMethod AuthMethod { get; init; } = GitAuthMethod.Token;
}
/// <summary>
/// Supported Git providers.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum GitProvider
{
/// <summary>GitHub.</summary>
GitHub,
/// <summary>GitLab.</summary>
GitLab,
/// <summary>Bitbucket.</summary>
Bitbucket,
/// <summary>Azure DevOps.</summary>
AzureDevOps,
/// <summary>Gitea.</summary>
Gitea,
/// <summary>Generic Git (no webhook support).</summary>
Generic
}
/// <summary>
/// Authentication method for Git.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum GitAuthMethod
{
/// <summary>Personal access token.</summary>
Token,
/// <summary>SSH key.</summary>
Ssh,
/// <summary>OAuth app credentials.</summary>
OAuth,
/// <summary>GitHub App installation.</summary>
GitHubApp
}
/// <summary>
/// Branch filter configuration.
/// </summary>
public sealed record GitBranchConfig
{
/// <summary>Branch patterns to include.</summary>
[JsonPropertyName("include")]
public required string[] Include { get; init; }
/// <summary>Branch patterns to exclude.</summary>
[JsonPropertyName("exclude")]
public string[]? Exclude { get; init; }
/// <summary>Default branch name (if not auto-detected).</summary>
[JsonPropertyName("defaultBranch")]
public string? DefaultBranch { get; init; }
}
/// <summary>
/// Trigger configuration for Git sources.
/// </summary>
public sealed record GitTriggerConfig
{
/// <summary>Trigger on push events.</summary>
[JsonPropertyName("onPush")]
public bool OnPush { get; init; }
/// <summary>Trigger on pull request events.</summary>
[JsonPropertyName("onPullRequest")]
public bool OnPullRequest { get; init; }
/// <summary>Trigger on tag events.</summary>
[JsonPropertyName("onTag")]
public bool OnTag { get; init; }
/// <summary>Tag patterns to trigger on.</summary>
[JsonPropertyName("tagPatterns")]
public string[]? TagPatterns { get; init; }
/// <summary>Pull request actions to trigger on.</summary>
[JsonPropertyName("prActions")]
public PullRequestAction[]? PrActions { get; init; }
}
/// <summary>
/// Pull request actions that can trigger scans.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum PullRequestAction
{
/// <summary>PR opened.</summary>
Opened,
/// <summary>PR synchronized (new commits pushed).</summary>
Synchronize,
/// <summary>PR reopened.</summary>
Reopened,
/// <summary>PR ready for review.</summary>
ReadyForReview
}
/// <summary>
/// Scan options for Git sources.
/// </summary>
public sealed record GitScanOptions
{
/// <summary>Analyzers to run.</summary>
[JsonPropertyName("analyzers")]
public required string[] Analyzers { get; init; }
/// <summary>Paths to scan (relative to repo root).</summary>
[JsonPropertyName("scanPaths")]
public string[]? ScanPaths { get; init; }
/// <summary>Paths to exclude from scanning.</summary>
[JsonPropertyName("excludePaths")]
public string[]? ExcludePaths { get; init; }
/// <summary>Only analyze lockfiles (skip manifest-only).</summary>
[JsonPropertyName("lockfileOnly")]
public bool LockfileOnly { get; init; }
/// <summary>Enable reachability analysis.</summary>
[JsonPropertyName("enableReachability")]
public bool EnableReachability { get; init; }
/// <summary>Enable VEX lookup.</summary>
[JsonPropertyName("enableVexLookup")]
public bool EnableVexLookup { get; init; }
/// <summary>Clone depth (0 = full clone).</summary>
[JsonPropertyName("cloneDepth")]
public int CloneDepth { get; init; } = 1;
/// <summary>Include submodules.</summary>
[JsonPropertyName("includeSubmodules")]
public bool IncludeSubmodules { get; init; }
/// <summary>Maximum repository size in MB (skip if larger).</summary>
[JsonPropertyName("maxRepoSizeMb")]
public int MaxRepoSizeMb { get; init; } = 500;
}

View File

@@ -0,0 +1,47 @@
using System.Text.Json;
using StellaOps.Scanner.Sources.Domain;
namespace StellaOps.Scanner.Sources.Configuration;
/// <summary>
/// Validation result for source configuration.
/// </summary>
public sealed record ConfigValidationResult
{
public bool IsValid { get; init; }
public IReadOnlyList<string> Errors { get; init; } = [];
public IReadOnlyList<string> Warnings { get; init; } = [];
public static ConfigValidationResult Success() => new() { IsValid = true };
public static ConfigValidationResult Failure(params string[] errors) =>
new() { IsValid = false, Errors = errors };
public static ConfigValidationResult Failure(IEnumerable<string> errors) =>
new() { IsValid = false, Errors = errors.ToList() };
public static ConfigValidationResult WithWarnings(params string[] warnings) =>
new() { IsValid = true, Warnings = warnings };
}
/// <summary>
/// Interface for validating source configurations.
/// </summary>
public interface ISourceConfigValidator
{
/// <summary>
/// Validates configuration for the specified source type.
/// </summary>
ConfigValidationResult Validate(SbomSourceType sourceType, JsonDocument configuration);
/// <summary>
/// Validates configuration and returns typed configuration if valid.
/// </summary>
ConfigValidationResult ValidateAndParse<T>(SbomSourceType sourceType, JsonDocument configuration, out T? parsed)
where T : class;
/// <summary>
/// Gets the JSON schema for a source type configuration.
/// </summary>
string? GetConfigurationSchema(SbomSourceType sourceType);
}

View File

@@ -0,0 +1,525 @@
using System.Text.Json;
using Microsoft.Extensions.Logging;
using StellaOps.Scanner.Sources.Domain;
namespace StellaOps.Scanner.Sources.Configuration;
/// <summary>
/// Validates source configurations based on source type.
/// </summary>
public sealed class SourceConfigValidator : ISourceConfigValidator
{
private readonly ILogger<SourceConfigValidator> _logger;
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
public SourceConfigValidator(ILogger<SourceConfigValidator> logger)
{
_logger = logger;
}
public ConfigValidationResult Validate(SbomSourceType sourceType, JsonDocument configuration)
{
return sourceType switch
{
SbomSourceType.Zastava => ValidateZastavaConfig(configuration),
SbomSourceType.Docker => ValidateDockerConfig(configuration),
SbomSourceType.Cli => ValidateCliConfig(configuration),
SbomSourceType.Git => ValidateGitConfig(configuration),
_ => ConfigValidationResult.Failure($"Unknown source type: {sourceType}")
};
}
public ConfigValidationResult ValidateAndParse<T>(
SbomSourceType sourceType,
JsonDocument configuration,
out T? parsed) where T : class
{
parsed = null;
var validationResult = Validate(sourceType, configuration);
if (!validationResult.IsValid)
{
return validationResult;
}
try
{
parsed = configuration.Deserialize<T>(JsonOptions);
if (parsed == null)
{
return ConfigValidationResult.Failure("Failed to parse configuration");
}
return validationResult;
}
catch (JsonException ex)
{
_logger.LogWarning(ex, "Failed to parse configuration for {SourceType}", sourceType);
return ConfigValidationResult.Failure($"JSON parse error: {ex.Message}");
}
}
public string? GetConfigurationSchema(SbomSourceType sourceType)
{
// Return JSON schema for the source type (for UI validation)
return sourceType switch
{
SbomSourceType.Zastava => GetZastavaSchema(),
SbomSourceType.Docker => GetDockerSchema(),
SbomSourceType.Cli => GetCliSchema(),
SbomSourceType.Git => GetGitSchema(),
_ => null
};
}
private ConfigValidationResult ValidateZastavaConfig(JsonDocument config)
{
var errors = new List<string>();
var warnings = new List<string>();
try
{
var root = config.RootElement;
// Required: registryType
if (!root.TryGetProperty("registryType", out var registryType))
{
errors.Add("registryType is required");
}
else
{
var registryTypeStr = registryType.GetString();
if (!Enum.TryParse<RegistryType>(registryTypeStr, true, out _))
{
errors.Add($"Invalid registryType: {registryTypeStr}. Valid values: {string.Join(", ", Enum.GetNames<RegistryType>())}");
}
}
// Required: registryUrl
if (!root.TryGetProperty("registryUrl", out var registryUrl) ||
string.IsNullOrWhiteSpace(registryUrl.GetString()))
{
errors.Add("registryUrl is required");
}
else
{
var url = registryUrl.GetString();
if (!Uri.TryCreate(url, UriKind.Absolute, out var uri) ||
(uri.Scheme != "http" && uri.Scheme != "https"))
{
errors.Add("registryUrl must be a valid HTTP/HTTPS URL");
}
}
// Optional but recommended: filters
if (!root.TryGetProperty("filters", out _))
{
warnings.Add("No filters specified - all images will be processed");
}
// Optional: scanOptions
if (root.TryGetProperty("scanOptions", out var scanOptions))
{
ValidateScanOptions(scanOptions, errors);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Error validating Zastava configuration");
errors.Add($"Configuration validation error: {ex.Message}");
}
return errors.Count > 0
? ConfigValidationResult.Failure(errors)
: warnings.Count > 0
? new ConfigValidationResult { IsValid = true, Warnings = warnings }
: ConfigValidationResult.Success();
}
private ConfigValidationResult ValidateDockerConfig(JsonDocument config)
{
var errors = new List<string>();
var warnings = new List<string>();
try
{
var root = config.RootElement;
// Required: images (at least one) OR discoveryOptions
var hasImages = root.TryGetProperty("images", out var images) &&
images.ValueKind == JsonValueKind.Array &&
images.GetArrayLength() > 0;
var hasDiscovery = root.TryGetProperty("discoveryOptions", out var discovery) &&
discovery.ValueKind == JsonValueKind.Object;
if (!hasImages && !hasDiscovery)
{
errors.Add("Either 'images' array or 'discoveryOptions' must be specified");
}
// Validate images if present
if (hasImages)
{
var imageIndex = 0;
foreach (var image in images.EnumerateArray())
{
ValidateImageSpec(image, imageIndex++, errors);
}
}
// Optional: registryUrl
if (root.TryGetProperty("registryUrl", out var registryUrl))
{
var url = registryUrl.GetString();
if (!string.IsNullOrEmpty(url) &&
!Uri.TryCreate(url, UriKind.Absolute, out var uri))
{
errors.Add("registryUrl must be a valid URL");
}
}
// Optional: scanOptions
if (root.TryGetProperty("scanOptions", out var scanOptions))
{
ValidateScanOptions(scanOptions, errors);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Error validating Docker configuration");
errors.Add($"Configuration validation error: {ex.Message}");
}
return errors.Count > 0
? ConfigValidationResult.Failure(errors)
: warnings.Count > 0
? new ConfigValidationResult { IsValid = true, Warnings = warnings }
: ConfigValidationResult.Success();
}
private ConfigValidationResult ValidateCliConfig(JsonDocument config)
{
var errors = new List<string>();
var warnings = new List<string>();
try
{
var root = config.RootElement;
// Optional: acceptedFormats
if (root.TryGetProperty("acceptedFormats", out var formats) &&
formats.ValueKind == JsonValueKind.Array)
{
foreach (var format in formats.EnumerateArray())
{
var formatStr = format.GetString();
if (!Enum.TryParse<SbomFormat>(formatStr, true, out _))
{
errors.Add($"Invalid SBOM format: {formatStr}. Valid values: {string.Join(", ", Enum.GetNames<SbomFormat>())}");
}
}
}
// Optional: validationRules
if (root.TryGetProperty("validationRules", out var validation))
{
if (validation.TryGetProperty("maxFileSizeBytes", out var maxSize))
{
if (maxSize.TryGetInt64(out var size) && size <= 0)
{
errors.Add("maxFileSizeBytes must be positive");
}
}
}
// Warnings for missing recommended settings
if (!root.TryGetProperty("validationRules", out _))
{
warnings.Add("No validation rules specified - using defaults");
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Error validating CLI configuration");
errors.Add($"Configuration validation error: {ex.Message}");
}
return errors.Count > 0
? ConfigValidationResult.Failure(errors)
: warnings.Count > 0
? new ConfigValidationResult { IsValid = true, Warnings = warnings }
: ConfigValidationResult.Success();
}
private ConfigValidationResult ValidateGitConfig(JsonDocument config)
{
var errors = new List<string>();
var warnings = new List<string>();
try
{
var root = config.RootElement;
// Required: repositoryUrl
if (!root.TryGetProperty("repositoryUrl", out var repoUrl) ||
string.IsNullOrWhiteSpace(repoUrl.GetString()))
{
errors.Add("repositoryUrl is required");
}
else
{
var url = repoUrl.GetString()!;
// Allow git://, https://, ssh:// URLs
if (!Uri.TryCreate(url, UriKind.Absolute, out var uri))
{
// Also check for SSH-style URLs (git@github.com:org/repo.git)
if (!url.Contains('@') || !url.Contains(':'))
{
errors.Add("repositoryUrl must be a valid Git URL (https://, git://, ssh://, or git@host:path)");
}
}
}
// Optional: provider (for better integration)
if (root.TryGetProperty("provider", out var provider))
{
var providerStr = provider.GetString();
if (!Enum.TryParse<GitProvider>(providerStr, true, out _))
{
errors.Add($"Invalid provider: {providerStr}. Valid values: {string.Join(", ", Enum.GetNames<GitProvider>())}");
}
}
// Optional: authMethod
if (root.TryGetProperty("authMethod", out var authMethod))
{
var authStr = authMethod.GetString();
if (!Enum.TryParse<GitAuthMethod>(authStr, true, out _))
{
errors.Add($"Invalid authMethod: {authStr}. Valid values: {string.Join(", ", Enum.GetNames<GitAuthMethod>())}");
}
}
// Optional: branchConfig
if (root.TryGetProperty("branchConfig", out var branchConfig))
{
ValidateBranchConfig(branchConfig, errors);
}
// Warnings
if (!root.TryGetProperty("branchConfig", out _))
{
warnings.Add("No branch configuration - using default branch only");
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Error validating Git configuration");
errors.Add($"Configuration validation error: {ex.Message}");
}
return errors.Count > 0
? ConfigValidationResult.Failure(errors)
: warnings.Count > 0
? new ConfigValidationResult { IsValid = true, Warnings = warnings }
: ConfigValidationResult.Success();
}
private static void ValidateScanOptions(JsonElement scanOptions, List<string> errors)
{
if (scanOptions.TryGetProperty("timeoutSeconds", out var timeout))
{
if (timeout.TryGetInt32(out var seconds) && seconds <= 0)
{
errors.Add("scanOptions.timeoutSeconds must be positive");
}
}
if (scanOptions.TryGetProperty("maxConcurrency", out var concurrency))
{
if (concurrency.TryGetInt32(out var value) && value <= 0)
{
errors.Add("scanOptions.maxConcurrency must be positive");
}
}
}
private static void ValidateImageSpec(JsonElement image, int index, List<string> errors)
{
if (!image.TryGetProperty("repository", out var repo) ||
string.IsNullOrWhiteSpace(repo.GetString()))
{
errors.Add($"images[{index}].repository is required");
}
// At least one of: tag, tags, tagPattern
var hasTag = image.TryGetProperty("tag", out var tag) &&
!string.IsNullOrWhiteSpace(tag.GetString());
var hasTags = image.TryGetProperty("tags", out var tags) &&
tags.ValueKind == JsonValueKind.Array &&
tags.GetArrayLength() > 0;
var hasPattern = image.TryGetProperty("tagPattern", out var pattern) &&
!string.IsNullOrWhiteSpace(pattern.GetString());
if (!hasTag && !hasTags && !hasPattern)
{
errors.Add($"images[{index}] must specify at least one of: tag, tags, tagPattern");
}
}
private static void ValidateBranchConfig(JsonElement branchConfig, List<string> errors)
{
if (branchConfig.TryGetProperty("branchPatterns", out var patterns) &&
patterns.ValueKind == JsonValueKind.Array)
{
foreach (var pattern in patterns.EnumerateArray())
{
var patternStr = pattern.GetString();
if (string.IsNullOrWhiteSpace(patternStr))
{
errors.Add("branchConfig.branchPatterns contains empty pattern");
}
}
}
}
#region JSON Schemas
private static string GetZastavaSchema() => """
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"required": ["registryType", "registryUrl"],
"properties": {
"registryType": {
"type": "string",
"enum": ["DockerHub", "Harbor", "Ecr", "Gcr", "Acr", "Ghcr", "Quay", "JFrog", "Nexus", "GitLab", "Custom"]
},
"registryUrl": {
"type": "string",
"format": "uri"
},
"filters": {
"type": "object",
"properties": {
"repositoryPatterns": { "type": "array", "items": { "type": "string" } },
"tagPatterns": { "type": "array", "items": { "type": "string" } },
"excludePatterns": { "type": "array", "items": { "type": "string" } }
}
},
"scanOptions": {
"type": "object",
"properties": {
"scanOnPush": { "type": "boolean" },
"scanOnPull": { "type": "boolean" },
"timeoutSeconds": { "type": "integer", "minimum": 1 }
}
}
}
}
""";
private static string GetDockerSchema() => """
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"properties": {
"registryUrl": { "type": "string", "format": "uri" },
"images": {
"type": "array",
"items": {
"type": "object",
"required": ["repository"],
"properties": {
"repository": { "type": "string" },
"tag": { "type": "string" },
"tags": { "type": "array", "items": { "type": "string" } },
"tagPattern": { "type": "string" }
}
}
},
"discoveryOptions": {
"type": "object",
"properties": {
"repositoryPattern": { "type": "string" },
"tagPattern": { "type": "string" },
"maxTagsPerRepo": { "type": "integer", "minimum": 1 }
}
}
}
}
""";
private static string GetCliSchema() => """
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"properties": {
"acceptedFormats": {
"type": "array",
"items": {
"type": "string",
"enum": ["CycloneDX", "SPDX", "Syft", "Auto"]
}
},
"validationRules": {
"type": "object",
"properties": {
"requireSignature": { "type": "boolean" },
"maxFileSizeBytes": { "type": "integer", "minimum": 1 },
"maxComponents": { "type": "integer", "minimum": 1 }
}
},
"attributionRules": {
"type": "object",
"properties": {
"requirePipelineId": { "type": "boolean" },
"requireArtifactRef": { "type": "boolean" }
}
}
}
}
""";
private static string GetGitSchema() => """
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"required": ["repositoryUrl"],
"properties": {
"repositoryUrl": { "type": "string" },
"provider": {
"type": "string",
"enum": ["GitHub", "GitLab", "Bitbucket", "AzureDevOps", "Gitea", "Custom"]
},
"authMethod": {
"type": "string",
"enum": ["None", "Token", "SshKey", "App", "BasicAuth"]
},
"branchConfig": {
"type": "object",
"properties": {
"defaultBranch": { "type": "string" },
"branchPatterns": { "type": "array", "items": { "type": "string" } },
"excludeBranches": { "type": "array", "items": { "type": "string" } }
}
},
"triggerConfig": {
"type": "object",
"properties": {
"onPush": { "type": "boolean" },
"onPullRequest": { "type": "boolean" },
"onTag": { "type": "boolean" }
}
}
}
}
""";
#endregion
}

View File

@@ -0,0 +1,147 @@
using System.Text.Json.Serialization;
namespace StellaOps.Scanner.Sources.Configuration;
/// <summary>
/// Configuration for a Zastava (registry webhook) source.
/// Receives push events from container registries and triggers scans.
/// </summary>
public sealed record ZastavaSourceConfig
{
/// <summary>Type of container registry.</summary>
[JsonPropertyName("registryType")]
public required RegistryType RegistryType { get; init; }
/// <summary>Registry URL (e.g., https://registry-1.docker.io).</summary>
[JsonPropertyName("registryUrl")]
public required string RegistryUrl { get; init; }
/// <summary>Filter configuration for repositories and tags.</summary>
[JsonPropertyName("filters")]
public required ZastavaFilters Filters { get; init; }
/// <summary>Scan options for images from this source.</summary>
[JsonPropertyName("scanOptions")]
public required ScanOptions ScanOptions { get; init; }
/// <summary>Optional custom payload mapping for generic webhooks.</summary>
[JsonPropertyName("payloadMapping")]
public PayloadMapping? PayloadMapping { get; init; }
}
/// <summary>
/// Supported container registry types.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum RegistryType
{
/// <summary>Docker Hub.</summary>
DockerHub,
/// <summary>Harbor registry.</summary>
Harbor,
/// <summary>Quay.io.</summary>
Quay,
/// <summary>AWS Elastic Container Registry.</summary>
Ecr,
/// <summary>Google Container Registry.</summary>
Gcr,
/// <summary>Azure Container Registry.</summary>
Acr,
/// <summary>GitHub Container Registry.</summary>
Ghcr,
/// <summary>JFrog Artifactory.</summary>
Artifactory,
/// <summary>Generic registry with configurable payload mapping.</summary>
Generic
}
/// <summary>
/// Filter configuration for Zastava sources.
/// </summary>
public sealed record ZastavaFilters
{
/// <summary>Repository patterns to include (glob patterns).</summary>
[JsonPropertyName("repositories")]
public required string[] Repositories { get; init; }
/// <summary>Tag patterns to include (glob patterns).</summary>
[JsonPropertyName("tags")]
public required string[] Tags { get; init; }
/// <summary>Repository patterns to exclude (glob patterns).</summary>
[JsonPropertyName("excludeRepositories")]
public string[]? ExcludeRepositories { get; init; }
/// <summary>Tag patterns to exclude (glob patterns).</summary>
[JsonPropertyName("excludeTags")]
public string[]? ExcludeTags { get; init; }
}
/// <summary>
/// Custom payload mapping for generic webhooks.
/// Uses JSONPath expressions to extract values.
/// </summary>
public sealed record PayloadMapping
{
/// <summary>JSONPath to repository name.</summary>
[JsonPropertyName("repositoryPath")]
public required string RepositoryPath { get; init; }
/// <summary>JSONPath to tag name.</summary>
[JsonPropertyName("tagPath")]
public required string TagPath { get; init; }
/// <summary>JSONPath to digest (optional).</summary>
[JsonPropertyName("digestPath")]
public string? DigestPath { get; init; }
/// <summary>JSONPath to timestamp (optional).</summary>
[JsonPropertyName("timestampPath")]
public string? TimestampPath { get; init; }
/// <summary>Expected header for webhook signature verification.</summary>
[JsonPropertyName("signatureHeader")]
public string? SignatureHeader { get; init; }
/// <summary>Signature algorithm (hmac-sha256, etc.).</summary>
[JsonPropertyName("signatureAlgorithm")]
public string? SignatureAlgorithm { get; init; }
}
/// <summary>
/// Common scan options for all source types.
/// </summary>
public sealed record ScanOptions
{
/// <summary>Analyzers to run (e.g., "os", "lang.node", "lang.python").</summary>
[JsonPropertyName("analyzers")]
public required string[] Analyzers { get; init; }
/// <summary>Enable reachability analysis.</summary>
[JsonPropertyName("enableReachability")]
public bool EnableReachability { get; init; }
/// <summary>Enable VEX lookup for vulnerability suppression.</summary>
[JsonPropertyName("enableVexLookup")]
public bool EnableVexLookup { get; init; }
/// <summary>Target platforms for multi-arch images.</summary>
[JsonPropertyName("platforms")]
public string[]? Platforms { get; init; }
/// <summary>Maximum scan timeout in seconds.</summary>
[JsonPropertyName("timeoutSeconds")]
public int TimeoutSeconds { get; init; } = 600;
/// <summary>Priority for scan jobs (higher = more urgent).</summary>
[JsonPropertyName("priority")]
public int Priority { get; init; } = 0;
}

View File

@@ -0,0 +1,348 @@
using System.Text.Json;
using StellaOps.Scanner.Sources.Domain;
namespace StellaOps.Scanner.Sources.Contracts;
// =============================================================================
// Request DTOs
// =============================================================================
/// <summary>
/// Request to create a new SBOM source.
/// </summary>
public sealed record CreateSourceRequest
{
/// <summary>Human-readable name for the source.</summary>
public required string Name { get; init; }
/// <summary>Optional description.</summary>
public string? Description { get; init; }
/// <summary>Type of source.</summary>
public required SbomSourceType SourceType { get; init; }
/// <summary>Type-specific configuration.</summary>
public required JsonDocument Configuration { get; init; }
/// <summary>Reference to credentials in vault.</summary>
public string? AuthRef { get; init; }
/// <summary>Cron schedule for scheduled sources.</summary>
public string? CronSchedule { get; init; }
/// <summary>Timezone for cron schedule.</summary>
public string? CronTimezone { get; init; }
/// <summary>Maximum scans per hour (rate limiting).</summary>
public int? MaxScansPerHour { get; init; }
/// <summary>Tags for organization.</summary>
public List<string>? Tags { get; init; }
/// <summary>Custom metadata.</summary>
public Dictionary<string, string>? Metadata { get; init; }
}
/// <summary>
/// Request to update an existing source.
/// </summary>
public sealed record UpdateSourceRequest
{
/// <summary>Updated name.</summary>
public string? Name { get; init; }
/// <summary>Updated description.</summary>
public string? Description { get; init; }
/// <summary>Updated configuration.</summary>
public JsonDocument? Configuration { get; init; }
/// <summary>Updated auth reference.</summary>
public string? AuthRef { get; init; }
/// <summary>Updated cron schedule.</summary>
public string? CronSchedule { get; init; }
/// <summary>Updated cron timezone.</summary>
public string? CronTimezone { get; init; }
/// <summary>Updated rate limit.</summary>
public int? MaxScansPerHour { get; init; }
/// <summary>Updated tags.</summary>
public List<string>? Tags { get; init; }
/// <summary>Updated metadata.</summary>
public Dictionary<string, string>? Metadata { get; init; }
}
/// <summary>
/// Request to pause a source.
/// </summary>
public sealed record PauseSourceRequest
{
/// <summary>Reason for pausing.</summary>
public required string Reason { get; init; }
/// <summary>Optional ticket reference.</summary>
public string? Ticket { get; init; }
}
/// <summary>
/// Request to list sources with filters.
/// </summary>
public sealed record ListSourcesRequest
{
/// <summary>Filter by source type.</summary>
public SbomSourceType? SourceType { get; init; }
/// <summary>Filter by status.</summary>
public SbomSourceStatus? Status { get; init; }
/// <summary>Filter by tags (any match).</summary>
public List<string>? Tags { get; init; }
/// <summary>Search term (matches name, description).</summary>
public string? Search { get; init; }
/// <summary>Page size.</summary>
public int Limit { get; init; } = 25;
/// <summary>Cursor for pagination.</summary>
public string? Cursor { get; init; }
}
/// <summary>
/// Request to list source runs.
/// </summary>
public sealed record ListSourceRunsRequest
{
/// <summary>Filter by trigger type.</summary>
public SbomSourceRunTrigger? Trigger { get; init; }
/// <summary>Filter by status.</summary>
public SbomSourceRunStatus? Status { get; init; }
/// <summary>Filter by start date (from).</summary>
public DateTimeOffset? From { get; init; }
/// <summary>Filter by start date (to).</summary>
public DateTimeOffset? To { get; init; }
/// <summary>Page size.</summary>
public int Limit { get; init; } = 25;
/// <summary>Cursor for pagination.</summary>
public string? Cursor { get; init; }
}
/// <summary>
/// Request to trigger a manual scan.
/// </summary>
public sealed record TriggerScanRequest
{
/// <summary>Optional specific targets to scan (overrides discovery).</summary>
public string[]? Targets { get; init; }
/// <summary>Force scan even if rate limited.</summary>
public bool Force { get; init; }
}
/// <summary>
/// Request to test source connection.
/// </summary>
public sealed record TestConnectionRequest
{
/// <summary>Source type.</summary>
public required SbomSourceType SourceType { get; init; }
/// <summary>Configuration to test.</summary>
public required JsonDocument Configuration { get; init; }
/// <summary>Credentials to use.</summary>
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; }
}
// =============================================================================
// Response DTOs
// =============================================================================
/// <summary>
/// Response containing source details.
/// </summary>
public sealed record SourceResponse
{
public required Guid SourceId { get; init; }
public required string TenantId { get; init; }
public required string Name { get; init; }
public string? Description { get; init; }
public required SbomSourceType SourceType { get; init; }
public required SbomSourceStatus Status { get; init; }
public required JsonDocument Configuration { get; init; }
public string? WebhookEndpoint { get; init; }
public string? CronSchedule { get; init; }
public string? CronTimezone { get; init; }
public DateTimeOffset? NextScheduledRun { get; init; }
public DateTimeOffset? LastRunAt { get; init; }
public SbomSourceRunStatus? LastRunStatus { get; init; }
public string? LastRunError { get; init; }
public int ConsecutiveFailures { get; init; }
public bool Paused { get; init; }
public string? PauseReason { get; init; }
public string? PauseTicket { get; init; }
public DateTimeOffset? PausedAt { get; init; }
public int? MaxScansPerHour { get; init; }
public DateTimeOffset CreatedAt { get; init; }
public string CreatedBy { get; init; } = null!;
public DateTimeOffset UpdatedAt { get; init; }
public string UpdatedBy { get; init; } = null!;
public List<string> Tags { get; init; } = [];
public Dictionary<string, string> Metadata { get; init; } = [];
public static SourceResponse FromDomain(SbomSource source) => new()
{
SourceId = source.SourceId,
TenantId = source.TenantId,
Name = source.Name,
Description = source.Description,
SourceType = source.SourceType,
Status = source.Status,
Configuration = source.Configuration,
WebhookEndpoint = source.WebhookEndpoint,
CronSchedule = source.CronSchedule,
CronTimezone = source.CronTimezone,
NextScheduledRun = source.NextScheduledRun,
LastRunAt = source.LastRunAt,
LastRunStatus = source.LastRunStatus,
LastRunError = source.LastRunError,
ConsecutiveFailures = source.ConsecutiveFailures,
Paused = source.Paused,
PauseReason = source.PauseReason,
PauseTicket = source.PauseTicket,
PausedAt = source.PausedAt,
MaxScansPerHour = source.MaxScansPerHour,
CreatedAt = source.CreatedAt,
CreatedBy = source.CreatedBy,
UpdatedAt = source.UpdatedAt,
UpdatedBy = source.UpdatedBy,
Tags = source.Tags,
Metadata = source.Metadata
};
}
/// <summary>
/// Response containing source run details.
/// </summary>
public sealed record SourceRunResponse
{
public required Guid RunId { get; init; }
public required Guid SourceId { get; init; }
public required SbomSourceRunTrigger Trigger { get; init; }
public string? TriggerDetails { get; init; }
public required SbomSourceRunStatus Status { get; init; }
public required DateTimeOffset StartedAt { get; init; }
public DateTimeOffset? CompletedAt { get; init; }
public long DurationMs { get; init; }
public int ItemsDiscovered { get; init; }
public int ItemsScanned { get; init; }
public int ItemsSucceeded { get; init; }
public int ItemsFailed { get; init; }
public int ItemsSkipped { get; init; }
public List<Guid> ScanJobIds { get; init; } = [];
public string? ErrorMessage { get; init; }
public required string CorrelationId { get; init; }
public static SourceRunResponse FromDomain(SbomSourceRun run) => new()
{
RunId = run.RunId,
SourceId = run.SourceId,
Trigger = run.Trigger,
TriggerDetails = run.TriggerDetails,
Status = run.Status,
StartedAt = run.StartedAt,
CompletedAt = run.CompletedAt,
DurationMs = run.DurationMs,
ItemsDiscovered = run.ItemsDiscovered,
ItemsScanned = run.ItemsScanned,
ItemsSucceeded = run.ItemsSucceeded,
ItemsFailed = run.ItemsFailed,
ItemsSkipped = run.ItemsSkipped,
ScanJobIds = run.ScanJobIds,
ErrorMessage = run.ErrorMessage,
CorrelationId = run.CorrelationId
};
}
/// <summary>
/// Paginated list response.
/// </summary>
public sealed record PagedResponse<T>
{
public required IReadOnlyList<T> Items { get; init; }
public required int TotalCount { get; init; }
public string? NextCursor { get; init; }
public bool HasMore => NextCursor != null;
}
/// <summary>
/// Connection test result.
/// </summary>
public sealed record ConnectionTestResult
{
public required bool Success { get; init; }
public string? Message { get; init; }
public string? ErrorCode { get; init; }
public List<ConnectionTestCheck> Checks { get; init; } = [];
public static ConnectionTestResult Succeeded(string? message = null) => new()
{
Success = true,
Message = message ?? "Connection successful"
};
public static ConnectionTestResult Failed(string message, string? errorCode = null) => new()
{
Success = false,
Message = message,
ErrorCode = errorCode
};
}
/// <summary>
/// Individual check within a connection test.
/// </summary>
public sealed record ConnectionTestCheck
{
public required string Name { get; init; }
public required bool Passed { get; init; }
public string? Message { get; init; }
}
/// <summary>
/// Result of triggering a scan.
/// </summary>
public sealed record TriggerScanResult
{
public required Guid RunId { get; init; }
public required SbomSourceRunStatus Status { get; init; }
public int TargetsQueued { get; init; }
public string? Message { get; init; }
}

View File

@@ -0,0 +1,406 @@
using System.Text.Json;
namespace StellaOps.Scanner.Sources.Domain;
/// <summary>
/// Represents a configured SBOM ingestion source.
/// Sources can be registry webhooks (Zastava), direct Docker image scans,
/// CLI submissions, or Git repository scans.
/// </summary>
public sealed class SbomSource
{
/// <summary>Unique source identifier.</summary>
public Guid SourceId { get; init; }
/// <summary>Tenant owning this source.</summary>
public string TenantId { get; init; } = null!;
/// <summary>Human-readable source name.</summary>
public string Name { get; init; } = null!;
/// <summary>Optional description.</summary>
public string? Description { get; set; }
/// <summary>Type of source (Zastava, Docker, CLI, Git).</summary>
public SbomSourceType SourceType { get; init; }
/// <summary>Current status of the source.</summary>
public SbomSourceStatus Status { get; private set; } = SbomSourceStatus.Pending;
/// <summary>Type-specific configuration (JSON).</summary>
public JsonDocument Configuration { get; set; } = null!;
/// <summary>Reference to credentials in vault (never the actual secret).</summary>
public string? AuthRef { get; set; }
/// <summary>Generated webhook endpoint for webhook-based sources.</summary>
public string? WebhookEndpoint { get; private set; }
/// <summary>Reference to webhook secret in vault.</summary>
public string? WebhookSecretRef { get; private set; }
/// <summary>Cron schedule expression for scheduled sources.</summary>
public string? CronSchedule { get; set; }
/// <summary>Timezone for cron schedule (default: UTC).</summary>
public string? CronTimezone { get; set; }
/// <summary>Next scheduled run time.</summary>
public DateTimeOffset? NextScheduledRun { get; private set; }
/// <summary>When the source last ran.</summary>
public DateTimeOffset? LastRunAt { get; private set; }
/// <summary>Status of the last run.</summary>
public SbomSourceRunStatus? LastRunStatus { get; private set; }
/// <summary>Error message from last run (if failed).</summary>
public string? LastRunError { get; private set; }
/// <summary>Number of consecutive failures.</summary>
public int ConsecutiveFailures { get; private set; }
/// <summary>Whether the source is paused.</summary>
public bool Paused { get; private set; }
/// <summary>Reason for pause (operator-provided).</summary>
public string? PauseReason { get; private set; }
/// <summary>Ticket reference for pause audit.</summary>
public string? PauseTicket { get; private set; }
/// <summary>When the source was paused.</summary>
public DateTimeOffset? PausedAt { get; private set; }
/// <summary>Who paused the source.</summary>
public string? PausedBy { get; private set; }
/// <summary>Maximum scans per hour (rate limiting).</summary>
public int? MaxScansPerHour { get; set; }
/// <summary>Current scans in the hour window.</summary>
public int CurrentHourScans { get; private set; }
/// <summary>Start of the current hour window.</summary>
public DateTimeOffset? HourWindowStart { get; private set; }
/// <summary>When the source was created.</summary>
public DateTimeOffset CreatedAt { get; init; }
/// <summary>Who created the source.</summary>
public string CreatedBy { get; init; } = null!;
/// <summary>When the source was last updated.</summary>
public DateTimeOffset UpdatedAt { get; private set; }
/// <summary>Who last updated the source.</summary>
public string UpdatedBy { get; private set; } = null!;
/// <summary>Tags for organization.</summary>
public List<string> Tags { get; set; } = [];
/// <summary>Custom metadata key-value pairs.</summary>
public Dictionary<string, string> Metadata { get; set; } = [];
// -------------------------------------------------------------------------
// Factory Methods
// -------------------------------------------------------------------------
/// <summary>
/// Create a new SBOM source.
/// </summary>
public static SbomSource Create(
string tenantId,
string name,
SbomSourceType sourceType,
JsonDocument configuration,
string createdBy,
string? description = null,
string? authRef = null,
string? cronSchedule = null,
string? cronTimezone = null)
{
var now = DateTimeOffset.UtcNow;
var source = new SbomSource
{
SourceId = Guid.NewGuid(),
TenantId = tenantId,
Name = name,
Description = description,
SourceType = sourceType,
Status = SbomSourceStatus.Pending,
Configuration = configuration,
AuthRef = authRef,
CronSchedule = cronSchedule,
CronTimezone = cronTimezone ?? "UTC",
CreatedAt = now,
CreatedBy = createdBy,
UpdatedAt = now,
UpdatedBy = createdBy
};
// Generate webhook endpoint for webhook-based sources
if (sourceType == SbomSourceType.Zastava || sourceType == SbomSourceType.Git)
{
source.GenerateWebhookEndpoint();
}
// Calculate next scheduled run
if (!string.IsNullOrEmpty(cronSchedule))
{
source.CalculateNextScheduledRun();
}
return source;
}
// -------------------------------------------------------------------------
// State Transitions
// -------------------------------------------------------------------------
/// <summary>
/// Activate the source (after successful validation).
/// </summary>
public void Activate(string updatedBy)
{
if (Status == SbomSourceStatus.Disabled)
throw new InvalidOperationException("Cannot activate a disabled source. Enable it first.");
Status = SbomSourceStatus.Active;
UpdatedAt = DateTimeOffset.UtcNow;
UpdatedBy = updatedBy;
}
/// <summary>
/// Pause the source with a reason.
/// </summary>
public void Pause(string reason, string? ticket, string pausedBy)
{
if (Paused) return;
Paused = true;
PauseReason = reason;
PauseTicket = ticket;
PausedAt = DateTimeOffset.UtcNow;
PausedBy = pausedBy;
Status = SbomSourceStatus.Paused;
UpdatedAt = DateTimeOffset.UtcNow;
UpdatedBy = pausedBy;
}
/// <summary>
/// Resume a paused source.
/// </summary>
public void Resume(string resumedBy)
{
if (!Paused) return;
Paused = false;
PauseReason = null;
PauseTicket = null;
PausedAt = null;
PausedBy = null;
Status = ConsecutiveFailures > 0 ? SbomSourceStatus.Error : SbomSourceStatus.Active;
UpdatedAt = DateTimeOffset.UtcNow;
UpdatedBy = resumedBy;
}
/// <summary>
/// Disable the source administratively.
/// </summary>
public void Disable(string disabledBy)
{
Status = SbomSourceStatus.Disabled;
UpdatedAt = DateTimeOffset.UtcNow;
UpdatedBy = disabledBy;
}
/// <summary>
/// Enable a disabled source.
/// </summary>
public void Enable(string enabledBy)
{
if (Status != SbomSourceStatus.Disabled)
throw new InvalidOperationException("Source is not disabled.");
Status = SbomSourceStatus.Pending;
UpdatedAt = DateTimeOffset.UtcNow;
UpdatedBy = enabledBy;
}
// -------------------------------------------------------------------------
// Run Tracking
// -------------------------------------------------------------------------
/// <summary>
/// Record a successful run.
/// </summary>
public void RecordSuccessfulRun(DateTimeOffset runAt)
{
LastRunAt = runAt;
LastRunStatus = SbomSourceRunStatus.Succeeded;
LastRunError = null;
ConsecutiveFailures = 0;
if (Status == SbomSourceStatus.Error)
{
Status = SbomSourceStatus.Active;
}
IncrementHourScans();
CalculateNextScheduledRun();
}
/// <summary>
/// Record a failed run.
/// </summary>
public void RecordFailedRun(DateTimeOffset runAt, string error)
{
LastRunAt = runAt;
LastRunStatus = SbomSourceRunStatus.Failed;
LastRunError = error;
ConsecutiveFailures++;
if (!Paused)
{
Status = SbomSourceStatus.Error;
}
IncrementHourScans();
CalculateNextScheduledRun();
}
/// <summary>
/// Record a partial success run.
/// </summary>
public void RecordPartialRun(DateTimeOffset runAt, string? warning = null)
{
LastRunAt = runAt;
LastRunStatus = SbomSourceRunStatus.PartialSuccess;
LastRunError = warning;
// Don't reset consecutive failures for partial success
IncrementHourScans();
CalculateNextScheduledRun();
}
// -------------------------------------------------------------------------
// Rate Limiting
// -------------------------------------------------------------------------
/// <summary>
/// Check if the source is rate limited.
/// </summary>
public bool IsRateLimited()
{
if (!MaxScansPerHour.HasValue) return false;
// Check if we're in a new hour window
var now = DateTimeOffset.UtcNow;
if (!HourWindowStart.HasValue || now - HourWindowStart.Value >= TimeSpan.FromHours(1))
{
return false; // New window, not rate limited
}
return CurrentHourScans >= MaxScansPerHour.Value;
}
private void IncrementHourScans()
{
var now = DateTimeOffset.UtcNow;
if (!HourWindowStart.HasValue || now - HourWindowStart.Value >= TimeSpan.FromHours(1))
{
HourWindowStart = now;
CurrentHourScans = 1;
}
else
{
CurrentHourScans++;
}
}
// -------------------------------------------------------------------------
// Webhook Management
// -------------------------------------------------------------------------
/// <summary>
/// Generate a new webhook endpoint.
/// </summary>
public void GenerateWebhookEndpoint()
{
var typePrefix = SourceType switch
{
SbomSourceType.Zastava => "zastava",
SbomSourceType.Git => "git",
_ => throw new InvalidOperationException($"Source type {SourceType} does not support webhooks")
};
WebhookEndpoint = $"/api/v1/webhooks/{typePrefix}/{SourceId}";
WebhookSecretRef = $"webhook.{SourceId}.secret";
}
/// <summary>
/// Regenerate webhook secret (for rotation).
/// </summary>
public void RotateWebhookSecret(string updatedBy)
{
if (WebhookEndpoint == null)
throw new InvalidOperationException("Source does not have a webhook endpoint.");
// The actual secret rotation happens in the credential store
// This just updates the audit trail
UpdatedAt = DateTimeOffset.UtcNow;
UpdatedBy = updatedBy;
}
// -------------------------------------------------------------------------
// Scheduling
// -------------------------------------------------------------------------
/// <summary>
/// Calculate the next scheduled run time.
/// </summary>
public void CalculateNextScheduledRun()
{
if (string.IsNullOrEmpty(CronSchedule))
{
NextScheduledRun = null;
return;
}
try
{
var cron = Cronos.CronExpression.Parse(CronSchedule);
var timezone = TimeZoneInfo.FindSystemTimeZoneById(CronTimezone ?? "UTC");
NextScheduledRun = cron.GetNextOccurrence(DateTimeOffset.UtcNow, timezone);
}
catch
{
NextScheduledRun = null;
}
}
// -------------------------------------------------------------------------
// Configuration Access
// -------------------------------------------------------------------------
/// <summary>
/// Get the typed configuration.
/// </summary>
public T GetConfiguration<T>() where T : class
{
return Configuration.Deserialize<T>()
?? throw new InvalidOperationException($"Failed to deserialize configuration as {typeof(T).Name}");
}
/// <summary>
/// Update the configuration.
/// </summary>
public void UpdateConfiguration(JsonDocument newConfiguration, string updatedBy)
{
Configuration = newConfiguration;
UpdatedAt = DateTimeOffset.UtcNow;
UpdatedBy = updatedBy;
}
}

View File

@@ -0,0 +1,85 @@
namespace StellaOps.Scanner.Sources.Domain;
/// <summary>
/// Type of SBOM ingestion source.
/// </summary>
public enum SbomSourceType
{
/// <summary>Registry webhook source (receives push events from container registries).</summary>
Zastava = 0,
/// <summary>Direct Docker image scanning (scheduled or on-demand).</summary>
Docker = 1,
/// <summary>External CLI submissions (receives SBOMs from CI/CD pipelines).</summary>
Cli = 2,
/// <summary>Git repository source scanning.</summary>
Git = 3
}
/// <summary>
/// Status of an SBOM source.
/// </summary>
public enum SbomSourceStatus
{
/// <summary>Source is pending initial validation/test.</summary>
Pending = 0,
/// <summary>Source is active and processing events.</summary>
Active = 1,
/// <summary>Source is manually paused by operator.</summary>
Paused = 2,
/// <summary>Source encountered an error (last run failed).</summary>
Error = 3,
/// <summary>Source is administratively disabled.</summary>
Disabled = 4
}
/// <summary>
/// Status of an individual source run.
/// </summary>
public enum SbomSourceRunStatus
{
/// <summary>Run is in progress.</summary>
Running = 0,
/// <summary>Run completed successfully.</summary>
Succeeded = 1,
/// <summary>Run failed.</summary>
Failed = 2,
/// <summary>Run partially succeeded (some items failed).</summary>
PartialSuccess = 3,
/// <summary>Run was skipped (no matching items).</summary>
Skipped = 4,
/// <summary>Run was cancelled.</summary>
Cancelled = 5
}
/// <summary>
/// Trigger type for a source run.
/// </summary>
public enum SbomSourceRunTrigger
{
/// <summary>Scheduled trigger (cron-based).</summary>
Scheduled = 0,
/// <summary>Webhook trigger (registry push, git push).</summary>
Webhook = 1,
/// <summary>Manual trigger (user-initiated).</summary>
Manual = 2,
/// <summary>Backfill trigger (historical scan).</summary>
Backfill = 3,
/// <summary>Retry trigger (retry of failed run).</summary>
Retry = 4
}

View File

@@ -0,0 +1,169 @@
namespace StellaOps.Scanner.Sources.Domain;
/// <summary>
/// Represents a single execution run of an SBOM source.
/// Tracks status, timing, item counts, and any errors.
/// </summary>
public sealed class SbomSourceRun
{
/// <summary>Unique run identifier.</summary>
public Guid RunId { get; init; }
/// <summary>Source that was run.</summary>
public Guid SourceId { get; init; }
/// <summary>Tenant owning the source.</summary>
public string TenantId { get; init; } = null!;
/// <summary>What triggered this run.</summary>
public SbomSourceRunTrigger Trigger { get; init; }
/// <summary>Additional trigger details (webhook payload digest, cron expression, etc.).</summary>
public string? TriggerDetails { get; init; }
/// <summary>Current status of the run.</summary>
public SbomSourceRunStatus Status { get; private set; } = SbomSourceRunStatus.Running;
/// <summary>When the run started.</summary>
public DateTimeOffset StartedAt { get; init; }
/// <summary>When the run completed (if finished).</summary>
public DateTimeOffset? CompletedAt { get; private set; }
/// <summary>Duration in milliseconds.</summary>
public long DurationMs => CompletedAt.HasValue
? (long)(CompletedAt.Value - StartedAt).TotalMilliseconds
: (long)(DateTimeOffset.UtcNow - StartedAt).TotalMilliseconds;
/// <summary>Number of items discovered to scan.</summary>
public int ItemsDiscovered { get; private set; }
/// <summary>Number of items that were scanned.</summary>
public int ItemsScanned { get; private set; }
/// <summary>Number of items that succeeded.</summary>
public int ItemsSucceeded { get; private set; }
/// <summary>Number of items that failed.</summary>
public int ItemsFailed { get; private set; }
/// <summary>Number of items that were skipped.</summary>
public int ItemsSkipped { get; private set; }
/// <summary>IDs of scan jobs created by this run.</summary>
public List<Guid> ScanJobIds { get; init; } = [];
/// <summary>Error message if failed.</summary>
public string? ErrorMessage { get; private set; }
/// <summary>Error stack trace if failed.</summary>
public string? ErrorStackTrace { get; private set; }
/// <summary>Correlation ID for distributed tracing.</summary>
public string CorrelationId { get; init; } = null!;
// -------------------------------------------------------------------------
// Factory Methods
// -------------------------------------------------------------------------
/// <summary>
/// Create a new source run.
/// </summary>
public static SbomSourceRun Create(
Guid sourceId,
string tenantId,
SbomSourceRunTrigger trigger,
string correlationId,
string? triggerDetails = null)
{
return new SbomSourceRun
{
RunId = Guid.NewGuid(),
SourceId = sourceId,
TenantId = tenantId,
Trigger = trigger,
TriggerDetails = triggerDetails,
Status = SbomSourceRunStatus.Running,
StartedAt = DateTimeOffset.UtcNow,
CorrelationId = correlationId
};
}
// -------------------------------------------------------------------------
// Progress Updates
// -------------------------------------------------------------------------
/// <summary>
/// Set the number of discovered items.
/// </summary>
public void SetDiscoveredItems(int count)
{
ItemsDiscovered = count;
}
/// <summary>
/// Record a successfully scanned item.
/// </summary>
public void RecordItemSuccess(Guid scanJobId)
{
ItemsScanned++;
ItemsSucceeded++;
ScanJobIds.Add(scanJobId);
}
/// <summary>
/// Record a failed item.
/// </summary>
public void RecordItemFailure()
{
ItemsScanned++;
ItemsFailed++;
}
/// <summary>
/// Record a skipped item.
/// </summary>
public void RecordItemSkipped()
{
ItemsSkipped++;
}
// -------------------------------------------------------------------------
// Completion
// -------------------------------------------------------------------------
/// <summary>
/// Complete the run successfully.
/// </summary>
public void Complete()
{
Status = ItemsFailed > 0
? SbomSourceRunStatus.PartialSuccess
: ItemsSucceeded > 0
? SbomSourceRunStatus.Succeeded
: SbomSourceRunStatus.Skipped;
CompletedAt = DateTimeOffset.UtcNow;
}
/// <summary>
/// Fail the run with an error.
/// </summary>
public void Fail(string message, string? stackTrace = null)
{
Status = SbomSourceRunStatus.Failed;
ErrorMessage = message;
ErrorStackTrace = stackTrace;
CompletedAt = DateTimeOffset.UtcNow;
}
/// <summary>
/// Cancel the run.
/// </summary>
public void Cancel(string reason)
{
Status = SbomSourceRunStatus.Cancelled;
ErrorMessage = reason;
CompletedAt = DateTimeOffset.UtcNow;
}
}

View File

@@ -0,0 +1,112 @@
using StellaOps.Scanner.Sources.Contracts;
using StellaOps.Scanner.Sources.Domain;
namespace StellaOps.Scanner.Sources.Persistence;
/// <summary>
/// Repository for SBOM source persistence operations.
/// </summary>
public interface ISbomSourceRepository
{
/// <summary>
/// Get a source by ID.
/// </summary>
Task<SbomSource?> GetByIdAsync(string tenantId, Guid sourceId, CancellationToken ct = default);
/// <summary>
/// Get a source by name.
/// </summary>
Task<SbomSource?> GetByNameAsync(string tenantId, string name, CancellationToken ct = default);
/// <summary>
/// List sources with optional filters.
/// </summary>
Task<PagedResponse<SbomSource>> ListAsync(
string tenantId,
ListSourcesRequest request,
CancellationToken ct = default);
/// <summary>
/// Get sources that are due for scheduled execution.
/// </summary>
Task<IReadOnlyList<SbomSource>> GetDueScheduledSourcesAsync(
DateTimeOffset asOf,
int limit = 100,
CancellationToken ct = default);
/// <summary>
/// Create a new source.
/// </summary>
Task CreateAsync(SbomSource source, CancellationToken ct = default);
/// <summary>
/// Update an existing source.
/// </summary>
Task UpdateAsync(SbomSource source, CancellationToken ct = default);
/// <summary>
/// Delete a source.
/// </summary>
Task DeleteAsync(string tenantId, Guid sourceId, CancellationToken ct = default);
/// <summary>
/// Check if a source name exists in the tenant.
/// </summary>
Task<bool> NameExistsAsync(string tenantId, string name, Guid? excludeSourceId = null, CancellationToken ct = default);
}
/// <summary>
/// Repository for SBOM source run persistence operations.
/// </summary>
public interface ISbomSourceRunRepository
{
/// <summary>
/// Get a run by ID.
/// </summary>
Task<SbomSourceRun?> GetByIdAsync(Guid runId, CancellationToken ct = default);
/// <summary>
/// List runs for a source.
/// </summary>
Task<PagedResponse<SbomSourceRun>> ListForSourceAsync(
Guid sourceId,
ListSourceRunsRequest request,
CancellationToken ct = default);
/// <summary>
/// Create a new run.
/// </summary>
Task CreateAsync(SbomSourceRun run, CancellationToken ct = default);
/// <summary>
/// Update an existing run.
/// </summary>
Task UpdateAsync(SbomSourceRun run, CancellationToken ct = default);
/// <summary>
/// Get runs that are still running (for cleanup/recovery).
/// </summary>
Task<IReadOnlyList<SbomSourceRun>> GetStaleRunsAsync(
TimeSpan olderThan,
int limit = 100,
CancellationToken ct = default);
/// <summary>
/// Get aggregate statistics for a source.
/// </summary>
Task<SourceRunStats> GetStatsAsync(Guid sourceId, CancellationToken ct = default);
}
/// <summary>
/// Aggregate run statistics for a source.
/// </summary>
public sealed record SourceRunStats
{
public int TotalRuns { get; init; }
public int SuccessfulRuns { get; init; }
public int FailedRuns { get; init; }
public int PartialRuns { get; init; }
public long AverageDurationMs { get; init; }
public DateTimeOffset? LastSuccessAt { get; init; }
public DateTimeOffset? LastFailureAt { get; init; }
}

View File

@@ -0,0 +1,434 @@
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using Npgsql;
using NpgsqlTypes;
using StellaOps.Infrastructure.Postgres.Repositories;
using StellaOps.Scanner.Sources.Contracts;
using StellaOps.Scanner.Sources.Domain;
namespace StellaOps.Scanner.Sources.Persistence;
/// <summary>
/// PostgreSQL implementation of SBOM source repository.
/// </summary>
public sealed class SbomSourceRepository : RepositoryBase<ScannerSourcesDataSource>, ISbomSourceRepository
{
private const string Schema = "scanner";
private const string Table = "sbom_sources";
private const string FullTable = $"{Schema}.{Table}";
public SbomSourceRepository(
ScannerSourcesDataSource dataSource,
ILogger<SbomSourceRepository> logger)
: base(dataSource, logger)
{
}
public async Task<SbomSource?> GetByIdAsync(string tenantId, Guid sourceId, CancellationToken ct = default)
{
const string sql = $"""
SELECT * FROM {FullTable}
WHERE tenant_id = @tenantId AND source_id = @sourceId
""";
return await QuerySingleOrDefaultAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenantId", tenantId);
AddParameter(cmd, "sourceId", sourceId);
},
MapSource,
ct);
}
public async Task<SbomSource?> GetByNameAsync(string tenantId, string name, CancellationToken ct = default)
{
const string sql = $"""
SELECT * FROM {FullTable}
WHERE tenant_id = @tenantId AND name = @name
""";
return await QuerySingleOrDefaultAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenantId", tenantId);
AddParameter(cmd, "name", name);
},
MapSource,
ct);
}
public async Task<PagedResponse<SbomSource>> ListAsync(
string tenantId,
ListSourcesRequest request,
CancellationToken ct = default)
{
var sb = new StringBuilder($"SELECT * FROM {FullTable} WHERE tenant_id = @tenantId");
var countSb = new StringBuilder($"SELECT COUNT(*) FROM {FullTable} WHERE tenant_id = @tenantId");
void AddFilters(NpgsqlCommand cmd)
{
AddParameter(cmd, "tenantId", tenantId);
if (request.SourceType.HasValue)
{
sb.Append(" AND source_type = @sourceType");
countSb.Append(" AND source_type = @sourceType");
AddParameter(cmd, "sourceType", request.SourceType.Value.ToString());
}
if (request.Status.HasValue)
{
sb.Append(" AND status = @status");
countSb.Append(" AND status = @status");
AddParameter(cmd, "status", request.Status.Value.ToString());
}
if (request.Tags?.Count > 0)
{
sb.Append(" AND tags && @tags");
countSb.Append(" AND tags && @tags");
AddTextArrayParameter(cmd, "tags", request.Tags.ToArray());
}
if (!string.IsNullOrWhiteSpace(request.Search))
{
sb.Append(" AND (name ILIKE @search OR description ILIKE @search)");
countSb.Append(" AND (name ILIKE @search OR description ILIKE @search)");
AddParameter(cmd, "search", $"%{request.Search}%");
}
}
sb.Append(" ORDER BY created_at DESC, source_id");
sb.Append($" LIMIT {request.Limit + 1}");
if (!string.IsNullOrEmpty(request.Cursor))
{
// Cursor is base64 encoded offset
var offset = int.Parse(
Encoding.UTF8.GetString(Convert.FromBase64String(request.Cursor)));
sb.Append($" OFFSET {offset}");
}
var items = await QueryAsync(
tenantId,
sb.ToString(),
AddFilters,
MapSource,
ct);
var totalCount = await ExecuteScalarAsync<long>(
tenantId,
countSb.ToString(),
AddFilters,
ct) ?? 0;
string? nextCursor = null;
if (items.Count > request.Limit)
{
var currentOffset = string.IsNullOrEmpty(request.Cursor)
? 0
: int.Parse(Encoding.UTF8.GetString(Convert.FromBase64String(request.Cursor)));
nextCursor = Convert.ToBase64String(
Encoding.UTF8.GetBytes((currentOffset + request.Limit).ToString()));
items = items.Take(request.Limit).ToList();
}
return new PagedResponse<SbomSource>
{
Items = items,
TotalCount = (int)totalCount,
NextCursor = nextCursor
};
}
public async Task<IReadOnlyList<SbomSource>> GetDueScheduledSourcesAsync(
DateTimeOffset asOf,
int limit = 100,
CancellationToken ct = default)
{
const string sql = $"""
SELECT * FROM {FullTable}
WHERE next_scheduled_run <= @asOf
AND status = 'Active'
AND paused = false
AND cron_schedule IS NOT NULL
ORDER BY next_scheduled_run
LIMIT @limit
""";
// Use a system tenant context for cross-tenant queries
return await QueryAsync(
"__system__",
sql,
cmd =>
{
AddParameter(cmd, "asOf", asOf);
AddParameter(cmd, "limit", limit);
},
MapSource,
ct);
}
public async Task CreateAsync(SbomSource source, CancellationToken ct = default)
{
const string sql = $"""
INSERT INTO {FullTable} (
source_id, tenant_id, name, description, source_type, status,
configuration, auth_ref, webhook_endpoint, webhook_secret_ref,
cron_schedule, cron_timezone, next_scheduled_run,
last_run_at, last_run_status, last_run_error, consecutive_failures,
paused, pause_reason, pause_ticket, paused_at, paused_by,
max_scans_per_hour, current_hour_scans, hour_window_start,
created_at, created_by, updated_at, updated_by, tags, metadata
) VALUES (
@sourceId, @tenantId, @name, @description, @sourceType, @status,
@configuration, @authRef, @webhookEndpoint, @webhookSecretRef,
@cronSchedule, @cronTimezone, @nextScheduledRun,
@lastRunAt, @lastRunStatus, @lastRunError, @consecutiveFailures,
@paused, @pauseReason, @pauseTicket, @pausedAt, @pausedBy,
@maxScansPerHour, @currentHourScans, @hourWindowStart,
@createdAt, @createdBy, @updatedAt, @updatedBy, @tags, @metadata
)
""";
await ExecuteAsync(
source.TenantId,
sql,
cmd => ConfigureSourceParams(cmd, source),
ct);
}
public async Task UpdateAsync(SbomSource source, CancellationToken ct = default)
{
const string sql = $"""
UPDATE {FullTable} SET
name = @name,
description = @description,
status = @status,
configuration = @configuration,
auth_ref = @authRef,
webhook_endpoint = @webhookEndpoint,
webhook_secret_ref = @webhookSecretRef,
cron_schedule = @cronSchedule,
cron_timezone = @cronTimezone,
next_scheduled_run = @nextScheduledRun,
last_run_at = @lastRunAt,
last_run_status = @lastRunStatus,
last_run_error = @lastRunError,
consecutive_failures = @consecutiveFailures,
paused = @paused,
pause_reason = @pauseReason,
pause_ticket = @pauseTicket,
paused_at = @pausedAt,
paused_by = @pausedBy,
max_scans_per_hour = @maxScansPerHour,
current_hour_scans = @currentHourScans,
hour_window_start = @hourWindowStart,
updated_at = @updatedAt,
updated_by = @updatedBy,
tags = @tags,
metadata = @metadata
WHERE tenant_id = @tenantId AND source_id = @sourceId
""";
await ExecuteAsync(
source.TenantId,
sql,
cmd => ConfigureSourceParams(cmd, source),
ct);
}
public async Task DeleteAsync(string tenantId, Guid sourceId, CancellationToken ct = default)
{
const string sql = $"""
DELETE FROM {FullTable}
WHERE tenant_id = @tenantId AND source_id = @sourceId
""";
await ExecuteAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenantId", tenantId);
AddParameter(cmd, "sourceId", sourceId);
},
ct);
}
public async Task<bool> NameExistsAsync(
string tenantId,
string name,
Guid? excludeSourceId = null,
CancellationToken ct = default)
{
var sql = $"""
SELECT EXISTS(
SELECT 1 FROM {FullTable}
WHERE tenant_id = @tenantId AND name = @name
""";
if (excludeSourceId.HasValue)
{
sql += " AND source_id != @excludeSourceId";
}
sql += ")";
return await ExecuteScalarAsync<bool>(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenantId", tenantId);
AddParameter(cmd, "name", name);
if (excludeSourceId.HasValue)
{
AddParameter(cmd, "excludeSourceId", excludeSourceId.Value);
}
},
ct);
}
private void ConfigureSourceParams(NpgsqlCommand cmd, SbomSource source)
{
AddParameter(cmd, "sourceId", source.SourceId);
AddParameter(cmd, "tenantId", source.TenantId);
AddParameter(cmd, "name", source.Name);
AddParameter(cmd, "description", source.Description);
AddParameter(cmd, "sourceType", source.SourceType.ToString());
AddParameter(cmd, "status", source.Status.ToString());
// JSONB configuration
cmd.Parameters.Add(new NpgsqlParameter("configuration", NpgsqlDbType.Jsonb)
{
Value = source.Configuration.RootElement.GetRawText()
});
AddParameter(cmd, "authRef", source.AuthRef);
AddParameter(cmd, "webhookEndpoint", source.WebhookEndpoint);
AddParameter(cmd, "webhookSecretRef", source.WebhookSecretRef);
AddParameter(cmd, "cronSchedule", source.CronSchedule);
AddParameter(cmd, "cronTimezone", source.CronTimezone);
AddParameter(cmd, "nextScheduledRun", source.NextScheduledRun);
AddParameter(cmd, "lastRunAt", source.LastRunAt);
AddParameter(cmd, "lastRunStatus", source.LastRunStatus?.ToString());
AddParameter(cmd, "lastRunError", source.LastRunError);
AddParameter(cmd, "consecutiveFailures", source.ConsecutiveFailures);
AddParameter(cmd, "paused", source.Paused);
AddParameter(cmd, "pauseReason", source.PauseReason);
AddParameter(cmd, "pauseTicket", source.PauseTicket);
AddParameter(cmd, "pausedAt", source.PausedAt);
AddParameter(cmd, "pausedBy", source.PausedBy);
AddParameter(cmd, "maxScansPerHour", source.MaxScansPerHour);
AddParameter(cmd, "currentHourScans", source.CurrentHourScans);
AddParameter(cmd, "hourWindowStart", source.HourWindowStart);
AddParameter(cmd, "createdAt", source.CreatedAt);
AddParameter(cmd, "createdBy", source.CreatedBy);
AddParameter(cmd, "updatedAt", source.UpdatedAt);
AddParameter(cmd, "updatedBy", source.UpdatedBy);
AddTextArrayParameter(cmd, "tags", source.Tags.ToArray());
// JSONB metadata
cmd.Parameters.Add(new NpgsqlParameter("metadata", NpgsqlDbType.Jsonb)
{
Value = JsonSerializer.Serialize(source.Metadata)
});
}
private static SbomSource MapSource(NpgsqlDataReader reader)
{
var sourceIdOrd = reader.GetOrdinal("source_id");
var tenantIdOrd = reader.GetOrdinal("tenant_id");
var nameOrd = reader.GetOrdinal("name");
var descriptionOrd = reader.GetOrdinal("description");
var sourceTypeOrd = reader.GetOrdinal("source_type");
var statusOrd = reader.GetOrdinal("status");
var configurationOrd = reader.GetOrdinal("configuration");
var authRefOrd = reader.GetOrdinal("auth_ref");
var webhookEndpointOrd = reader.GetOrdinal("webhook_endpoint");
var webhookSecretRefOrd = reader.GetOrdinal("webhook_secret_ref");
var cronScheduleOrd = reader.GetOrdinal("cron_schedule");
var cronTimezoneOrd = reader.GetOrdinal("cron_timezone");
var nextScheduledRunOrd = reader.GetOrdinal("next_scheduled_run");
var lastRunAtOrd = reader.GetOrdinal("last_run_at");
var lastRunStatusOrd = reader.GetOrdinal("last_run_status");
var lastRunErrorOrd = reader.GetOrdinal("last_run_error");
var consecutiveFailuresOrd = reader.GetOrdinal("consecutive_failures");
var pausedOrd = reader.GetOrdinal("paused");
var pauseReasonOrd = reader.GetOrdinal("pause_reason");
var pauseTicketOrd = reader.GetOrdinal("pause_ticket");
var pausedAtOrd = reader.GetOrdinal("paused_at");
var pausedByOrd = reader.GetOrdinal("paused_by");
var maxScansPerHourOrd = reader.GetOrdinal("max_scans_per_hour");
var currentHourScansOrd = reader.GetOrdinal("current_hour_scans");
var hourWindowStartOrd = reader.GetOrdinal("hour_window_start");
var createdAtOrd = reader.GetOrdinal("created_at");
var createdByOrd = reader.GetOrdinal("created_by");
var updatedAtOrd = reader.GetOrdinal("updated_at");
var updatedByOrd = reader.GetOrdinal("updated_by");
var tagsOrd = reader.GetOrdinal("tags");
var metadataOrd = reader.GetOrdinal("metadata");
var configJson = reader.GetString(configurationOrd);
var metadataJson = GetNullableString(reader, metadataOrd) ?? "{}";
// Use reflection to set private setters (domain model encapsulation)
var source = new SbomSource
{
SourceId = reader.GetGuid(sourceIdOrd),
TenantId = reader.GetString(tenantIdOrd),
Name = reader.GetString(nameOrd),
Description = GetNullableString(reader, descriptionOrd),
SourceType = Enum.Parse<SbomSourceType>(reader.GetString(sourceTypeOrd)),
Configuration = JsonDocument.Parse(configJson),
AuthRef = GetNullableString(reader, authRefOrd),
CronSchedule = GetNullableString(reader, cronScheduleOrd),
CronTimezone = GetNullableString(reader, cronTimezoneOrd),
MaxScansPerHour = GetNullableInt32(reader, maxScansPerHourOrd),
CreatedAt = reader.GetFieldValue<DateTimeOffset>(createdAtOrd),
CreatedBy = reader.GetString(createdByOrd),
Tags = reader.GetFieldValue<string[]>(tagsOrd).ToList(),
Metadata = JsonSerializer.Deserialize<Dictionary<string, string>>(metadataJson) ?? []
};
// Set private properties via reflection (maintaining domain encapsulation)
SetPrivateProperty(source, "Status", Enum.Parse<SbomSourceStatus>(reader.GetString(statusOrd)));
SetPrivateProperty(source, "WebhookEndpoint", GetNullableString(reader, webhookEndpointOrd));
SetPrivateProperty(source, "WebhookSecretRef", GetNullableString(reader, webhookSecretRefOrd));
SetPrivateProperty(source, "NextScheduledRun", GetNullableDateTimeOffset(reader, nextScheduledRunOrd));
SetPrivateProperty(source, "LastRunAt", GetNullableDateTimeOffset(reader, lastRunAtOrd));
var lastRunStatusStr = GetNullableString(reader, lastRunStatusOrd);
if (lastRunStatusStr != null)
{
SetPrivateProperty(source, "LastRunStatus", Enum.Parse<SbomSourceRunStatus>(lastRunStatusStr));
}
SetPrivateProperty(source, "LastRunError", GetNullableString(reader, lastRunErrorOrd));
SetPrivateProperty(source, "ConsecutiveFailures", reader.GetInt32(consecutiveFailuresOrd));
SetPrivateProperty(source, "Paused", reader.GetBoolean(pausedOrd));
SetPrivateProperty(source, "PauseReason", GetNullableString(reader, pauseReasonOrd));
SetPrivateProperty(source, "PauseTicket", GetNullableString(reader, pauseTicketOrd));
SetPrivateProperty(source, "PausedAt", GetNullableDateTimeOffset(reader, pausedAtOrd));
SetPrivateProperty(source, "PausedBy", GetNullableString(reader, pausedByOrd));
SetPrivateProperty(source, "CurrentHourScans", reader.GetInt32(currentHourScansOrd));
SetPrivateProperty(source, "HourWindowStart", GetNullableDateTimeOffset(reader, hourWindowStartOrd));
SetPrivateProperty(source, "UpdatedAt", reader.GetFieldValue<DateTimeOffset>(updatedAtOrd));
SetPrivateProperty(source, "UpdatedBy", reader.GetString(updatedByOrd));
return source;
}
private static void SetPrivateProperty(object obj, string propertyName, object? value)
{
var property = obj.GetType().GetProperty(propertyName);
property?.SetValue(obj, value);
}
}

View File

@@ -0,0 +1,307 @@
using System.Text;
using Microsoft.Extensions.Logging;
using Npgsql;
using NpgsqlTypes;
using StellaOps.Infrastructure.Postgres.Repositories;
using StellaOps.Scanner.Sources.Contracts;
using StellaOps.Scanner.Sources.Domain;
namespace StellaOps.Scanner.Sources.Persistence;
/// <summary>
/// PostgreSQL implementation of SBOM source run repository.
/// </summary>
public sealed class SbomSourceRunRepository : RepositoryBase<ScannerSourcesDataSource>, ISbomSourceRunRepository
{
private const string Schema = "scanner";
private const string Table = "sbom_source_runs";
private const string FullTable = $"{Schema}.{Table}";
public SbomSourceRunRepository(
ScannerSourcesDataSource dataSource,
ILogger<SbomSourceRunRepository> logger)
: base(dataSource, logger)
{
}
public async Task<SbomSourceRun?> GetByIdAsync(Guid runId, CancellationToken ct = default)
{
const string sql = $"""
SELECT * FROM {FullTable}
WHERE run_id = @runId
""";
// Use system tenant for run queries (runs have their own tenant_id)
return await QuerySingleOrDefaultAsync(
"__system__",
sql,
cmd => AddParameter(cmd, "runId", runId),
MapRun,
ct);
}
public async Task<PagedResponse<SbomSourceRun>> ListForSourceAsync(
Guid sourceId,
ListSourceRunsRequest request,
CancellationToken ct = default)
{
var sb = new StringBuilder($"SELECT * FROM {FullTable} WHERE source_id = @sourceId");
var countSb = new StringBuilder($"SELECT COUNT(*) FROM {FullTable} WHERE source_id = @sourceId");
void AddFilters(NpgsqlCommand cmd)
{
AddParameter(cmd, "sourceId", sourceId);
if (request.Trigger.HasValue)
{
sb.Append(" AND trigger = @trigger");
countSb.Append(" AND trigger = @trigger");
AddParameter(cmd, "trigger", request.Trigger.Value.ToString());
}
if (request.Status.HasValue)
{
sb.Append(" AND status = @status");
countSb.Append(" AND status = @status");
AddParameter(cmd, "status", request.Status.Value.ToString());
}
if (request.From.HasValue)
{
sb.Append(" AND started_at >= @from");
countSb.Append(" AND started_at >= @from");
AddParameter(cmd, "from", request.From.Value);
}
if (request.To.HasValue)
{
sb.Append(" AND started_at <= @to");
countSb.Append(" AND started_at <= @to");
AddParameter(cmd, "to", request.To.Value);
}
}
sb.Append(" ORDER BY started_at DESC, run_id");
sb.Append($" LIMIT {request.Limit + 1}");
if (!string.IsNullOrEmpty(request.Cursor))
{
var offset = int.Parse(
Encoding.UTF8.GetString(Convert.FromBase64String(request.Cursor)));
sb.Append($" OFFSET {offset}");
}
var items = await QueryAsync(
"__system__",
sb.ToString(),
AddFilters,
MapRun,
ct);
var totalCount = await ExecuteScalarAsync<long>(
"__system__",
countSb.ToString(),
AddFilters,
ct) ?? 0;
string? nextCursor = null;
if (items.Count > request.Limit)
{
var currentOffset = string.IsNullOrEmpty(request.Cursor)
? 0
: int.Parse(Encoding.UTF8.GetString(Convert.FromBase64String(request.Cursor)));
nextCursor = Convert.ToBase64String(
Encoding.UTF8.GetBytes((currentOffset + request.Limit).ToString()));
items = items.Take(request.Limit).ToList();
}
return new PagedResponse<SbomSourceRun>
{
Items = items,
TotalCount = (int)totalCount,
NextCursor = nextCursor
};
}
public async Task CreateAsync(SbomSourceRun run, CancellationToken ct = default)
{
const string sql = $"""
INSERT INTO {FullTable} (
run_id, source_id, tenant_id, trigger, trigger_details,
status, started_at, completed_at,
items_discovered, items_scanned, items_succeeded, items_failed, items_skipped,
scan_job_ids, error_message, error_stack_trace, correlation_id
) VALUES (
@runId, @sourceId, @tenantId, @trigger, @triggerDetails,
@status, @startedAt, @completedAt,
@itemsDiscovered, @itemsScanned, @itemsSucceeded, @itemsFailed, @itemsSkipped,
@scanJobIds, @errorMessage, @errorStackTrace, @correlationId
)
""";
await ExecuteAsync(
run.TenantId,
sql,
cmd => ConfigureRunParams(cmd, run),
ct);
}
public async Task UpdateAsync(SbomSourceRun run, CancellationToken ct = default)
{
const string sql = $"""
UPDATE {FullTable} SET
status = @status,
completed_at = @completedAt,
items_discovered = @itemsDiscovered,
items_scanned = @itemsScanned,
items_succeeded = @itemsSucceeded,
items_failed = @itemsFailed,
items_skipped = @itemsSkipped,
scan_job_ids = @scanJobIds,
error_message = @errorMessage,
error_stack_trace = @errorStackTrace
WHERE run_id = @runId
""";
await ExecuteAsync(
run.TenantId,
sql,
cmd => ConfigureRunParams(cmd, run),
ct);
}
public async Task<IReadOnlyList<SbomSourceRun>> GetStaleRunsAsync(
TimeSpan olderThan,
int limit = 100,
CancellationToken ct = default)
{
const string sql = $"""
SELECT * FROM {FullTable}
WHERE status = 'Running'
AND started_at < @threshold
ORDER BY started_at
LIMIT @limit
""";
return await QueryAsync(
"__system__",
sql,
cmd =>
{
AddParameter(cmd, "threshold", DateTimeOffset.UtcNow - olderThan);
AddParameter(cmd, "limit", limit);
},
MapRun,
ct);
}
public async Task<SourceRunStats> GetStatsAsync(Guid sourceId, CancellationToken ct = default)
{
const string sql = $"""
SELECT
COUNT(*) as total_runs,
COUNT(*) FILTER (WHERE status = 'Succeeded') as successful_runs,
COUNT(*) FILTER (WHERE status = 'Failed') as failed_runs,
COUNT(*) FILTER (WHERE status = 'PartialSuccess') as partial_runs,
AVG(EXTRACT(EPOCH FROM (completed_at - started_at)) * 1000)::bigint as avg_duration_ms,
MAX(completed_at) FILTER (WHERE status = 'Succeeded') as last_success_at,
MAX(completed_at) FILTER (WHERE status = 'Failed') as last_failure_at
FROM {FullTable}
WHERE source_id = @sourceId
AND completed_at IS NOT NULL
""";
var result = await QuerySingleOrDefaultAsync(
"__system__",
sql,
cmd => AddParameter(cmd, "sourceId", sourceId),
reader => new SourceRunStats
{
TotalRuns = reader.GetInt32(reader.GetOrdinal("total_runs")),
SuccessfulRuns = reader.GetInt32(reader.GetOrdinal("successful_runs")),
FailedRuns = reader.GetInt32(reader.GetOrdinal("failed_runs")),
PartialRuns = reader.GetInt32(reader.GetOrdinal("partial_runs")),
AverageDurationMs = GetNullableInt64(reader, reader.GetOrdinal("avg_duration_ms")) ?? 0,
LastSuccessAt = GetNullableDateTimeOffset(reader, reader.GetOrdinal("last_success_at")),
LastFailureAt = GetNullableDateTimeOffset(reader, reader.GetOrdinal("last_failure_at"))
},
ct);
return result ?? new SourceRunStats();
}
private void ConfigureRunParams(NpgsqlCommand cmd, SbomSourceRun run)
{
AddParameter(cmd, "runId", run.RunId);
AddParameter(cmd, "sourceId", run.SourceId);
AddParameter(cmd, "tenantId", run.TenantId);
AddParameter(cmd, "trigger", run.Trigger.ToString());
AddParameter(cmd, "triggerDetails", run.TriggerDetails);
AddParameter(cmd, "status", run.Status.ToString());
AddParameter(cmd, "startedAt", run.StartedAt);
AddParameter(cmd, "completedAt", run.CompletedAt);
AddParameter(cmd, "itemsDiscovered", run.ItemsDiscovered);
AddParameter(cmd, "itemsScanned", run.ItemsScanned);
AddParameter(cmd, "itemsSucceeded", run.ItemsSucceeded);
AddParameter(cmd, "itemsFailed", run.ItemsFailed);
AddParameter(cmd, "itemsSkipped", run.ItemsSkipped);
AddUuidArrayParameter(cmd, "scanJobIds", run.ScanJobIds.ToArray());
AddParameter(cmd, "errorMessage", run.ErrorMessage);
AddParameter(cmd, "errorStackTrace", run.ErrorStackTrace);
AddParameter(cmd, "correlationId", run.CorrelationId);
}
private static SbomSourceRun MapRun(NpgsqlDataReader reader)
{
var runIdOrd = reader.GetOrdinal("run_id");
var sourceIdOrd = reader.GetOrdinal("source_id");
var tenantIdOrd = reader.GetOrdinal("tenant_id");
var triggerOrd = reader.GetOrdinal("trigger");
var triggerDetailsOrd = reader.GetOrdinal("trigger_details");
var statusOrd = reader.GetOrdinal("status");
var startedAtOrd = reader.GetOrdinal("started_at");
var completedAtOrd = reader.GetOrdinal("completed_at");
var itemsDiscoveredOrd = reader.GetOrdinal("items_discovered");
var itemsScannedOrd = reader.GetOrdinal("items_scanned");
var itemsSucceededOrd = reader.GetOrdinal("items_succeeded");
var itemsFailedOrd = reader.GetOrdinal("items_failed");
var itemsSkippedOrd = reader.GetOrdinal("items_skipped");
var scanJobIdsOrd = reader.GetOrdinal("scan_job_ids");
var errorMessageOrd = reader.GetOrdinal("error_message");
var errorStackTraceOrd = reader.GetOrdinal("error_stack_trace");
var correlationIdOrd = reader.GetOrdinal("correlation_id");
var run = new SbomSourceRun
{
RunId = reader.GetGuid(runIdOrd),
SourceId = reader.GetGuid(sourceIdOrd),
TenantId = reader.GetString(tenantIdOrd),
Trigger = Enum.Parse<SbomSourceRunTrigger>(reader.GetString(triggerOrd)),
TriggerDetails = GetNullableString(reader, triggerDetailsOrd),
StartedAt = reader.GetFieldValue<DateTimeOffset>(startedAtOrd),
ScanJobIds = reader.IsDBNull(scanJobIdsOrd)
? []
: reader.GetFieldValue<Guid[]>(scanJobIdsOrd).ToList(),
CorrelationId = reader.GetString(correlationIdOrd)
};
// Set private properties
SetPrivateProperty(run, "Status", Enum.Parse<SbomSourceRunStatus>(reader.GetString(statusOrd)));
SetPrivateProperty(run, "CompletedAt", GetNullableDateTimeOffset(reader, completedAtOrd));
SetPrivateProperty(run, "ItemsDiscovered", reader.GetInt32(itemsDiscoveredOrd));
SetPrivateProperty(run, "ItemsScanned", reader.GetInt32(itemsScannedOrd));
SetPrivateProperty(run, "ItemsSucceeded", reader.GetInt32(itemsSucceededOrd));
SetPrivateProperty(run, "ItemsFailed", reader.GetInt32(itemsFailedOrd));
SetPrivateProperty(run, "ItemsSkipped", reader.GetInt32(itemsSkippedOrd));
SetPrivateProperty(run, "ErrorMessage", GetNullableString(reader, errorMessageOrd));
SetPrivateProperty(run, "ErrorStackTrace", GetNullableString(reader, errorStackTraceOrd));
return run;
}
private static void SetPrivateProperty(object obj, string propertyName, object? value)
{
var property = obj.GetType().GetProperty(propertyName);
property?.SetValue(obj, value);
}
}

View File

@@ -0,0 +1,22 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Infrastructure.Postgres.Connections;
using StellaOps.Infrastructure.Postgres.Options;
namespace StellaOps.Scanner.Sources.Persistence;
/// <summary>
/// Data source for Scanner Sources PostgreSQL connections.
/// </summary>
public sealed class ScannerSourcesDataSource : DataSourceBase
{
/// <summary>
/// Creates a new Scanner Sources data source.
/// </summary>
public ScannerSourcesDataSource(
IOptions<PostgresOptions> options,
ILogger<ScannerSourcesDataSource> logger)
: base(options, logger)
{
}
}

View File

@@ -0,0 +1,124 @@
using StellaOps.Scanner.Sources.Contracts;
using StellaOps.Scanner.Sources.Domain;
namespace StellaOps.Scanner.Sources.Services;
/// <summary>
/// Service for managing SBOM sources.
/// </summary>
public interface ISbomSourceService
{
/// <summary>
/// Get a source by ID.
/// </summary>
Task<SourceResponse?> GetAsync(string tenantId, Guid sourceId, CancellationToken ct = default);
/// <summary>
/// Get a source by name.
/// </summary>
Task<SourceResponse?> GetByNameAsync(string tenantId, string name, CancellationToken ct = default);
/// <summary>
/// List sources with optional filters.
/// </summary>
Task<PagedResponse<SourceResponse>> ListAsync(
string tenantId,
ListSourcesRequest request,
CancellationToken ct = default);
/// <summary>
/// Create a new source.
/// </summary>
Task<SourceResponse> CreateAsync(
string tenantId,
CreateSourceRequest request,
string createdBy,
CancellationToken ct = default);
/// <summary>
/// Update an existing source.
/// </summary>
Task<SourceResponse> UpdateAsync(
string tenantId,
Guid sourceId,
UpdateSourceRequest request,
string updatedBy,
CancellationToken ct = default);
/// <summary>
/// Delete a source.
/// </summary>
Task DeleteAsync(string tenantId, Guid sourceId, CancellationToken ct = default);
/// <summary>
/// Test source connection.
/// </summary>
Task<ConnectionTestResult> TestConnectionAsync(
string tenantId,
Guid sourceId,
CancellationToken ct = default);
/// <summary>
/// Test connection for a new source (before creation).
/// </summary>
Task<ConnectionTestResult> TestNewConnectionAsync(
string tenantId,
TestConnectionRequest request,
CancellationToken ct = default);
/// <summary>
/// Pause a source.
/// </summary>
Task<SourceResponse> PauseAsync(
string tenantId,
Guid sourceId,
PauseSourceRequest request,
string pausedBy,
CancellationToken ct = default);
/// <summary>
/// Resume a paused source.
/// </summary>
Task<SourceResponse> ResumeAsync(
string tenantId,
Guid sourceId,
string resumedBy,
CancellationToken ct = default);
/// <summary>
/// Trigger a manual scan for a source.
/// </summary>
Task<TriggerScanResult> TriggerScanAsync(
string tenantId,
Guid sourceId,
TriggerScanRequest? request,
string triggeredBy,
CancellationToken ct = default);
/// <summary>
/// Get run history for a source.
/// </summary>
Task<PagedResponse<SourceRunResponse>> GetRunsAsync(
string tenantId,
Guid sourceId,
ListSourceRunsRequest request,
CancellationToken ct = default);
/// <summary>
/// Get run details.
/// </summary>
Task<SourceRunResponse?> GetRunAsync(
string tenantId,
Guid sourceId,
Guid runId,
CancellationToken ct = default);
/// <summary>
/// Activate a source (after validation).
/// </summary>
Task<SourceResponse> ActivateAsync(
string tenantId,
Guid sourceId,
string activatedBy,
CancellationToken ct = default);
}

View File

@@ -0,0 +1,43 @@
using System.Text.Json;
using StellaOps.Scanner.Sources.Contracts;
using StellaOps.Scanner.Sources.Domain;
namespace StellaOps.Scanner.Sources.Services;
/// <summary>
/// Interface for testing source connections.
/// </summary>
public interface ISourceConnectionTester
{
/// <summary>
/// Tests connection to the source using stored credentials.
/// </summary>
Task<ConnectionTestResult> TestAsync(SbomSource source, CancellationToken ct = default);
/// <summary>
/// Tests connection using provided test credentials (for setup validation).
/// </summary>
Task<ConnectionTestResult> TestAsync(
SbomSource source,
JsonDocument? testCredentials,
CancellationToken ct = default);
}
/// <summary>
/// Interface for type-specific connection testing.
/// </summary>
public interface ISourceTypeConnectionTester
{
/// <summary>
/// The source type this tester handles.
/// </summary>
SbomSourceType SourceType { get; }
/// <summary>
/// Tests connection to the source.
/// </summary>
Task<ConnectionTestResult> TestAsync(
SbomSource source,
JsonDocument? overrideCredentials,
CancellationToken ct = default);
}

View File

@@ -0,0 +1,422 @@
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.Persistence;
namespace StellaOps.Scanner.Sources.Services;
/// <summary>
/// Service for managing SBOM sources.
/// </summary>
public sealed class SbomSourceService : ISbomSourceService
{
private readonly ISbomSourceRepository _sourceRepository;
private readonly ISbomSourceRunRepository _runRepository;
private readonly ISourceConfigValidator _configValidator;
private readonly ISourceConnectionTester _connectionTester;
private readonly ILogger<SbomSourceService> _logger;
public SbomSourceService(
ISbomSourceRepository sourceRepository,
ISbomSourceRunRepository runRepository,
ISourceConfigValidator configValidator,
ISourceConnectionTester connectionTester,
ILogger<SbomSourceService> logger)
{
_sourceRepository = sourceRepository;
_runRepository = runRepository;
_configValidator = configValidator;
_connectionTester = connectionTester;
_logger = logger;
}
public async Task<SourceResponse?> GetAsync(string tenantId, Guid sourceId, CancellationToken ct = default)
{
var source = await _sourceRepository.GetByIdAsync(tenantId, sourceId, ct);
return source == null ? null : SourceResponse.FromDomain(source);
}
public async Task<SourceResponse?> GetByNameAsync(string tenantId, string name, CancellationToken ct = default)
{
var source = await _sourceRepository.GetByNameAsync(tenantId, name, ct);
return source == null ? null : SourceResponse.FromDomain(source);
}
public async Task<PagedResponse<SourceResponse>> ListAsync(
string tenantId,
ListSourcesRequest request,
CancellationToken ct = default)
{
var result = await _sourceRepository.ListAsync(tenantId, request, ct);
return new PagedResponse<SourceResponse>
{
Items = result.Items.Select(SourceResponse.FromDomain).ToList(),
TotalCount = result.TotalCount,
NextCursor = result.NextCursor
};
}
public async Task<SourceResponse> CreateAsync(
string tenantId,
CreateSourceRequest request,
string createdBy,
CancellationToken ct = default)
{
// Validate name uniqueness
if (await _sourceRepository.NameExistsAsync(tenantId, request.Name, ct: ct))
{
throw new InvalidOperationException($"Source with name '{request.Name}' already exists");
}
// Validate configuration
var validationResult = _configValidator.Validate(request.SourceType, request.Configuration);
if (!validationResult.IsValid)
{
throw new ArgumentException($"Invalid configuration: {string.Join(", ", validationResult.Errors)}");
}
// Validate cron schedule if provided
if (!string.IsNullOrEmpty(request.CronSchedule))
{
try
{
Cronos.CronExpression.Parse(request.CronSchedule);
}
catch (Exception ex)
{
throw new ArgumentException($"Invalid cron schedule: {ex.Message}", ex);
}
}
// Create domain entity
var source = SbomSource.Create(
tenantId,
request.Name,
request.SourceType,
request.Configuration,
createdBy,
request.Description,
request.AuthRef,
request.CronSchedule,
request.CronTimezone);
if (request.MaxScansPerHour.HasValue)
{
source.MaxScansPerHour = request.MaxScansPerHour;
}
if (request.Tags != null)
{
source.Tags = request.Tags;
}
if (request.Metadata != null)
{
source.Metadata = request.Metadata;
}
await _sourceRepository.CreateAsync(source, ct);
_logger.LogInformation(
"Created source {SourceId} ({Name}) of type {SourceType} for tenant {TenantId}",
source.SourceId, source.Name, source.SourceType, tenantId);
return SourceResponse.FromDomain(source);
}
public async Task<SourceResponse> UpdateAsync(
string tenantId,
Guid sourceId,
UpdateSourceRequest request,
string updatedBy,
CancellationToken ct = default)
{
var source = await _sourceRepository.GetByIdAsync(tenantId, sourceId, ct)
?? throw new KeyNotFoundException($"Source {sourceId} not found");
// Validate name uniqueness if changed
if (request.Name != null && request.Name != source.Name)
{
if (await _sourceRepository.NameExistsAsync(tenantId, request.Name, sourceId, ct))
{
throw new InvalidOperationException($"Source with name '{request.Name}' already exists");
}
}
// Validate configuration if provided
if (request.Configuration != null)
{
var validationResult = _configValidator.Validate(source.SourceType, request.Configuration);
if (!validationResult.IsValid)
{
throw new ArgumentException($"Invalid configuration: {string.Join(", ", validationResult.Errors)}");
}
source.UpdateConfiguration(request.Configuration, updatedBy);
}
// Validate cron schedule if provided
if (request.CronSchedule != null)
{
if (!string.IsNullOrEmpty(request.CronSchedule))
{
try
{
Cronos.CronExpression.Parse(request.CronSchedule);
}
catch (Exception ex)
{
throw new ArgumentException($"Invalid cron schedule: {ex.Message}", ex);
}
}
source.CronSchedule = request.CronSchedule;
source.CalculateNextScheduledRun();
}
// Update simple fields via reflection (maintaining encapsulation)
if (request.Name != null)
{
SetProperty(source, "Name", request.Name);
}
if (request.Description != null)
{
source.Description = request.Description;
}
if (request.AuthRef != null)
{
source.AuthRef = request.AuthRef;
}
if (request.CronTimezone != null)
{
source.CronTimezone = request.CronTimezone;
source.CalculateNextScheduledRun();
}
if (request.MaxScansPerHour.HasValue)
{
source.MaxScansPerHour = request.MaxScansPerHour;
}
if (request.Tags != null)
{
source.Tags = request.Tags;
}
if (request.Metadata != null)
{
source.Metadata = request.Metadata;
}
// Touch updated fields
SetProperty(source, "UpdatedAt", DateTimeOffset.UtcNow);
SetProperty(source, "UpdatedBy", updatedBy);
await _sourceRepository.UpdateAsync(source, ct);
_logger.LogInformation(
"Updated source {SourceId} ({Name}) for tenant {TenantId}",
source.SourceId, source.Name, tenantId);
return SourceResponse.FromDomain(source);
}
public async Task DeleteAsync(string tenantId, Guid sourceId, CancellationToken ct = default)
{
var source = await _sourceRepository.GetByIdAsync(tenantId, sourceId, ct)
?? throw new KeyNotFoundException($"Source {sourceId} not found");
await _sourceRepository.DeleteAsync(tenantId, sourceId, ct);
_logger.LogInformation(
"Deleted source {SourceId} ({Name}) for tenant {TenantId}",
sourceId, source.Name, tenantId);
}
public async Task<ConnectionTestResult> TestConnectionAsync(
string tenantId,
Guid sourceId,
CancellationToken ct = default)
{
var source = await _sourceRepository.GetByIdAsync(tenantId, sourceId, ct)
?? throw new KeyNotFoundException($"Source {sourceId} not found");
return await _connectionTester.TestAsync(source, ct);
}
public async Task<ConnectionTestResult> TestNewConnectionAsync(
string tenantId,
TestConnectionRequest request,
CancellationToken ct = default)
{
// Create a temporary source for testing
var tempSource = SbomSource.Create(
tenantId,
"__test__",
request.SourceType,
request.Configuration,
"__test__",
authRef: request.AuthRef);
return await _connectionTester.TestAsync(tempSource, request.TestCredentials, ct);
}
public async Task<SourceResponse> PauseAsync(
string tenantId,
Guid sourceId,
PauseSourceRequest request,
string pausedBy,
CancellationToken ct = default)
{
var source = await _sourceRepository.GetByIdAsync(tenantId, sourceId, ct)
?? throw new KeyNotFoundException($"Source {sourceId} not found");
source.Pause(request.Reason, request.Ticket, pausedBy);
await _sourceRepository.UpdateAsync(source, ct);
_logger.LogInformation(
"Paused source {SourceId} ({Name}) by {PausedBy}: {Reason}",
sourceId, source.Name, pausedBy, request.Reason);
return SourceResponse.FromDomain(source);
}
public async Task<SourceResponse> ResumeAsync(
string tenantId,
Guid sourceId,
string resumedBy,
CancellationToken ct = default)
{
var source = await _sourceRepository.GetByIdAsync(tenantId, sourceId, ct)
?? throw new KeyNotFoundException($"Source {sourceId} not found");
source.Resume(resumedBy);
await _sourceRepository.UpdateAsync(source, ct);
_logger.LogInformation(
"Resumed source {SourceId} ({Name}) by {ResumedBy}",
sourceId, source.Name, resumedBy);
return SourceResponse.FromDomain(source);
}
public async Task<TriggerScanResult> TriggerScanAsync(
string tenantId,
Guid sourceId,
TriggerScanRequest? request,
string triggeredBy,
CancellationToken ct = default)
{
var source = await _sourceRepository.GetByIdAsync(tenantId, sourceId, ct)
?? throw new KeyNotFoundException($"Source {sourceId} not found");
// Check if source can be triggered
if (source.Status == SbomSourceStatus.Disabled)
{
throw new InvalidOperationException("Cannot trigger a disabled source");
}
if (source.Paused && request?.Force != true)
{
throw new InvalidOperationException($"Source is paused: {source.PauseReason}");
}
if (source.IsRateLimited() && request?.Force != true)
{
throw new InvalidOperationException("Source is rate limited. Use force=true to override.");
}
// Create a run record
var run = SbomSourceRun.Create(
sourceId,
tenantId,
SbomSourceRunTrigger.Manual,
Guid.NewGuid().ToString("N"),
$"Triggered by {triggeredBy}");
await _runRepository.CreateAsync(run, ct);
_logger.LogInformation(
"Triggered manual scan for source {SourceId} ({Name}), run {RunId}",
sourceId, source.Name, run.RunId);
// TODO: Actually dispatch the scan to the trigger service
// For now, just return the run info
return new TriggerScanResult
{
RunId = run.RunId,
Status = run.Status,
Message = "Scan triggered successfully"
};
}
public async Task<PagedResponse<SourceRunResponse>> GetRunsAsync(
string tenantId,
Guid sourceId,
ListSourceRunsRequest request,
CancellationToken ct = default)
{
// Verify source exists and belongs to tenant
var source = await _sourceRepository.GetByIdAsync(tenantId, sourceId, ct)
?? throw new KeyNotFoundException($"Source {sourceId} not found");
var result = await _runRepository.ListForSourceAsync(sourceId, request, ct);
return new PagedResponse<SourceRunResponse>
{
Items = result.Items.Select(SourceRunResponse.FromDomain).ToList(),
TotalCount = result.TotalCount,
NextCursor = result.NextCursor
};
}
public async Task<SourceRunResponse?> GetRunAsync(
string tenantId,
Guid sourceId,
Guid runId,
CancellationToken ct = default)
{
// Verify source exists and belongs to tenant
_ = await _sourceRepository.GetByIdAsync(tenantId, sourceId, ct)
?? throw new KeyNotFoundException($"Source {sourceId} not found");
var run = await _runRepository.GetByIdAsync(runId, ct);
if (run == null || run.SourceId != sourceId)
{
return null;
}
return SourceRunResponse.FromDomain(run);
}
public async Task<SourceResponse> ActivateAsync(
string tenantId,
Guid sourceId,
string activatedBy,
CancellationToken ct = default)
{
var source = await _sourceRepository.GetByIdAsync(tenantId, sourceId, ct)
?? throw new KeyNotFoundException($"Source {sourceId} not found");
source.Activate(activatedBy);
await _sourceRepository.UpdateAsync(source, ct);
_logger.LogInformation(
"Activated source {SourceId} ({Name}) by {ActivatedBy}",
sourceId, source.Name, activatedBy);
return SourceResponse.FromDomain(source);
}
private static void SetProperty(object obj, string propertyName, object value)
{
var property = obj.GetType().GetProperty(propertyName);
property?.SetValue(obj, value);
}
}

View File

@@ -0,0 +1,85 @@
using System.Text.Json;
using Microsoft.Extensions.Logging;
using StellaOps.Scanner.Sources.Contracts;
using StellaOps.Scanner.Sources.Domain;
namespace StellaOps.Scanner.Sources.Services;
/// <summary>
/// Dispatches connection tests to type-specific testers.
/// </summary>
public sealed class SourceConnectionTester : ISourceConnectionTester
{
private readonly IEnumerable<ISourceTypeConnectionTester> _testers;
private readonly ILogger<SourceConnectionTester> _logger;
public SourceConnectionTester(
IEnumerable<ISourceTypeConnectionTester> testers,
ILogger<SourceConnectionTester> logger)
{
_testers = testers;
_logger = logger;
}
public Task<ConnectionTestResult> TestAsync(SbomSource source, CancellationToken ct = default)
{
return TestAsync(source, null, ct);
}
public async Task<ConnectionTestResult> TestAsync(
SbomSource source,
JsonDocument? testCredentials,
CancellationToken ct = default)
{
var tester = _testers.FirstOrDefault(t => t.SourceType == source.SourceType);
if (tester == null)
{
_logger.LogWarning(
"No connection tester registered for source type {SourceType}",
source.SourceType);
return new ConnectionTestResult
{
Success = false,
Message = $"No connection tester available for source type {source.SourceType}",
TestedAt = DateTimeOffset.UtcNow
};
}
try
{
_logger.LogDebug(
"Testing connection for source {SourceId} ({SourceType})",
source.SourceId, source.SourceType);
var result = await tester.TestAsync(source, testCredentials, ct);
_logger.LogInformation(
"Connection test for source {SourceId} {Result}: {Message}",
source.SourceId,
result.Success ? "succeeded" : "failed",
result.Message);
return result;
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
_logger.LogError(ex, "Connection test failed for source {SourceId}", source.SourceId);
return new ConnectionTestResult
{
Success = false,
Message = $"Connection test error: {ex.Message}",
TestedAt = DateTimeOffset.UtcNow,
Details = new Dictionary<string, object>
{
["exceptionType"] = ex.GetType().Name
}
};
}
}
}

View File

@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="utf-8"?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
<RootNamespace>StellaOps.Scanner.Sources</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Options" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" />
<PackageReference Include="Cronos" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../../__Libraries/StellaOps.Infrastructure.Postgres/StellaOps.Infrastructure.Postgres.csproj" />
<ProjectReference Include="../../../__Libraries/StellaOps.Cryptography/StellaOps.Cryptography.csproj" />
</ItemGroup>
</Project>