Fix build and code structure improvements. New but essential UI functionality. CI improvements. Documentation improvements. AI module improvements.
This commit is contained in:
@@ -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; }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user