wip - advisories and ui extensions

This commit is contained in:
StellaOps Bot
2025-12-29 08:39:52 +02:00
parent c2b9cd8d1f
commit 1b61c72c90
56 changed files with 15187 additions and 24 deletions

View File

@@ -0,0 +1,358 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
using Microsoft.Extensions.Logging;
using StellaOps.Scanner.Sources.Configuration;
using StellaOps.Scanner.Sources.Contracts;
using StellaOps.Scanner.Sources.Domain;
using StellaOps.Scanner.Sources.Services;
using StellaOps.Scanner.Sources.Triggers;
namespace StellaOps.Scanner.Sources.Handlers.Cli;
/// <summary>
/// Handler for CLI (external submission) sources.
/// Receives SBOM uploads from CI/CD pipelines via the CLI tool.
/// </summary>
/// <remarks>
/// CLI sources are passive - they don't discover targets but receive
/// submissions from external systems. The handler validates submissions
/// against the configured rules.
/// </remarks>
public sealed class CliSourceHandler : ISourceTypeHandler
{
private readonly ISourceConfigValidator _configValidator;
private readonly ILogger<CliSourceHandler> _logger;
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
public SbomSourceType SourceType => SbomSourceType.Cli;
public bool SupportsWebhooks => false;
public bool SupportsScheduling => false;
public int MaxConcurrentTargets => 100;
public CliSourceHandler(
ISourceConfigValidator configValidator,
ILogger<CliSourceHandler> logger)
{
_configValidator = configValidator;
_logger = logger;
}
/// <summary>
/// CLI sources don't discover targets - submissions come via API.
/// This method returns an empty list for scheduled/manual triggers.
/// For submissions, the target is created from the submission metadata.
/// </summary>
public Task<IReadOnlyList<ScanTarget>> DiscoverTargetsAsync(
SbomSource source,
TriggerContext context,
CancellationToken ct = default)
{
var config = source.Configuration.Deserialize<CliSourceConfig>(JsonOptions);
if (config == null)
{
_logger.LogWarning("Invalid configuration for source {SourceId}", source.SourceId);
return Task.FromResult<IReadOnlyList<ScanTarget>>([]);
}
// CLI sources only process submissions via the SubmissionContext
if (context.Metadata.TryGetValue("submissionId", out var submissionId))
{
// Create target from submission metadata
var target = new ScanTarget
{
Reference = context.Metadata.TryGetValue("reference", out var refValue) ? refValue : submissionId,
Metadata = new Dictionary<string, string>(context.Metadata)
};
_logger.LogInformation(
"Created target from CLI submission {SubmissionId} for source {SourceId}",
submissionId, source.SourceId);
return Task.FromResult<IReadOnlyList<ScanTarget>>([target]);
}
// For scheduled/manual triggers, CLI sources have nothing to discover
_logger.LogDebug(
"CLI source {SourceId} has no targets to discover for trigger {Trigger}",
source.SourceId, context.Trigger);
return Task.FromResult<IReadOnlyList<ScanTarget>>([]);
}
public ConfigValidationResult ValidateConfiguration(JsonDocument configuration)
{
return _configValidator.Validate(SbomSourceType.Cli, configuration);
}
public Task<ConnectionTestResult> TestConnectionAsync(
SbomSource source,
CancellationToken ct = default)
{
var config = source.Configuration.Deserialize<CliSourceConfig>(JsonOptions);
if (config == null)
{
return Task.FromResult(new ConnectionTestResult
{
Success = false,
Message = "Invalid configuration",
TestedAt = DateTimeOffset.UtcNow
});
}
// CLI sources don't have external connections to test
// We just validate the configuration
return Task.FromResult(new ConnectionTestResult
{
Success = true,
Message = "CLI source configuration is valid",
TestedAt = DateTimeOffset.UtcNow,
Details = new Dictionary<string, object>
{
["allowedTools"] = config.AllowedTools,
["allowedFormats"] = config.Validation.AllowedFormats.Select(f => f.ToString()).ToArray(),
["requireSignedSbom"] = config.Validation.RequireSignedSbom,
["maxSbomSizeMb"] = config.Validation.MaxSbomSizeBytes / (1024 * 1024)
}
});
}
/// <summary>
/// Validate an SBOM submission against the source configuration.
/// </summary>
public SubmissionValidationResult ValidateSubmission(
SbomSource source,
CliSubmissionRequest submission)
{
var config = source.Configuration.Deserialize<CliSourceConfig>(JsonOptions);
if (config == null)
{
return SubmissionValidationResult.Failed("Invalid source configuration");
}
var errors = new List<string>();
// Validate tool
if (!config.AllowedTools.Contains(submission.Tool, StringComparer.OrdinalIgnoreCase))
{
errors.Add($"Tool '{submission.Tool}' is not allowed. Allowed tools: {string.Join(", ", config.AllowedTools)}");
}
// Validate CI system if specified
if (config.AllowedCiSystems is { Length: > 0 } && submission.CiSystem != null)
{
if (!config.AllowedCiSystems.Contains(submission.CiSystem, StringComparer.OrdinalIgnoreCase))
{
errors.Add($"CI system '{submission.CiSystem}' is not allowed. Allowed systems: {string.Join(", ", config.AllowedCiSystems)}");
}
}
// Validate format
if (!config.Validation.AllowedFormats.Contains(submission.Format))
{
errors.Add($"Format '{submission.Format}' is not allowed. Allowed formats: {string.Join(", ", config.Validation.AllowedFormats)}");
}
// Validate size
if (submission.SbomSizeBytes > config.Validation.MaxSbomSizeBytes)
{
var maxMb = config.Validation.MaxSbomSizeBytes / (1024 * 1024);
var actualMb = submission.SbomSizeBytes / (1024 * 1024);
errors.Add($"SBOM size ({actualMb} MB) exceeds maximum allowed size ({maxMb} MB)");
}
// Validate signature if required
if (config.Validation.RequireSignedSbom && string.IsNullOrEmpty(submission.Signature))
{
errors.Add("Signed SBOM is required but no signature was provided");
}
// Validate signer if signature is present
if (!string.IsNullOrEmpty(submission.Signature) &&
config.Validation.AllowedSigners is { Length: > 0 })
{
if (!config.Validation.AllowedSigners.Contains(submission.SignerFingerprint, StringComparer.OrdinalIgnoreCase))
{
errors.Add($"Signer fingerprint '{submission.SignerFingerprint}' is not in the allowed list");
}
}
// Validate attribution requirements
if (config.Attribution.RequireBuildId && string.IsNullOrEmpty(submission.BuildId))
{
errors.Add("Build ID is required");
}
if (config.Attribution.RequireRepository && string.IsNullOrEmpty(submission.Repository))
{
errors.Add("Repository reference is required");
}
if (config.Attribution.RequireCommitSha && string.IsNullOrEmpty(submission.CommitSha))
{
errors.Add("Commit SHA is required");
}
if (config.Attribution.RequirePipelineId && string.IsNullOrEmpty(submission.PipelineId))
{
errors.Add("Pipeline ID is required");
}
// Validate repository against allowed patterns
if (!string.IsNullOrEmpty(submission.Repository) &&
config.Attribution.AllowedRepositories is { Length: > 0 })
{
var repoAllowed = config.Attribution.AllowedRepositories
.Any(p => MatchesPattern(submission.Repository, p));
if (!repoAllowed)
{
errors.Add($"Repository '{submission.Repository}' is not in the allowed list");
}
}
if (errors.Count > 0)
{
return SubmissionValidationResult.Failed(errors);
}
return SubmissionValidationResult.Valid();
}
/// <summary>
/// Generate a token for CLI authentication to this source.
/// </summary>
public CliAuthToken GenerateAuthToken(SbomSource source, TimeSpan validity)
{
var tokenBytes = new byte[32];
RandomNumberGenerator.Fill(tokenBytes);
var token = Convert.ToBase64String(tokenBytes);
// Create token hash for storage
var tokenHash = SHA256.HashData(Encoding.UTF8.GetBytes(token));
return new CliAuthToken
{
Token = token,
TokenHash = Convert.ToHexString(tokenHash).ToLowerInvariant(),
SourceId = source.SourceId,
ExpiresAt = DateTimeOffset.UtcNow.Add(validity),
CreatedAt = DateTimeOffset.UtcNow
};
}
private static bool MatchesPattern(string value, string pattern)
{
var regexPattern = "^" + Regex.Escape(pattern)
.Replace("\\*\\*", ".*")
.Replace("\\*", "[^/]*")
.Replace("\\?", ".") + "$";
return Regex.IsMatch(value, regexPattern, RegexOptions.IgnoreCase);
}
}
/// <summary>
/// Request for CLI SBOM submission.
/// </summary>
public sealed record CliSubmissionRequest
{
/// <summary>Scanner/tool that generated the SBOM.</summary>
public required string Tool { get; init; }
/// <summary>Tool version.</summary>
public string? ToolVersion { get; init; }
/// <summary>CI system (e.g., "github-actions", "gitlab-ci").</summary>
public string? CiSystem { get; init; }
/// <summary>SBOM format.</summary>
public required SbomFormat Format { get; init; }
/// <summary>SBOM format version.</summary>
public string? FormatVersion { get; init; }
/// <summary>SBOM size in bytes.</summary>
public long SbomSizeBytes { get; init; }
/// <summary>SBOM content hash (for verification).</summary>
public string? ContentHash { get; init; }
/// <summary>SBOM signature (if signed).</summary>
public string? Signature { get; init; }
/// <summary>Signer key fingerprint.</summary>
public string? SignerFingerprint { get; init; }
/// <summary>Build ID.</summary>
public string? BuildId { get; init; }
/// <summary>Repository URL.</summary>
public string? Repository { get; init; }
/// <summary>Commit SHA.</summary>
public string? CommitSha { get; init; }
/// <summary>Branch name.</summary>
public string? Branch { get; init; }
/// <summary>Pipeline/workflow ID.</summary>
public string? PipelineId { get; init; }
/// <summary>Pipeline/workflow name.</summary>
public string? PipelineName { get; init; }
/// <summary>Subject reference (what was scanned).</summary>
public required string Subject { get; init; }
/// <summary>Subject digest.</summary>
public string? SubjectDigest { get; init; }
/// <summary>Additional metadata.</summary>
public Dictionary<string, string> Metadata { get; init; } = [];
}
/// <summary>
/// Result of submission validation.
/// </summary>
public sealed record SubmissionValidationResult
{
public bool IsValid { get; init; }
public IReadOnlyList<string> Errors { get; init; } = [];
public static SubmissionValidationResult Valid() =>
new() { IsValid = true };
public static SubmissionValidationResult Failed(string error) =>
new() { IsValid = false, Errors = [error] };
public static SubmissionValidationResult Failed(IReadOnlyList<string> errors) =>
new() { IsValid = false, Errors = errors };
}
/// <summary>
/// CLI authentication token.
/// </summary>
public sealed record CliAuthToken
{
/// <summary>The raw token (only returned once on creation).</summary>
public required string Token { get; init; }
/// <summary>Hash of the token (stored in database).</summary>
public required string TokenHash { get; init; }
/// <summary>Source this token is for.</summary>
public Guid SourceId { get; init; }
/// <summary>When the token expires.</summary>
public DateTimeOffset ExpiresAt { get; init; }
/// <summary>When the token was created.</summary>
public DateTimeOffset CreatedAt { get; init; }
}

View File

@@ -0,0 +1,341 @@
using System.Text.Json;
using System.Text.RegularExpressions;
using Microsoft.Extensions.Logging;
using StellaOps.Scanner.Sources.Configuration;
using StellaOps.Scanner.Sources.Contracts;
using StellaOps.Scanner.Sources.Domain;
using StellaOps.Scanner.Sources.Handlers.Zastava;
using StellaOps.Scanner.Sources.Services;
using StellaOps.Scanner.Sources.Triggers;
namespace StellaOps.Scanner.Sources.Handlers.Docker;
/// <summary>
/// Handler for Docker (direct image scan) sources.
/// Scans specific images from container registries on schedule or on-demand.
/// </summary>
public sealed class DockerSourceHandler : ISourceTypeHandler
{
private readonly IRegistryClientFactory _clientFactory;
private readonly ICredentialResolver _credentialResolver;
private readonly ISourceConfigValidator _configValidator;
private readonly IImageDiscoveryService _discoveryService;
private readonly ILogger<DockerSourceHandler> _logger;
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
public SbomSourceType SourceType => SbomSourceType.Docker;
public bool SupportsWebhooks => false;
public bool SupportsScheduling => true;
public int MaxConcurrentTargets => 50;
public DockerSourceHandler(
IRegistryClientFactory clientFactory,
ICredentialResolver credentialResolver,
ISourceConfigValidator configValidator,
IImageDiscoveryService discoveryService,
ILogger<DockerSourceHandler> logger)
{
_clientFactory = clientFactory;
_credentialResolver = credentialResolver;
_configValidator = configValidator;
_discoveryService = discoveryService;
_logger = logger;
}
public async Task<IReadOnlyList<ScanTarget>> DiscoverTargetsAsync(
SbomSource source,
TriggerContext context,
CancellationToken ct = default)
{
var config = source.Configuration.Deserialize<DockerSourceConfig>(JsonOptions);
if (config == null)
{
_logger.LogWarning("Invalid configuration for source {SourceId}", source.SourceId);
return [];
}
var credentials = await GetCredentialsAsync(source.AuthRef, ct);
var registryType = InferRegistryType(config.RegistryUrl);
using var client = _clientFactory.Create(registryType, config.RegistryUrl, credentials);
var targets = new List<ScanTarget>();
foreach (var imageSpec in config.Images)
{
try
{
var discovered = await DiscoverImageTargetsAsync(
client, config, imageSpec, ct);
targets.AddRange(discovered);
}
catch (Exception ex)
{
_logger.LogWarning(ex,
"Failed to discover targets for image {Reference}",
imageSpec.Reference);
}
}
_logger.LogInformation(
"Discovered {Count} targets from {ImageCount} image specs for source {SourceId}",
targets.Count, config.Images.Length, source.SourceId);
return targets;
}
private async Task<IReadOnlyList<ScanTarget>> DiscoverImageTargetsAsync(
IRegistryClient client,
DockerSourceConfig config,
ImageSpec imageSpec,
CancellationToken ct)
{
var targets = new List<ScanTarget>();
// Parse the reference to get repository and optional tag
var (repository, tag) = ParseReference(imageSpec.Reference);
// If the reference has a specific tag and no patterns, just scan that image
if (tag != null && (imageSpec.TagPatterns == null || imageSpec.TagPatterns.Length == 0))
{
var digest = await client.GetDigestAsync(repository, tag, ct);
targets.Add(new ScanTarget
{
Reference = BuildFullReference(config.RegistryUrl, repository, tag),
Digest = digest,
Priority = config.ScanOptions.Priority,
Metadata = new Dictionary<string, string>
{
["repository"] = repository,
["tag"] = tag,
["registryUrl"] = config.RegistryUrl
}
});
return targets;
}
// Discover tags based on patterns
var tagPatterns = imageSpec.TagPatterns ?? ["*"];
var allTags = await client.ListTagsAsync(repository, tagPatterns, imageSpec.MaxTags * 2, ct);
// Filter and sort tags
var filteredTags = _discoveryService.FilterTags(
allTags,
config.Discovery?.ExcludePatterns,
config.Discovery?.IncludePreRelease ?? false);
var sortedTags = _discoveryService.SortTags(
filteredTags,
config.Discovery?.SortOrder ?? TagSortOrder.SemVerDescending);
// Apply age filter if specified
if (imageSpec.MaxAgeHours.HasValue)
{
var cutoff = DateTimeOffset.UtcNow.AddHours(-imageSpec.MaxAgeHours.Value);
sortedTags = sortedTags
.Where(t => t.LastUpdated == null || t.LastUpdated >= cutoff)
.ToList();
}
// Take the configured number of tags
var tagsToScan = sortedTags.Take(imageSpec.MaxTags).ToList();
foreach (var tagInfo in tagsToScan)
{
targets.Add(new ScanTarget
{
Reference = BuildFullReference(config.RegistryUrl, repository, tagInfo.Name),
Digest = tagInfo.Digest,
Priority = config.ScanOptions.Priority,
Metadata = new Dictionary<string, string>
{
["repository"] = repository,
["tag"] = tagInfo.Name,
["registryUrl"] = config.RegistryUrl,
["digestPin"] = imageSpec.DigestPin.ToString().ToLowerInvariant()
}
});
}
return targets;
}
public ConfigValidationResult ValidateConfiguration(JsonDocument configuration)
{
return _configValidator.Validate(SbomSourceType.Docker, configuration);
}
public async Task<ConnectionTestResult> TestConnectionAsync(
SbomSource source,
CancellationToken ct = default)
{
var config = source.Configuration.Deserialize<DockerSourceConfig>(JsonOptions);
if (config == null)
{
return new ConnectionTestResult
{
Success = false,
Message = "Invalid configuration",
TestedAt = DateTimeOffset.UtcNow
};
}
try
{
var credentials = await GetCredentialsAsync(source.AuthRef, ct);
var registryType = InferRegistryType(config.RegistryUrl);
using var client = _clientFactory.Create(registryType, config.RegistryUrl, credentials);
var pingSuccess = await client.PingAsync(ct);
if (!pingSuccess)
{
return new ConnectionTestResult
{
Success = false,
Message = "Registry ping failed",
TestedAt = DateTimeOffset.UtcNow,
Details = new Dictionary<string, object>
{
["registryUrl"] = config.RegistryUrl
}
};
}
// Try to get digest for the first image to verify access
if (config.Images.Length > 0)
{
var (repo, tag) = ParseReference(config.Images[0].Reference);
var digest = await client.GetDigestAsync(repo, tag ?? "latest", ct);
return new ConnectionTestResult
{
Success = true,
Message = "Successfully connected to registry",
TestedAt = DateTimeOffset.UtcNow,
Details = new Dictionary<string, object>
{
["registryUrl"] = config.RegistryUrl,
["testImage"] = config.Images[0].Reference,
["imageAccessible"] = digest != null
}
};
}
return new ConnectionTestResult
{
Success = true,
Message = "Successfully connected to registry",
TestedAt = DateTimeOffset.UtcNow,
Details = new Dictionary<string, object>
{
["registryUrl"] = config.RegistryUrl
}
};
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Connection test failed for source {SourceId}", source.SourceId);
return new ConnectionTestResult
{
Success = false,
Message = $"Connection failed: {ex.Message}",
TestedAt = DateTimeOffset.UtcNow
};
}
}
private async Task<RegistryCredentials?> GetCredentialsAsync(string? authRef, CancellationToken ct)
{
if (string.IsNullOrEmpty(authRef))
{
return null;
}
var resolved = await _credentialResolver.ResolveAsync(authRef, ct);
if (resolved == null)
{
return null;
}
return resolved.Type switch
{
CredentialType.BasicAuth => new RegistryCredentials
{
AuthType = RegistryAuthType.Basic,
Username = resolved.Username,
Password = resolved.Password
},
CredentialType.BearerToken => new RegistryCredentials
{
AuthType = RegistryAuthType.Token,
Token = resolved.Token
},
CredentialType.AwsCredentials => new RegistryCredentials
{
AuthType = RegistryAuthType.AwsEcr,
AwsAccessKey = resolved.Properties?.GetValueOrDefault("accessKey"),
AwsSecretKey = resolved.Properties?.GetValueOrDefault("secretKey"),
AwsRegion = resolved.Properties?.GetValueOrDefault("region")
},
_ => null
};
}
private static RegistryType InferRegistryType(string registryUrl)
{
var host = new Uri(registryUrl).Host.ToLowerInvariant();
return host switch
{
_ when host.Contains("docker.io") || host.Contains("docker.com") => RegistryType.DockerHub,
_ when host.Contains("ecr.") && host.Contains("amazonaws.com") => RegistryType.Ecr,
_ when host.Contains("gcr.io") || host.Contains("pkg.dev") => RegistryType.Gcr,
_ when host.Contains("azurecr.io") => RegistryType.Acr,
_ when host.Contains("ghcr.io") => RegistryType.Ghcr,
_ when host.Contains("quay.io") => RegistryType.Quay,
_ when host.Contains("jfrog.io") || host.Contains("artifactory") => RegistryType.Artifactory,
_ => RegistryType.Generic
};
}
private static (string Repository, string? Tag) ParseReference(string reference)
{
// Handle digest references
if (reference.Contains('@'))
{
var parts = reference.Split('@', 2);
return (parts[0], null);
}
// Handle tag references
if (reference.Contains(':'))
{
var lastColon = reference.LastIndexOf(':');
return (reference[..lastColon], reference[(lastColon + 1)..]);
}
return (reference, null);
}
private static string BuildFullReference(string registryUrl, string repository, string tag)
{
var host = new Uri(registryUrl).Host;
// Docker Hub special case
if (host.Contains("docker.io") || host.Contains("docker.com"))
{
if (!repository.Contains('/'))
{
repository = $"library/{repository}";
}
return $"{repository}:{tag}";
}
return $"{host}/{repository}:{tag}";
}
}

View File

@@ -0,0 +1,206 @@
using System.Text.RegularExpressions;
using StellaOps.Scanner.Sources.Configuration;
using StellaOps.Scanner.Sources.Handlers.Zastava;
namespace StellaOps.Scanner.Sources.Handlers.Docker;
/// <summary>
/// Service for discovering and filtering container image tags.
/// </summary>
public interface IImageDiscoveryService
{
/// <summary>
/// Filter tags based on exclusion patterns and pre-release settings.
/// </summary>
IReadOnlyList<RegistryTag> FilterTags(
IReadOnlyList<RegistryTag> tags,
string[]? excludePatterns,
bool includePreRelease);
/// <summary>
/// Sort tags according to the specified sort order.
/// </summary>
IReadOnlyList<RegistryTag> SortTags(
IReadOnlyList<RegistryTag> tags,
TagSortOrder sortOrder);
/// <summary>
/// Parse a semantic version from a tag name.
/// </summary>
SemVer? ParseSemVer(string tag);
}
/// <summary>
/// Default implementation of tag discovery and filtering.
/// </summary>
public sealed class ImageDiscoveryService : IImageDiscoveryService
{
private static readonly Regex SemVerRegex = new(
@"^v?(?<major>\d+)\.(?<minor>\d+)\.(?<patch>\d+)" +
@"(?:-(?<prerelease>[a-zA-Z0-9.-]+))?" +
@"(?:\+(?<metadata>[a-zA-Z0-9.-]+))?$",
RegexOptions.Compiled | RegexOptions.IgnoreCase);
private static readonly Regex PreReleasePattern = new(
@"(?:alpha|beta|rc|pre|preview|dev|snapshot|canary|nightly)",
RegexOptions.Compiled | RegexOptions.IgnoreCase);
public IReadOnlyList<RegistryTag> FilterTags(
IReadOnlyList<RegistryTag> tags,
string[]? excludePatterns,
bool includePreRelease)
{
var filtered = tags.AsEnumerable();
// Apply exclusion patterns
if (excludePatterns is { Length: > 0 })
{
var regexPatterns = excludePatterns
.Select(p => new Regex(
"^" + Regex.Escape(p).Replace("\\*", ".*").Replace("\\?", ".") + "$",
RegexOptions.IgnoreCase))
.ToList();
filtered = filtered.Where(t =>
!regexPatterns.Any(r => r.IsMatch(t.Name)));
}
// Filter pre-release tags if not included
if (!includePreRelease)
{
filtered = filtered.Where(t => !IsPreRelease(t.Name));
}
return filtered.ToList();
}
public IReadOnlyList<RegistryTag> SortTags(
IReadOnlyList<RegistryTag> tags,
TagSortOrder sortOrder)
{
return sortOrder switch
{
TagSortOrder.SemVerDescending => tags
.Select(t => (Tag: t, SemVer: ParseSemVer(t.Name)))
.OrderByDescending(x => x.SemVer?.Major ?? 0)
.ThenByDescending(x => x.SemVer?.Minor ?? 0)
.ThenByDescending(x => x.SemVer?.Patch ?? 0)
.ThenBy(x => x.SemVer?.PreRelease ?? "")
.ThenByDescending(x => x.Tag.Name)
.Select(x => x.Tag)
.ToList(),
TagSortOrder.SemVerAscending => tags
.Select(t => (Tag: t, SemVer: ParseSemVer(t.Name)))
.OrderBy(x => x.SemVer?.Major ?? int.MaxValue)
.ThenBy(x => x.SemVer?.Minor ?? int.MaxValue)
.ThenBy(x => x.SemVer?.Patch ?? int.MaxValue)
.ThenByDescending(x => x.SemVer?.PreRelease ?? "")
.ThenBy(x => x.Tag.Name)
.Select(x => x.Tag)
.ToList(),
TagSortOrder.AlphaDescending => tags
.OrderByDescending(t => t.Name)
.ToList(),
TagSortOrder.AlphaAscending => tags
.OrderBy(t => t.Name)
.ToList(),
TagSortOrder.DateDescending => tags
.OrderByDescending(t => t.LastUpdated ?? DateTimeOffset.MinValue)
.ThenByDescending(t => t.Name)
.ToList(),
TagSortOrder.DateAscending => tags
.OrderBy(t => t.LastUpdated ?? DateTimeOffset.MaxValue)
.ThenBy(t => t.Name)
.ToList(),
_ => tags.ToList()
};
}
public SemVer? ParseSemVer(string tag)
{
var match = SemVerRegex.Match(tag);
if (!match.Success)
{
return null;
}
return new SemVer
{
Major = int.Parse(match.Groups["major"].Value),
Minor = int.Parse(match.Groups["minor"].Value),
Patch = int.Parse(match.Groups["patch"].Value),
PreRelease = match.Groups["prerelease"].Success
? match.Groups["prerelease"].Value
: null,
Metadata = match.Groups["metadata"].Success
? match.Groups["metadata"].Value
: null
};
}
private static bool IsPreRelease(string tagName)
{
// Check common pre-release indicators
if (PreReleasePattern.IsMatch(tagName))
{
return true;
}
// Also check parsed semver
var semver = new ImageDiscoveryService().ParseSemVer(tagName);
return semver?.PreRelease != null;
}
}
/// <summary>
/// Represents a parsed semantic version.
/// </summary>
public sealed record SemVer : IComparable<SemVer>
{
public int Major { get; init; }
public int Minor { get; init; }
public int Patch { get; init; }
public string? PreRelease { get; init; }
public string? Metadata { get; init; }
public int CompareTo(SemVer? other)
{
if (other is null) return 1;
var majorCompare = Major.CompareTo(other.Major);
if (majorCompare != 0) return majorCompare;
var minorCompare = Minor.CompareTo(other.Minor);
if (minorCompare != 0) return minorCompare;
var patchCompare = Patch.CompareTo(other.Patch);
if (patchCompare != 0) return patchCompare;
// Pre-release versions have lower precedence than release versions
if (PreRelease is null && other.PreRelease is not null) return 1;
if (PreRelease is not null && other.PreRelease is null) return -1;
if (PreRelease is null && other.PreRelease is null) return 0;
return string.Compare(PreRelease, other.PreRelease, StringComparison.Ordinal);
}
public override string ToString()
{
var result = $"{Major}.{Minor}.{Patch}";
if (!string.IsNullOrEmpty(PreRelease))
{
result += $"-{PreRelease}";
}
if (!string.IsNullOrEmpty(Metadata))
{
result += $"+{Metadata}";
}
return result;
}
}

View File

@@ -0,0 +1,511 @@
using System.Text.Json;
using System.Text.RegularExpressions;
using Microsoft.Extensions.Logging;
using StellaOps.Scanner.Sources.Configuration;
using StellaOps.Scanner.Sources.Contracts;
using StellaOps.Scanner.Sources.Domain;
using StellaOps.Scanner.Sources.Services;
using StellaOps.Scanner.Sources.Triggers;
namespace StellaOps.Scanner.Sources.Handlers.Git;
/// <summary>
/// Handler for Git (repository) sources.
/// Scans source code repositories for dependencies and vulnerabilities.
/// </summary>
public sealed class GitSourceHandler : ISourceTypeHandler, IWebhookCapableHandler
{
private readonly IGitClientFactory _gitClientFactory;
private readonly ICredentialResolver _credentialResolver;
private readonly ISourceConfigValidator _configValidator;
private readonly ILogger<GitSourceHandler> _logger;
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
public SbomSourceType SourceType => SbomSourceType.Git;
public bool SupportsWebhooks => true;
public bool SupportsScheduling => true;
public int MaxConcurrentTargets => 10;
public GitSourceHandler(
IGitClientFactory gitClientFactory,
ICredentialResolver credentialResolver,
ISourceConfigValidator configValidator,
ILogger<GitSourceHandler> logger)
{
_gitClientFactory = gitClientFactory;
_credentialResolver = credentialResolver;
_configValidator = configValidator;
_logger = logger;
}
public async Task<IReadOnlyList<ScanTarget>> DiscoverTargetsAsync(
SbomSource source,
TriggerContext context,
CancellationToken ct = default)
{
var config = source.Configuration.Deserialize<GitSourceConfig>(JsonOptions);
if (config == null)
{
_logger.LogWarning("Invalid configuration for source {SourceId}", source.SourceId);
return [];
}
// For webhook triggers, extract target from payload
if (context.Trigger == SbomSourceRunTrigger.Webhook)
{
if (context.WebhookPayload != null)
{
var payloadInfo = ParseWebhookPayload(context.WebhookPayload);
// Check if it matches configured triggers and branch filters
if (!ShouldTrigger(payloadInfo, config))
{
_logger.LogInformation(
"Webhook payload does not match triggers for source {SourceId}",
source.SourceId);
return [];
}
return
[
new ScanTarget
{
Reference = BuildReference(config.RepositoryUrl, payloadInfo.Branch ?? payloadInfo.Reference),
Metadata = new Dictionary<string, string>
{
["repository"] = config.RepositoryUrl,
["branch"] = payloadInfo.Branch ?? "",
["commit"] = payloadInfo.CommitSha ?? "",
["eventType"] = payloadInfo.EventType,
["actor"] = payloadInfo.Actor ?? "unknown"
}
}
];
}
}
// For scheduled/manual triggers, discover branches to scan
return await DiscoverBranchTargetsAsync(source, config, ct);
}
private async Task<IReadOnlyList<ScanTarget>> DiscoverBranchTargetsAsync(
SbomSource source,
GitSourceConfig config,
CancellationToken ct)
{
var credentials = await GetCredentialsAsync(source.AuthRef, config.AuthMethod, ct);
using var client = _gitClientFactory.Create(config.Provider, config.RepositoryUrl, credentials);
var branches = await client.ListBranchesAsync(ct);
var targets = new List<ScanTarget>();
foreach (var branch in branches)
{
// Check inclusion patterns
var included = config.Branches.Include
.Any(p => MatchesPattern(branch.Name, p));
if (!included)
{
continue;
}
// Check exclusion patterns
var excluded = config.Branches.Exclude?
.Any(p => MatchesPattern(branch.Name, p)) ?? false;
if (excluded)
{
continue;
}
targets.Add(new ScanTarget
{
Reference = BuildReference(config.RepositoryUrl, branch.Name),
Metadata = new Dictionary<string, string>
{
["repository"] = config.RepositoryUrl,
["branch"] = branch.Name,
["commit"] = branch.HeadCommit ?? "",
["eventType"] = "scheduled"
}
});
}
_logger.LogInformation(
"Discovered {Count} branch targets for source {SourceId}",
targets.Count, source.SourceId);
return targets;
}
public ConfigValidationResult ValidateConfiguration(JsonDocument configuration)
{
return _configValidator.Validate(SbomSourceType.Git, configuration);
}
public async Task<ConnectionTestResult> TestConnectionAsync(
SbomSource source,
CancellationToken ct = default)
{
var config = source.Configuration.Deserialize<GitSourceConfig>(JsonOptions);
if (config == null)
{
return new ConnectionTestResult
{
Success = false,
Message = "Invalid configuration",
TestedAt = DateTimeOffset.UtcNow
};
}
try
{
var credentials = await GetCredentialsAsync(source.AuthRef, config.AuthMethod, ct);
using var client = _gitClientFactory.Create(config.Provider, config.RepositoryUrl, credentials);
var repoInfo = await client.GetRepositoryInfoAsync(ct);
if (repoInfo == null)
{
return new ConnectionTestResult
{
Success = false,
Message = "Repository not found or inaccessible",
TestedAt = DateTimeOffset.UtcNow,
Details = new Dictionary<string, object>
{
["repositoryUrl"] = config.RepositoryUrl,
["provider"] = config.Provider.ToString()
}
};
}
return new ConnectionTestResult
{
Success = true,
Message = "Successfully connected to repository",
TestedAt = DateTimeOffset.UtcNow,
Details = new Dictionary<string, object>
{
["repositoryUrl"] = config.RepositoryUrl,
["provider"] = config.Provider.ToString(),
["defaultBranch"] = repoInfo.DefaultBranch ?? "",
["sizeKb"] = repoInfo.SizeKb
}
};
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Connection test failed for source {SourceId}", source.SourceId);
return new ConnectionTestResult
{
Success = false,
Message = $"Connection failed: {ex.Message}",
TestedAt = DateTimeOffset.UtcNow
};
}
}
public bool VerifyWebhookSignature(byte[] payload, string signature, string secret)
{
if (string.IsNullOrEmpty(signature) || string.IsNullOrEmpty(secret))
{
return false;
}
// GitHub uses HMAC-SHA256 with "sha256=" prefix
if (signature.StartsWith("sha256=", StringComparison.OrdinalIgnoreCase))
{
return VerifyHmacSha256(payload, signature[7..], secret);
}
// GitHub legacy uses HMAC-SHA1 with "sha1=" prefix
if (signature.StartsWith("sha1=", StringComparison.OrdinalIgnoreCase))
{
return VerifyHmacSha1(payload, signature[5..], secret);
}
// GitLab uses X-Gitlab-Token header (direct secret comparison)
if (!signature.Contains('='))
{
return string.Equals(signature, secret, StringComparison.Ordinal);
}
return false;
}
public WebhookPayloadInfo ParseWebhookPayload(JsonDocument payload)
{
var root = payload.RootElement;
// GitHub push event
if (root.TryGetProperty("ref", out var refProp) &&
root.TryGetProperty("repository", out var ghRepo))
{
var refValue = refProp.GetString() ?? "";
var branch = refValue.StartsWith("refs/heads/")
? refValue[11..]
: refValue.StartsWith("refs/tags/")
? refValue[10..]
: refValue;
var isTag = refValue.StartsWith("refs/tags/");
return new WebhookPayloadInfo
{
EventType = isTag ? "tag" : "push",
Reference = ghRepo.TryGetProperty("full_name", out var fullName)
? fullName.GetString()!
: "",
Branch = branch,
CommitSha = root.TryGetProperty("after", out var after)
? after.GetString()
: null,
Actor = root.TryGetProperty("sender", out var sender) &&
sender.TryGetProperty("login", out var login)
? login.GetString()
: null,
Timestamp = DateTimeOffset.UtcNow
};
}
// GitHub pull request event
if (root.TryGetProperty("action", out var action) &&
root.TryGetProperty("pull_request", out var pr))
{
return new WebhookPayloadInfo
{
EventType = "pull_request",
Reference = root.TryGetProperty("repository", out var prRepo) &&
prRepo.TryGetProperty("full_name", out var prFullName)
? prFullName.GetString()!
: "",
Branch = pr.TryGetProperty("head", out var head) &&
head.TryGetProperty("ref", out var headRef)
? headRef.GetString()
: null,
CommitSha = head.TryGetProperty("sha", out var sha)
? sha.GetString()
: null,
Actor = pr.TryGetProperty("user", out var user) &&
user.TryGetProperty("login", out var prLogin)
? prLogin.GetString()
: null,
Metadata = new Dictionary<string, string>
{
["action"] = action.GetString() ?? "",
["prNumber"] = pr.TryGetProperty("number", out var num)
? num.GetInt32().ToString()
: ""
},
Timestamp = DateTimeOffset.UtcNow
};
}
// GitLab push event
if (root.TryGetProperty("object_kind", out var objectKind))
{
var kind = objectKind.GetString();
if (kind == "push")
{
return new WebhookPayloadInfo
{
EventType = "push",
Reference = root.TryGetProperty("project", out var project) &&
project.TryGetProperty("path_with_namespace", out var path)
? path.GetString()!
: "",
Branch = root.TryGetProperty("ref", out var glRef)
? glRef.GetString()?.Replace("refs/heads/", "") ?? ""
: null,
CommitSha = root.TryGetProperty("after", out var glAfter)
? glAfter.GetString()
: null,
Actor = root.TryGetProperty("user_name", out var userName)
? userName.GetString()
: null,
Timestamp = DateTimeOffset.UtcNow
};
}
if (kind == "merge_request")
{
var mrAttrs = root.TryGetProperty("object_attributes", out var oa) ? oa : default;
return new WebhookPayloadInfo
{
EventType = "pull_request",
Reference = root.TryGetProperty("project", out var mrProject) &&
mrProject.TryGetProperty("path_with_namespace", out var mrPath)
? mrPath.GetString()!
: "",
Branch = mrAttrs.TryGetProperty("source_branch", out var srcBranch)
? srcBranch.GetString()
: null,
CommitSha = mrAttrs.TryGetProperty("last_commit", out var lastCommit) &&
lastCommit.TryGetProperty("id", out var commitId)
? commitId.GetString()
: null,
Actor = root.TryGetProperty("user", out var glUser) &&
glUser.TryGetProperty("username", out var glUsername)
? glUsername.GetString()
: null,
Metadata = new Dictionary<string, string>
{
["action"] = mrAttrs.TryGetProperty("action", out var mrAction)
? mrAction.GetString() ?? ""
: ""
},
Timestamp = DateTimeOffset.UtcNow
};
}
}
_logger.LogWarning("Unable to parse Git webhook payload format");
return new WebhookPayloadInfo
{
EventType = "unknown",
Reference = "",
Timestamp = DateTimeOffset.UtcNow
};
}
private bool ShouldTrigger(WebhookPayloadInfo payload, GitSourceConfig config)
{
// Check event type against configured triggers
switch (payload.EventType)
{
case "push":
if (!config.Triggers.OnPush)
return false;
break;
case "tag":
if (!config.Triggers.OnTag)
return false;
// Check tag patterns if specified
if (config.Triggers.TagPatterns is { Length: > 0 })
{
if (!config.Triggers.TagPatterns.Any(p => MatchesPattern(payload.Branch ?? "", p)))
return false;
}
break;
case "pull_request":
if (!config.Triggers.OnPullRequest)
return false;
// Check PR action if specified
if (config.Triggers.PrActions is { Length: > 0 })
{
var actionStr = payload.Metadata.GetValueOrDefault("action", "");
var matchedAction = Enum.TryParse<PullRequestAction>(actionStr, ignoreCase: true, out var action)
&& config.Triggers.PrActions.Contains(action);
if (!matchedAction)
return false;
}
break;
default:
return false;
}
// Check branch filters (only for push and PR, not tags)
if (payload.EventType != "tag" && !string.IsNullOrEmpty(payload.Branch))
{
var included = config.Branches.Include.Any(p => MatchesPattern(payload.Branch, p));
if (!included)
return false;
var excluded = config.Branches.Exclude?.Any(p => MatchesPattern(payload.Branch, p)) ?? false;
if (excluded)
return false;
}
return true;
}
private async Task<GitCredentials?> GetCredentialsAsync(
string? authRef,
GitAuthMethod authMethod,
CancellationToken ct)
{
if (string.IsNullOrEmpty(authRef))
{
return null;
}
var resolved = await _credentialResolver.ResolveAsync(authRef, ct);
if (resolved == null)
{
return null;
}
return authMethod switch
{
GitAuthMethod.Token => new GitCredentials
{
AuthType = GitAuthType.Token,
Token = resolved.Token ?? resolved.Password
},
GitAuthMethod.Ssh => new GitCredentials
{
AuthType = GitAuthType.Ssh,
SshPrivateKey = resolved.Properties?.GetValueOrDefault("privateKey"),
SshPassphrase = resolved.Properties?.GetValueOrDefault("passphrase")
},
GitAuthMethod.OAuth => new GitCredentials
{
AuthType = GitAuthType.OAuth,
Token = resolved.Token
},
GitAuthMethod.GitHubApp => new GitCredentials
{
AuthType = GitAuthType.GitHubApp,
AppId = resolved.Properties?.GetValueOrDefault("appId"),
PrivateKey = resolved.Properties?.GetValueOrDefault("privateKey"),
InstallationId = resolved.Properties?.GetValueOrDefault("installationId")
},
_ => null
};
}
private static bool MatchesPattern(string value, string pattern)
{
var regexPattern = "^" + Regex.Escape(pattern)
.Replace("\\*\\*", ".*")
.Replace("\\*", "[^/]*")
.Replace("\\?", ".") + "$";
return Regex.IsMatch(value, regexPattern, RegexOptions.IgnoreCase);
}
private static string BuildReference(string repositoryUrl, string branchOrRef)
{
return $"{repositoryUrl}@{branchOrRef}";
}
private static bool VerifyHmacSha256(byte[] payload, string expected, string secret)
{
using var hmac = new System.Security.Cryptography.HMACSHA256(
System.Text.Encoding.UTF8.GetBytes(secret));
var computed = Convert.ToHexString(hmac.ComputeHash(payload)).ToLowerInvariant();
return System.Security.Cryptography.CryptographicOperations.FixedTimeEquals(
System.Text.Encoding.UTF8.GetBytes(computed),
System.Text.Encoding.UTF8.GetBytes(expected.ToLowerInvariant()));
}
private static bool VerifyHmacSha1(byte[] payload, string expected, string secret)
{
using var hmac = new System.Security.Cryptography.HMACSHA1(
System.Text.Encoding.UTF8.GetBytes(secret));
var computed = Convert.ToHexString(hmac.ComputeHash(payload)).ToLowerInvariant();
return System.Security.Cryptography.CryptographicOperations.FixedTimeEquals(
System.Text.Encoding.UTF8.GetBytes(computed),
System.Text.Encoding.UTF8.GetBytes(expected.ToLowerInvariant()));
}
}

View File

@@ -0,0 +1,172 @@
using StellaOps.Scanner.Sources.Configuration;
namespace StellaOps.Scanner.Sources.Handlers.Git;
/// <summary>
/// Interface for interacting with Git repositories via API.
/// </summary>
public interface IGitClient : IDisposable
{
/// <summary>
/// Get repository information.
/// </summary>
Task<RepositoryInfo?> GetRepositoryInfoAsync(CancellationToken ct = default);
/// <summary>
/// List branches in the repository.
/// </summary>
Task<IReadOnlyList<BranchInfo>> ListBranchesAsync(CancellationToken ct = default);
/// <summary>
/// List tags in the repository.
/// </summary>
Task<IReadOnlyList<TagInfo>> ListTagsAsync(CancellationToken ct = default);
/// <summary>
/// Get commit information.
/// </summary>
Task<CommitInfo?> GetCommitAsync(string sha, CancellationToken ct = default);
}
/// <summary>
/// Factory for creating Git clients.
/// </summary>
public interface IGitClientFactory
{
/// <summary>
/// Create a Git client for the specified provider.
/// </summary>
IGitClient Create(
GitProvider provider,
string repositoryUrl,
GitCredentials? credentials = null);
}
/// <summary>
/// Credentials for Git repository authentication.
/// </summary>
public sealed record GitCredentials
{
/// <summary>Type of authentication.</summary>
public required GitAuthType AuthType { get; init; }
/// <summary>Personal access token or OAuth token.</summary>
public string? Token { get; init; }
/// <summary>SSH private key content.</summary>
public string? SshPrivateKey { get; init; }
/// <summary>SSH key passphrase.</summary>
public string? SshPassphrase { get; init; }
/// <summary>GitHub App ID.</summary>
public string? AppId { get; init; }
/// <summary>GitHub App private key.</summary>
public string? PrivateKey { get; init; }
/// <summary>GitHub App installation ID.</summary>
public string? InstallationId { get; init; }
}
/// <summary>
/// Git authentication types.
/// </summary>
public enum GitAuthType
{
None,
Token,
Ssh,
OAuth,
GitHubApp
}
/// <summary>
/// Repository information.
/// </summary>
public sealed record RepositoryInfo
{
/// <summary>Repository name.</summary>
public required string Name { get; init; }
/// <summary>Full path or full name.</summary>
public required string FullName { get; init; }
/// <summary>Default branch name.</summary>
public string? DefaultBranch { get; init; }
/// <summary>Repository size in KB.</summary>
public long SizeKb { get; init; }
/// <summary>Whether the repository is private.</summary>
public bool IsPrivate { get; init; }
/// <summary>Repository description.</summary>
public string? Description { get; init; }
/// <summary>Clone URL (HTTPS).</summary>
public string? CloneUrl { get; init; }
/// <summary>SSH clone URL.</summary>
public string? SshUrl { get; init; }
}
/// <summary>
/// Branch information.
/// </summary>
public sealed record BranchInfo
{
/// <summary>Branch name.</summary>
public required string Name { get; init; }
/// <summary>HEAD commit SHA.</summary>
public string? HeadCommit { get; init; }
/// <summary>Whether this is the default branch.</summary>
public bool IsDefault { get; init; }
/// <summary>Whether the branch is protected.</summary>
public bool IsProtected { get; init; }
}
/// <summary>
/// Tag information.
/// </summary>
public sealed record TagInfo
{
/// <summary>Tag name.</summary>
public required string Name { get; init; }
/// <summary>Commit SHA the tag points to.</summary>
public string? CommitSha { get; init; }
/// <summary>Tag message (for annotated tags).</summary>
public string? Message { get; init; }
/// <summary>When the tag was created.</summary>
public DateTimeOffset? CreatedAt { get; init; }
}
/// <summary>
/// Commit information.
/// </summary>
public sealed record CommitInfo
{
/// <summary>Commit SHA.</summary>
public required string Sha { get; init; }
/// <summary>Commit message.</summary>
public string? Message { get; init; }
/// <summary>Author name.</summary>
public string? AuthorName { get; init; }
/// <summary>Author email.</summary>
public string? AuthorEmail { get; init; }
/// <summary>When the commit was authored.</summary>
public DateTimeOffset? AuthoredAt { get; init; }
/// <summary>Parent commit SHAs.</summary>
public IReadOnlyList<string> Parents { get; init; } = [];
}

View File

@@ -0,0 +1,113 @@
using System.Text.Json;
using StellaOps.Scanner.Sources.Configuration;
using StellaOps.Scanner.Sources.Contracts;
using StellaOps.Scanner.Sources.Domain;
using StellaOps.Scanner.Sources.Triggers;
namespace StellaOps.Scanner.Sources.Handlers;
/// <summary>
/// Interface for source type-specific handlers.
/// Each source type (Zastava, Docker, CLI, Git) has its own handler.
/// </summary>
public interface ISourceTypeHandler
{
/// <summary>The source type this handler manages.</summary>
SbomSourceType SourceType { get; }
/// <summary>
/// Discover targets to scan based on source configuration and trigger context.
/// </summary>
/// <param name="source">The source configuration.</param>
/// <param name="context">The trigger context.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>List of targets to scan.</returns>
Task<IReadOnlyList<ScanTarget>> DiscoverTargetsAsync(
SbomSource source,
TriggerContext context,
CancellationToken ct = default);
/// <summary>
/// Validate source configuration.
/// </summary>
/// <param name="configuration">The configuration to validate.</param>
/// <returns>Validation result.</returns>
ConfigValidationResult ValidateConfiguration(JsonDocument configuration);
/// <summary>
/// Test connection to the source.
/// </summary>
/// <param name="source">The source to test.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Connection test result.</returns>
Task<ConnectionTestResult> TestConnectionAsync(
SbomSource source,
CancellationToken ct = default);
/// <summary>
/// Gets the maximum number of concurrent targets this handler supports.
/// </summary>
int MaxConcurrentTargets => 10;
/// <summary>
/// Whether this handler supports webhook triggers.
/// </summary>
bool SupportsWebhooks => false;
/// <summary>
/// Whether this handler supports scheduled triggers.
/// </summary>
bool SupportsScheduling => true;
}
/// <summary>
/// Extended interface for handlers that can process webhooks.
/// </summary>
public interface IWebhookCapableHandler : ISourceTypeHandler
{
/// <summary>
/// Verify webhook signature.
/// </summary>
bool VerifyWebhookSignature(
byte[] payload,
string signature,
string secret);
/// <summary>
/// Parse webhook payload to extract trigger information.
/// </summary>
WebhookPayloadInfo ParseWebhookPayload(JsonDocument payload);
}
/// <summary>
/// Parsed webhook payload information.
/// </summary>
public sealed record WebhookPayloadInfo
{
/// <summary>Type of event (push, tag, delete, etc.).</summary>
public required string EventType { get; init; }
/// <summary>Repository or image reference.</summary>
public required string Reference { get; init; }
/// <summary>Tag if applicable.</summary>
public string? Tag { get; init; }
/// <summary>Digest if applicable.</summary>
public string? Digest { get; init; }
/// <summary>Branch if applicable (git webhooks).</summary>
public string? Branch { get; init; }
/// <summary>Commit SHA if applicable (git webhooks).</summary>
public string? CommitSha { get; init; }
/// <summary>User who triggered the event.</summary>
public string? Actor { get; init; }
/// <summary>Timestamp of the event.</summary>
public DateTimeOffset Timestamp { get; init; } = DateTimeOffset.UtcNow;
/// <summary>Additional metadata from the payload.</summary>
public Dictionary<string, string> Metadata { get; init; } = [];
}

View File

@@ -0,0 +1,128 @@
namespace StellaOps.Scanner.Sources.Handlers.Zastava;
/// <summary>
/// Interface for interacting with container registries.
/// </summary>
public interface IRegistryClient : IDisposable
{
/// <summary>
/// Test connectivity to the registry.
/// </summary>
Task<bool> PingAsync(CancellationToken ct = default);
/// <summary>
/// List repositories matching a pattern.
/// </summary>
/// <param name="pattern">Glob pattern (e.g., "library/*").</param>
/// <param name="limit">Maximum number of repositories to return.</param>
/// <param name="ct">Cancellation token.</param>
Task<IReadOnlyList<string>> ListRepositoriesAsync(
string? pattern = null,
int limit = 100,
CancellationToken ct = default);
/// <summary>
/// List tags for a repository.
/// </summary>
/// <param name="repository">Repository name.</param>
/// <param name="patterns">Tag patterns to match (null = all).</param>
/// <param name="limit">Maximum number of tags to return.</param>
/// <param name="ct">Cancellation token.</param>
Task<IReadOnlyList<RegistryTag>> ListTagsAsync(
string repository,
IReadOnlyList<string>? patterns = null,
int limit = 100,
CancellationToken ct = default);
/// <summary>
/// Get manifest digest for an image reference.
/// </summary>
Task<string?> GetDigestAsync(
string repository,
string tag,
CancellationToken ct = default);
}
/// <summary>
/// Represents a tag in a container registry.
/// </summary>
public sealed record RegistryTag
{
/// <summary>The tag name.</summary>
public required string Name { get; init; }
/// <summary>The manifest digest.</summary>
public string? Digest { get; init; }
/// <summary>When the tag was last updated.</summary>
public DateTimeOffset? LastUpdated { get; init; }
/// <summary>Size of the image in bytes.</summary>
public long? SizeBytes { get; init; }
}
/// <summary>
/// Factory for creating registry clients.
/// </summary>
public interface IRegistryClientFactory
{
/// <summary>
/// Create a registry client for the specified registry.
/// </summary>
IRegistryClient Create(
Configuration.RegistryType registryType,
string registryUrl,
RegistryCredentials? credentials = null);
}
/// <summary>
/// Credentials for registry authentication.
/// </summary>
public sealed record RegistryCredentials
{
/// <summary>Type of authentication.</summary>
public required RegistryAuthType AuthType { get; init; }
/// <summary>Username for basic auth.</summary>
public string? Username { get; init; }
/// <summary>Password or token for basic auth.</summary>
public string? Password { get; init; }
/// <summary>Bearer token for token auth.</summary>
public string? Token { get; init; }
/// <summary>AWS access key for ECR.</summary>
public string? AwsAccessKey { get; init; }
/// <summary>AWS secret key for ECR.</summary>
public string? AwsSecretKey { get; init; }
/// <summary>AWS region for ECR.</summary>
public string? AwsRegion { get; init; }
/// <summary>GCP service account JSON for GCR.</summary>
public string? GcpServiceAccountJson { get; init; }
/// <summary>Azure client ID for ACR.</summary>
public string? AzureClientId { get; init; }
/// <summary>Azure client secret for ACR.</summary>
public string? AzureClientSecret { get; init; }
/// <summary>Azure tenant ID for ACR.</summary>
public string? AzureTenantId { get; init; }
}
/// <summary>
/// Registry authentication types.
/// </summary>
public enum RegistryAuthType
{
None,
Basic,
Token,
AwsEcr,
GcpGcr,
AzureAcr
}

View File

@@ -0,0 +1,456 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
using Microsoft.Extensions.Logging;
using StellaOps.Scanner.Sources.Configuration;
using StellaOps.Scanner.Sources.Contracts;
using StellaOps.Scanner.Sources.Domain;
using StellaOps.Scanner.Sources.Services;
using StellaOps.Scanner.Sources.Triggers;
namespace StellaOps.Scanner.Sources.Handlers.Zastava;
/// <summary>
/// Handler for Zastava (container registry webhook) sources.
/// </summary>
public sealed class ZastavaSourceHandler : ISourceTypeHandler, IWebhookCapableHandler
{
private readonly IRegistryClientFactory _clientFactory;
private readonly ICredentialResolver _credentialResolver;
private readonly ISourceConfigValidator _configValidator;
private readonly ILogger<ZastavaSourceHandler> _logger;
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
public SbomSourceType SourceType => SbomSourceType.Zastava;
public bool SupportsWebhooks => true;
public bool SupportsScheduling => true;
public int MaxConcurrentTargets => 20;
public ZastavaSourceHandler(
IRegistryClientFactory clientFactory,
ICredentialResolver credentialResolver,
ISourceConfigValidator configValidator,
ILogger<ZastavaSourceHandler> logger)
{
_clientFactory = clientFactory;
_credentialResolver = credentialResolver;
_configValidator = configValidator;
_logger = logger;
}
public async Task<IReadOnlyList<ScanTarget>> DiscoverTargetsAsync(
SbomSource source,
TriggerContext context,
CancellationToken ct = default)
{
var config = source.Configuration.Deserialize<ZastavaSourceConfig>(JsonOptions);
if (config == null)
{
_logger.LogWarning("Invalid configuration for source {SourceId}", source.SourceId);
return [];
}
// For webhook triggers, extract target from payload
if (context.Trigger == SbomSourceRunTrigger.Webhook)
{
if (context.WebhookPayload != null)
{
var payloadInfo = ParseWebhookPayload(context.WebhookPayload);
// Check if it matches filters
if (!MatchesFilters(payloadInfo, config.Filters))
{
_logger.LogInformation(
"Webhook payload does not match filters for source {SourceId}",
source.SourceId);
return [];
}
var reference = BuildReference(config.RegistryUrl, payloadInfo.Reference, payloadInfo.Tag);
return
[
new ScanTarget
{
Reference = reference,
Digest = payloadInfo.Digest,
Metadata = new Dictionary<string, string>
{
["repository"] = payloadInfo.Reference,
["tag"] = payloadInfo.Tag ?? "latest",
["pushedBy"] = payloadInfo.Actor ?? "unknown",
["eventType"] = payloadInfo.EventType
}
}
];
}
}
// For scheduled/manual triggers, discover from registry
return await DiscoverFromRegistryAsync(source, config, ct);
}
private async Task<IReadOnlyList<ScanTarget>> DiscoverFromRegistryAsync(
SbomSource source,
ZastavaSourceConfig config,
CancellationToken ct)
{
var credentials = await GetCredentialsAsync(source.AuthRef, ct);
using var client = _clientFactory.Create(config.RegistryType, config.RegistryUrl, credentials);
var targets = new List<ScanTarget>();
var repoPatterns = config.Filters?.RepositoryPatterns ?? ["*"];
foreach (var pattern in repoPatterns)
{
var repos = await client.ListRepositoriesAsync(pattern, 100, ct);
foreach (var repo in repos)
{
// Check exclusions
if (config.Filters?.ExcludePatterns?.Any(ex => MatchesPattern(repo, ex)) == true)
{
continue;
}
var tagPatterns = config.Filters?.TagPatterns ?? ["*"];
var tags = await client.ListTagsAsync(repo, tagPatterns, 50, ct);
foreach (var tag in tags)
{
// Check tag exclusions
if (config.Filters?.ExcludePatterns?.Any(ex => MatchesPattern(tag.Name, ex)) == true)
{
continue;
}
var reference = BuildReference(config.RegistryUrl, repo, tag.Name);
targets.Add(new ScanTarget
{
Reference = reference,
Digest = tag.Digest,
Metadata = new Dictionary<string, string>
{
["repository"] = repo,
["tag"] = tag.Name
}
});
}
}
}
_logger.LogInformation(
"Discovered {Count} targets from registry for source {SourceId}",
targets.Count, source.SourceId);
return targets;
}
public ConfigValidationResult ValidateConfiguration(JsonDocument configuration)
{
return _configValidator.Validate(SbomSourceType.Zastava, configuration);
}
public async Task<ConnectionTestResult> TestConnectionAsync(
SbomSource source,
CancellationToken ct = default)
{
var config = source.Configuration.Deserialize<ZastavaSourceConfig>(JsonOptions);
if (config == null)
{
return new ConnectionTestResult
{
Success = false,
Message = "Invalid configuration",
TestedAt = DateTimeOffset.UtcNow
};
}
try
{
var credentials = await GetCredentialsAsync(source.AuthRef, ct);
using var client = _clientFactory.Create(config.RegistryType, config.RegistryUrl, credentials);
var pingSuccess = await client.PingAsync(ct);
if (!pingSuccess)
{
return new ConnectionTestResult
{
Success = false,
Message = "Registry ping failed",
TestedAt = DateTimeOffset.UtcNow,
Details = new Dictionary<string, object>
{
["registryUrl"] = config.RegistryUrl,
["registryType"] = config.RegistryType.ToString()
}
};
}
// Try to list repositories to verify access
var repos = await client.ListRepositoriesAsync(limit: 1, ct: ct);
return new ConnectionTestResult
{
Success = true,
Message = "Successfully connected to registry",
TestedAt = DateTimeOffset.UtcNow,
Details = new Dictionary<string, object>
{
["registryUrl"] = config.RegistryUrl,
["registryType"] = config.RegistryType.ToString(),
["repositoriesAccessible"] = repos.Count > 0
}
};
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Connection test failed for source {SourceId}", source.SourceId);
return new ConnectionTestResult
{
Success = false,
Message = $"Connection failed: {ex.Message}",
TestedAt = DateTimeOffset.UtcNow
};
}
}
public bool VerifyWebhookSignature(byte[] payload, string signature, string secret)
{
// Support multiple signature formats
// Docker Hub: X-Hub-Signature (SHA1)
// Harbor: Authorization header with shared secret
// Generic: HMAC-SHA256
if (string.IsNullOrEmpty(signature) || string.IsNullOrEmpty(secret))
{
return false;
}
// Try HMAC-SHA256 first (most common)
var secretBytes = Encoding.UTF8.GetBytes(secret);
using var hmac256 = new HMACSHA256(secretBytes);
var computed256 = Convert.ToHexString(hmac256.ComputeHash(payload)).ToLowerInvariant();
if (signature.StartsWith("sha256=", StringComparison.OrdinalIgnoreCase))
{
var expected = signature[7..].ToLowerInvariant();
return CryptographicOperations.FixedTimeEquals(
Encoding.UTF8.GetBytes(computed256),
Encoding.UTF8.GetBytes(expected));
}
// Try SHA1 (Docker Hub legacy)
using var hmac1 = new HMACSHA1(secretBytes);
var computed1 = Convert.ToHexString(hmac1.ComputeHash(payload)).ToLowerInvariant();
if (signature.StartsWith("sha1=", StringComparison.OrdinalIgnoreCase))
{
var expected = signature[5..].ToLowerInvariant();
return CryptographicOperations.FixedTimeEquals(
Encoding.UTF8.GetBytes(computed1),
Encoding.UTF8.GetBytes(expected));
}
// Plain comparison (Harbor style)
return CryptographicOperations.FixedTimeEquals(
Encoding.UTF8.GetBytes(signature),
Encoding.UTF8.GetBytes(secret));
}
public WebhookPayloadInfo ParseWebhookPayload(JsonDocument payload)
{
var root = payload.RootElement;
// Try different webhook formats
// Docker Hub format
if (root.TryGetProperty("push_data", out var pushData) &&
root.TryGetProperty("repository", out var repository))
{
return new WebhookPayloadInfo
{
EventType = "push",
Reference = repository.TryGetProperty("repo_name", out var repoName)
? repoName.GetString()!
: repository.GetProperty("name").GetString()!,
Tag = pushData.TryGetProperty("tag", out var tag) ? tag.GetString() : "latest",
Actor = pushData.TryGetProperty("pusher", out var pusher) ? pusher.GetString() : null,
Timestamp = DateTimeOffset.UtcNow
};
}
// Harbor format
if (root.TryGetProperty("type", out var eventType) &&
root.TryGetProperty("event_data", out var eventData))
{
var resources = eventData.TryGetProperty("resources", out var res) ? res : default;
var firstResource = resources.ValueKind == JsonValueKind.Array && resources.GetArrayLength() > 0
? resources[0]
: default;
return new WebhookPayloadInfo
{
EventType = eventType.GetString() ?? "push",
Reference = eventData.TryGetProperty("repository", out var repo)
? (repo.TryGetProperty("repo_full_name", out var fullName)
? fullName.GetString()!
: repo.GetProperty("name").GetString()!)
: "",
Tag = firstResource.TryGetProperty("tag", out var harborTag)
? harborTag.GetString()
: null,
Digest = firstResource.TryGetProperty("digest", out var digest)
? digest.GetString()
: null,
Actor = eventData.TryGetProperty("operator", out var op) ? op.GetString() : null,
Timestamp = DateTimeOffset.UtcNow
};
}
// Generic OCI distribution format
if (root.TryGetProperty("events", out var events) &&
events.ValueKind == JsonValueKind.Array &&
events.GetArrayLength() > 0)
{
var firstEvent = events[0];
return new WebhookPayloadInfo
{
EventType = firstEvent.TryGetProperty("action", out var action)
? action.GetString() ?? "push"
: "push",
Reference = firstEvent.TryGetProperty("target", out var target) &&
target.TryGetProperty("repository", out var targetRepo)
? targetRepo.GetString()!
: "",
Tag = target.TryGetProperty("tag", out var ociTag)
? ociTag.GetString()
: null,
Digest = target.TryGetProperty("digest", out var ociDigest)
? ociDigest.GetString()
: null,
Actor = firstEvent.TryGetProperty("actor", out var actor) &&
actor.TryGetProperty("name", out var actorName)
? actorName.GetString()
: null,
Timestamp = DateTimeOffset.UtcNow
};
}
_logger.LogWarning("Unable to parse webhook payload format");
return new WebhookPayloadInfo
{
EventType = "unknown",
Reference = "",
Timestamp = DateTimeOffset.UtcNow
};
}
private async Task<RegistryCredentials?> GetCredentialsAsync(string? authRef, CancellationToken ct)
{
if (string.IsNullOrEmpty(authRef))
{
return null;
}
var resolved = await _credentialResolver.ResolveAsync(authRef, ct);
if (resolved == null)
{
return null;
}
return resolved.Type switch
{
CredentialType.BasicAuth => new RegistryCredentials
{
AuthType = RegistryAuthType.Basic,
Username = resolved.Username,
Password = resolved.Password
},
CredentialType.BearerToken => new RegistryCredentials
{
AuthType = RegistryAuthType.Token,
Token = resolved.Token
},
CredentialType.AwsCredentials => new RegistryCredentials
{
AuthType = RegistryAuthType.AwsEcr,
AwsAccessKey = resolved.Properties?.GetValueOrDefault("accessKey"),
AwsSecretKey = resolved.Properties?.GetValueOrDefault("secretKey"),
AwsRegion = resolved.Properties?.GetValueOrDefault("region")
},
_ => null
};
}
private static bool MatchesFilters(WebhookPayloadInfo payload, ZastavaFilters? filters)
{
if (filters == null)
{
return true;
}
// Check repository patterns
if (filters.RepositoryPatterns?.Count > 0)
{
if (!filters.RepositoryPatterns.Any(p => MatchesPattern(payload.Reference, p)))
{
return false;
}
}
// Check tag patterns
if (filters.TagPatterns?.Count > 0 && payload.Tag != null)
{
if (!filters.TagPatterns.Any(p => MatchesPattern(payload.Tag, p)))
{
return false;
}
}
// Check exclusions
if (filters.ExcludePatterns?.Count > 0)
{
if (filters.ExcludePatterns.Any(p =>
MatchesPattern(payload.Reference, p) ||
(payload.Tag != null && MatchesPattern(payload.Tag, p))))
{
return false;
}
}
return true;
}
private static bool MatchesPattern(string value, string pattern)
{
// Convert glob pattern to regex
var regexPattern = "^" + Regex.Escape(pattern)
.Replace("\\*", ".*")
.Replace("\\?", ".") + "$";
return Regex.IsMatch(value, regexPattern, RegexOptions.IgnoreCase);
}
private static string BuildReference(string registryUrl, string repository, string? tag)
{
var host = new Uri(registryUrl).Host;
// Docker Hub special case
if (host.Contains("docker.io") || host.Contains("docker.com"))
{
if (!repository.Contains('/'))
{
repository = $"library/{repository}";
}
return $"{repository}:{tag ?? "latest"}";
}
return $"{host}/{repository}:{tag ?? "latest"}";
}
}