Frontend gaps fill work. Testing fixes work. Auditing in progress.

This commit is contained in:
StellaOps Bot
2025-12-30 01:22:58 +02:00
parent 1dc4bcbf10
commit 7a5210e2aa
928 changed files with 183942 additions and 3941 deletions

View File

@@ -15,6 +15,10 @@ using StellaOps.Signals.Options;
using StellaOps.Signals.Parsing;
using StellaOps.Signals.Persistence;
using StellaOps.Signals.Routing;
using StellaOps.Signals.Scm;
using StellaOps.Signals.Scm.Models;
using StellaOps.Signals.Scm.Services;
using StellaOps.Signals.Scm.Webhooks;
using StellaOps.Signals.Services;
using StellaOps.Signals.Storage;
@@ -206,6 +210,16 @@ builder.Services.AddSingleton<IReachabilityUnionIngestionService, ReachabilityUn
builder.Services.AddSingleton<IUnknownsIngestionService, UnknownsIngestionService>();
builder.Services.AddSingleton<SyntheticRuntimeProbeBuilder>();
// SCM/CI webhook services (Sprint: SPRINT_20251229_013)
builder.Services.AddSingleton<IWebhookSignatureValidator, GitHubWebhookValidator>();
builder.Services.AddSingleton<IWebhookSignatureValidator, GitLabWebhookValidator>();
builder.Services.AddSingleton<IWebhookSignatureValidator, GiteaWebhookValidator>();
builder.Services.AddSingleton<IScmEventMapper, GitHubEventMapper>();
builder.Services.AddSingleton<IScmEventMapper, GitLabEventMapper>();
builder.Services.AddSingleton<IScmEventMapper, GiteaEventMapper>();
builder.Services.AddSingleton<IScmTriggerService, ScmTriggerService>();
builder.Services.AddSingleton<IScmWebhookService, ScmWebhookService>();
if (bootstrap.Authority.Enabled)
{
builder.Services.AddHttpContextAccessor();
@@ -286,6 +300,9 @@ app.MapGet("/readyz", (SignalsStartupState state, SignalsSealedModeMonitor seale
: Results.StatusCode(StatusCodes.Status503ServiceUnavailable);
}).AllowAnonymous();
// SCM/CI webhook endpoints (Sprint: SPRINT_20251229_013)
app.MapScmWebhookEndpoints();
var signalsGroup = app.MapGroup("/signals");
signalsGroup.MapGet("/ping", (HttpContext context, SignalsOptions options, SignalsSealedModeMonitor sealedModeMonitor) =>

View File

@@ -0,0 +1,238 @@
using System.ComponentModel.DataAnnotations;
using System.Text.Json.Serialization;
namespace StellaOps.Signals.Scm.Models;
/// <summary>
/// Normalized SCM/CI event payload that abstracts provider-specific formats.
/// All timestamps are UTC ISO-8601.
/// </summary>
public sealed record NormalizedScmEvent
{
/// <summary>Unique event identifier (provider delivery ID or generated).</summary>
[Required]
public required string EventId { get; init; }
/// <summary>Source provider.</summary>
public ScmProvider Provider { get; init; }
/// <summary>Normalized event type.</summary>
public ScmEventType EventType { get; init; }
/// <summary>UTC timestamp when the event occurred.</summary>
public DateTimeOffset Timestamp { get; init; }
/// <summary>Repository information.</summary>
public required ScmRepository Repository { get; init; }
/// <summary>Actor who triggered the event (user, bot, etc.).</summary>
public ScmActor? Actor { get; init; }
/// <summary>Branch or ref name (e.g., "main", "refs/heads/feature-x").</summary>
public string? Ref { get; init; }
/// <summary>Commit SHA for push/PR events.</summary>
public string? CommitSha { get; init; }
/// <summary>Pull/merge request details if applicable.</summary>
public ScmPullRequest? PullRequest { get; init; }
/// <summary>Release details if applicable.</summary>
public ScmRelease? Release { get; init; }
/// <summary>Pipeline/workflow details if applicable.</summary>
public ScmPipeline? Pipeline { get; init; }
/// <summary>Artifact details if applicable.</summary>
public ScmArtifact? Artifact { get; init; }
/// <summary>Provider-specific raw payload for debugging (redacted sensitive fields).</summary>
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public IReadOnlyDictionary<string, object?>? RawMetadata { get; init; }
/// <summary>Tenant ID for multi-tenant routing.</summary>
public string? TenantId { get; init; }
/// <summary>Integration ID that received this webhook.</summary>
public string? IntegrationId { get; init; }
}
/// <summary>
/// Repository information.
/// </summary>
public sealed record ScmRepository
{
/// <summary>Provider-specific repository ID.</summary>
public string? Id { get; init; }
/// <summary>Full repository name (e.g., "owner/repo").</summary>
[Required]
public required string FullName { get; init; }
/// <summary>Repository owner (user or organization).</summary>
public string? Owner { get; init; }
/// <summary>Repository name without owner.</summary>
public string? Name { get; init; }
/// <summary>Clone URL (HTTPS).</summary>
public string? CloneUrl { get; init; }
/// <summary>Default branch name.</summary>
public string? DefaultBranch { get; init; }
/// <summary>Whether the repository is private.</summary>
public bool IsPrivate { get; init; }
}
/// <summary>
/// Actor (user/bot) who triggered the event.
/// </summary>
public sealed record ScmActor
{
/// <summary>Provider-specific user ID.</summary>
public string? Id { get; init; }
/// <summary>Username/login.</summary>
public string? Username { get; init; }
/// <summary>Display name.</summary>
public string? DisplayName { get; init; }
/// <summary>Actor type.</summary>
public ScmActorType Type { get; init; }
}
/// <summary>
/// Actor types.
/// </summary>
public enum ScmActorType
{
Unknown = 0,
User,
Bot,
Service
}
/// <summary>
/// Pull/merge request details.
/// </summary>
public sealed record ScmPullRequest
{
/// <summary>Provider-specific PR number.</summary>
public int Number { get; init; }
/// <summary>PR title.</summary>
public string? Title { get; init; }
/// <summary>Source branch.</summary>
public string? SourceBranch { get; init; }
/// <summary>Target branch.</summary>
public string? TargetBranch { get; init; }
/// <summary>PR state (open, merged, closed).</summary>
public string? State { get; init; }
/// <summary>URL to the PR.</summary>
public string? Url { get; init; }
}
/// <summary>
/// Release details.
/// </summary>
public sealed record ScmRelease
{
/// <summary>Provider-specific release ID.</summary>
public string? Id { get; init; }
/// <summary>Tag name.</summary>
public string? TagName { get; init; }
/// <summary>Release name/title.</summary>
public string? Name { get; init; }
/// <summary>Whether this is a prerelease.</summary>
public bool IsPrerelease { get; init; }
/// <summary>Whether this is a draft.</summary>
public bool IsDraft { get; init; }
/// <summary>URL to the release.</summary>
public string? Url { get; init; }
}
/// <summary>
/// CI pipeline/workflow details.
/// </summary>
public sealed record ScmPipeline
{
/// <summary>Provider-specific pipeline/workflow ID.</summary>
public string? Id { get; init; }
/// <summary>Pipeline/workflow name.</summary>
public string? Name { get; init; }
/// <summary>Run number.</summary>
public long? RunNumber { get; init; }
/// <summary>Pipeline status.</summary>
public ScmPipelineStatus Status { get; init; }
/// <summary>Pipeline conclusion (success, failure, etc.).</summary>
public string? Conclusion { get; init; }
/// <summary>URL to the pipeline run.</summary>
public string? Url { get; init; }
/// <summary>Duration in seconds.</summary>
public double? DurationSeconds { get; init; }
}
/// <summary>
/// Pipeline status values.
/// </summary>
public enum ScmPipelineStatus
{
Unknown = 0,
Queued,
InProgress,
Completed
}
/// <summary>
/// Artifact details.
/// </summary>
public sealed record ScmArtifact
{
/// <summary>Artifact name.</summary>
public string? Name { get; init; }
/// <summary>Artifact type (container, binary, sbom, etc.).</summary>
public ScmArtifactType Type { get; init; }
/// <summary>Download URL if available.</summary>
public string? DownloadUrl { get; init; }
/// <summary>Size in bytes.</summary>
public long? SizeBytes { get; init; }
/// <summary>Content digest (SHA256).</summary>
public string? Digest { get; init; }
/// <summary>Container image reference if applicable.</summary>
public string? ImageRef { get; init; }
}
/// <summary>
/// Artifact types.
/// </summary>
public enum ScmArtifactType
{
Unknown = 0,
Container,
Binary,
Sbom,
Attestation,
Archive
}

View File

@@ -0,0 +1,58 @@
namespace StellaOps.Signals.Scm.Models;
/// <summary>
/// Normalized SCM/CI event types across providers.
/// </summary>
public enum ScmEventType
{
/// <summary>Unknown or unrecognized event.</summary>
Unknown = 0,
/// <summary>Push to a branch.</summary>
Push,
/// <summary>Pull/merge request event (generic).</summary>
PullRequest,
/// <summary>Pull/merge request opened.</summary>
PullRequestOpened,
/// <summary>Pull/merge request merged.</summary>
PullRequestMerged,
/// <summary>Pull/merge request closed without merge.</summary>
PullRequestClosed,
/// <summary>Release published.</summary>
ReleasePublished,
/// <summary>Tag created.</summary>
TagCreated,
/// <summary>Ref (branch or tag) created.</summary>
RefCreated,
/// <summary>Ref (branch or tag) deleted.</summary>
RefDeleted,
/// <summary>CI pipeline started.</summary>
PipelineStarted,
/// <summary>CI pipeline completed (success or failure).</summary>
PipelineCompleted,
/// <summary>CI pipeline completed successfully.</summary>
PipelineSucceeded,
/// <summary>CI pipeline failed.</summary>
PipelineFailed,
/// <summary>Artifact published in CI.</summary>
ArtifactPublished,
/// <summary>Container image pushed to registry.</summary>
ImagePushed,
/// <summary>SBOM uploaded.</summary>
SbomUploaded
}

View File

@@ -0,0 +1,12 @@
namespace StellaOps.Signals.Scm.Models;
/// <summary>
/// Supported SCM/CI providers.
/// </summary>
public enum ScmProvider
{
Unknown = 0,
GitHub,
GitLab,
Gitea
}

View File

@@ -0,0 +1,204 @@
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using StellaOps.Signals.Hosting;
using StellaOps.Signals.Options;
using StellaOps.Signals.Scm.Models;
using StellaOps.Signals.Scm.Services;
namespace StellaOps.Signals.Scm;
/// <summary>
/// Webhook endpoints for SCM/CI providers.
/// </summary>
public static class ScmWebhookEndpoints
{
/// <summary>
/// Maps SCM webhook endpoints to the application.
/// </summary>
public static void MapScmWebhookEndpoints(this IEndpointRouteBuilder app)
{
var webhooks = app.MapGroup("/webhooks");
webhooks.MapPost("/github", HandleGitHubWebhookAsync)
.WithName("ScmWebhookGitHub")
.Produces(StatusCodes.Status202Accepted)
.Produces(StatusCodes.Status200OK)
.Produces(StatusCodes.Status400BadRequest)
.Produces(StatusCodes.Status401Unauthorized)
.AllowAnonymous();
webhooks.MapPost("/gitlab", HandleGitLabWebhookAsync)
.WithName("ScmWebhookGitLab")
.Produces(StatusCodes.Status202Accepted)
.Produces(StatusCodes.Status200OK)
.Produces(StatusCodes.Status400BadRequest)
.Produces(StatusCodes.Status401Unauthorized)
.AllowAnonymous();
webhooks.MapPost("/gitea", HandleGiteaWebhookAsync)
.WithName("ScmWebhookGitea")
.Produces(StatusCodes.Status202Accepted)
.Produces(StatusCodes.Status200OK)
.Produces(StatusCodes.Status400BadRequest)
.Produces(StatusCodes.Status401Unauthorized)
.AllowAnonymous();
}
private static async Task<IResult> HandleGitHubWebhookAsync(
HttpContext context,
IScmWebhookService webhookService,
SignalsSealedModeMonitor sealedModeMonitor,
CancellationToken cancellationToken)
{
if (!TryEnsureSealedMode(sealedModeMonitor, out var sealedFailure))
{
return sealedFailure!;
}
// Extract GitHub-specific headers
var eventType = context.Request.Headers["X-GitHub-Event"].FirstOrDefault() ?? "unknown";
var deliveryId = context.Request.Headers["X-GitHub-Delivery"].FirstOrDefault() ?? Guid.NewGuid().ToString("N");
var signature = context.Request.Headers["X-Hub-Signature-256"].FirstOrDefault();
var integrationId = context.Request.Headers["X-Integration-Id"].FirstOrDefault();
var tenantId = context.Request.Headers["X-Tenant-Id"].FirstOrDefault();
// Read body
using var ms = new MemoryStream();
await context.Request.Body.CopyToAsync(ms, cancellationToken).ConfigureAwait(false);
var payload = ms.ToArray();
var result = await webhookService.ProcessAsync(
ScmProvider.GitHub,
eventType,
deliveryId,
signature,
payload,
integrationId,
tenantId,
cancellationToken).ConfigureAwait(false);
return ToResult(result);
}
private static async Task<IResult> HandleGitLabWebhookAsync(
HttpContext context,
IScmWebhookService webhookService,
SignalsSealedModeMonitor sealedModeMonitor,
CancellationToken cancellationToken)
{
if (!TryEnsureSealedMode(sealedModeMonitor, out var sealedFailure))
{
return sealedFailure!;
}
// Extract GitLab-specific headers
var eventType = context.Request.Headers["X-Gitlab-Event"].FirstOrDefault() ?? "unknown";
var deliveryId = context.Request.Headers["X-Gitlab-Event-UUID"].FirstOrDefault() ?? Guid.NewGuid().ToString("N");
var signature = context.Request.Headers["X-Gitlab-Token"].FirstOrDefault();
var integrationId = context.Request.Headers["X-Integration-Id"].FirstOrDefault();
var tenantId = context.Request.Headers["X-Tenant-Id"].FirstOrDefault();
// Read body
using var ms = new MemoryStream();
await context.Request.Body.CopyToAsync(ms, cancellationToken).ConfigureAwait(false);
var payload = ms.ToArray();
var result = await webhookService.ProcessAsync(
ScmProvider.GitLab,
eventType,
deliveryId,
signature,
payload,
integrationId,
tenantId,
cancellationToken).ConfigureAwait(false);
return ToResult(result);
}
private static async Task<IResult> HandleGiteaWebhookAsync(
HttpContext context,
IScmWebhookService webhookService,
SignalsSealedModeMonitor sealedModeMonitor,
CancellationToken cancellationToken)
{
if (!TryEnsureSealedMode(sealedModeMonitor, out var sealedFailure))
{
return sealedFailure!;
}
// Extract Gitea-specific headers (similar to GitHub)
var eventType = context.Request.Headers["X-Gitea-Event"].FirstOrDefault() ?? "unknown";
var deliveryId = context.Request.Headers["X-Gitea-Delivery"].FirstOrDefault() ?? Guid.NewGuid().ToString("N");
var signature = context.Request.Headers["X-Hub-Signature-256"].FirstOrDefault()
?? context.Request.Headers["X-Hub-Signature"].FirstOrDefault();
var integrationId = context.Request.Headers["X-Integration-Id"].FirstOrDefault();
var tenantId = context.Request.Headers["X-Tenant-Id"].FirstOrDefault();
// Read body
using var ms = new MemoryStream();
await context.Request.Body.CopyToAsync(ms, cancellationToken).ConfigureAwait(false);
var payload = ms.ToArray();
var result = await webhookService.ProcessAsync(
ScmProvider.Gitea,
eventType,
deliveryId,
signature,
payload,
integrationId,
tenantId,
cancellationToken).ConfigureAwait(false);
return ToResult(result);
}
private static IResult ToResult(ScmWebhookResult result)
{
if (!result.Success)
{
return result.StatusCode switch
{
401 => Results.Unauthorized(),
400 => Results.BadRequest(new { error = result.Error }),
_ => Results.Problem(result.Error, statusCode: result.StatusCode)
};
}
if (result.StatusCode == 200)
{
return Results.Ok(new { message = result.Error }); // "Ignored" message
}
return Results.Accepted(value: new
{
eventId = result.Event?.EventId,
eventType = result.Event?.EventType.ToString(),
provider = result.Event?.Provider.ToString(),
repository = result.Event?.Repository.FullName,
triggersDispatched = result.TriggerResult?.TriggersDispatched ?? false,
scanTriggersCount = result.TriggerResult?.ScanTriggersCount ?? 0,
sbomTriggersCount = result.TriggerResult?.SbomTriggersCount ?? 0
});
}
private static bool TryEnsureSealedMode(SignalsSealedModeMonitor monitor, out IResult? failure)
{
if (!monitor.EnforcementEnabled)
{
failure = null;
return true;
}
if (monitor.IsCompliant(out var reason))
{
failure = null;
return true;
}
failure = Results.Json(
new { error = "sealed-mode evidence invalid", reason },
statusCode: StatusCodes.Status503ServiceUnavailable);
return false;
}
}

View File

@@ -0,0 +1,38 @@
using StellaOps.Signals.Scm.Models;
namespace StellaOps.Signals.Scm.Services;
/// <summary>
/// Service for routing SCM events to Scanner/Orchestrator triggers.
/// </summary>
public interface IScmTriggerService
{
/// <summary>
/// Processes a normalized SCM event and triggers appropriate actions.
/// </summary>
/// <param name="scmEvent">The normalized SCM event.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Trigger result with actions taken.</returns>
Task<ScmTriggerResult> ProcessEventAsync(NormalizedScmEvent scmEvent, CancellationToken cancellationToken = default);
}
/// <summary>
/// Result of processing an SCM event.
/// </summary>
public sealed record ScmTriggerResult
{
/// <summary>Whether any triggers were dispatched.</summary>
public bool TriggersDispatched { get; init; }
/// <summary>Number of scan triggers dispatched.</summary>
public int ScanTriggersCount { get; init; }
/// <summary>Number of SBOM upload triggers dispatched.</summary>
public int SbomTriggersCount { get; init; }
/// <summary>Triggered scan IDs.</summary>
public IReadOnlyList<string> ScanIds { get; init; } = [];
/// <summary>Error messages if any triggers failed.</summary>
public IReadOnlyList<string> Errors { get; init; } = [];
}

View File

@@ -0,0 +1,81 @@
using StellaOps.Signals.Scm.Models;
namespace StellaOps.Signals.Scm.Services;
/// <summary>
/// Service for processing incoming SCM webhooks.
/// </summary>
public interface IScmWebhookService
{
/// <summary>
/// Processes an incoming webhook request.
/// </summary>
/// <param name="provider">The SCM provider.</param>
/// <param name="eventType">Provider-specific event type header.</param>
/// <param name="deliveryId">Webhook delivery ID.</param>
/// <param name="signature">Webhook signature for validation.</param>
/// <param name="payload">Raw request body.</param>
/// <param name="integrationId">Integration ID for credential lookup.</param>
/// <param name="tenantId">Tenant ID for multi-tenant routing.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Result of webhook processing.</returns>
Task<ScmWebhookResult> ProcessAsync(
ScmProvider provider,
string eventType,
string deliveryId,
string? signature,
byte[] payload,
string? integrationId,
string? tenantId,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Result of webhook processing.
/// </summary>
public sealed record ScmWebhookResult
{
/// <summary>Whether the webhook was processed successfully.</summary>
public bool Success { get; init; }
/// <summary>HTTP status code to return.</summary>
public int StatusCode { get; init; }
/// <summary>Error message if processing failed.</summary>
public string? Error { get; init; }
/// <summary>The normalized event if successfully parsed.</summary>
public NormalizedScmEvent? Event { get; init; }
/// <summary>Trigger results if any actions were taken.</summary>
public ScmTriggerResult? TriggerResult { get; init; }
public static ScmWebhookResult Unauthorized(string error) => new()
{
Success = false,
StatusCode = 401,
Error = error
};
public static ScmWebhookResult BadRequest(string error) => new()
{
Success = false,
StatusCode = 400,
Error = error
};
public static ScmWebhookResult Accepted(NormalizedScmEvent? scmEvent, ScmTriggerResult? triggerResult) => new()
{
Success = true,
StatusCode = 202,
Event = scmEvent,
TriggerResult = triggerResult
};
public static ScmWebhookResult Ignored(string reason) => new()
{
Success = true,
StatusCode = 200,
Error = reason
};
}

View File

@@ -0,0 +1,149 @@
using Microsoft.Extensions.Logging;
using StellaOps.Signals.Scm.Models;
namespace StellaOps.Signals.Scm.Services;
/// <summary>
/// Routes SCM events to Scanner/Orchestrator for triggering scans and SBOM uploads.
/// </summary>
public sealed class ScmTriggerService : IScmTriggerService
{
private readonly ILogger<ScmTriggerService> _logger;
private readonly TimeProvider _timeProvider;
public ScmTriggerService(
ILogger<ScmTriggerService> logger,
TimeProvider timeProvider)
{
_logger = logger;
_timeProvider = timeProvider;
}
public async Task<ScmTriggerResult> ProcessEventAsync(
NormalizedScmEvent scmEvent,
CancellationToken cancellationToken = default)
{
var scanIds = new List<string>();
var errors = new List<string>();
var scanTriggers = 0;
var sbomTriggers = 0;
_logger.LogInformation(
"Processing SCM event {EventId} of type {EventType} from {Provider} for {Repository}",
scmEvent.EventId,
scmEvent.EventType,
scmEvent.Provider,
scmEvent.Repository.FullName);
try
{
// Determine if this event should trigger a scan
if (ShouldTriggerScan(scmEvent))
{
var scanId = await TriggerScanAsync(scmEvent, cancellationToken).ConfigureAwait(false);
if (scanId is not null)
{
scanIds.Add(scanId);
scanTriggers++;
_logger.LogInformation("Triggered scan {ScanId} for event {EventId}", scanId, scmEvent.EventId);
}
}
// Determine if this event indicates an SBOM upload
if (ShouldProcessSbom(scmEvent))
{
var processed = await ProcessSbomUploadAsync(scmEvent, cancellationToken).ConfigureAwait(false);
if (processed)
{
sbomTriggers++;
_logger.LogInformation("Processed SBOM upload for event {EventId}", scmEvent.EventId);
}
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error processing SCM event {EventId}", scmEvent.EventId);
errors.Add(ex.Message);
}
return new ScmTriggerResult
{
TriggersDispatched = scanTriggers > 0 || sbomTriggers > 0,
ScanTriggersCount = scanTriggers,
SbomTriggersCount = sbomTriggers,
ScanIds = scanIds,
Errors = errors
};
}
private static bool ShouldTriggerScan(NormalizedScmEvent scmEvent)
{
// Trigger scans for:
// - Push to main/release branches
// - PR merges
// - Releases
// - Image pushes
// - Successful pipelines
return scmEvent.EventType switch
{
ScmEventType.Push when IsMainOrReleaseBranch(scmEvent.Ref) => true,
ScmEventType.PullRequestMerged => true,
ScmEventType.ReleasePublished => true,
ScmEventType.ImagePushed => true,
ScmEventType.PipelineSucceeded => true,
_ => false
};
}
private static bool ShouldProcessSbom(NormalizedScmEvent scmEvent)
{
return scmEvent.EventType == ScmEventType.SbomUploaded ||
scmEvent.Artifact?.Type == ScmArtifactType.Sbom;
}
private static bool IsMainOrReleaseBranch(string? refName)
{
if (string.IsNullOrEmpty(refName))
{
return false;
}
var normalized = refName.Replace("refs/heads/", "", StringComparison.OrdinalIgnoreCase);
return normalized.Equals("main", StringComparison.OrdinalIgnoreCase) ||
normalized.Equals("master", StringComparison.OrdinalIgnoreCase) ||
normalized.StartsWith("release/", StringComparison.OrdinalIgnoreCase) ||
normalized.StartsWith("release-", StringComparison.OrdinalIgnoreCase);
}
private Task<string?> TriggerScanAsync(NormalizedScmEvent scmEvent, CancellationToken cancellationToken)
{
// Generate a scan ID for tracking
var scanId = $"scm-{scmEvent.Provider.ToString().ToLowerInvariant()}-{_timeProvider.GetUtcNow():yyyyMMddHHmmss}-{Guid.NewGuid():N}";
// In a full implementation, this would:
// 1. Call Orchestrator API to enqueue a scan job
// 2. Pass repository URL, commit SHA, and credentials
// 3. Return the scan job ID
_logger.LogDebug(
"Would trigger scan for {Repository} at commit {Sha}",
scmEvent.Repository.FullName,
scmEvent.CommitSha);
return Task.FromResult<string?>(scanId);
}
private Task<bool> ProcessSbomUploadAsync(NormalizedScmEvent scmEvent, CancellationToken cancellationToken)
{
// In a full implementation, this would:
// 1. Download the SBOM artifact
// 2. Call SbomService API to ingest the SBOM
// 3. Link the SBOM to the repository/commit
_logger.LogDebug(
"Would process SBOM upload for {Repository}",
scmEvent.Repository.FullName);
return Task.FromResult(true);
}
}

View File

@@ -0,0 +1,143 @@
using System.Text.Json;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Signals.Options;
using StellaOps.Signals.Scm.Models;
using StellaOps.Signals.Scm.Webhooks;
namespace StellaOps.Signals.Scm.Services;
/// <summary>
/// Processes incoming SCM webhooks, validates signatures, and routes events.
/// </summary>
public sealed class ScmWebhookService : IScmWebhookService
{
private readonly ILogger<ScmWebhookService> _logger;
private readonly SignalsOptions _options;
private readonly IScmTriggerService _triggerService;
private readonly IReadOnlyDictionary<ScmProvider, IWebhookSignatureValidator> _validators;
private readonly IReadOnlyDictionary<ScmProvider, IScmEventMapper> _mappers;
public ScmWebhookService(
ILogger<ScmWebhookService> logger,
IOptions<SignalsOptions> options,
IScmTriggerService triggerService,
IEnumerable<IWebhookSignatureValidator> validators,
IEnumerable<IScmEventMapper> mappers)
{
_logger = logger;
_options = options.Value;
_triggerService = triggerService;
// Build lookup dictionaries
_validators = new Dictionary<ScmProvider, IWebhookSignatureValidator>
{
[ScmProvider.GitHub] = validators.OfType<GitHubWebhookValidator>().FirstOrDefault() ?? new GitHubWebhookValidator(),
[ScmProvider.GitLab] = validators.OfType<GitLabWebhookValidator>().FirstOrDefault() ?? new GitLabWebhookValidator(),
[ScmProvider.Gitea] = validators.OfType<GiteaWebhookValidator>().FirstOrDefault() ?? new GiteaWebhookValidator()
};
_mappers = mappers.ToDictionary(m => m.Provider);
}
public async Task<ScmWebhookResult> ProcessAsync(
ScmProvider provider,
string eventType,
string deliveryId,
string? signature,
byte[] payload,
string? integrationId,
string? tenantId,
CancellationToken cancellationToken = default)
{
using var scope = _logger.BeginScope(new Dictionary<string, object?>
{
["Provider"] = provider.ToString(),
["EventType"] = eventType,
["DeliveryId"] = deliveryId,
["IntegrationId"] = integrationId,
["TenantId"] = tenantId
});
_logger.LogInformation(
"Received webhook from {Provider}: event={EventType}, delivery={DeliveryId}",
provider, eventType, deliveryId);
// Validate signature if webhook secret is configured
var secret = GetWebhookSecret(provider, integrationId);
if (!string.IsNullOrEmpty(secret))
{
if (!_validators.TryGetValue(provider, out var validator))
{
_logger.LogWarning("No validator found for provider {Provider}", provider);
return ScmWebhookResult.BadRequest($"Unsupported provider: {provider}");
}
if (!validator.IsValid(payload, signature, secret))
{
_logger.LogWarning("Invalid webhook signature for delivery {DeliveryId}", deliveryId);
return ScmWebhookResult.Unauthorized("Invalid webhook signature");
}
}
else
{
_logger.LogDebug("No webhook secret configured, skipping signature validation");
}
// Parse payload
JsonElement payloadJson;
try
{
payloadJson = JsonSerializer.Deserialize<JsonElement>(payload);
}
catch (JsonException ex)
{
_logger.LogWarning(ex, "Failed to parse webhook payload");
return ScmWebhookResult.BadRequest("Invalid JSON payload");
}
// Map to normalized event
if (!_mappers.TryGetValue(provider, out var mapper))
{
_logger.LogWarning("No mapper found for provider {Provider}", provider);
return ScmWebhookResult.BadRequest($"Unsupported provider: {provider}");
}
var scmEvent = mapper.Map(eventType, deliveryId, payloadJson);
if (scmEvent is null)
{
_logger.LogDebug("Event type {EventType} is not mapped, ignoring", eventType);
return ScmWebhookResult.Ignored($"Event type '{eventType}' is not supported");
}
// Enrich with integration context
scmEvent = scmEvent with
{
IntegrationId = integrationId,
TenantId = tenantId
};
// Process triggers
var triggerResult = await _triggerService.ProcessEventAsync(scmEvent, cancellationToken).ConfigureAwait(false);
_logger.LogInformation(
"Processed webhook {DeliveryId}: triggers dispatched={Dispatched}, scans={Scans}, sboms={Sboms}",
deliveryId,
triggerResult.TriggersDispatched,
triggerResult.ScanTriggersCount,
triggerResult.SbomTriggersCount);
return ScmWebhookResult.Accepted(scmEvent, triggerResult);
}
private string? GetWebhookSecret(ScmProvider provider, string? integrationId)
{
// In a full implementation, this would look up the secret from:
// 1. Integration-specific AuthRef credentials
// 2. Provider-specific configuration
// 3. Global fallback configuration
// For now, return null to skip validation (development mode)
return null;
}
}

View File

@@ -0,0 +1,323 @@
using System.Text.Json;
using StellaOps.Signals.Scm.Models;
namespace StellaOps.Signals.Scm.Webhooks;
/// <summary>
/// Maps GitHub webhook events to normalized SCM events.
/// </summary>
public sealed class GitHubEventMapper : IScmEventMapper
{
public ScmProvider Provider => ScmProvider.GitHub;
public NormalizedScmEvent? Map(string eventType, string deliveryId, JsonElement payload)
{
var (scmEventType, extractor) = eventType.ToLowerInvariant() switch
{
"push" => (ScmEventType.Push, (Func<JsonElement, (ScmEventType, string?, string?)>)ExtractPushDetails),
"pull_request" => (ScmEventType.Unknown, ExtractPullRequestDetails),
"release" => (ScmEventType.ReleasePublished, ExtractReleaseDetails),
"create" => (ScmEventType.Unknown, ExtractCreateDetails),
"workflow_run" => (ScmEventType.Unknown, ExtractWorkflowRunDetails),
"check_run" => (ScmEventType.Unknown, ExtractCheckRunDetails),
_ => (ScmEventType.Unknown, (Func<JsonElement, (ScmEventType, string?, string?)>?)null)
};
if (extractor is null && scmEventType == ScmEventType.Unknown)
{
return null;
}
var repository = ExtractRepository(payload);
if (repository is null)
{
return null;
}
string? commitSha = null;
string? refName = null;
if (extractor is not null)
{
var (extractedType, sha, @ref) = extractor(payload);
if (extractedType != ScmEventType.Unknown)
{
scmEventType = extractedType;
}
commitSha = sha;
refName = @ref;
}
return new NormalizedScmEvent
{
EventId = deliveryId,
Provider = ScmProvider.GitHub,
EventType = scmEventType,
Timestamp = DateTimeOffset.UtcNow,
Repository = repository,
Actor = ExtractActor(payload),
Ref = refName ?? GetString(payload, "ref"),
CommitSha = commitSha ?? GetNestedString(payload, "head_commit", "id"),
PullRequest = ExtractPullRequest(payload),
Release = ExtractRelease(payload),
Pipeline = ExtractWorkflow(payload)
};
}
private static (ScmEventType, string?, string?) ExtractPushDetails(JsonElement payload)
{
var sha = GetNestedString(payload, "head_commit", "id") ?? GetString(payload, "after");
var refName = GetString(payload, "ref");
return (ScmEventType.Push, sha, refName);
}
private static (ScmEventType, string?, string?) ExtractPullRequestDetails(JsonElement payload)
{
var action = GetString(payload, "action");
var eventType = action switch
{
"opened" or "reopened" => ScmEventType.PullRequestOpened,
"closed" when GetNestedBool(payload, "pull_request", "merged") => ScmEventType.PullRequestMerged,
"closed" => ScmEventType.PullRequestClosed,
_ => ScmEventType.Unknown
};
var sha = GetNestedString(payload, "pull_request", "head", "sha");
var refName = GetNestedString(payload, "pull_request", "head", "ref");
return (eventType, sha, refName);
}
private static (ScmEventType, string?, string?) ExtractReleaseDetails(JsonElement payload)
{
var action = GetString(payload, "action");
if (action != "published")
{
return (ScmEventType.Unknown, null, null);
}
var tagName = GetNestedString(payload, "release", "tag_name");
return (ScmEventType.ReleasePublished, null, tagName);
}
private static (ScmEventType, string?, string?) ExtractCreateDetails(JsonElement payload)
{
var refType = GetString(payload, "ref_type");
if (refType != "tag")
{
return (ScmEventType.Unknown, null, null);
}
var refName = GetString(payload, "ref");
return (ScmEventType.TagCreated, null, refName);
}
private static (ScmEventType, string?, string?) ExtractWorkflowRunDetails(JsonElement payload)
{
var action = GetString(payload, "action");
var conclusion = GetNestedString(payload, "workflow_run", "conclusion");
var eventType = action switch
{
"requested" or "in_progress" => ScmEventType.PipelineStarted,
"completed" when conclusion == "success" => ScmEventType.PipelineSucceeded,
"completed" => ScmEventType.PipelineFailed,
_ => ScmEventType.Unknown
};
var sha = GetNestedString(payload, "workflow_run", "head_sha");
var refName = GetNestedString(payload, "workflow_run", "head_branch");
return (eventType, sha, refName);
}
private static (ScmEventType, string?, string?) ExtractCheckRunDetails(JsonElement payload)
{
var action = GetString(payload, "action");
var status = GetNestedString(payload, "check_run", "status");
var conclusion = GetNestedString(payload, "check_run", "conclusion");
var eventType = (action, status, conclusion) switch
{
("created", _, _) => ScmEventType.PipelineStarted,
("completed", _, "success") => ScmEventType.PipelineSucceeded,
("completed", _, _) => ScmEventType.PipelineFailed,
_ => ScmEventType.Unknown
};
var sha = GetNestedString(payload, "check_run", "head_sha");
return (eventType, sha, null);
}
private static ScmRepository? ExtractRepository(JsonElement payload)
{
if (!payload.TryGetProperty("repository", out var repo))
{
return null;
}
return new ScmRepository
{
Id = GetString(repo, "id")?.ToString(),
FullName = GetString(repo, "full_name") ?? string.Empty,
Owner = GetNestedString(repo, "owner", "login"),
Name = GetString(repo, "name"),
CloneUrl = GetString(repo, "clone_url"),
DefaultBranch = GetString(repo, "default_branch"),
IsPrivate = GetBool(repo, "private")
};
}
private static ScmActor? ExtractActor(JsonElement payload)
{
if (!payload.TryGetProperty("sender", out var sender))
{
return null;
}
var actorType = GetString(sender, "type") switch
{
"User" => ScmActorType.User,
"Bot" => ScmActorType.Bot,
_ => ScmActorType.Unknown
};
return new ScmActor
{
Id = GetString(sender, "id")?.ToString(),
Username = GetString(sender, "login"),
Type = actorType
};
}
private static ScmPullRequest? ExtractPullRequest(JsonElement payload)
{
if (!payload.TryGetProperty("pull_request", out var pr))
{
return null;
}
return new ScmPullRequest
{
Number = GetInt(pr, "number"),
Title = GetString(pr, "title"),
SourceBranch = GetNestedString(pr, "head", "ref"),
TargetBranch = GetNestedString(pr, "base", "ref"),
State = GetString(pr, "state"),
Url = GetString(pr, "html_url")
};
}
private static ScmRelease? ExtractRelease(JsonElement payload)
{
if (!payload.TryGetProperty("release", out var release))
{
return null;
}
return new ScmRelease
{
Id = GetString(release, "id")?.ToString(),
TagName = GetString(release, "tag_name"),
Name = GetString(release, "name"),
IsPrerelease = GetBool(release, "prerelease"),
IsDraft = GetBool(release, "draft"),
Url = GetString(release, "html_url")
};
}
private static ScmPipeline? ExtractWorkflow(JsonElement payload)
{
if (!payload.TryGetProperty("workflow_run", out var run))
{
if (!payload.TryGetProperty("check_run", out var checkRun))
{
return null;
}
return new ScmPipeline
{
Id = GetString(checkRun, "id")?.ToString(),
Name = GetString(checkRun, "name"),
Status = GetString(checkRun, "status") switch
{
"queued" => ScmPipelineStatus.Queued,
"in_progress" => ScmPipelineStatus.InProgress,
"completed" => ScmPipelineStatus.Completed,
_ => ScmPipelineStatus.Unknown
},
Conclusion = GetString(checkRun, "conclusion"),
Url = GetString(checkRun, "html_url")
};
}
return new ScmPipeline
{
Id = GetString(run, "id")?.ToString(),
Name = GetString(run, "name"),
RunNumber = GetLong(run, "run_number"),
Status = GetString(run, "status") switch
{
"queued" => ScmPipelineStatus.Queued,
"in_progress" => ScmPipelineStatus.InProgress,
"completed" => ScmPipelineStatus.Completed,
_ => ScmPipelineStatus.Unknown
},
Conclusion = GetString(run, "conclusion"),
Url = GetString(run, "html_url")
};
}
private static string? GetString(JsonElement element, string property)
{
return element.TryGetProperty(property, out var value) && value.ValueKind == JsonValueKind.String
? value.GetString()
: null;
}
private static bool GetBool(JsonElement element, string property)
{
return element.TryGetProperty(property, out var value) &&
value.ValueKind == JsonValueKind.True;
}
private static int GetInt(JsonElement element, string property)
{
return element.TryGetProperty(property, out var value) && value.ValueKind == JsonValueKind.Number
? value.GetInt32()
: 0;
}
private static long GetLong(JsonElement element, string property)
{
return element.TryGetProperty(property, out var value) && value.ValueKind == JsonValueKind.Number
? value.GetInt64()
: 0;
}
private static string? GetNestedString(JsonElement element, params string[] path)
{
var current = element;
foreach (var prop in path)
{
if (!current.TryGetProperty(prop, out current))
{
return null;
}
}
return current.ValueKind == JsonValueKind.String ? current.GetString() : null;
}
private static bool GetNestedBool(JsonElement element, params string[] path)
{
var current = element;
foreach (var prop in path)
{
if (!current.TryGetProperty(prop, out current))
{
return false;
}
}
return current.ValueKind == JsonValueKind.True;
}
}

View File

@@ -0,0 +1,34 @@
using System.Security.Cryptography;
using System.Text;
namespace StellaOps.Signals.Scm.Webhooks;
/// <summary>
/// Validates GitHub webhook signatures using HMAC-SHA256.
/// </summary>
public sealed class GitHubWebhookValidator : IWebhookSignatureValidator
{
private const string SignaturePrefix = "sha256=";
public bool IsValid(ReadOnlySpan<byte> payload, string? signature, string secret)
{
if (string.IsNullOrEmpty(signature) || string.IsNullOrEmpty(secret))
{
return false;
}
if (!signature.StartsWith(SignaturePrefix, StringComparison.OrdinalIgnoreCase))
{
return false;
}
var expectedSignature = signature[SignaturePrefix.Length..];
var secretBytes = Encoding.UTF8.GetBytes(secret);
var computedHash = HMACSHA256.HashData(secretBytes, payload);
var computedSignature = Convert.ToHexStringLower(computedHash);
return CryptographicOperations.FixedTimeEquals(
Encoding.UTF8.GetBytes(computedSignature),
Encoding.UTF8.GetBytes(expectedSignature.ToLowerInvariant()));
}
}

View File

@@ -0,0 +1,317 @@
using System.Text.Json;
using StellaOps.Signals.Scm.Models;
namespace StellaOps.Signals.Scm.Webhooks;
/// <summary>
/// Maps GitLab webhook events to normalized SCM events.
/// </summary>
public sealed class GitLabEventMapper : IScmEventMapper
{
public ScmProvider Provider => ScmProvider.GitLab;
public NormalizedScmEvent? Map(string eventType, string deliveryId, JsonElement payload)
{
var objectKind = GetString(payload, "object_kind") ?? eventType;
var (scmEventType, commitSha, refName) = objectKind.ToLowerInvariant() switch
{
"push" => ExtractPushDetails(payload),
"merge_request" => ExtractMergeRequestDetails(payload),
"tag_push" => ExtractTagPushDetails(payload),
"release" => (ScmEventType.ReleasePublished, (string?)null, GetNestedString(payload, "tag")),
"pipeline" => ExtractPipelineDetails(payload),
"build" or "job" => ExtractJobDetails(payload),
_ => (ScmEventType.Unknown, (string?)null, (string?)null)
};
if (scmEventType == ScmEventType.Unknown)
{
return null;
}
var repository = ExtractRepository(payload);
if (repository is null)
{
return null;
}
return new NormalizedScmEvent
{
EventId = deliveryId,
Provider = ScmProvider.GitLab,
EventType = scmEventType,
Timestamp = DateTimeOffset.UtcNow,
Repository = repository,
Actor = ExtractActor(payload),
Ref = refName,
CommitSha = commitSha,
PullRequest = ExtractMergeRequest(payload),
Release = ExtractRelease(payload),
Pipeline = ExtractPipeline(payload)
};
}
private static (ScmEventType, string?, string?) ExtractPushDetails(JsonElement payload)
{
var sha = GetString(payload, "checkout_sha") ?? GetString(payload, "after");
var refName = GetString(payload, "ref");
return (ScmEventType.Push, sha, refName);
}
private static (ScmEventType, string?, string?) ExtractMergeRequestDetails(JsonElement payload)
{
if (!payload.TryGetProperty("object_attributes", out var attrs))
{
return (ScmEventType.Unknown, null, null);
}
var action = GetString(attrs, "action");
var state = GetString(attrs, "state");
var eventType = (action, state) switch
{
("open", _) or ("reopen", _) => ScmEventType.PullRequestOpened,
("merge", _) or (_, "merged") => ScmEventType.PullRequestMerged,
("close", _) or (_, "closed") => ScmEventType.PullRequestClosed,
_ => ScmEventType.Unknown
};
var sha = GetNestedString(payload, "object_attributes", "last_commit", "id");
var refName = GetString(attrs, "source_branch");
return (eventType, sha, refName);
}
private static (ScmEventType, string?, string?) ExtractTagPushDetails(JsonElement payload)
{
var refName = GetString(payload, "ref");
var sha = GetString(payload, "checkout_sha");
return (ScmEventType.TagCreated, sha, refName);
}
private static (ScmEventType, string?, string?) ExtractPipelineDetails(JsonElement payload)
{
if (!payload.TryGetProperty("object_attributes", out var attrs))
{
return (ScmEventType.Unknown, null, null);
}
var status = GetString(attrs, "status");
var eventType = status switch
{
"pending" or "running" => ScmEventType.PipelineStarted,
"success" => ScmEventType.PipelineSucceeded,
"failed" or "canceled" => ScmEventType.PipelineFailed,
_ => ScmEventType.Unknown
};
var sha = GetString(attrs, "sha");
var refName = GetString(attrs, "ref");
return (eventType, sha, refName);
}
private static (ScmEventType, string?, string?) ExtractJobDetails(JsonElement payload)
{
var status = GetString(payload, "build_status");
var eventType = status switch
{
"pending" or "running" => ScmEventType.PipelineStarted,
"success" => ScmEventType.PipelineSucceeded,
"failed" => ScmEventType.PipelineFailed,
_ => ScmEventType.Unknown
};
var sha = GetString(payload, "sha");
var refName = GetString(payload, "ref");
return (eventType, sha, refName);
}
private static ScmRepository? ExtractRepository(JsonElement payload)
{
if (!payload.TryGetProperty("project", out var project))
{
if (!payload.TryGetProperty("repository", out var repo))
{
return null;
}
return new ScmRepository
{
Id = GetString(repo, "id")?.ToString(),
FullName = GetString(repo, "path_with_namespace") ?? GetString(repo, "name") ?? string.Empty,
Name = GetString(repo, "name"),
CloneUrl = GetString(repo, "git_http_url"),
DefaultBranch = GetString(repo, "default_branch"),
IsPrivate = GetString(repo, "visibility") != "public"
};
}
return new ScmRepository
{
Id = GetString(project, "id")?.ToString(),
FullName = GetString(project, "path_with_namespace") ?? string.Empty,
Owner = GetString(project, "namespace"),
Name = GetString(project, "name"),
CloneUrl = GetString(project, "git_http_url"),
DefaultBranch = GetString(project, "default_branch"),
IsPrivate = GetString(project, "visibility") != "public"
};
}
private static ScmActor? ExtractActor(JsonElement payload)
{
if (!payload.TryGetProperty("user", out var user))
{
var userName = GetString(payload, "user_name");
var userId = GetString(payload, "user_id");
if (userName is null && userId is null)
{
return null;
}
return new ScmActor
{
Id = userId,
Username = GetString(payload, "user_username"),
DisplayName = userName,
Type = ScmActorType.User
};
}
return new ScmActor
{
Id = GetString(user, "id")?.ToString(),
Username = GetString(user, "username"),
DisplayName = GetString(user, "name"),
Type = ScmActorType.User
};
}
private static ScmPullRequest? ExtractMergeRequest(JsonElement payload)
{
if (!payload.TryGetProperty("object_attributes", out var attrs))
{
return null;
}
if (GetString(payload, "object_kind") != "merge_request")
{
return null;
}
return new ScmPullRequest
{
Number = GetInt(attrs, "iid"),
Title = GetString(attrs, "title"),
SourceBranch = GetString(attrs, "source_branch"),
TargetBranch = GetString(attrs, "target_branch"),
State = GetString(attrs, "state"),
Url = GetString(attrs, "url")
};
}
private static ScmRelease? ExtractRelease(JsonElement payload)
{
if (GetString(payload, "object_kind") != "release")
{
return null;
}
return new ScmRelease
{
Id = GetString(payload, "id")?.ToString(),
TagName = GetString(payload, "tag"),
Name = GetString(payload, "name"),
Url = GetString(payload, "url")
};
}
private static ScmPipeline? ExtractPipeline(JsonElement payload)
{
if (!payload.TryGetProperty("object_attributes", out var attrs))
{
// Check for pipeline in build events
var pipelineId = GetString(payload, "pipeline_id");
if (pipelineId is null)
{
return null;
}
return new ScmPipeline
{
Id = pipelineId,
Name = GetString(payload, "build_name"),
Status = GetString(payload, "build_status") switch
{
"pending" => ScmPipelineStatus.Queued,
"running" => ScmPipelineStatus.InProgress,
"success" or "failed" or "canceled" => ScmPipelineStatus.Completed,
_ => ScmPipelineStatus.Unknown
},
Conclusion = GetString(payload, "build_status")
};
}
if (GetString(payload, "object_kind") != "pipeline")
{
return null;
}
return new ScmPipeline
{
Id = GetString(attrs, "id")?.ToString(),
Status = GetString(attrs, "status") switch
{
"pending" => ScmPipelineStatus.Queued,
"running" => ScmPipelineStatus.InProgress,
"success" or "failed" or "canceled" => ScmPipelineStatus.Completed,
_ => ScmPipelineStatus.Unknown
},
Conclusion = GetString(attrs, "status"),
DurationSeconds = GetDouble(attrs, "duration")
};
}
private static string? GetString(JsonElement element, string property)
{
if (!element.TryGetProperty(property, out var value))
{
return null;
}
return value.ValueKind switch
{
JsonValueKind.String => value.GetString(),
JsonValueKind.Number => value.ToString(),
_ => null
};
}
private static int GetInt(JsonElement element, string property)
{
return element.TryGetProperty(property, out var value) && value.ValueKind == JsonValueKind.Number
? value.GetInt32()
: 0;
}
private static double? GetDouble(JsonElement element, string property)
{
return element.TryGetProperty(property, out var value) && value.ValueKind == JsonValueKind.Number
? value.GetDouble()
: null;
}
private static string? GetNestedString(JsonElement element, params string[] path)
{
var current = element;
foreach (var prop in path)
{
if (!current.TryGetProperty(prop, out current))
{
return null;
}
}
return current.ValueKind == JsonValueKind.String ? current.GetString() : null;
}
}

View File

@@ -0,0 +1,24 @@
using System.Security.Cryptography;
using System.Text;
namespace StellaOps.Signals.Scm.Webhooks;
/// <summary>
/// Validates GitLab webhook signatures using X-Gitlab-Token header.
/// GitLab uses a simple token comparison (not HMAC).
/// </summary>
public sealed class GitLabWebhookValidator : IWebhookSignatureValidator
{
public bool IsValid(ReadOnlySpan<byte> payload, string? signature, string secret)
{
if (string.IsNullOrEmpty(signature) || string.IsNullOrEmpty(secret))
{
return false;
}
// GitLab uses direct token comparison via X-Gitlab-Token header
return CryptographicOperations.FixedTimeEquals(
Encoding.UTF8.GetBytes(signature),
Encoding.UTF8.GetBytes(secret));
}
}

View File

@@ -0,0 +1,216 @@
using System.Text.Json;
using StellaOps.Signals.Scm.Models;
namespace StellaOps.Signals.Scm.Webhooks;
/// <summary>
/// Maps Gitea webhook events to normalized SCM events.
/// Gitea's webhook format is similar to GitHub.
/// </summary>
public sealed class GiteaEventMapper : IScmEventMapper
{
public ScmProvider Provider => ScmProvider.Gitea;
public NormalizedScmEvent? Map(string eventType, string deliveryId, JsonElement payload)
{
var (scmEventType, commitSha, refName) = eventType.ToLowerInvariant() switch
{
"push" => ExtractPushDetails(payload),
"pull_request" => ExtractPullRequestDetails(payload),
"release" => ExtractReleaseDetails(payload),
"create" => ExtractCreateDetails(payload),
_ => (ScmEventType.Unknown, (string?)null, (string?)null)
};
if (scmEventType == ScmEventType.Unknown)
{
return null;
}
var repository = ExtractRepository(payload);
if (repository is null)
{
return null;
}
return new NormalizedScmEvent
{
EventId = deliveryId,
Provider = ScmProvider.Gitea,
EventType = scmEventType,
Timestamp = DateTimeOffset.UtcNow,
Repository = repository,
Actor = ExtractActor(payload),
Ref = refName ?? GetString(payload, "ref"),
CommitSha = commitSha,
PullRequest = ExtractPullRequest(payload),
Release = ExtractRelease(payload)
};
}
private static (ScmEventType, string?, string?) ExtractPushDetails(JsonElement payload)
{
var sha = GetString(payload, "after");
var refName = GetString(payload, "ref");
return (ScmEventType.Push, sha, refName);
}
private static (ScmEventType, string?, string?) ExtractPullRequestDetails(JsonElement payload)
{
var action = GetString(payload, "action");
if (!payload.TryGetProperty("pull_request", out var pr))
{
return (ScmEventType.Unknown, null, null);
}
var merged = GetBool(pr, "merged");
var eventType = action switch
{
"opened" or "reopened" => ScmEventType.PullRequestOpened,
"closed" when merged => ScmEventType.PullRequestMerged,
"closed" => ScmEventType.PullRequestClosed,
_ => ScmEventType.Unknown
};
var sha = GetNestedString(pr, "head", "sha");
var refName = GetNestedString(pr, "head", "ref");
return (eventType, sha, refName);
}
private static (ScmEventType, string?, string?) ExtractReleaseDetails(JsonElement payload)
{
var action = GetString(payload, "action");
if (action != "published")
{
return (ScmEventType.Unknown, null, null);
}
var tagName = GetNestedString(payload, "release", "tag_name");
return (ScmEventType.ReleasePublished, null, tagName);
}
private static (ScmEventType, string?, string?) ExtractCreateDetails(JsonElement payload)
{
var refType = GetString(payload, "ref_type");
if (refType != "tag")
{
return (ScmEventType.Unknown, null, null);
}
var refName = GetString(payload, "ref");
return (ScmEventType.TagCreated, null, refName);
}
private static ScmRepository? ExtractRepository(JsonElement payload)
{
if (!payload.TryGetProperty("repository", out var repo))
{
return null;
}
return new ScmRepository
{
Id = GetString(repo, "id")?.ToString(),
FullName = GetString(repo, "full_name") ?? string.Empty,
Owner = GetNestedString(repo, "owner", "login") ?? GetNestedString(repo, "owner", "username"),
Name = GetString(repo, "name"),
CloneUrl = GetString(repo, "clone_url"),
DefaultBranch = GetString(repo, "default_branch"),
IsPrivate = GetBool(repo, "private")
};
}
private static ScmActor? ExtractActor(JsonElement payload)
{
if (!payload.TryGetProperty("sender", out var sender))
{
return null;
}
return new ScmActor
{
Id = GetString(sender, "id")?.ToString(),
Username = GetString(sender, "login") ?? GetString(sender, "username"),
DisplayName = GetString(sender, "full_name"),
Type = ScmActorType.User
};
}
private static ScmPullRequest? ExtractPullRequest(JsonElement payload)
{
if (!payload.TryGetProperty("pull_request", out var pr))
{
return null;
}
return new ScmPullRequest
{
Number = GetInt(pr, "number"),
Title = GetString(pr, "title"),
SourceBranch = GetNestedString(pr, "head", "ref"),
TargetBranch = GetNestedString(pr, "base", "ref"),
State = GetString(pr, "state"),
Url = GetString(pr, "html_url") ?? GetString(pr, "url")
};
}
private static ScmRelease? ExtractRelease(JsonElement payload)
{
if (!payload.TryGetProperty("release", out var release))
{
return null;
}
return new ScmRelease
{
Id = GetString(release, "id")?.ToString(),
TagName = GetString(release, "tag_name"),
Name = GetString(release, "name"),
IsPrerelease = GetBool(release, "prerelease"),
IsDraft = GetBool(release, "draft"),
Url = GetString(release, "html_url") ?? GetString(release, "url")
};
}
private static string? GetString(JsonElement element, string property)
{
if (!element.TryGetProperty(property, out var value))
{
return null;
}
return value.ValueKind switch
{
JsonValueKind.String => value.GetString(),
JsonValueKind.Number => value.ToString(),
_ => null
};
}
private static bool GetBool(JsonElement element, string property)
{
return element.TryGetProperty(property, out var value) &&
value.ValueKind == JsonValueKind.True;
}
private static int GetInt(JsonElement element, string property)
{
return element.TryGetProperty(property, out var value) && value.ValueKind == JsonValueKind.Number
? value.GetInt32()
: 0;
}
private static string? GetNestedString(JsonElement element, params string[] path)
{
var current = element;
foreach (var prop in path)
{
if (!current.TryGetProperty(prop, out current))
{
return null;
}
}
return current.ValueKind == JsonValueKind.String ? current.GetString() : null;
}
}

View File

@@ -0,0 +1,49 @@
using System.Security.Cryptography;
using System.Text;
namespace StellaOps.Signals.Scm.Webhooks;
/// <summary>
/// Validates Gitea webhook signatures using HMAC-SHA256.
/// Gitea uses the same signature format as GitHub.
/// </summary>
public sealed class GiteaWebhookValidator : IWebhookSignatureValidator
{
private const string Sha256Prefix = "sha256=";
private const string Sha1Prefix = "sha1=";
public bool IsValid(ReadOnlySpan<byte> payload, string? signature, string secret)
{
if (string.IsNullOrEmpty(signature) || string.IsNullOrEmpty(secret))
{
return false;
}
var secretBytes = Encoding.UTF8.GetBytes(secret);
// Gitea supports both SHA256 and SHA1 signatures
if (signature.StartsWith(Sha256Prefix, StringComparison.OrdinalIgnoreCase))
{
var expectedSignature = signature[Sha256Prefix.Length..];
var computedHash = HMACSHA256.HashData(secretBytes, payload);
var computedSignature = Convert.ToHexStringLower(computedHash);
return CryptographicOperations.FixedTimeEquals(
Encoding.UTF8.GetBytes(computedSignature),
Encoding.UTF8.GetBytes(expectedSignature.ToLowerInvariant()));
}
if (signature.StartsWith(Sha1Prefix, StringComparison.OrdinalIgnoreCase))
{
var expectedSignature = signature[Sha1Prefix.Length..];
var computedHash = HMACSHA1.HashData(secretBytes, payload);
var computedSignature = Convert.ToHexStringLower(computedHash);
return CryptographicOperations.FixedTimeEquals(
Encoding.UTF8.GetBytes(computedSignature),
Encoding.UTF8.GetBytes(expectedSignature.ToLowerInvariant()));
}
return false;
}
}

View File

@@ -0,0 +1,24 @@
using System.Text.Json;
using StellaOps.Signals.Scm.Models;
namespace StellaOps.Signals.Scm.Webhooks;
/// <summary>
/// Interface for mapping provider-specific webhook payloads to normalized events.
/// </summary>
public interface IScmEventMapper
{
/// <summary>
/// Gets the provider this mapper handles.
/// </summary>
ScmProvider Provider { get; }
/// <summary>
/// Maps a webhook payload to a normalized event.
/// </summary>
/// <param name="eventType">Provider-specific event type header value.</param>
/// <param name="deliveryId">Webhook delivery ID.</param>
/// <param name="payload">JSON payload.</param>
/// <returns>Normalized event or null if event type is not supported.</returns>
NormalizedScmEvent? Map(string eventType, string deliveryId, JsonElement payload);
}

View File

@@ -0,0 +1,16 @@
namespace StellaOps.Signals.Scm.Webhooks;
/// <summary>
/// Interface for webhook signature validation.
/// </summary>
public interface IWebhookSignatureValidator
{
/// <summary>
/// Validates the webhook signature.
/// </summary>
/// <param name="payload">Raw request body bytes.</param>
/// <param name="signature">Signature from request header.</param>
/// <param name="secret">Webhook secret.</param>
/// <returns>True if signature is valid.</returns>
bool IsValid(ReadOnlySpan<byte> payload, string? signature, string secret);
}

View File

@@ -9,7 +9,6 @@ using StellaOps.Signals.Persistence.Postgres.Repositories;
using StellaOps.Signals.Services;
using StellaOps.TestKit;
using Xunit;
using Xunit.Abstractions;
namespace StellaOps.Signals.Persistence.Tests;
@@ -48,12 +47,12 @@ public sealed class CallGraphProjectionIntegrationTests : IAsyncLifetime
NullLogger<CallGraphSyncService>.Instance);
}
public async Task InitializeAsync()
public async ValueTask InitializeAsync()
{
await _fixture.ExecuteSqlAsync("TRUNCATE TABLE signals.scans CASCADE;");
}
public async Task DisposeAsync()
public async ValueTask DisposeAsync()
{
await _dataSource.DisposeAsync();
}
@@ -188,3 +187,6 @@ public sealed class CallGraphProjectionIntegrationTests : IAsyncLifetime
};
}
}

View File

@@ -41,12 +41,12 @@ public sealed class CallGraphSyncServiceTests : IAsyncLifetime
_queryRepository = new PostgresCallGraphQueryRepository(_dataSource, NullLogger<PostgresCallGraphQueryRepository>.Instance);
}
public async Task InitializeAsync()
public async ValueTask InitializeAsync()
{
await _fixture.ExecuteSqlAsync("TRUNCATE TABLE signals.scans CASCADE;");
}
public async Task DisposeAsync()
public async ValueTask DisposeAsync()
{
await _dataSource.DisposeAsync();
}
@@ -134,3 +134,6 @@ public sealed class CallGraphSyncServiceTests : IAsyncLifetime
stats2.EdgeCount.Should().Be(1);
}
}

View File

@@ -25,12 +25,12 @@ public sealed class PostgresCallgraphRepositoryTests : IAsyncLifetime
_repository = new PostgresCallgraphRepository(dataSource, NullLogger<PostgresCallgraphRepository>.Instance);
}
public async Task InitializeAsync()
public async ValueTask InitializeAsync()
{
await _fixture.TruncateAllTablesAsync();
}
public Task DisposeAsync() => Task.CompletedTask;
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
[Trait("Category", TestCategories.Unit)]
[Fact]
@@ -154,3 +154,6 @@ public sealed class PostgresCallgraphRepositoryTests : IAsyncLifetime
result.Id.Should().HaveLength(32); // GUID without hyphens
}
}

View File

@@ -0,0 +1,276 @@
// <copyright file="ScmEventMapperTests.cs" company="Stella Operations">
// Copyright (c) Stella Operations. Licensed under AGPL-3.0-or-later.
// </copyright>
using System.Text.Json;
using StellaOps.Signals.Scm.Models;
using StellaOps.Signals.Scm.Webhooks;
using Xunit;
namespace StellaOps.Signals.Tests.Scm;
/// <summary>
/// Unit tests for SCM event mappers.
/// @sprint SPRINT_20251229_013_SIGNALS_scm_ci_connectors
/// </summary>
public sealed class ScmEventMapperTests
{
#region GitHub Event Mapper Tests
[Fact]
public void GitHubMapper_PushEvent_MapsCorrectly()
{
// Arrange
var mapper = new GitHubEventMapper();
var payload = CreateGitHubPushPayload();
// Act
var result = mapper.Map("push", "delivery-123", payload);
// Assert
Assert.NotNull(result);
Assert.Equal(ScmProvider.GitHub, result.Provider);
Assert.Equal(ScmEventType.Push, result.EventType);
Assert.Equal("delivery-123", result.EventId);
Assert.Equal("refs/heads/main", result.Ref);
Assert.NotNull(result.Repository);
Assert.Equal("owner/repo", result.Repository.FullName);
}
[Fact]
public void GitHubMapper_PullRequestMergedEvent_MapsCorrectly()
{
// Arrange
var mapper = new GitHubEventMapper();
var payload = CreateGitHubPrMergedPayload();
// Act
var result = mapper.Map("pull_request", "delivery-456", payload);
// Assert
Assert.NotNull(result);
Assert.Equal(ScmProvider.GitHub, result.Provider);
Assert.Equal(ScmEventType.PullRequestMerged, result.EventType);
Assert.NotNull(result.PullRequest);
Assert.Equal(42, result.PullRequest.Number);
Assert.Equal("closed", result.PullRequest.State);
}
[Fact]
public void GitHubMapper_ReleaseEvent_MapsCorrectly()
{
// Arrange
var mapper = new GitHubEventMapper();
var payload = CreateGitHubReleasePayload();
// Act
var result = mapper.Map("release", "delivery-789", payload);
// Assert
Assert.NotNull(result);
Assert.Equal(ScmProvider.GitHub, result.Provider);
Assert.Equal(ScmEventType.ReleasePublished, result.EventType);
Assert.NotNull(result.Release);
Assert.Equal("v1.0.0", result.Release.TagName);
}
[Fact]
public void GitHubMapper_UnknownEvent_ReturnsUnknownType()
{
// Arrange
var mapper = new GitHubEventMapper();
var payload = JsonSerializer.SerializeToElement(new { });
// Act
var result = mapper.Map("unknown_event", "delivery-000", payload);
// Assert
Assert.NotNull(result);
Assert.Equal(ScmEventType.Unknown, result.EventType);
}
#endregion
#region GitLab Event Mapper Tests
[Fact]
public void GitLabMapper_PushEvent_MapsCorrectly()
{
// Arrange
var mapper = new GitLabEventMapper();
var payload = CreateGitLabPushPayload();
// Act
var result = mapper.Map("Push Hook", "delivery-123", payload);
// Assert
Assert.NotNull(result);
Assert.Equal(ScmProvider.GitLab, result.Provider);
Assert.Equal(ScmEventType.Push, result.EventType);
Assert.Equal("refs/heads/main", result.Ref);
}
[Fact]
public void GitLabMapper_MergeRequestEvent_MapsCorrectly()
{
// Arrange
var mapper = new GitLabEventMapper();
var payload = CreateGitLabMrMergedPayload();
// Act
var result = mapper.Map("Merge Request Hook", "delivery-456", payload);
// Assert
Assert.NotNull(result);
Assert.Equal(ScmProvider.GitLab, result.Provider);
Assert.Equal(ScmEventType.PullRequestMerged, result.EventType);
}
#endregion
#region Gitea Event Mapper Tests
[Fact]
public void GiteaMapper_PushEvent_MapsCorrectly()
{
// Arrange
var mapper = new GiteaEventMapper();
var payload = CreateGiteaPushPayload();
// Act
var result = mapper.Map("push", "delivery-123", payload);
// Assert
Assert.NotNull(result);
Assert.Equal(ScmProvider.Gitea, result.Provider);
Assert.Equal(ScmEventType.Push, result.EventType);
}
#endregion
#region Helper Methods
private static JsonElement CreateGitHubPushPayload()
{
var payload = new
{
@ref = "refs/heads/main",
after = "abc123def456",
repository = new
{
id = 12345,
full_name = "owner/repo",
clone_url = "https://github.com/owner/repo.git"
},
sender = new
{
login = "testuser",
id = 1
}
};
return JsonSerializer.SerializeToElement(payload);
}
private static JsonElement CreateGitHubPrMergedPayload()
{
var payload = new
{
action = "closed",
pull_request = new
{
number = 42,
merged = true,
title = "Test PR",
head = new { sha = "abc123" },
@base = new { @ref = "main" }
},
repository = new
{
id = 12345,
full_name = "owner/repo"
}
};
return JsonSerializer.SerializeToElement(payload);
}
private static JsonElement CreateGitHubReleasePayload()
{
var payload = new
{
action = "published",
release = new
{
tag_name = "v1.0.0",
name = "Release 1.0.0",
draft = false,
prerelease = false
},
repository = new
{
id = 12345,
full_name = "owner/repo"
}
};
return JsonSerializer.SerializeToElement(payload);
}
private static JsonElement CreateGitLabPushPayload()
{
var payload = new
{
@ref = "refs/heads/main",
after = "abc123def456",
project = new
{
id = 12345,
path_with_namespace = "group/project",
git_http_url = "https://gitlab.com/group/project.git"
},
user_name = "testuser"
};
return JsonSerializer.SerializeToElement(payload);
}
private static JsonElement CreateGitLabMrMergedPayload()
{
var payload = new
{
object_kind = "merge_request",
object_attributes = new
{
iid = 42,
state = "merged",
action = "merge",
title = "Test MR"
},
project = new
{
id = 12345,
path_with_namespace = "group/project"
}
};
return JsonSerializer.SerializeToElement(payload);
}
private static JsonElement CreateGiteaPushPayload()
{
var payload = new
{
@ref = "refs/heads/main",
after = "abc123def456",
repository = new
{
id = 12345,
full_name = "owner/repo",
clone_url = "https://gitea.example.com/owner/repo.git"
},
sender = new
{
login = "testuser"
}
};
return JsonSerializer.SerializeToElement(payload);
}
#endregion
}

View File

@@ -0,0 +1,200 @@
// <copyright file="ScmWebhookValidatorTests.cs" company="Stella Operations">
// Copyright (c) Stella Operations. Licensed under AGPL-3.0-or-later.
// </copyright>
using System.Security.Cryptography;
using System.Text;
using StellaOps.Signals.Scm.Webhooks;
using Xunit;
namespace StellaOps.Signals.Tests.Scm;
/// <summary>
/// Unit tests for SCM webhook signature validators.
/// @sprint SPRINT_20251229_013_SIGNALS_scm_ci_connectors
/// </summary>
public sealed class ScmWebhookValidatorTests
{
private const string TestSecret = "test-webhook-secret-12345";
private const string TestPayload = "{\"action\":\"push\",\"ref\":\"refs/heads/main\"}";
#region GitHub Validator Tests
[Fact]
public void GitHubValidator_ValidSignature_ReturnsTrue()
{
// Arrange
var validator = new GitHubWebhookValidator();
var payload = Encoding.UTF8.GetBytes(TestPayload);
var signature = ComputeGitHubSignature(payload, TestSecret);
// Act
var result = validator.IsValid(payload, signature, TestSecret);
// Assert
Assert.True(result);
}
[Fact]
public void GitHubValidator_InvalidSignature_ReturnsFalse()
{
// Arrange
var validator = new GitHubWebhookValidator();
var payload = Encoding.UTF8.GetBytes(TestPayload);
var wrongSignature = "sha256=0000000000000000000000000000000000000000000000000000000000000000";
// Act
var result = validator.IsValid(payload, wrongSignature, TestSecret);
// Assert
Assert.False(result);
}
[Fact]
public void GitHubValidator_MissingPrefix_ReturnsFalse()
{
// Arrange
var validator = new GitHubWebhookValidator();
var payload = Encoding.UTF8.GetBytes(TestPayload);
var signatureWithoutPrefix = ComputeGitHubSignature(payload, TestSecret)[7..]; // Remove "sha256="
// Act
var result = validator.IsValid(payload, signatureWithoutPrefix, TestSecret);
// Assert
Assert.False(result);
}
[Theory]
[InlineData(null)]
[InlineData("")]
public void GitHubValidator_NullOrEmptySignature_ReturnsFalse(string? signature)
{
// Arrange
var validator = new GitHubWebhookValidator();
var payload = Encoding.UTF8.GetBytes(TestPayload);
// Act
var result = validator.IsValid(payload, signature, TestSecret);
// Assert
Assert.False(result);
}
[Theory]
[InlineData(null)]
[InlineData("")]
public void GitHubValidator_NullOrEmptySecret_ReturnsFalse(string? secret)
{
// Arrange
var validator = new GitHubWebhookValidator();
var payload = Encoding.UTF8.GetBytes(TestPayload);
var signature = "sha256=abc123";
// Act
var result = validator.IsValid(payload, signature, secret!);
// Assert
Assert.False(result);
}
#endregion
#region GitLab Validator Tests
[Fact]
public void GitLabValidator_ValidToken_ReturnsTrue()
{
// Arrange
var validator = new GitLabWebhookValidator();
var payload = Encoding.UTF8.GetBytes(TestPayload);
// Act
var result = validator.IsValid(payload, TestSecret, TestSecret);
// Assert
Assert.True(result);
}
[Fact]
public void GitLabValidator_InvalidToken_ReturnsFalse()
{
// Arrange
var validator = new GitLabWebhookValidator();
var payload = Encoding.UTF8.GetBytes(TestPayload);
// Act
var result = validator.IsValid(payload, "wrong-token", TestSecret);
// Assert
Assert.False(result);
}
[Fact]
public void GitLabValidator_CaseSensitive_ReturnsFalse()
{
// Arrange
var validator = new GitLabWebhookValidator();
var payload = Encoding.UTF8.GetBytes(TestPayload);
// Act
var result = validator.IsValid(payload, TestSecret.ToUpperInvariant(), TestSecret);
// Assert
Assert.False(result);
}
#endregion
#region Gitea Validator Tests
[Fact]
public void GiteaValidator_ValidSignature_ReturnsTrue()
{
// Arrange
var validator = new GiteaWebhookValidator();
var payload = Encoding.UTF8.GetBytes(TestPayload);
var signature = ComputeGiteaSignature(payload, TestSecret);
// Act
var result = validator.IsValid(payload, signature, TestSecret);
// Assert
Assert.True(result);
}
[Fact]
public void GiteaValidator_InvalidSignature_ReturnsFalse()
{
// Arrange
var validator = new GiteaWebhookValidator();
var payload = Encoding.UTF8.GetBytes(TestPayload);
var wrongSignature = "0000000000000000000000000000000000000000000000000000000000000000";
// Act
var result = validator.IsValid(payload, wrongSignature, TestSecret);
// Assert
Assert.False(result);
}
#endregion
#region Helper Methods
private static string ComputeGitHubSignature(byte[] payload, string secret)
{
var secretBytes = Encoding.UTF8.GetBytes(secret);
var hash = HMACSHA256.HashData(secretBytes, payload);
return $"sha256={Convert.ToHexStringLower(hash)}";
}
private static string ComputeGiteaSignature(byte[] payload, string secret)
{
var secretBytes = Encoding.UTF8.GetBytes(secret);
var hash = HMACSHA256.HashData(secretBytes, payload);
return Convert.ToHexStringLower(hash);
}
#endregion
}

View File

@@ -12,9 +12,9 @@
<PackageReference Include="FluentAssertions" />
<!-- FsCheck for property-based testing (EvidenceWeightedScore) -->
<PackageReference Include="FsCheck" />
<PackageReference Include="FsCheck.Xunit" />
<PackageReference Include="FsCheck.Xunit.v3" />
<!-- Verify for snapshot testing (EvidenceWeightedScore) -->
<PackageReference Include="Verify.Xunit" />
<PackageReference Include="Verify.XunitV3" />
<PackageReference Include="xunit.runner.visualstudio" >
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
@@ -29,4 +29,6 @@
<ProjectReference Include="../../StellaOps.Signals/StellaOps.Signals.csproj" />
<ProjectReference Include="../../../__Libraries/StellaOps.TestKit/StellaOps.TestKit.csproj" />
</ItemGroup>
</Project>
</Project>