wip - advisories and ui extensions
This commit is contained in:
@@ -0,0 +1,358 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Scanner.Sources.Configuration;
|
||||
using StellaOps.Scanner.Sources.Contracts;
|
||||
using StellaOps.Scanner.Sources.Domain;
|
||||
using StellaOps.Scanner.Sources.Services;
|
||||
using StellaOps.Scanner.Sources.Triggers;
|
||||
|
||||
namespace StellaOps.Scanner.Sources.Handlers.Cli;
|
||||
|
||||
/// <summary>
|
||||
/// Handler for CLI (external submission) sources.
|
||||
/// Receives SBOM uploads from CI/CD pipelines via the CLI tool.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// CLI sources are passive - they don't discover targets but receive
|
||||
/// submissions from external systems. The handler validates submissions
|
||||
/// against the configured rules.
|
||||
/// </remarks>
|
||||
public sealed class CliSourceHandler : ISourceTypeHandler
|
||||
{
|
||||
private readonly ISourceConfigValidator _configValidator;
|
||||
private readonly ILogger<CliSourceHandler> _logger;
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
};
|
||||
|
||||
public SbomSourceType SourceType => SbomSourceType.Cli;
|
||||
public bool SupportsWebhooks => false;
|
||||
public bool SupportsScheduling => false;
|
||||
public int MaxConcurrentTargets => 100;
|
||||
|
||||
public CliSourceHandler(
|
||||
ISourceConfigValidator configValidator,
|
||||
ILogger<CliSourceHandler> logger)
|
||||
{
|
||||
_configValidator = configValidator;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// CLI sources don't discover targets - submissions come via API.
|
||||
/// This method returns an empty list for scheduled/manual triggers.
|
||||
/// For submissions, the target is created from the submission metadata.
|
||||
/// </summary>
|
||||
public Task<IReadOnlyList<ScanTarget>> DiscoverTargetsAsync(
|
||||
SbomSource source,
|
||||
TriggerContext context,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var config = source.Configuration.Deserialize<CliSourceConfig>(JsonOptions);
|
||||
if (config == null)
|
||||
{
|
||||
_logger.LogWarning("Invalid configuration for source {SourceId}", source.SourceId);
|
||||
return Task.FromResult<IReadOnlyList<ScanTarget>>([]);
|
||||
}
|
||||
|
||||
// CLI sources only process submissions via the SubmissionContext
|
||||
if (context.Metadata.TryGetValue("submissionId", out var submissionId))
|
||||
{
|
||||
// Create target from submission metadata
|
||||
var target = new ScanTarget
|
||||
{
|
||||
Reference = context.Metadata.TryGetValue("reference", out var refValue) ? refValue : submissionId,
|
||||
Metadata = new Dictionary<string, string>(context.Metadata)
|
||||
};
|
||||
|
||||
_logger.LogInformation(
|
||||
"Created target from CLI submission {SubmissionId} for source {SourceId}",
|
||||
submissionId, source.SourceId);
|
||||
|
||||
return Task.FromResult<IReadOnlyList<ScanTarget>>([target]);
|
||||
}
|
||||
|
||||
// For scheduled/manual triggers, CLI sources have nothing to discover
|
||||
_logger.LogDebug(
|
||||
"CLI source {SourceId} has no targets to discover for trigger {Trigger}",
|
||||
source.SourceId, context.Trigger);
|
||||
|
||||
return Task.FromResult<IReadOnlyList<ScanTarget>>([]);
|
||||
}
|
||||
|
||||
public ConfigValidationResult ValidateConfiguration(JsonDocument configuration)
|
||||
{
|
||||
return _configValidator.Validate(SbomSourceType.Cli, configuration);
|
||||
}
|
||||
|
||||
public Task<ConnectionTestResult> TestConnectionAsync(
|
||||
SbomSource source,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var config = source.Configuration.Deserialize<CliSourceConfig>(JsonOptions);
|
||||
if (config == null)
|
||||
{
|
||||
return Task.FromResult(new ConnectionTestResult
|
||||
{
|
||||
Success = false,
|
||||
Message = "Invalid configuration",
|
||||
TestedAt = DateTimeOffset.UtcNow
|
||||
});
|
||||
}
|
||||
|
||||
// CLI sources don't have external connections to test
|
||||
// We just validate the configuration
|
||||
return Task.FromResult(new ConnectionTestResult
|
||||
{
|
||||
Success = true,
|
||||
Message = "CLI source configuration is valid",
|
||||
TestedAt = DateTimeOffset.UtcNow,
|
||||
Details = new Dictionary<string, object>
|
||||
{
|
||||
["allowedTools"] = config.AllowedTools,
|
||||
["allowedFormats"] = config.Validation.AllowedFormats.Select(f => f.ToString()).ToArray(),
|
||||
["requireSignedSbom"] = config.Validation.RequireSignedSbom,
|
||||
["maxSbomSizeMb"] = config.Validation.MaxSbomSizeBytes / (1024 * 1024)
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validate an SBOM submission against the source configuration.
|
||||
/// </summary>
|
||||
public SubmissionValidationResult ValidateSubmission(
|
||||
SbomSource source,
|
||||
CliSubmissionRequest submission)
|
||||
{
|
||||
var config = source.Configuration.Deserialize<CliSourceConfig>(JsonOptions);
|
||||
if (config == null)
|
||||
{
|
||||
return SubmissionValidationResult.Failed("Invalid source configuration");
|
||||
}
|
||||
|
||||
var errors = new List<string>();
|
||||
|
||||
// Validate tool
|
||||
if (!config.AllowedTools.Contains(submission.Tool, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
errors.Add($"Tool '{submission.Tool}' is not allowed. Allowed tools: {string.Join(", ", config.AllowedTools)}");
|
||||
}
|
||||
|
||||
// Validate CI system if specified
|
||||
if (config.AllowedCiSystems is { Length: > 0 } && submission.CiSystem != null)
|
||||
{
|
||||
if (!config.AllowedCiSystems.Contains(submission.CiSystem, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
errors.Add($"CI system '{submission.CiSystem}' is not allowed. Allowed systems: {string.Join(", ", config.AllowedCiSystems)}");
|
||||
}
|
||||
}
|
||||
|
||||
// Validate format
|
||||
if (!config.Validation.AllowedFormats.Contains(submission.Format))
|
||||
{
|
||||
errors.Add($"Format '{submission.Format}' is not allowed. Allowed formats: {string.Join(", ", config.Validation.AllowedFormats)}");
|
||||
}
|
||||
|
||||
// Validate size
|
||||
if (submission.SbomSizeBytes > config.Validation.MaxSbomSizeBytes)
|
||||
{
|
||||
var maxMb = config.Validation.MaxSbomSizeBytes / (1024 * 1024);
|
||||
var actualMb = submission.SbomSizeBytes / (1024 * 1024);
|
||||
errors.Add($"SBOM size ({actualMb} MB) exceeds maximum allowed size ({maxMb} MB)");
|
||||
}
|
||||
|
||||
// Validate signature if required
|
||||
if (config.Validation.RequireSignedSbom && string.IsNullOrEmpty(submission.Signature))
|
||||
{
|
||||
errors.Add("Signed SBOM is required but no signature was provided");
|
||||
}
|
||||
|
||||
// Validate signer if signature is present
|
||||
if (!string.IsNullOrEmpty(submission.Signature) &&
|
||||
config.Validation.AllowedSigners is { Length: > 0 })
|
||||
{
|
||||
if (!config.Validation.AllowedSigners.Contains(submission.SignerFingerprint, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
errors.Add($"Signer fingerprint '{submission.SignerFingerprint}' is not in the allowed list");
|
||||
}
|
||||
}
|
||||
|
||||
// Validate attribution requirements
|
||||
if (config.Attribution.RequireBuildId && string.IsNullOrEmpty(submission.BuildId))
|
||||
{
|
||||
errors.Add("Build ID is required");
|
||||
}
|
||||
|
||||
if (config.Attribution.RequireRepository && string.IsNullOrEmpty(submission.Repository))
|
||||
{
|
||||
errors.Add("Repository reference is required");
|
||||
}
|
||||
|
||||
if (config.Attribution.RequireCommitSha && string.IsNullOrEmpty(submission.CommitSha))
|
||||
{
|
||||
errors.Add("Commit SHA is required");
|
||||
}
|
||||
|
||||
if (config.Attribution.RequirePipelineId && string.IsNullOrEmpty(submission.PipelineId))
|
||||
{
|
||||
errors.Add("Pipeline ID is required");
|
||||
}
|
||||
|
||||
// Validate repository against allowed patterns
|
||||
if (!string.IsNullOrEmpty(submission.Repository) &&
|
||||
config.Attribution.AllowedRepositories is { Length: > 0 })
|
||||
{
|
||||
var repoAllowed = config.Attribution.AllowedRepositories
|
||||
.Any(p => MatchesPattern(submission.Repository, p));
|
||||
|
||||
if (!repoAllowed)
|
||||
{
|
||||
errors.Add($"Repository '{submission.Repository}' is not in the allowed list");
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.Count > 0)
|
||||
{
|
||||
return SubmissionValidationResult.Failed(errors);
|
||||
}
|
||||
|
||||
return SubmissionValidationResult.Valid();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generate a token for CLI authentication to this source.
|
||||
/// </summary>
|
||||
public CliAuthToken GenerateAuthToken(SbomSource source, TimeSpan validity)
|
||||
{
|
||||
var tokenBytes = new byte[32];
|
||||
RandomNumberGenerator.Fill(tokenBytes);
|
||||
var token = Convert.ToBase64String(tokenBytes);
|
||||
|
||||
// Create token hash for storage
|
||||
var tokenHash = SHA256.HashData(Encoding.UTF8.GetBytes(token));
|
||||
|
||||
return new CliAuthToken
|
||||
{
|
||||
Token = token,
|
||||
TokenHash = Convert.ToHexString(tokenHash).ToLowerInvariant(),
|
||||
SourceId = source.SourceId,
|
||||
ExpiresAt = DateTimeOffset.UtcNow.Add(validity),
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
}
|
||||
|
||||
private static bool MatchesPattern(string value, string pattern)
|
||||
{
|
||||
var regexPattern = "^" + Regex.Escape(pattern)
|
||||
.Replace("\\*\\*", ".*")
|
||||
.Replace("\\*", "[^/]*")
|
||||
.Replace("\\?", ".") + "$";
|
||||
|
||||
return Regex.IsMatch(value, regexPattern, RegexOptions.IgnoreCase);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request for CLI SBOM submission.
|
||||
/// </summary>
|
||||
public sealed record CliSubmissionRequest
|
||||
{
|
||||
/// <summary>Scanner/tool that generated the SBOM.</summary>
|
||||
public required string Tool { get; init; }
|
||||
|
||||
/// <summary>Tool version.</summary>
|
||||
public string? ToolVersion { get; init; }
|
||||
|
||||
/// <summary>CI system (e.g., "github-actions", "gitlab-ci").</summary>
|
||||
public string? CiSystem { get; init; }
|
||||
|
||||
/// <summary>SBOM format.</summary>
|
||||
public required SbomFormat Format { get; init; }
|
||||
|
||||
/// <summary>SBOM format version.</summary>
|
||||
public string? FormatVersion { get; init; }
|
||||
|
||||
/// <summary>SBOM size in bytes.</summary>
|
||||
public long SbomSizeBytes { get; init; }
|
||||
|
||||
/// <summary>SBOM content hash (for verification).</summary>
|
||||
public string? ContentHash { get; init; }
|
||||
|
||||
/// <summary>SBOM signature (if signed).</summary>
|
||||
public string? Signature { get; init; }
|
||||
|
||||
/// <summary>Signer key fingerprint.</summary>
|
||||
public string? SignerFingerprint { get; init; }
|
||||
|
||||
/// <summary>Build ID.</summary>
|
||||
public string? BuildId { get; init; }
|
||||
|
||||
/// <summary>Repository URL.</summary>
|
||||
public string? Repository { get; init; }
|
||||
|
||||
/// <summary>Commit SHA.</summary>
|
||||
public string? CommitSha { get; init; }
|
||||
|
||||
/// <summary>Branch name.</summary>
|
||||
public string? Branch { get; init; }
|
||||
|
||||
/// <summary>Pipeline/workflow ID.</summary>
|
||||
public string? PipelineId { get; init; }
|
||||
|
||||
/// <summary>Pipeline/workflow name.</summary>
|
||||
public string? PipelineName { get; init; }
|
||||
|
||||
/// <summary>Subject reference (what was scanned).</summary>
|
||||
public required string Subject { get; init; }
|
||||
|
||||
/// <summary>Subject digest.</summary>
|
||||
public string? SubjectDigest { get; init; }
|
||||
|
||||
/// <summary>Additional metadata.</summary>
|
||||
public Dictionary<string, string> Metadata { get; init; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of submission validation.
|
||||
/// </summary>
|
||||
public sealed record SubmissionValidationResult
|
||||
{
|
||||
public bool IsValid { get; init; }
|
||||
public IReadOnlyList<string> Errors { get; init; } = [];
|
||||
|
||||
public static SubmissionValidationResult Valid() =>
|
||||
new() { IsValid = true };
|
||||
|
||||
public static SubmissionValidationResult Failed(string error) =>
|
||||
new() { IsValid = false, Errors = [error] };
|
||||
|
||||
public static SubmissionValidationResult Failed(IReadOnlyList<string> errors) =>
|
||||
new() { IsValid = false, Errors = errors };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// CLI authentication token.
|
||||
/// </summary>
|
||||
public sealed record CliAuthToken
|
||||
{
|
||||
/// <summary>The raw token (only returned once on creation).</summary>
|
||||
public required string Token { get; init; }
|
||||
|
||||
/// <summary>Hash of the token (stored in database).</summary>
|
||||
public required string TokenHash { get; init; }
|
||||
|
||||
/// <summary>Source this token is for.</summary>
|
||||
public Guid SourceId { get; init; }
|
||||
|
||||
/// <summary>When the token expires.</summary>
|
||||
public DateTimeOffset ExpiresAt { get; init; }
|
||||
|
||||
/// <summary>When the token was created.</summary>
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Scanner.Sources.Configuration;
|
||||
using StellaOps.Scanner.Sources.Contracts;
|
||||
using StellaOps.Scanner.Sources.Domain;
|
||||
using StellaOps.Scanner.Sources.Handlers.Zastava;
|
||||
using StellaOps.Scanner.Sources.Services;
|
||||
using StellaOps.Scanner.Sources.Triggers;
|
||||
|
||||
namespace StellaOps.Scanner.Sources.Handlers.Docker;
|
||||
|
||||
/// <summary>
|
||||
/// Handler for Docker (direct image scan) sources.
|
||||
/// Scans specific images from container registries on schedule or on-demand.
|
||||
/// </summary>
|
||||
public sealed class DockerSourceHandler : ISourceTypeHandler
|
||||
{
|
||||
private readonly IRegistryClientFactory _clientFactory;
|
||||
private readonly ICredentialResolver _credentialResolver;
|
||||
private readonly ISourceConfigValidator _configValidator;
|
||||
private readonly IImageDiscoveryService _discoveryService;
|
||||
private readonly ILogger<DockerSourceHandler> _logger;
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
};
|
||||
|
||||
public SbomSourceType SourceType => SbomSourceType.Docker;
|
||||
public bool SupportsWebhooks => false;
|
||||
public bool SupportsScheduling => true;
|
||||
public int MaxConcurrentTargets => 50;
|
||||
|
||||
public DockerSourceHandler(
|
||||
IRegistryClientFactory clientFactory,
|
||||
ICredentialResolver credentialResolver,
|
||||
ISourceConfigValidator configValidator,
|
||||
IImageDiscoveryService discoveryService,
|
||||
ILogger<DockerSourceHandler> logger)
|
||||
{
|
||||
_clientFactory = clientFactory;
|
||||
_credentialResolver = credentialResolver;
|
||||
_configValidator = configValidator;
|
||||
_discoveryService = discoveryService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<ScanTarget>> DiscoverTargetsAsync(
|
||||
SbomSource source,
|
||||
TriggerContext context,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var config = source.Configuration.Deserialize<DockerSourceConfig>(JsonOptions);
|
||||
if (config == null)
|
||||
{
|
||||
_logger.LogWarning("Invalid configuration for source {SourceId}", source.SourceId);
|
||||
return [];
|
||||
}
|
||||
|
||||
var credentials = await GetCredentialsAsync(source.AuthRef, ct);
|
||||
var registryType = InferRegistryType(config.RegistryUrl);
|
||||
|
||||
using var client = _clientFactory.Create(registryType, config.RegistryUrl, credentials);
|
||||
|
||||
var targets = new List<ScanTarget>();
|
||||
|
||||
foreach (var imageSpec in config.Images)
|
||||
{
|
||||
try
|
||||
{
|
||||
var discovered = await DiscoverImageTargetsAsync(
|
||||
client, config, imageSpec, ct);
|
||||
targets.AddRange(discovered);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex,
|
||||
"Failed to discover targets for image {Reference}",
|
||||
imageSpec.Reference);
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Discovered {Count} targets from {ImageCount} image specs for source {SourceId}",
|
||||
targets.Count, config.Images.Length, source.SourceId);
|
||||
|
||||
return targets;
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<ScanTarget>> DiscoverImageTargetsAsync(
|
||||
IRegistryClient client,
|
||||
DockerSourceConfig config,
|
||||
ImageSpec imageSpec,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var targets = new List<ScanTarget>();
|
||||
|
||||
// Parse the reference to get repository and optional tag
|
||||
var (repository, tag) = ParseReference(imageSpec.Reference);
|
||||
|
||||
// If the reference has a specific tag and no patterns, just scan that image
|
||||
if (tag != null && (imageSpec.TagPatterns == null || imageSpec.TagPatterns.Length == 0))
|
||||
{
|
||||
var digest = await client.GetDigestAsync(repository, tag, ct);
|
||||
targets.Add(new ScanTarget
|
||||
{
|
||||
Reference = BuildFullReference(config.RegistryUrl, repository, tag),
|
||||
Digest = digest,
|
||||
Priority = config.ScanOptions.Priority,
|
||||
Metadata = new Dictionary<string, string>
|
||||
{
|
||||
["repository"] = repository,
|
||||
["tag"] = tag,
|
||||
["registryUrl"] = config.RegistryUrl
|
||||
}
|
||||
});
|
||||
return targets;
|
||||
}
|
||||
|
||||
// Discover tags based on patterns
|
||||
var tagPatterns = imageSpec.TagPatterns ?? ["*"];
|
||||
var allTags = await client.ListTagsAsync(repository, tagPatterns, imageSpec.MaxTags * 2, ct);
|
||||
|
||||
// Filter and sort tags
|
||||
var filteredTags = _discoveryService.FilterTags(
|
||||
allTags,
|
||||
config.Discovery?.ExcludePatterns,
|
||||
config.Discovery?.IncludePreRelease ?? false);
|
||||
|
||||
var sortedTags = _discoveryService.SortTags(
|
||||
filteredTags,
|
||||
config.Discovery?.SortOrder ?? TagSortOrder.SemVerDescending);
|
||||
|
||||
// Apply age filter if specified
|
||||
if (imageSpec.MaxAgeHours.HasValue)
|
||||
{
|
||||
var cutoff = DateTimeOffset.UtcNow.AddHours(-imageSpec.MaxAgeHours.Value);
|
||||
sortedTags = sortedTags
|
||||
.Where(t => t.LastUpdated == null || t.LastUpdated >= cutoff)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
// Take the configured number of tags
|
||||
var tagsToScan = sortedTags.Take(imageSpec.MaxTags).ToList();
|
||||
|
||||
foreach (var tagInfo in tagsToScan)
|
||||
{
|
||||
targets.Add(new ScanTarget
|
||||
{
|
||||
Reference = BuildFullReference(config.RegistryUrl, repository, tagInfo.Name),
|
||||
Digest = tagInfo.Digest,
|
||||
Priority = config.ScanOptions.Priority,
|
||||
Metadata = new Dictionary<string, string>
|
||||
{
|
||||
["repository"] = repository,
|
||||
["tag"] = tagInfo.Name,
|
||||
["registryUrl"] = config.RegistryUrl,
|
||||
["digestPin"] = imageSpec.DigestPin.ToString().ToLowerInvariant()
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return targets;
|
||||
}
|
||||
|
||||
public ConfigValidationResult ValidateConfiguration(JsonDocument configuration)
|
||||
{
|
||||
return _configValidator.Validate(SbomSourceType.Docker, configuration);
|
||||
}
|
||||
|
||||
public async Task<ConnectionTestResult> TestConnectionAsync(
|
||||
SbomSource source,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var config = source.Configuration.Deserialize<DockerSourceConfig>(JsonOptions);
|
||||
if (config == null)
|
||||
{
|
||||
return new ConnectionTestResult
|
||||
{
|
||||
Success = false,
|
||||
Message = "Invalid configuration",
|
||||
TestedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var credentials = await GetCredentialsAsync(source.AuthRef, ct);
|
||||
var registryType = InferRegistryType(config.RegistryUrl);
|
||||
using var client = _clientFactory.Create(registryType, config.RegistryUrl, credentials);
|
||||
|
||||
var pingSuccess = await client.PingAsync(ct);
|
||||
if (!pingSuccess)
|
||||
{
|
||||
return new ConnectionTestResult
|
||||
{
|
||||
Success = false,
|
||||
Message = "Registry ping failed",
|
||||
TestedAt = DateTimeOffset.UtcNow,
|
||||
Details = new Dictionary<string, object>
|
||||
{
|
||||
["registryUrl"] = config.RegistryUrl
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Try to get digest for the first image to verify access
|
||||
if (config.Images.Length > 0)
|
||||
{
|
||||
var (repo, tag) = ParseReference(config.Images[0].Reference);
|
||||
var digest = await client.GetDigestAsync(repo, tag ?? "latest", ct);
|
||||
|
||||
return new ConnectionTestResult
|
||||
{
|
||||
Success = true,
|
||||
Message = "Successfully connected to registry",
|
||||
TestedAt = DateTimeOffset.UtcNow,
|
||||
Details = new Dictionary<string, object>
|
||||
{
|
||||
["registryUrl"] = config.RegistryUrl,
|
||||
["testImage"] = config.Images[0].Reference,
|
||||
["imageAccessible"] = digest != null
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return new ConnectionTestResult
|
||||
{
|
||||
Success = true,
|
||||
Message = "Successfully connected to registry",
|
||||
TestedAt = DateTimeOffset.UtcNow,
|
||||
Details = new Dictionary<string, object>
|
||||
{
|
||||
["registryUrl"] = config.RegistryUrl
|
||||
}
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Connection test failed for source {SourceId}", source.SourceId);
|
||||
return new ConnectionTestResult
|
||||
{
|
||||
Success = false,
|
||||
Message = $"Connection failed: {ex.Message}",
|
||||
TestedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<RegistryCredentials?> GetCredentialsAsync(string? authRef, CancellationToken ct)
|
||||
{
|
||||
if (string.IsNullOrEmpty(authRef))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var resolved = await _credentialResolver.ResolveAsync(authRef, ct);
|
||||
if (resolved == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return resolved.Type switch
|
||||
{
|
||||
CredentialType.BasicAuth => new RegistryCredentials
|
||||
{
|
||||
AuthType = RegistryAuthType.Basic,
|
||||
Username = resolved.Username,
|
||||
Password = resolved.Password
|
||||
},
|
||||
CredentialType.BearerToken => new RegistryCredentials
|
||||
{
|
||||
AuthType = RegistryAuthType.Token,
|
||||
Token = resolved.Token
|
||||
},
|
||||
CredentialType.AwsCredentials => new RegistryCredentials
|
||||
{
|
||||
AuthType = RegistryAuthType.AwsEcr,
|
||||
AwsAccessKey = resolved.Properties?.GetValueOrDefault("accessKey"),
|
||||
AwsSecretKey = resolved.Properties?.GetValueOrDefault("secretKey"),
|
||||
AwsRegion = resolved.Properties?.GetValueOrDefault("region")
|
||||
},
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
private static RegistryType InferRegistryType(string registryUrl)
|
||||
{
|
||||
var host = new Uri(registryUrl).Host.ToLowerInvariant();
|
||||
|
||||
return host switch
|
||||
{
|
||||
_ when host.Contains("docker.io") || host.Contains("docker.com") => RegistryType.DockerHub,
|
||||
_ when host.Contains("ecr.") && host.Contains("amazonaws.com") => RegistryType.Ecr,
|
||||
_ when host.Contains("gcr.io") || host.Contains("pkg.dev") => RegistryType.Gcr,
|
||||
_ when host.Contains("azurecr.io") => RegistryType.Acr,
|
||||
_ when host.Contains("ghcr.io") => RegistryType.Ghcr,
|
||||
_ when host.Contains("quay.io") => RegistryType.Quay,
|
||||
_ when host.Contains("jfrog.io") || host.Contains("artifactory") => RegistryType.Artifactory,
|
||||
_ => RegistryType.Generic
|
||||
};
|
||||
}
|
||||
|
||||
private static (string Repository, string? Tag) ParseReference(string reference)
|
||||
{
|
||||
// Handle digest references
|
||||
if (reference.Contains('@'))
|
||||
{
|
||||
var parts = reference.Split('@', 2);
|
||||
return (parts[0], null);
|
||||
}
|
||||
|
||||
// Handle tag references
|
||||
if (reference.Contains(':'))
|
||||
{
|
||||
var lastColon = reference.LastIndexOf(':');
|
||||
return (reference[..lastColon], reference[(lastColon + 1)..]);
|
||||
}
|
||||
|
||||
return (reference, null);
|
||||
}
|
||||
|
||||
private static string BuildFullReference(string registryUrl, string repository, string tag)
|
||||
{
|
||||
var host = new Uri(registryUrl).Host;
|
||||
|
||||
// Docker Hub special case
|
||||
if (host.Contains("docker.io") || host.Contains("docker.com"))
|
||||
{
|
||||
if (!repository.Contains('/'))
|
||||
{
|
||||
repository = $"library/{repository}";
|
||||
}
|
||||
return $"{repository}:{tag}";
|
||||
}
|
||||
|
||||
return $"{host}/{repository}:{tag}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using StellaOps.Scanner.Sources.Configuration;
|
||||
using StellaOps.Scanner.Sources.Handlers.Zastava;
|
||||
|
||||
namespace StellaOps.Scanner.Sources.Handlers.Docker;
|
||||
|
||||
/// <summary>
|
||||
/// Service for discovering and filtering container image tags.
|
||||
/// </summary>
|
||||
public interface IImageDiscoveryService
|
||||
{
|
||||
/// <summary>
|
||||
/// Filter tags based on exclusion patterns and pre-release settings.
|
||||
/// </summary>
|
||||
IReadOnlyList<RegistryTag> FilterTags(
|
||||
IReadOnlyList<RegistryTag> tags,
|
||||
string[]? excludePatterns,
|
||||
bool includePreRelease);
|
||||
|
||||
/// <summary>
|
||||
/// Sort tags according to the specified sort order.
|
||||
/// </summary>
|
||||
IReadOnlyList<RegistryTag> SortTags(
|
||||
IReadOnlyList<RegistryTag> tags,
|
||||
TagSortOrder sortOrder);
|
||||
|
||||
/// <summary>
|
||||
/// Parse a semantic version from a tag name.
|
||||
/// </summary>
|
||||
SemVer? ParseSemVer(string tag);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of tag discovery and filtering.
|
||||
/// </summary>
|
||||
public sealed class ImageDiscoveryService : IImageDiscoveryService
|
||||
{
|
||||
private static readonly Regex SemVerRegex = new(
|
||||
@"^v?(?<major>\d+)\.(?<minor>\d+)\.(?<patch>\d+)" +
|
||||
@"(?:-(?<prerelease>[a-zA-Z0-9.-]+))?" +
|
||||
@"(?:\+(?<metadata>[a-zA-Z0-9.-]+))?$",
|
||||
RegexOptions.Compiled | RegexOptions.IgnoreCase);
|
||||
|
||||
private static readonly Regex PreReleasePattern = new(
|
||||
@"(?:alpha|beta|rc|pre|preview|dev|snapshot|canary|nightly)",
|
||||
RegexOptions.Compiled | RegexOptions.IgnoreCase);
|
||||
|
||||
public IReadOnlyList<RegistryTag> FilterTags(
|
||||
IReadOnlyList<RegistryTag> tags,
|
||||
string[]? excludePatterns,
|
||||
bool includePreRelease)
|
||||
{
|
||||
var filtered = tags.AsEnumerable();
|
||||
|
||||
// Apply exclusion patterns
|
||||
if (excludePatterns is { Length: > 0 })
|
||||
{
|
||||
var regexPatterns = excludePatterns
|
||||
.Select(p => new Regex(
|
||||
"^" + Regex.Escape(p).Replace("\\*", ".*").Replace("\\?", ".") + "$",
|
||||
RegexOptions.IgnoreCase))
|
||||
.ToList();
|
||||
|
||||
filtered = filtered.Where(t =>
|
||||
!regexPatterns.Any(r => r.IsMatch(t.Name)));
|
||||
}
|
||||
|
||||
// Filter pre-release tags if not included
|
||||
if (!includePreRelease)
|
||||
{
|
||||
filtered = filtered.Where(t => !IsPreRelease(t.Name));
|
||||
}
|
||||
|
||||
return filtered.ToList();
|
||||
}
|
||||
|
||||
public IReadOnlyList<RegistryTag> SortTags(
|
||||
IReadOnlyList<RegistryTag> tags,
|
||||
TagSortOrder sortOrder)
|
||||
{
|
||||
return sortOrder switch
|
||||
{
|
||||
TagSortOrder.SemVerDescending => tags
|
||||
.Select(t => (Tag: t, SemVer: ParseSemVer(t.Name)))
|
||||
.OrderByDescending(x => x.SemVer?.Major ?? 0)
|
||||
.ThenByDescending(x => x.SemVer?.Minor ?? 0)
|
||||
.ThenByDescending(x => x.SemVer?.Patch ?? 0)
|
||||
.ThenBy(x => x.SemVer?.PreRelease ?? "")
|
||||
.ThenByDescending(x => x.Tag.Name)
|
||||
.Select(x => x.Tag)
|
||||
.ToList(),
|
||||
|
||||
TagSortOrder.SemVerAscending => tags
|
||||
.Select(t => (Tag: t, SemVer: ParseSemVer(t.Name)))
|
||||
.OrderBy(x => x.SemVer?.Major ?? int.MaxValue)
|
||||
.ThenBy(x => x.SemVer?.Minor ?? int.MaxValue)
|
||||
.ThenBy(x => x.SemVer?.Patch ?? int.MaxValue)
|
||||
.ThenByDescending(x => x.SemVer?.PreRelease ?? "")
|
||||
.ThenBy(x => x.Tag.Name)
|
||||
.Select(x => x.Tag)
|
||||
.ToList(),
|
||||
|
||||
TagSortOrder.AlphaDescending => tags
|
||||
.OrderByDescending(t => t.Name)
|
||||
.ToList(),
|
||||
|
||||
TagSortOrder.AlphaAscending => tags
|
||||
.OrderBy(t => t.Name)
|
||||
.ToList(),
|
||||
|
||||
TagSortOrder.DateDescending => tags
|
||||
.OrderByDescending(t => t.LastUpdated ?? DateTimeOffset.MinValue)
|
||||
.ThenByDescending(t => t.Name)
|
||||
.ToList(),
|
||||
|
||||
TagSortOrder.DateAscending => tags
|
||||
.OrderBy(t => t.LastUpdated ?? DateTimeOffset.MaxValue)
|
||||
.ThenBy(t => t.Name)
|
||||
.ToList(),
|
||||
|
||||
_ => tags.ToList()
|
||||
};
|
||||
}
|
||||
|
||||
public SemVer? ParseSemVer(string tag)
|
||||
{
|
||||
var match = SemVerRegex.Match(tag);
|
||||
if (!match.Success)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new SemVer
|
||||
{
|
||||
Major = int.Parse(match.Groups["major"].Value),
|
||||
Minor = int.Parse(match.Groups["minor"].Value),
|
||||
Patch = int.Parse(match.Groups["patch"].Value),
|
||||
PreRelease = match.Groups["prerelease"].Success
|
||||
? match.Groups["prerelease"].Value
|
||||
: null,
|
||||
Metadata = match.Groups["metadata"].Success
|
||||
? match.Groups["metadata"].Value
|
||||
: null
|
||||
};
|
||||
}
|
||||
|
||||
private static bool IsPreRelease(string tagName)
|
||||
{
|
||||
// Check common pre-release indicators
|
||||
if (PreReleasePattern.IsMatch(tagName))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// Also check parsed semver
|
||||
var semver = new ImageDiscoveryService().ParseSemVer(tagName);
|
||||
return semver?.PreRelease != null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a parsed semantic version.
|
||||
/// </summary>
|
||||
public sealed record SemVer : IComparable<SemVer>
|
||||
{
|
||||
public int Major { get; init; }
|
||||
public int Minor { get; init; }
|
||||
public int Patch { get; init; }
|
||||
public string? PreRelease { get; init; }
|
||||
public string? Metadata { get; init; }
|
||||
|
||||
public int CompareTo(SemVer? other)
|
||||
{
|
||||
if (other is null) return 1;
|
||||
|
||||
var majorCompare = Major.CompareTo(other.Major);
|
||||
if (majorCompare != 0) return majorCompare;
|
||||
|
||||
var minorCompare = Minor.CompareTo(other.Minor);
|
||||
if (minorCompare != 0) return minorCompare;
|
||||
|
||||
var patchCompare = Patch.CompareTo(other.Patch);
|
||||
if (patchCompare != 0) return patchCompare;
|
||||
|
||||
// Pre-release versions have lower precedence than release versions
|
||||
if (PreRelease is null && other.PreRelease is not null) return 1;
|
||||
if (PreRelease is not null && other.PreRelease is null) return -1;
|
||||
if (PreRelease is null && other.PreRelease is null) return 0;
|
||||
|
||||
return string.Compare(PreRelease, other.PreRelease, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
public override string ToString()
|
||||
{
|
||||
var result = $"{Major}.{Minor}.{Patch}";
|
||||
if (!string.IsNullOrEmpty(PreRelease))
|
||||
{
|
||||
result += $"-{PreRelease}";
|
||||
}
|
||||
if (!string.IsNullOrEmpty(Metadata))
|
||||
{
|
||||
result += $"+{Metadata}";
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,511 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Scanner.Sources.Configuration;
|
||||
using StellaOps.Scanner.Sources.Contracts;
|
||||
using StellaOps.Scanner.Sources.Domain;
|
||||
using StellaOps.Scanner.Sources.Services;
|
||||
using StellaOps.Scanner.Sources.Triggers;
|
||||
|
||||
namespace StellaOps.Scanner.Sources.Handlers.Git;
|
||||
|
||||
/// <summary>
|
||||
/// Handler for Git (repository) sources.
|
||||
/// Scans source code repositories for dependencies and vulnerabilities.
|
||||
/// </summary>
|
||||
public sealed class GitSourceHandler : ISourceTypeHandler, IWebhookCapableHandler
|
||||
{
|
||||
private readonly IGitClientFactory _gitClientFactory;
|
||||
private readonly ICredentialResolver _credentialResolver;
|
||||
private readonly ISourceConfigValidator _configValidator;
|
||||
private readonly ILogger<GitSourceHandler> _logger;
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
};
|
||||
|
||||
public SbomSourceType SourceType => SbomSourceType.Git;
|
||||
public bool SupportsWebhooks => true;
|
||||
public bool SupportsScheduling => true;
|
||||
public int MaxConcurrentTargets => 10;
|
||||
|
||||
public GitSourceHandler(
|
||||
IGitClientFactory gitClientFactory,
|
||||
ICredentialResolver credentialResolver,
|
||||
ISourceConfigValidator configValidator,
|
||||
ILogger<GitSourceHandler> logger)
|
||||
{
|
||||
_gitClientFactory = gitClientFactory;
|
||||
_credentialResolver = credentialResolver;
|
||||
_configValidator = configValidator;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<ScanTarget>> DiscoverTargetsAsync(
|
||||
SbomSource source,
|
||||
TriggerContext context,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var config = source.Configuration.Deserialize<GitSourceConfig>(JsonOptions);
|
||||
if (config == null)
|
||||
{
|
||||
_logger.LogWarning("Invalid configuration for source {SourceId}", source.SourceId);
|
||||
return [];
|
||||
}
|
||||
|
||||
// For webhook triggers, extract target from payload
|
||||
if (context.Trigger == SbomSourceRunTrigger.Webhook)
|
||||
{
|
||||
if (context.WebhookPayload != null)
|
||||
{
|
||||
var payloadInfo = ParseWebhookPayload(context.WebhookPayload);
|
||||
|
||||
// Check if it matches configured triggers and branch filters
|
||||
if (!ShouldTrigger(payloadInfo, config))
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Webhook payload does not match triggers for source {SourceId}",
|
||||
source.SourceId);
|
||||
return [];
|
||||
}
|
||||
|
||||
return
|
||||
[
|
||||
new ScanTarget
|
||||
{
|
||||
Reference = BuildReference(config.RepositoryUrl, payloadInfo.Branch ?? payloadInfo.Reference),
|
||||
Metadata = new Dictionary<string, string>
|
||||
{
|
||||
["repository"] = config.RepositoryUrl,
|
||||
["branch"] = payloadInfo.Branch ?? "",
|
||||
["commit"] = payloadInfo.CommitSha ?? "",
|
||||
["eventType"] = payloadInfo.EventType,
|
||||
["actor"] = payloadInfo.Actor ?? "unknown"
|
||||
}
|
||||
}
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// For scheduled/manual triggers, discover branches to scan
|
||||
return await DiscoverBranchTargetsAsync(source, config, ct);
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<ScanTarget>> DiscoverBranchTargetsAsync(
|
||||
SbomSource source,
|
||||
GitSourceConfig config,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var credentials = await GetCredentialsAsync(source.AuthRef, config.AuthMethod, ct);
|
||||
using var client = _gitClientFactory.Create(config.Provider, config.RepositoryUrl, credentials);
|
||||
|
||||
var branches = await client.ListBranchesAsync(ct);
|
||||
var targets = new List<ScanTarget>();
|
||||
|
||||
foreach (var branch in branches)
|
||||
{
|
||||
// Check inclusion patterns
|
||||
var included = config.Branches.Include
|
||||
.Any(p => MatchesPattern(branch.Name, p));
|
||||
|
||||
if (!included)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check exclusion patterns
|
||||
var excluded = config.Branches.Exclude?
|
||||
.Any(p => MatchesPattern(branch.Name, p)) ?? false;
|
||||
|
||||
if (excluded)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
targets.Add(new ScanTarget
|
||||
{
|
||||
Reference = BuildReference(config.RepositoryUrl, branch.Name),
|
||||
Metadata = new Dictionary<string, string>
|
||||
{
|
||||
["repository"] = config.RepositoryUrl,
|
||||
["branch"] = branch.Name,
|
||||
["commit"] = branch.HeadCommit ?? "",
|
||||
["eventType"] = "scheduled"
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Discovered {Count} branch targets for source {SourceId}",
|
||||
targets.Count, source.SourceId);
|
||||
|
||||
return targets;
|
||||
}
|
||||
|
||||
public ConfigValidationResult ValidateConfiguration(JsonDocument configuration)
|
||||
{
|
||||
return _configValidator.Validate(SbomSourceType.Git, configuration);
|
||||
}
|
||||
|
||||
public async Task<ConnectionTestResult> TestConnectionAsync(
|
||||
SbomSource source,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var config = source.Configuration.Deserialize<GitSourceConfig>(JsonOptions);
|
||||
if (config == null)
|
||||
{
|
||||
return new ConnectionTestResult
|
||||
{
|
||||
Success = false,
|
||||
Message = "Invalid configuration",
|
||||
TestedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var credentials = await GetCredentialsAsync(source.AuthRef, config.AuthMethod, ct);
|
||||
using var client = _gitClientFactory.Create(config.Provider, config.RepositoryUrl, credentials);
|
||||
|
||||
var repoInfo = await client.GetRepositoryInfoAsync(ct);
|
||||
if (repoInfo == null)
|
||||
{
|
||||
return new ConnectionTestResult
|
||||
{
|
||||
Success = false,
|
||||
Message = "Repository not found or inaccessible",
|
||||
TestedAt = DateTimeOffset.UtcNow,
|
||||
Details = new Dictionary<string, object>
|
||||
{
|
||||
["repositoryUrl"] = config.RepositoryUrl,
|
||||
["provider"] = config.Provider.ToString()
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return new ConnectionTestResult
|
||||
{
|
||||
Success = true,
|
||||
Message = "Successfully connected to repository",
|
||||
TestedAt = DateTimeOffset.UtcNow,
|
||||
Details = new Dictionary<string, object>
|
||||
{
|
||||
["repositoryUrl"] = config.RepositoryUrl,
|
||||
["provider"] = config.Provider.ToString(),
|
||||
["defaultBranch"] = repoInfo.DefaultBranch ?? "",
|
||||
["sizeKb"] = repoInfo.SizeKb
|
||||
}
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Connection test failed for source {SourceId}", source.SourceId);
|
||||
return new ConnectionTestResult
|
||||
{
|
||||
Success = false,
|
||||
Message = $"Connection failed: {ex.Message}",
|
||||
TestedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public bool VerifyWebhookSignature(byte[] payload, string signature, string secret)
|
||||
{
|
||||
if (string.IsNullOrEmpty(signature) || string.IsNullOrEmpty(secret))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// GitHub uses HMAC-SHA256 with "sha256=" prefix
|
||||
if (signature.StartsWith("sha256=", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return VerifyHmacSha256(payload, signature[7..], secret);
|
||||
}
|
||||
|
||||
// GitHub legacy uses HMAC-SHA1 with "sha1=" prefix
|
||||
if (signature.StartsWith("sha1=", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return VerifyHmacSha1(payload, signature[5..], secret);
|
||||
}
|
||||
|
||||
// GitLab uses X-Gitlab-Token header (direct secret comparison)
|
||||
if (!signature.Contains('='))
|
||||
{
|
||||
return string.Equals(signature, secret, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public WebhookPayloadInfo ParseWebhookPayload(JsonDocument payload)
|
||||
{
|
||||
var root = payload.RootElement;
|
||||
|
||||
// GitHub push event
|
||||
if (root.TryGetProperty("ref", out var refProp) &&
|
||||
root.TryGetProperty("repository", out var ghRepo))
|
||||
{
|
||||
var refValue = refProp.GetString() ?? "";
|
||||
var branch = refValue.StartsWith("refs/heads/")
|
||||
? refValue[11..]
|
||||
: refValue.StartsWith("refs/tags/")
|
||||
? refValue[10..]
|
||||
: refValue;
|
||||
|
||||
var isTag = refValue.StartsWith("refs/tags/");
|
||||
|
||||
return new WebhookPayloadInfo
|
||||
{
|
||||
EventType = isTag ? "tag" : "push",
|
||||
Reference = ghRepo.TryGetProperty("full_name", out var fullName)
|
||||
? fullName.GetString()!
|
||||
: "",
|
||||
Branch = branch,
|
||||
CommitSha = root.TryGetProperty("after", out var after)
|
||||
? after.GetString()
|
||||
: null,
|
||||
Actor = root.TryGetProperty("sender", out var sender) &&
|
||||
sender.TryGetProperty("login", out var login)
|
||||
? login.GetString()
|
||||
: null,
|
||||
Timestamp = DateTimeOffset.UtcNow
|
||||
};
|
||||
}
|
||||
|
||||
// GitHub pull request event
|
||||
if (root.TryGetProperty("action", out var action) &&
|
||||
root.TryGetProperty("pull_request", out var pr))
|
||||
{
|
||||
return new WebhookPayloadInfo
|
||||
{
|
||||
EventType = "pull_request",
|
||||
Reference = root.TryGetProperty("repository", out var prRepo) &&
|
||||
prRepo.TryGetProperty("full_name", out var prFullName)
|
||||
? prFullName.GetString()!
|
||||
: "",
|
||||
Branch = pr.TryGetProperty("head", out var head) &&
|
||||
head.TryGetProperty("ref", out var headRef)
|
||||
? headRef.GetString()
|
||||
: null,
|
||||
CommitSha = head.TryGetProperty("sha", out var sha)
|
||||
? sha.GetString()
|
||||
: null,
|
||||
Actor = pr.TryGetProperty("user", out var user) &&
|
||||
user.TryGetProperty("login", out var prLogin)
|
||||
? prLogin.GetString()
|
||||
: null,
|
||||
Metadata = new Dictionary<string, string>
|
||||
{
|
||||
["action"] = action.GetString() ?? "",
|
||||
["prNumber"] = pr.TryGetProperty("number", out var num)
|
||||
? num.GetInt32().ToString()
|
||||
: ""
|
||||
},
|
||||
Timestamp = DateTimeOffset.UtcNow
|
||||
};
|
||||
}
|
||||
|
||||
// GitLab push event
|
||||
if (root.TryGetProperty("object_kind", out var objectKind))
|
||||
{
|
||||
var kind = objectKind.GetString();
|
||||
|
||||
if (kind == "push")
|
||||
{
|
||||
return new WebhookPayloadInfo
|
||||
{
|
||||
EventType = "push",
|
||||
Reference = root.TryGetProperty("project", out var project) &&
|
||||
project.TryGetProperty("path_with_namespace", out var path)
|
||||
? path.GetString()!
|
||||
: "",
|
||||
Branch = root.TryGetProperty("ref", out var glRef)
|
||||
? glRef.GetString()?.Replace("refs/heads/", "") ?? ""
|
||||
: null,
|
||||
CommitSha = root.TryGetProperty("after", out var glAfter)
|
||||
? glAfter.GetString()
|
||||
: null,
|
||||
Actor = root.TryGetProperty("user_name", out var userName)
|
||||
? userName.GetString()
|
||||
: null,
|
||||
Timestamp = DateTimeOffset.UtcNow
|
||||
};
|
||||
}
|
||||
|
||||
if (kind == "merge_request")
|
||||
{
|
||||
var mrAttrs = root.TryGetProperty("object_attributes", out var oa) ? oa : default;
|
||||
return new WebhookPayloadInfo
|
||||
{
|
||||
EventType = "pull_request",
|
||||
Reference = root.TryGetProperty("project", out var mrProject) &&
|
||||
mrProject.TryGetProperty("path_with_namespace", out var mrPath)
|
||||
? mrPath.GetString()!
|
||||
: "",
|
||||
Branch = mrAttrs.TryGetProperty("source_branch", out var srcBranch)
|
||||
? srcBranch.GetString()
|
||||
: null,
|
||||
CommitSha = mrAttrs.TryGetProperty("last_commit", out var lastCommit) &&
|
||||
lastCommit.TryGetProperty("id", out var commitId)
|
||||
? commitId.GetString()
|
||||
: null,
|
||||
Actor = root.TryGetProperty("user", out var glUser) &&
|
||||
glUser.TryGetProperty("username", out var glUsername)
|
||||
? glUsername.GetString()
|
||||
: null,
|
||||
Metadata = new Dictionary<string, string>
|
||||
{
|
||||
["action"] = mrAttrs.TryGetProperty("action", out var mrAction)
|
||||
? mrAction.GetString() ?? ""
|
||||
: ""
|
||||
},
|
||||
Timestamp = DateTimeOffset.UtcNow
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogWarning("Unable to parse Git webhook payload format");
|
||||
return new WebhookPayloadInfo
|
||||
{
|
||||
EventType = "unknown",
|
||||
Reference = "",
|
||||
Timestamp = DateTimeOffset.UtcNow
|
||||
};
|
||||
}
|
||||
|
||||
private bool ShouldTrigger(WebhookPayloadInfo payload, GitSourceConfig config)
|
||||
{
|
||||
// Check event type against configured triggers
|
||||
switch (payload.EventType)
|
||||
{
|
||||
case "push":
|
||||
if (!config.Triggers.OnPush)
|
||||
return false;
|
||||
break;
|
||||
|
||||
case "tag":
|
||||
if (!config.Triggers.OnTag)
|
||||
return false;
|
||||
// Check tag patterns if specified
|
||||
if (config.Triggers.TagPatterns is { Length: > 0 })
|
||||
{
|
||||
if (!config.Triggers.TagPatterns.Any(p => MatchesPattern(payload.Branch ?? "", p)))
|
||||
return false;
|
||||
}
|
||||
break;
|
||||
|
||||
case "pull_request":
|
||||
if (!config.Triggers.OnPullRequest)
|
||||
return false;
|
||||
// Check PR action if specified
|
||||
if (config.Triggers.PrActions is { Length: > 0 })
|
||||
{
|
||||
var actionStr = payload.Metadata.GetValueOrDefault("action", "");
|
||||
var matchedAction = Enum.TryParse<PullRequestAction>(actionStr, ignoreCase: true, out var action)
|
||||
&& config.Triggers.PrActions.Contains(action);
|
||||
if (!matchedAction)
|
||||
return false;
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check branch filters (only for push and PR, not tags)
|
||||
if (payload.EventType != "tag" && !string.IsNullOrEmpty(payload.Branch))
|
||||
{
|
||||
var included = config.Branches.Include.Any(p => MatchesPattern(payload.Branch, p));
|
||||
if (!included)
|
||||
return false;
|
||||
|
||||
var excluded = config.Branches.Exclude?.Any(p => MatchesPattern(payload.Branch, p)) ?? false;
|
||||
if (excluded)
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private async Task<GitCredentials?> GetCredentialsAsync(
|
||||
string? authRef,
|
||||
GitAuthMethod authMethod,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (string.IsNullOrEmpty(authRef))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var resolved = await _credentialResolver.ResolveAsync(authRef, ct);
|
||||
if (resolved == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return authMethod switch
|
||||
{
|
||||
GitAuthMethod.Token => new GitCredentials
|
||||
{
|
||||
AuthType = GitAuthType.Token,
|
||||
Token = resolved.Token ?? resolved.Password
|
||||
},
|
||||
GitAuthMethod.Ssh => new GitCredentials
|
||||
{
|
||||
AuthType = GitAuthType.Ssh,
|
||||
SshPrivateKey = resolved.Properties?.GetValueOrDefault("privateKey"),
|
||||
SshPassphrase = resolved.Properties?.GetValueOrDefault("passphrase")
|
||||
},
|
||||
GitAuthMethod.OAuth => new GitCredentials
|
||||
{
|
||||
AuthType = GitAuthType.OAuth,
|
||||
Token = resolved.Token
|
||||
},
|
||||
GitAuthMethod.GitHubApp => new GitCredentials
|
||||
{
|
||||
AuthType = GitAuthType.GitHubApp,
|
||||
AppId = resolved.Properties?.GetValueOrDefault("appId"),
|
||||
PrivateKey = resolved.Properties?.GetValueOrDefault("privateKey"),
|
||||
InstallationId = resolved.Properties?.GetValueOrDefault("installationId")
|
||||
},
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
private static bool MatchesPattern(string value, string pattern)
|
||||
{
|
||||
var regexPattern = "^" + Regex.Escape(pattern)
|
||||
.Replace("\\*\\*", ".*")
|
||||
.Replace("\\*", "[^/]*")
|
||||
.Replace("\\?", ".") + "$";
|
||||
|
||||
return Regex.IsMatch(value, regexPattern, RegexOptions.IgnoreCase);
|
||||
}
|
||||
|
||||
private static string BuildReference(string repositoryUrl, string branchOrRef)
|
||||
{
|
||||
return $"{repositoryUrl}@{branchOrRef}";
|
||||
}
|
||||
|
||||
private static bool VerifyHmacSha256(byte[] payload, string expected, string secret)
|
||||
{
|
||||
using var hmac = new System.Security.Cryptography.HMACSHA256(
|
||||
System.Text.Encoding.UTF8.GetBytes(secret));
|
||||
var computed = Convert.ToHexString(hmac.ComputeHash(payload)).ToLowerInvariant();
|
||||
return System.Security.Cryptography.CryptographicOperations.FixedTimeEquals(
|
||||
System.Text.Encoding.UTF8.GetBytes(computed),
|
||||
System.Text.Encoding.UTF8.GetBytes(expected.ToLowerInvariant()));
|
||||
}
|
||||
|
||||
private static bool VerifyHmacSha1(byte[] payload, string expected, string secret)
|
||||
{
|
||||
using var hmac = new System.Security.Cryptography.HMACSHA1(
|
||||
System.Text.Encoding.UTF8.GetBytes(secret));
|
||||
var computed = Convert.ToHexString(hmac.ComputeHash(payload)).ToLowerInvariant();
|
||||
return System.Security.Cryptography.CryptographicOperations.FixedTimeEquals(
|
||||
System.Text.Encoding.UTF8.GetBytes(computed),
|
||||
System.Text.Encoding.UTF8.GetBytes(expected.ToLowerInvariant()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
using StellaOps.Scanner.Sources.Configuration;
|
||||
|
||||
namespace StellaOps.Scanner.Sources.Handlers.Git;
|
||||
|
||||
/// <summary>
|
||||
/// Interface for interacting with Git repositories via API.
|
||||
/// </summary>
|
||||
public interface IGitClient : IDisposable
|
||||
{
|
||||
/// <summary>
|
||||
/// Get repository information.
|
||||
/// </summary>
|
||||
Task<RepositoryInfo?> GetRepositoryInfoAsync(CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// List branches in the repository.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<BranchInfo>> ListBranchesAsync(CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// List tags in the repository.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<TagInfo>> ListTagsAsync(CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Get commit information.
|
||||
/// </summary>
|
||||
Task<CommitInfo?> GetCommitAsync(string sha, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Factory for creating Git clients.
|
||||
/// </summary>
|
||||
public interface IGitClientFactory
|
||||
{
|
||||
/// <summary>
|
||||
/// Create a Git client for the specified provider.
|
||||
/// </summary>
|
||||
IGitClient Create(
|
||||
GitProvider provider,
|
||||
string repositoryUrl,
|
||||
GitCredentials? credentials = null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Credentials for Git repository authentication.
|
||||
/// </summary>
|
||||
public sealed record GitCredentials
|
||||
{
|
||||
/// <summary>Type of authentication.</summary>
|
||||
public required GitAuthType AuthType { get; init; }
|
||||
|
||||
/// <summary>Personal access token or OAuth token.</summary>
|
||||
public string? Token { get; init; }
|
||||
|
||||
/// <summary>SSH private key content.</summary>
|
||||
public string? SshPrivateKey { get; init; }
|
||||
|
||||
/// <summary>SSH key passphrase.</summary>
|
||||
public string? SshPassphrase { get; init; }
|
||||
|
||||
/// <summary>GitHub App ID.</summary>
|
||||
public string? AppId { get; init; }
|
||||
|
||||
/// <summary>GitHub App private key.</summary>
|
||||
public string? PrivateKey { get; init; }
|
||||
|
||||
/// <summary>GitHub App installation ID.</summary>
|
||||
public string? InstallationId { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Git authentication types.
|
||||
/// </summary>
|
||||
public enum GitAuthType
|
||||
{
|
||||
None,
|
||||
Token,
|
||||
Ssh,
|
||||
OAuth,
|
||||
GitHubApp
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Repository information.
|
||||
/// </summary>
|
||||
public sealed record RepositoryInfo
|
||||
{
|
||||
/// <summary>Repository name.</summary>
|
||||
public required string Name { get; init; }
|
||||
|
||||
/// <summary>Full path or full name.</summary>
|
||||
public required string FullName { get; init; }
|
||||
|
||||
/// <summary>Default branch name.</summary>
|
||||
public string? DefaultBranch { get; init; }
|
||||
|
||||
/// <summary>Repository size in KB.</summary>
|
||||
public long SizeKb { get; init; }
|
||||
|
||||
/// <summary>Whether the repository is private.</summary>
|
||||
public bool IsPrivate { get; init; }
|
||||
|
||||
/// <summary>Repository description.</summary>
|
||||
public string? Description { get; init; }
|
||||
|
||||
/// <summary>Clone URL (HTTPS).</summary>
|
||||
public string? CloneUrl { get; init; }
|
||||
|
||||
/// <summary>SSH clone URL.</summary>
|
||||
public string? SshUrl { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Branch information.
|
||||
/// </summary>
|
||||
public sealed record BranchInfo
|
||||
{
|
||||
/// <summary>Branch name.</summary>
|
||||
public required string Name { get; init; }
|
||||
|
||||
/// <summary>HEAD commit SHA.</summary>
|
||||
public string? HeadCommit { get; init; }
|
||||
|
||||
/// <summary>Whether this is the default branch.</summary>
|
||||
public bool IsDefault { get; init; }
|
||||
|
||||
/// <summary>Whether the branch is protected.</summary>
|
||||
public bool IsProtected { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tag information.
|
||||
/// </summary>
|
||||
public sealed record TagInfo
|
||||
{
|
||||
/// <summary>Tag name.</summary>
|
||||
public required string Name { get; init; }
|
||||
|
||||
/// <summary>Commit SHA the tag points to.</summary>
|
||||
public string? CommitSha { get; init; }
|
||||
|
||||
/// <summary>Tag message (for annotated tags).</summary>
|
||||
public string? Message { get; init; }
|
||||
|
||||
/// <summary>When the tag was created.</summary>
|
||||
public DateTimeOffset? CreatedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Commit information.
|
||||
/// </summary>
|
||||
public sealed record CommitInfo
|
||||
{
|
||||
/// <summary>Commit SHA.</summary>
|
||||
public required string Sha { get; init; }
|
||||
|
||||
/// <summary>Commit message.</summary>
|
||||
public string? Message { get; init; }
|
||||
|
||||
/// <summary>Author name.</summary>
|
||||
public string? AuthorName { get; init; }
|
||||
|
||||
/// <summary>Author email.</summary>
|
||||
public string? AuthorEmail { get; init; }
|
||||
|
||||
/// <summary>When the commit was authored.</summary>
|
||||
public DateTimeOffset? AuthoredAt { get; init; }
|
||||
|
||||
/// <summary>Parent commit SHAs.</summary>
|
||||
public IReadOnlyList<string> Parents { get; init; } = [];
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
using System.Text.Json;
|
||||
using StellaOps.Scanner.Sources.Configuration;
|
||||
using StellaOps.Scanner.Sources.Contracts;
|
||||
using StellaOps.Scanner.Sources.Domain;
|
||||
using StellaOps.Scanner.Sources.Triggers;
|
||||
|
||||
namespace StellaOps.Scanner.Sources.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// Interface for source type-specific handlers.
|
||||
/// Each source type (Zastava, Docker, CLI, Git) has its own handler.
|
||||
/// </summary>
|
||||
public interface ISourceTypeHandler
|
||||
{
|
||||
/// <summary>The source type this handler manages.</summary>
|
||||
SbomSourceType SourceType { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Discover targets to scan based on source configuration and trigger context.
|
||||
/// </summary>
|
||||
/// <param name="source">The source configuration.</param>
|
||||
/// <param name="context">The trigger context.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>List of targets to scan.</returns>
|
||||
Task<IReadOnlyList<ScanTarget>> DiscoverTargetsAsync(
|
||||
SbomSource source,
|
||||
TriggerContext context,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Validate source configuration.
|
||||
/// </summary>
|
||||
/// <param name="configuration">The configuration to validate.</param>
|
||||
/// <returns>Validation result.</returns>
|
||||
ConfigValidationResult ValidateConfiguration(JsonDocument configuration);
|
||||
|
||||
/// <summary>
|
||||
/// Test connection to the source.
|
||||
/// </summary>
|
||||
/// <param name="source">The source to test.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Connection test result.</returns>
|
||||
Task<ConnectionTestResult> TestConnectionAsync(
|
||||
SbomSource source,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the maximum number of concurrent targets this handler supports.
|
||||
/// </summary>
|
||||
int MaxConcurrentTargets => 10;
|
||||
|
||||
/// <summary>
|
||||
/// Whether this handler supports webhook triggers.
|
||||
/// </summary>
|
||||
bool SupportsWebhooks => false;
|
||||
|
||||
/// <summary>
|
||||
/// Whether this handler supports scheduled triggers.
|
||||
/// </summary>
|
||||
bool SupportsScheduling => true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extended interface for handlers that can process webhooks.
|
||||
/// </summary>
|
||||
public interface IWebhookCapableHandler : ISourceTypeHandler
|
||||
{
|
||||
/// <summary>
|
||||
/// Verify webhook signature.
|
||||
/// </summary>
|
||||
bool VerifyWebhookSignature(
|
||||
byte[] payload,
|
||||
string signature,
|
||||
string secret);
|
||||
|
||||
/// <summary>
|
||||
/// Parse webhook payload to extract trigger information.
|
||||
/// </summary>
|
||||
WebhookPayloadInfo ParseWebhookPayload(JsonDocument payload);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parsed webhook payload information.
|
||||
/// </summary>
|
||||
public sealed record WebhookPayloadInfo
|
||||
{
|
||||
/// <summary>Type of event (push, tag, delete, etc.).</summary>
|
||||
public required string EventType { get; init; }
|
||||
|
||||
/// <summary>Repository or image reference.</summary>
|
||||
public required string Reference { get; init; }
|
||||
|
||||
/// <summary>Tag if applicable.</summary>
|
||||
public string? Tag { get; init; }
|
||||
|
||||
/// <summary>Digest if applicable.</summary>
|
||||
public string? Digest { get; init; }
|
||||
|
||||
/// <summary>Branch if applicable (git webhooks).</summary>
|
||||
public string? Branch { get; init; }
|
||||
|
||||
/// <summary>Commit SHA if applicable (git webhooks).</summary>
|
||||
public string? CommitSha { get; init; }
|
||||
|
||||
/// <summary>User who triggered the event.</summary>
|
||||
public string? Actor { get; init; }
|
||||
|
||||
/// <summary>Timestamp of the event.</summary>
|
||||
public DateTimeOffset Timestamp { get; init; } = DateTimeOffset.UtcNow;
|
||||
|
||||
/// <summary>Additional metadata from the payload.</summary>
|
||||
public Dictionary<string, string> Metadata { get; init; } = [];
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
namespace StellaOps.Scanner.Sources.Handlers.Zastava;
|
||||
|
||||
/// <summary>
|
||||
/// Interface for interacting with container registries.
|
||||
/// </summary>
|
||||
public interface IRegistryClient : IDisposable
|
||||
{
|
||||
/// <summary>
|
||||
/// Test connectivity to the registry.
|
||||
/// </summary>
|
||||
Task<bool> PingAsync(CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// List repositories matching a pattern.
|
||||
/// </summary>
|
||||
/// <param name="pattern">Glob pattern (e.g., "library/*").</param>
|
||||
/// <param name="limit">Maximum number of repositories to return.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
Task<IReadOnlyList<string>> ListRepositoriesAsync(
|
||||
string? pattern = null,
|
||||
int limit = 100,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// List tags for a repository.
|
||||
/// </summary>
|
||||
/// <param name="repository">Repository name.</param>
|
||||
/// <param name="patterns">Tag patterns to match (null = all).</param>
|
||||
/// <param name="limit">Maximum number of tags to return.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
Task<IReadOnlyList<RegistryTag>> ListTagsAsync(
|
||||
string repository,
|
||||
IReadOnlyList<string>? patterns = null,
|
||||
int limit = 100,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Get manifest digest for an image reference.
|
||||
/// </summary>
|
||||
Task<string?> GetDigestAsync(
|
||||
string repository,
|
||||
string tag,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a tag in a container registry.
|
||||
/// </summary>
|
||||
public sealed record RegistryTag
|
||||
{
|
||||
/// <summary>The tag name.</summary>
|
||||
public required string Name { get; init; }
|
||||
|
||||
/// <summary>The manifest digest.</summary>
|
||||
public string? Digest { get; init; }
|
||||
|
||||
/// <summary>When the tag was last updated.</summary>
|
||||
public DateTimeOffset? LastUpdated { get; init; }
|
||||
|
||||
/// <summary>Size of the image in bytes.</summary>
|
||||
public long? SizeBytes { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Factory for creating registry clients.
|
||||
/// </summary>
|
||||
public interface IRegistryClientFactory
|
||||
{
|
||||
/// <summary>
|
||||
/// Create a registry client for the specified registry.
|
||||
/// </summary>
|
||||
IRegistryClient Create(
|
||||
Configuration.RegistryType registryType,
|
||||
string registryUrl,
|
||||
RegistryCredentials? credentials = null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Credentials for registry authentication.
|
||||
/// </summary>
|
||||
public sealed record RegistryCredentials
|
||||
{
|
||||
/// <summary>Type of authentication.</summary>
|
||||
public required RegistryAuthType AuthType { get; init; }
|
||||
|
||||
/// <summary>Username for basic auth.</summary>
|
||||
public string? Username { get; init; }
|
||||
|
||||
/// <summary>Password or token for basic auth.</summary>
|
||||
public string? Password { get; init; }
|
||||
|
||||
/// <summary>Bearer token for token auth.</summary>
|
||||
public string? Token { get; init; }
|
||||
|
||||
/// <summary>AWS access key for ECR.</summary>
|
||||
public string? AwsAccessKey { get; init; }
|
||||
|
||||
/// <summary>AWS secret key for ECR.</summary>
|
||||
public string? AwsSecretKey { get; init; }
|
||||
|
||||
/// <summary>AWS region for ECR.</summary>
|
||||
public string? AwsRegion { get; init; }
|
||||
|
||||
/// <summary>GCP service account JSON for GCR.</summary>
|
||||
public string? GcpServiceAccountJson { get; init; }
|
||||
|
||||
/// <summary>Azure client ID for ACR.</summary>
|
||||
public string? AzureClientId { get; init; }
|
||||
|
||||
/// <summary>Azure client secret for ACR.</summary>
|
||||
public string? AzureClientSecret { get; init; }
|
||||
|
||||
/// <summary>Azure tenant ID for ACR.</summary>
|
||||
public string? AzureTenantId { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registry authentication types.
|
||||
/// </summary>
|
||||
public enum RegistryAuthType
|
||||
{
|
||||
None,
|
||||
Basic,
|
||||
Token,
|
||||
AwsEcr,
|
||||
GcpGcr,
|
||||
AzureAcr
|
||||
}
|
||||
@@ -0,0 +1,456 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Scanner.Sources.Configuration;
|
||||
using StellaOps.Scanner.Sources.Contracts;
|
||||
using StellaOps.Scanner.Sources.Domain;
|
||||
using StellaOps.Scanner.Sources.Services;
|
||||
using StellaOps.Scanner.Sources.Triggers;
|
||||
|
||||
namespace StellaOps.Scanner.Sources.Handlers.Zastava;
|
||||
|
||||
/// <summary>
|
||||
/// Handler for Zastava (container registry webhook) sources.
|
||||
/// </summary>
|
||||
public sealed class ZastavaSourceHandler : ISourceTypeHandler, IWebhookCapableHandler
|
||||
{
|
||||
private readonly IRegistryClientFactory _clientFactory;
|
||||
private readonly ICredentialResolver _credentialResolver;
|
||||
private readonly ISourceConfigValidator _configValidator;
|
||||
private readonly ILogger<ZastavaSourceHandler> _logger;
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
};
|
||||
|
||||
public SbomSourceType SourceType => SbomSourceType.Zastava;
|
||||
public bool SupportsWebhooks => true;
|
||||
public bool SupportsScheduling => true;
|
||||
public int MaxConcurrentTargets => 20;
|
||||
|
||||
public ZastavaSourceHandler(
|
||||
IRegistryClientFactory clientFactory,
|
||||
ICredentialResolver credentialResolver,
|
||||
ISourceConfigValidator configValidator,
|
||||
ILogger<ZastavaSourceHandler> logger)
|
||||
{
|
||||
_clientFactory = clientFactory;
|
||||
_credentialResolver = credentialResolver;
|
||||
_configValidator = configValidator;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<ScanTarget>> DiscoverTargetsAsync(
|
||||
SbomSource source,
|
||||
TriggerContext context,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var config = source.Configuration.Deserialize<ZastavaSourceConfig>(JsonOptions);
|
||||
if (config == null)
|
||||
{
|
||||
_logger.LogWarning("Invalid configuration for source {SourceId}", source.SourceId);
|
||||
return [];
|
||||
}
|
||||
|
||||
// For webhook triggers, extract target from payload
|
||||
if (context.Trigger == SbomSourceRunTrigger.Webhook)
|
||||
{
|
||||
if (context.WebhookPayload != null)
|
||||
{
|
||||
var payloadInfo = ParseWebhookPayload(context.WebhookPayload);
|
||||
|
||||
// Check if it matches filters
|
||||
if (!MatchesFilters(payloadInfo, config.Filters))
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Webhook payload does not match filters for source {SourceId}",
|
||||
source.SourceId);
|
||||
return [];
|
||||
}
|
||||
|
||||
var reference = BuildReference(config.RegistryUrl, payloadInfo.Reference, payloadInfo.Tag);
|
||||
return
|
||||
[
|
||||
new ScanTarget
|
||||
{
|
||||
Reference = reference,
|
||||
Digest = payloadInfo.Digest,
|
||||
Metadata = new Dictionary<string, string>
|
||||
{
|
||||
["repository"] = payloadInfo.Reference,
|
||||
["tag"] = payloadInfo.Tag ?? "latest",
|
||||
["pushedBy"] = payloadInfo.Actor ?? "unknown",
|
||||
["eventType"] = payloadInfo.EventType
|
||||
}
|
||||
}
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// For scheduled/manual triggers, discover from registry
|
||||
return await DiscoverFromRegistryAsync(source, config, ct);
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<ScanTarget>> DiscoverFromRegistryAsync(
|
||||
SbomSource source,
|
||||
ZastavaSourceConfig config,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var credentials = await GetCredentialsAsync(source.AuthRef, ct);
|
||||
using var client = _clientFactory.Create(config.RegistryType, config.RegistryUrl, credentials);
|
||||
|
||||
var targets = new List<ScanTarget>();
|
||||
var repoPatterns = config.Filters?.RepositoryPatterns ?? ["*"];
|
||||
|
||||
foreach (var pattern in repoPatterns)
|
||||
{
|
||||
var repos = await client.ListRepositoriesAsync(pattern, 100, ct);
|
||||
|
||||
foreach (var repo in repos)
|
||||
{
|
||||
// Check exclusions
|
||||
if (config.Filters?.ExcludePatterns?.Any(ex => MatchesPattern(repo, ex)) == true)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var tagPatterns = config.Filters?.TagPatterns ?? ["*"];
|
||||
var tags = await client.ListTagsAsync(repo, tagPatterns, 50, ct);
|
||||
|
||||
foreach (var tag in tags)
|
||||
{
|
||||
// Check tag exclusions
|
||||
if (config.Filters?.ExcludePatterns?.Any(ex => MatchesPattern(tag.Name, ex)) == true)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var reference = BuildReference(config.RegistryUrl, repo, tag.Name);
|
||||
targets.Add(new ScanTarget
|
||||
{
|
||||
Reference = reference,
|
||||
Digest = tag.Digest,
|
||||
Metadata = new Dictionary<string, string>
|
||||
{
|
||||
["repository"] = repo,
|
||||
["tag"] = tag.Name
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Discovered {Count} targets from registry for source {SourceId}",
|
||||
targets.Count, source.SourceId);
|
||||
|
||||
return targets;
|
||||
}
|
||||
|
||||
public ConfigValidationResult ValidateConfiguration(JsonDocument configuration)
|
||||
{
|
||||
return _configValidator.Validate(SbomSourceType.Zastava, configuration);
|
||||
}
|
||||
|
||||
public async Task<ConnectionTestResult> TestConnectionAsync(
|
||||
SbomSource source,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var config = source.Configuration.Deserialize<ZastavaSourceConfig>(JsonOptions);
|
||||
if (config == null)
|
||||
{
|
||||
return new ConnectionTestResult
|
||||
{
|
||||
Success = false,
|
||||
Message = "Invalid configuration",
|
||||
TestedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var credentials = await GetCredentialsAsync(source.AuthRef, ct);
|
||||
using var client = _clientFactory.Create(config.RegistryType, config.RegistryUrl, credentials);
|
||||
|
||||
var pingSuccess = await client.PingAsync(ct);
|
||||
if (!pingSuccess)
|
||||
{
|
||||
return new ConnectionTestResult
|
||||
{
|
||||
Success = false,
|
||||
Message = "Registry ping failed",
|
||||
TestedAt = DateTimeOffset.UtcNow,
|
||||
Details = new Dictionary<string, object>
|
||||
{
|
||||
["registryUrl"] = config.RegistryUrl,
|
||||
["registryType"] = config.RegistryType.ToString()
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Try to list repositories to verify access
|
||||
var repos = await client.ListRepositoriesAsync(limit: 1, ct: ct);
|
||||
|
||||
return new ConnectionTestResult
|
||||
{
|
||||
Success = true,
|
||||
Message = "Successfully connected to registry",
|
||||
TestedAt = DateTimeOffset.UtcNow,
|
||||
Details = new Dictionary<string, object>
|
||||
{
|
||||
["registryUrl"] = config.RegistryUrl,
|
||||
["registryType"] = config.RegistryType.ToString(),
|
||||
["repositoriesAccessible"] = repos.Count > 0
|
||||
}
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Connection test failed for source {SourceId}", source.SourceId);
|
||||
return new ConnectionTestResult
|
||||
{
|
||||
Success = false,
|
||||
Message = $"Connection failed: {ex.Message}",
|
||||
TestedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public bool VerifyWebhookSignature(byte[] payload, string signature, string secret)
|
||||
{
|
||||
// Support multiple signature formats
|
||||
// Docker Hub: X-Hub-Signature (SHA1)
|
||||
// Harbor: Authorization header with shared secret
|
||||
// Generic: HMAC-SHA256
|
||||
|
||||
if (string.IsNullOrEmpty(signature) || string.IsNullOrEmpty(secret))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Try HMAC-SHA256 first (most common)
|
||||
var secretBytes = Encoding.UTF8.GetBytes(secret);
|
||||
using var hmac256 = new HMACSHA256(secretBytes);
|
||||
var computed256 = Convert.ToHexString(hmac256.ComputeHash(payload)).ToLowerInvariant();
|
||||
|
||||
if (signature.StartsWith("sha256=", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var expected = signature[7..].ToLowerInvariant();
|
||||
return CryptographicOperations.FixedTimeEquals(
|
||||
Encoding.UTF8.GetBytes(computed256),
|
||||
Encoding.UTF8.GetBytes(expected));
|
||||
}
|
||||
|
||||
// Try SHA1 (Docker Hub legacy)
|
||||
using var hmac1 = new HMACSHA1(secretBytes);
|
||||
var computed1 = Convert.ToHexString(hmac1.ComputeHash(payload)).ToLowerInvariant();
|
||||
|
||||
if (signature.StartsWith("sha1=", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var expected = signature[5..].ToLowerInvariant();
|
||||
return CryptographicOperations.FixedTimeEquals(
|
||||
Encoding.UTF8.GetBytes(computed1),
|
||||
Encoding.UTF8.GetBytes(expected));
|
||||
}
|
||||
|
||||
// Plain comparison (Harbor style)
|
||||
return CryptographicOperations.FixedTimeEquals(
|
||||
Encoding.UTF8.GetBytes(signature),
|
||||
Encoding.UTF8.GetBytes(secret));
|
||||
}
|
||||
|
||||
public WebhookPayloadInfo ParseWebhookPayload(JsonDocument payload)
|
||||
{
|
||||
var root = payload.RootElement;
|
||||
|
||||
// Try different webhook formats
|
||||
|
||||
// Docker Hub format
|
||||
if (root.TryGetProperty("push_data", out var pushData) &&
|
||||
root.TryGetProperty("repository", out var repository))
|
||||
{
|
||||
return new WebhookPayloadInfo
|
||||
{
|
||||
EventType = "push",
|
||||
Reference = repository.TryGetProperty("repo_name", out var repoName)
|
||||
? repoName.GetString()!
|
||||
: repository.GetProperty("name").GetString()!,
|
||||
Tag = pushData.TryGetProperty("tag", out var tag) ? tag.GetString() : "latest",
|
||||
Actor = pushData.TryGetProperty("pusher", out var pusher) ? pusher.GetString() : null,
|
||||
Timestamp = DateTimeOffset.UtcNow
|
||||
};
|
||||
}
|
||||
|
||||
// Harbor format
|
||||
if (root.TryGetProperty("type", out var eventType) &&
|
||||
root.TryGetProperty("event_data", out var eventData))
|
||||
{
|
||||
var resources = eventData.TryGetProperty("resources", out var res) ? res : default;
|
||||
var firstResource = resources.ValueKind == JsonValueKind.Array && resources.GetArrayLength() > 0
|
||||
? resources[0]
|
||||
: default;
|
||||
|
||||
return new WebhookPayloadInfo
|
||||
{
|
||||
EventType = eventType.GetString() ?? "push",
|
||||
Reference = eventData.TryGetProperty("repository", out var repo)
|
||||
? (repo.TryGetProperty("repo_full_name", out var fullName)
|
||||
? fullName.GetString()!
|
||||
: repo.GetProperty("name").GetString()!)
|
||||
: "",
|
||||
Tag = firstResource.TryGetProperty("tag", out var harborTag)
|
||||
? harborTag.GetString()
|
||||
: null,
|
||||
Digest = firstResource.TryGetProperty("digest", out var digest)
|
||||
? digest.GetString()
|
||||
: null,
|
||||
Actor = eventData.TryGetProperty("operator", out var op) ? op.GetString() : null,
|
||||
Timestamp = DateTimeOffset.UtcNow
|
||||
};
|
||||
}
|
||||
|
||||
// Generic OCI distribution format
|
||||
if (root.TryGetProperty("events", out var events) &&
|
||||
events.ValueKind == JsonValueKind.Array &&
|
||||
events.GetArrayLength() > 0)
|
||||
{
|
||||
var firstEvent = events[0];
|
||||
return new WebhookPayloadInfo
|
||||
{
|
||||
EventType = firstEvent.TryGetProperty("action", out var action)
|
||||
? action.GetString() ?? "push"
|
||||
: "push",
|
||||
Reference = firstEvent.TryGetProperty("target", out var target) &&
|
||||
target.TryGetProperty("repository", out var targetRepo)
|
||||
? targetRepo.GetString()!
|
||||
: "",
|
||||
Tag = target.TryGetProperty("tag", out var ociTag)
|
||||
? ociTag.GetString()
|
||||
: null,
|
||||
Digest = target.TryGetProperty("digest", out var ociDigest)
|
||||
? ociDigest.GetString()
|
||||
: null,
|
||||
Actor = firstEvent.TryGetProperty("actor", out var actor) &&
|
||||
actor.TryGetProperty("name", out var actorName)
|
||||
? actorName.GetString()
|
||||
: null,
|
||||
Timestamp = DateTimeOffset.UtcNow
|
||||
};
|
||||
}
|
||||
|
||||
_logger.LogWarning("Unable to parse webhook payload format");
|
||||
return new WebhookPayloadInfo
|
||||
{
|
||||
EventType = "unknown",
|
||||
Reference = "",
|
||||
Timestamp = DateTimeOffset.UtcNow
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<RegistryCredentials?> GetCredentialsAsync(string? authRef, CancellationToken ct)
|
||||
{
|
||||
if (string.IsNullOrEmpty(authRef))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var resolved = await _credentialResolver.ResolveAsync(authRef, ct);
|
||||
if (resolved == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return resolved.Type switch
|
||||
{
|
||||
CredentialType.BasicAuth => new RegistryCredentials
|
||||
{
|
||||
AuthType = RegistryAuthType.Basic,
|
||||
Username = resolved.Username,
|
||||
Password = resolved.Password
|
||||
},
|
||||
CredentialType.BearerToken => new RegistryCredentials
|
||||
{
|
||||
AuthType = RegistryAuthType.Token,
|
||||
Token = resolved.Token
|
||||
},
|
||||
CredentialType.AwsCredentials => new RegistryCredentials
|
||||
{
|
||||
AuthType = RegistryAuthType.AwsEcr,
|
||||
AwsAccessKey = resolved.Properties?.GetValueOrDefault("accessKey"),
|
||||
AwsSecretKey = resolved.Properties?.GetValueOrDefault("secretKey"),
|
||||
AwsRegion = resolved.Properties?.GetValueOrDefault("region")
|
||||
},
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
private static bool MatchesFilters(WebhookPayloadInfo payload, ZastavaFilters? filters)
|
||||
{
|
||||
if (filters == null)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check repository patterns
|
||||
if (filters.RepositoryPatterns?.Count > 0)
|
||||
{
|
||||
if (!filters.RepositoryPatterns.Any(p => MatchesPattern(payload.Reference, p)))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Check tag patterns
|
||||
if (filters.TagPatterns?.Count > 0 && payload.Tag != null)
|
||||
{
|
||||
if (!filters.TagPatterns.Any(p => MatchesPattern(payload.Tag, p)))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Check exclusions
|
||||
if (filters.ExcludePatterns?.Count > 0)
|
||||
{
|
||||
if (filters.ExcludePatterns.Any(p =>
|
||||
MatchesPattern(payload.Reference, p) ||
|
||||
(payload.Tag != null && MatchesPattern(payload.Tag, p))))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool MatchesPattern(string value, string pattern)
|
||||
{
|
||||
// Convert glob pattern to regex
|
||||
var regexPattern = "^" + Regex.Escape(pattern)
|
||||
.Replace("\\*", ".*")
|
||||
.Replace("\\?", ".") + "$";
|
||||
|
||||
return Regex.IsMatch(value, regexPattern, RegexOptions.IgnoreCase);
|
||||
}
|
||||
|
||||
private static string BuildReference(string registryUrl, string repository, string? tag)
|
||||
{
|
||||
var host = new Uri(registryUrl).Host;
|
||||
|
||||
// Docker Hub special case
|
||||
if (host.Contains("docker.io") || host.Contains("docker.com"))
|
||||
{
|
||||
if (!repository.Contains('/'))
|
||||
{
|
||||
repository = $"library/{repository}";
|
||||
}
|
||||
return $"{repository}:{tag ?? "latest"}";
|
||||
}
|
||||
|
||||
return $"{host}/{repository}:{tag ?? "latest"}";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user