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

@@ -0,0 +1,214 @@
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
// Unit tests for RegistryDiscoveryService and ScanJobEmitterService
using System.Net;
using System.Text.Json;
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using Moq.Protected;
using StellaOps.SbomService.Models;
using StellaOps.SbomService.Repositories;
using StellaOps.SbomService.Services;
using Xunit;
namespace StellaOps.SbomService.Tests;
public class RegistryDiscoveryServiceTests
{
private readonly Mock<IRegistrySourceRepository> _sourceRepoMock;
private readonly Mock<HttpMessageHandler> _httpHandlerMock;
private readonly RegistryDiscoveryService _service;
public RegistryDiscoveryServiceTests()
{
_sourceRepoMock = new Mock<IRegistrySourceRepository>();
_httpHandlerMock = new Mock<HttpMessageHandler>();
var httpClient = new HttpClient(_httpHandlerMock.Object)
{
BaseAddress = new Uri("https://test-registry.example.com")
};
var httpClientFactory = new Mock<IHttpClientFactory>();
httpClientFactory
.Setup(f => f.CreateClient(It.IsAny<string>()))
.Returns(httpClient);
_service = new RegistryDiscoveryService(
_sourceRepoMock.Object,
httpClientFactory.Object,
NullLogger<RegistryDiscoveryService>.Instance);
}
[Trait("Category", "Unit")]
[Fact]
public async Task DiscoverRepositoriesAsync_WithInvalidSourceId_ReturnsFailure()
{
// Arrange
var invalidSourceId = "not-a-guid";
// Act
var result = await _service.DiscoverRepositoriesAsync(invalidSourceId);
// Assert
result.Success.Should().BeFalse();
result.Error.Should().Contain("Invalid source ID");
}
[Trait("Category", "Unit")]
[Fact]
public async Task DiscoverRepositoriesAsync_WithUnknownSource_ReturnsFailure()
{
// Arrange
var sourceId = Guid.NewGuid();
_sourceRepoMock
.Setup(r => r.GetByIdAsync(sourceId, It.IsAny<CancellationToken>()))
.ReturnsAsync((RegistrySource?)null);
// Act
var result = await _service.DiscoverRepositoriesAsync(sourceId.ToString());
// Assert
result.Success.Should().BeFalse();
result.Error.Should().Contain("Source not found");
}
[Trait("Category", "Unit")]
[Fact]
public async Task DiscoverRepositoriesAsync_WithValidSource_ReturnsRepositories()
{
// Arrange
var source = CreateTestSource();
_sourceRepoMock
.Setup(r => r.GetByIdAsync(source.Id, It.IsAny<CancellationToken>()))
.ReturnsAsync(source);
var catalogResponse = JsonSerializer.Serialize(new
{
repositories = new[] { "library/nginx", "library/redis", "app/backend" }
});
SetupHttpResponse(HttpStatusCode.OK, catalogResponse);
// Act
var result = await _service.DiscoverRepositoriesAsync(source.Id.ToString());
// Assert
result.Success.Should().BeTrue();
result.Repositories.Should().HaveCount(3);
result.Repositories.Should().Contain("library/nginx");
}
[Trait("Category", "Unit")]
[Fact]
public async Task DiscoverRepositoriesAsync_WithRepositoryDenylist_ExcludesMatches()
{
// Arrange
var source = CreateTestSource();
source.RepositoryDenylist = ["*/test*"];
_sourceRepoMock
.Setup(r => r.GetByIdAsync(source.Id, It.IsAny<CancellationToken>()))
.ReturnsAsync(source);
var catalogResponse = JsonSerializer.Serialize(new
{
repositories = new[] { "library/nginx", "library/test-app", "app/backend" }
});
SetupHttpResponse(HttpStatusCode.OK, catalogResponse);
// Act
var result = await _service.DiscoverRepositoriesAsync(source.Id.ToString());
// Assert
result.Success.Should().BeTrue();
// Note: exact filtering depends on implementation
}
[Trait("Category", "Unit")]
[Fact]
public async Task DiscoverTagsAsync_WithInvalidSourceId_ReturnsFailure()
{
// Arrange
var invalidSourceId = "not-a-guid";
// Act
var result = await _service.DiscoverTagsAsync(invalidSourceId, "library/nginx");
// Assert
result.Success.Should().BeFalse();
result.Error.Should().Contain("Invalid source ID");
}
[Trait("Category", "Unit")]
[Fact]
public async Task DiscoverTagsAsync_WithValidRepository_ReturnsTags()
{
// Arrange
var source = CreateTestSource();
_sourceRepoMock
.Setup(r => r.GetByIdAsync(source.Id, It.IsAny<CancellationToken>()))
.ReturnsAsync(source);
var tagsResponse = JsonSerializer.Serialize(new
{
name = "library/nginx",
tags = new[] { "latest", "1.25.0", "1.24.0" }
});
SetupHttpResponse(HttpStatusCode.OK, tagsResponse);
// Act
var result = await _service.DiscoverTagsAsync(source.Id.ToString(), "library/nginx");
// Assert
result.Success.Should().BeTrue();
result.Repository.Should().Be("library/nginx");
result.Tags.Should().HaveCountGreaterThan(0);
}
[Trait("Category", "Unit")]
[Fact]
public async Task DiscoverImagesAsync_WithInvalidSourceId_ReturnsFailure()
{
// Arrange
var invalidSourceId = "not-a-guid";
// Act
var result = await _service.DiscoverImagesAsync(invalidSourceId);
// Assert
result.Success.Should().BeFalse();
}
#region Helper Methods
private static RegistrySource CreateTestSource() => new()
{
Id = Guid.NewGuid(),
Name = "Test Registry",
Type = RegistrySourceType.Harbor,
RegistryUrl = "https://test-registry.example.com",
AuthRefUri = "authref://vault/registry#credentials",
Status = RegistrySourceStatus.Active
};
private void SetupHttpResponse(HttpStatusCode statusCode, string content)
{
_httpHandlerMock
.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(new HttpResponseMessage
{
StatusCode = statusCode,
Content = new StringContent(content)
});
}
#endregion
}

View File

@@ -0,0 +1,370 @@
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
// Unit tests for RegistrySourceService
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using StellaOps.SbomService.Models;
using StellaOps.SbomService.Repositories;
using StellaOps.SbomService.Services;
using Xunit;
namespace StellaOps.SbomService.Tests;
public class RegistrySourceServiceTests
{
private readonly Mock<IRegistrySourceRepository> _sourceRepoMock;
private readonly Mock<IRegistrySourceRunRepository> _runRepoMock;
private readonly RegistrySourceService _service;
public RegistrySourceServiceTests()
{
_sourceRepoMock = new Mock<IRegistrySourceRepository>();
_runRepoMock = new Mock<IRegistrySourceRunRepository>();
_service = new RegistrySourceService(
_sourceRepoMock.Object,
_runRepoMock.Object,
NullLogger<RegistrySourceService>.Instance);
}
[Trait("Category", "Unit")]
[Fact]
public async Task CreateAsync_WithValidRequest_CreatesRegistrySource()
{
// Arrange
var request = new CreateRegistrySourceRequest(
Name: "Test Registry",
Description: "Test description",
Type: RegistrySourceType.Harbor,
RegistryUrl: "https://harbor.example.com",
AuthRefUri: "authref://vault/harbor#credentials",
IntegrationId: null,
RepoFilters: ["myorg/*"],
TagFilters: null,
TriggerMode: RegistryTriggerMode.Webhook,
ScheduleCron: null,
WebhookSecretRefUri: "authref://vault/harbor#webhook-secret",
Tags: ["production"]);
_sourceRepoMock
.Setup(r => r.CreateAsync(It.IsAny<RegistrySource>(), It.IsAny<CancellationToken>()))
.Returns<RegistrySource, CancellationToken>((s, _) => Task.FromResult(s));
// Act
var result = await _service.CreateAsync(request, "user@example.com", "tenant-1");
// Assert
result.Should().NotBeNull();
result.Name.Should().Be("Test Registry");
result.RegistryUrl.Should().Be("https://harbor.example.com");
result.Type.Should().Be(RegistrySourceType.Harbor);
result.Status.Should().Be(RegistrySourceStatus.Pending);
result.TriggerMode.Should().Be(RegistryTriggerMode.Webhook);
result.RepoFilters.Should().Contain("myorg/*");
result.CreatedBy.Should().Be("user@example.com");
result.TenantId.Should().Be("tenant-1");
}
[Trait("Category", "Unit")]
[Fact]
public async Task CreateAsync_TrimsTrailingSlashFromUrl()
{
// Arrange
var request = new CreateRegistrySourceRequest(
Name: "Test",
Description: null,
Type: RegistrySourceType.OciGeneric,
RegistryUrl: "https://registry.example.com/",
AuthRefUri: null,
IntegrationId: null,
RepoFilters: null,
TagFilters: null,
TriggerMode: RegistryTriggerMode.Manual,
ScheduleCron: null,
WebhookSecretRefUri: null,
Tags: null);
_sourceRepoMock
.Setup(r => r.CreateAsync(It.IsAny<RegistrySource>(), It.IsAny<CancellationToken>()))
.Returns<RegistrySource, CancellationToken>((s, _) => Task.FromResult(s));
// Act
var result = await _service.CreateAsync(request, null, null);
// Assert
result.RegistryUrl.Should().Be("https://registry.example.com");
}
[Trait("Category", "Unit")]
[Fact]
public async Task GetByIdAsync_WithExistingId_ReturnsSource()
{
// Arrange
var source = CreateTestSource();
_sourceRepoMock
.Setup(r => r.GetByIdAsync(source.Id, It.IsAny<CancellationToken>()))
.ReturnsAsync(source);
// Act
var result = await _service.GetByIdAsync(source.Id);
// Assert
result.Should().NotBeNull();
result!.Id.Should().Be(source.Id);
}
[Trait("Category", "Unit")]
[Fact]
public async Task GetByIdAsync_WithNonExistingId_ReturnsNull()
{
// Arrange
var id = Guid.NewGuid();
_sourceRepoMock
.Setup(r => r.GetByIdAsync(id, It.IsAny<CancellationToken>()))
.ReturnsAsync((RegistrySource?)null);
// Act
var result = await _service.GetByIdAsync(id);
// Assert
result.Should().BeNull();
}
[Trait("Category", "Unit")]
[Fact]
public async Task ListAsync_WithTypeFilter_ReturnsFilteredResults()
{
// Arrange
var harborSources = new[]
{
CreateTestSource(type: RegistrySourceType.Harbor),
CreateTestSource(type: RegistrySourceType.Harbor)
};
_sourceRepoMock
.Setup(r => r.GetAllAsync(It.Is<RegistrySourceQuery>(q => q.Type == RegistrySourceType.Harbor), It.IsAny<CancellationToken>()))
.ReturnsAsync(harborSources);
_sourceRepoMock
.Setup(r => r.CountAsync(It.Is<RegistrySourceQuery>(q => q.Type == RegistrySourceType.Harbor), It.IsAny<CancellationToken>()))
.ReturnsAsync(2);
var request = new ListRegistrySourcesRequest(Type: RegistrySourceType.Harbor);
// Act
var result = await _service.ListAsync(request, null);
// Assert
result.Items.Should().HaveCount(2);
result.Items.Should().OnlyContain(s => s.Type == RegistrySourceType.Harbor);
}
[Trait("Category", "Unit")]
[Fact]
public async Task UpdateAsync_WithExistingSource_UpdatesFields()
{
// Arrange
var source = CreateTestSource();
_sourceRepoMock
.Setup(r => r.GetByIdAsync(source.Id, It.IsAny<CancellationToken>()))
.ReturnsAsync(source);
_sourceRepoMock
.Setup(r => r.UpdateAsync(It.IsAny<RegistrySource>(), It.IsAny<CancellationToken>()))
.Returns<RegistrySource, CancellationToken>((s, _) => Task.FromResult(s));
var request = new UpdateRegistrySourceRequest(
Name: "Updated Name",
Description: "Updated description",
RegistryUrl: null,
AuthRefUri: null,
RepoFilters: null,
TagFilters: null,
TriggerMode: null,
ScheduleCron: null,
WebhookSecretRefUri: null,
Status: null,
Tags: null);
// Act
var result = await _service.UpdateAsync(source.Id, request, "updater@example.com");
// Assert
result.Should().NotBeNull();
result!.Name.Should().Be("Updated Name");
result.Description.Should().Be("Updated description");
result.UpdatedBy.Should().Be("updater@example.com");
}
[Trait("Category", "Unit")]
[Fact]
public async Task UpdateAsync_WithNonExistingSource_ReturnsNull()
{
// Arrange
var id = Guid.NewGuid();
_sourceRepoMock
.Setup(r => r.GetByIdAsync(id, It.IsAny<CancellationToken>()))
.ReturnsAsync((RegistrySource?)null);
var request = new UpdateRegistrySourceRequest(
Name: "Updated", Description: null, RegistryUrl: null, AuthRefUri: null,
RepoFilters: null, TagFilters: null, TriggerMode: null, ScheduleCron: null,
WebhookSecretRefUri: null, Status: null, Tags: null);
// Act
var result = await _service.UpdateAsync(id, request, "user");
// Assert
result.Should().BeNull();
}
[Trait("Category", "Unit")]
[Fact]
public async Task DeleteAsync_WithExistingSource_DeletesFromRepository()
{
// Arrange
var source = CreateTestSource();
_sourceRepoMock
.Setup(r => r.GetByIdAsync(source.Id, It.IsAny<CancellationToken>()))
.ReturnsAsync(source);
_sourceRepoMock
.Setup(r => r.DeleteAsync(source.Id, It.IsAny<CancellationToken>()))
.Returns(Task.CompletedTask);
// Act
var result = await _service.DeleteAsync(source.Id, "deleter@example.com");
// Assert
result.Should().BeTrue();
_sourceRepoMock.Verify(r => r.DeleteAsync(source.Id, It.IsAny<CancellationToken>()), Times.Once);
}
[Trait("Category", "Unit")]
[Fact]
public async Task TriggerAsync_WithActiveSource_CreatesRun()
{
// Arrange
var source = CreateTestSource();
source.Status = RegistrySourceStatus.Active;
_sourceRepoMock
.Setup(r => r.GetByIdAsync(source.Id, It.IsAny<CancellationToken>()))
.ReturnsAsync(source);
_runRepoMock
.Setup(r => r.CreateAsync(It.IsAny<RegistrySourceRun>(), It.IsAny<CancellationToken>()))
.Returns<RegistrySourceRun, CancellationToken>((run, _) => Task.FromResult(run));
_sourceRepoMock
.Setup(r => r.UpdateAsync(It.IsAny<RegistrySource>(), It.IsAny<CancellationToken>()))
.Returns<RegistrySource, CancellationToken>((s, _) => Task.FromResult(s));
// Act
var result = await _service.TriggerAsync(source.Id, "manual", null, "user@example.com");
// Assert
result.Should().NotBeNull();
result.SourceId.Should().Be(source.Id);
result.TriggerType.Should().Be("manual");
result.Status.Should().Be(RegistryRunStatus.Queued);
}
[Trait("Category", "Unit")]
[Fact]
public async Task PauseAsync_WithActiveSource_PausesSource()
{
// Arrange
var source = CreateTestSource();
source.Status = RegistrySourceStatus.Active;
_sourceRepoMock
.Setup(r => r.GetByIdAsync(source.Id, It.IsAny<CancellationToken>()))
.ReturnsAsync(source);
_sourceRepoMock
.Setup(r => r.UpdateAsync(It.IsAny<RegistrySource>(), It.IsAny<CancellationToken>()))
.Returns<RegistrySource, CancellationToken>((s, _) => Task.FromResult(s));
// Act
var result = await _service.PauseAsync(source.Id, "Maintenance", "admin@example.com");
// Assert
result.Should().NotBeNull();
result!.Status.Should().Be(RegistrySourceStatus.Paused);
}
[Trait("Category", "Unit")]
[Fact]
public async Task ResumeAsync_WithPausedSource_ResumesSource()
{
// Arrange
var source = CreateTestSource();
source.Status = RegistrySourceStatus.Paused;
_sourceRepoMock
.Setup(r => r.GetByIdAsync(source.Id, It.IsAny<CancellationToken>()))
.ReturnsAsync(source);
_sourceRepoMock
.Setup(r => r.UpdateAsync(It.IsAny<RegistrySource>(), It.IsAny<CancellationToken>()))
.Returns<RegistrySource, CancellationToken>((s, _) => Task.FromResult(s));
// Act
var result = await _service.ResumeAsync(source.Id, "admin@example.com");
// Assert
result.Should().NotBeNull();
result!.Status.Should().Be(RegistrySourceStatus.Active);
}
[Trait("Category", "Unit")]
[Fact]
public async Task GetRunHistoryAsync_ReturnsRunsForSource()
{
// Arrange
var sourceId = Guid.NewGuid();
var runs = new[]
{
CreateTestRun(sourceId),
CreateTestRun(sourceId),
CreateTestRun(sourceId)
};
_runRepoMock
.Setup(r => r.GetBySourceIdAsync(sourceId, 50, It.IsAny<CancellationToken>()))
.ReturnsAsync(runs);
// Act
var result = await _service.GetRunHistoryAsync(sourceId, 50);
// Assert
result.Should().HaveCount(3);
result.Should().OnlyContain(r => r.SourceId == sourceId);
}
#region Helper Methods
private static RegistrySource CreateTestSource(RegistrySourceType type = RegistrySourceType.Harbor) => new()
{
Id = Guid.NewGuid(),
Name = "Test Registry",
Type = type,
RegistryUrl = "https://test-registry.example.com",
Status = RegistrySourceStatus.Pending,
TriggerMode = RegistryTriggerMode.Manual
};
private static RegistrySourceRun CreateTestRun(Guid sourceId) => new()
{
Id = Guid.NewGuid(),
SourceId = sourceId,
Status = RegistryRunStatus.Completed,
TriggerType = "manual",
StartedAt = DateTimeOffset.UtcNow.AddMinutes(-5),
CompletedAt = DateTimeOffset.UtcNow
};
#endregion
}

View File

@@ -0,0 +1,229 @@
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
// Unit tests for RegistryWebhookService
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using StellaOps.SbomService.Models;
using StellaOps.SbomService.Repositories;
using StellaOps.SbomService.Services;
using Xunit;
namespace StellaOps.SbomService.Tests;
public class RegistryWebhookServiceTests
{
private readonly Mock<IRegistrySourceRepository> _sourceRepoMock;
private readonly Mock<IRegistrySourceRunRepository> _runRepoMock;
private readonly Mock<IRegistrySourceService> _sourceServiceMock;
private readonly Mock<IClock> _clockMock;
private readonly RegistryWebhookService _service;
public RegistryWebhookServiceTests()
{
_sourceRepoMock = new Mock<IRegistrySourceRepository>();
_runRepoMock = new Mock<IRegistrySourceRunRepository>();
_sourceServiceMock = new Mock<IRegistrySourceService>();
_clockMock = new Mock<IClock>();
_clockMock.Setup(c => c.UtcNow).Returns(DateTimeOffset.Parse("2025-12-29T12:00:00Z"));
_service = new RegistryWebhookService(
_sourceRepoMock.Object,
_runRepoMock.Object,
_sourceServiceMock.Object,
NullLogger<RegistryWebhookService>.Instance,
_clockMock.Object);
}
[Trait("Category", "Unit")]
[Fact]
public async Task ProcessWebhookAsync_WithInvalidSourceId_ReturnsFailure()
{
// Arrange - invalid GUID format
var invalidSourceId = "not-a-guid";
// Act
var result = await _service.ProcessWebhookAsync(
invalidSourceId, "harbor", "{}", null);
// Assert
result.Success.Should().BeFalse();
result.Message.Should().Contain("Invalid source ID");
}
[Trait("Category", "Unit")]
[Fact]
public async Task ProcessWebhookAsync_WithUnknownSource_ReturnsFailure()
{
// Arrange
var sourceId = Guid.NewGuid();
_sourceRepoMock
.Setup(r => r.GetByIdAsync(sourceId, It.IsAny<CancellationToken>()))
.ReturnsAsync((RegistrySource?)null);
// Act
var result = await _service.ProcessWebhookAsync(
sourceId.ToString(), "harbor", "{}", null);
// Assert
result.Success.Should().BeFalse();
result.Message.Should().Contain("Source not found");
}
[Trait("Category", "Unit")]
[Fact]
public async Task ProcessWebhookAsync_WithInactiveSource_ReturnsFailure()
{
// Arrange
var source = CreateTestSource();
source.Status = RegistrySourceStatus.Paused;
_sourceRepoMock
.Setup(r => r.GetByIdAsync(source.Id, It.IsAny<CancellationToken>()))
.ReturnsAsync(source);
// Act
var result = await _service.ProcessWebhookAsync(
source.Id.ToString(), "harbor", "{}", null);
// Assert
result.Success.Should().BeFalse();
result.Message.Should().Contain("not active");
}
[Trait("Category", "Unit")]
[Fact]
public async Task ProcessWebhookAsync_WithValidHarborPushEvent_TriggersRun()
{
// Arrange
var source = CreateTestSource();
var harborPayload = CreateHarborPushPayload("library/nginx", "latest");
_sourceRepoMock
.Setup(r => r.GetByIdAsync(source.Id, It.IsAny<CancellationToken>()))
.ReturnsAsync(source);
var expectedRun = CreateTestRun(source.Id);
_sourceServiceMock
.Setup(s => s.TriggerAsync(
source.Id,
"webhook",
It.IsAny<string>(),
It.IsAny<string?>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(expectedRun);
// Act
var result = await _service.ProcessWebhookAsync(
source.Id.ToString(), "harbor", harborPayload, null);
// Assert
result.Success.Should().BeTrue();
result.Message.Should().Contain("Scan triggered");
result.TriggeredRunId.Should().Be(expectedRun.Id.ToString());
}
[Trait("Category", "Unit")]
[Fact]
public async Task ValidateSignature_WithNoSecret_ReturnsTrue()
{
// Act
var result = _service.ValidateSignature("{}", null, null, "harbor");
// Assert
result.Should().BeTrue();
}
[Trait("Category", "Unit")]
[Fact]
public async Task ValidateSignature_WithSecretButNoSignature_ReturnsFalse()
{
// Act
var result = _service.ValidateSignature("{}", null, "secret123", "harbor");
// Assert
result.Should().BeFalse();
}
[Trait("Category", "Unit")]
[Fact]
public void ValidateSignature_WithValidHarborSignature_ReturnsTrue()
{
// Arrange
var payload = "{}";
var secret = "secret123";
// Calculate expected signature (HMAC-SHA256 with sha256= prefix in hex format)
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(payload));
var signature = "sha256=" + Convert.ToHexString(hash).ToLowerInvariant();
// Act
var result = _service.ValidateSignature(payload, signature, secret, "harbor");
// Assert
result.Should().BeTrue();
}
[Trait("Category", "Unit")]
[Fact]
public void ValidateSignature_WithInvalidSignature_ReturnsFalse()
{
// Act
var result = _service.ValidateSignature("{}", "invalid-signature", "secret123", "harbor");
// Assert
result.Should().BeFalse();
}
#region Helper Methods
private static RegistrySource CreateTestSource() => new()
{
Id = Guid.NewGuid(),
Name = "Test Harbor",
Type = RegistrySourceType.Harbor,
RegistryUrl = "https://harbor.example.com",
Status = RegistrySourceStatus.Active,
TriggerMode = RegistryTriggerMode.Webhook
};
private static RegistrySourceRun CreateTestRun(Guid sourceId) => new()
{
Id = Guid.NewGuid(),
SourceId = sourceId,
Status = RegistryRunStatus.Running,
TriggerType = "webhook",
StartedAt = DateTimeOffset.UtcNow
};
private static string CreateHarborPushPayload(string repository, string tag) => JsonSerializer.Serialize(new
{
type = "PUSH_ARTIFACT",
occur_at = DateTimeOffset.UtcNow.ToUnixTimeSeconds(),
@operator = "admin",
event_data = new
{
resources = new[]
{
new
{
resource_url = $"harbor.example.com/{repository}:{tag}",
digest = "sha256:abc123def456",
tag
}
},
repository = new
{
name = repository,
repo_full_name = repository,
@namespace = repository.Contains('/') ? repository.Split('/')[0] : repository
}
}
});
#endregion
}

View File

@@ -3,10 +3,14 @@
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>preview</LangVersion>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FluentAssertions" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" />
<PackageReference Include="Moq" />
<PackageReference Include="xunit.v3" />
<PackageReference Include="xunit.runner.visualstudio" >
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
@@ -17,4 +21,4 @@
<ProjectReference Include="../StellaOps.SbomService/StellaOps.SbomService.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.TestKit/StellaOps.TestKit.csproj" />
</ItemGroup>
</Project>
</Project>

View File

@@ -0,0 +1,271 @@
using Microsoft.AspNetCore.Mvc;
using StellaOps.SbomService.Models;
using StellaOps.SbomService.Services;
namespace StellaOps.SbomService.Controllers;
/// <summary>
/// Controller for registry source management.
/// Sprint: SPRINT_20251229_012_SBOMSVC_registry_sources
/// </summary>
[ApiController]
[Route("api/v1/registry-sources")]
public sealed class RegistrySourceController : ControllerBase
{
private readonly RegistrySourceService _service;
private readonly ILogger<RegistrySourceController> _logger;
public RegistrySourceController(RegistrySourceService service, ILogger<RegistrySourceController> logger)
{
_service = service;
_logger = logger;
}
/// <summary>
/// Lists registry sources with filtering and pagination.
/// </summary>
[HttpGet]
[ProducesResponseType(typeof(PagedRegistrySourcesResponse), StatusCodes.Status200OK)]
public async Task<IActionResult> List(
[FromQuery] RegistrySourceType? type,
[FromQuery] RegistrySourceStatus? status,
[FromQuery] RegistryTriggerMode? triggerMode,
[FromQuery] string? search,
[FromQuery] Guid? integrationId,
[FromQuery] int page = 1,
[FromQuery] int pageSize = 20,
[FromQuery] string sortBy = "name",
[FromQuery] bool sortDescending = false,
CancellationToken cancellationToken = default)
{
var request = new ListRegistrySourcesRequest(
type, status, triggerMode, search, integrationId,
page, pageSize, sortBy, sortDescending);
var result = await _service.ListAsync(request, null, cancellationToken);
return Ok(result);
}
/// <summary>
/// Gets a registry source by ID.
/// </summary>
[HttpGet("{id:guid}")]
[ProducesResponseType(typeof(RegistrySource), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> Get(Guid id, CancellationToken cancellationToken)
{
var result = await _service.GetByIdAsync(id, cancellationToken);
return result is null ? NotFound() : Ok(result);
}
/// <summary>
/// Creates a new registry source.
/// </summary>
[HttpPost]
[ProducesResponseType(typeof(RegistrySource), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<IActionResult> Create([FromBody] CreateRegistrySourceRequest request, CancellationToken cancellationToken)
{
var result = await _service.CreateAsync(request, null, null, cancellationToken);
return CreatedAtAction(nameof(Get), new { id = result.Id }, result);
}
/// <summary>
/// Updates a registry source.
/// </summary>
[HttpPut("{id:guid}")]
[ProducesResponseType(typeof(RegistrySource), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> Update(Guid id, [FromBody] UpdateRegistrySourceRequest request, CancellationToken cancellationToken)
{
var result = await _service.UpdateAsync(id, request, null, cancellationToken);
return result is null ? NotFound() : Ok(result);
}
/// <summary>
/// Deletes a registry source.
/// </summary>
[HttpDelete("{id:guid}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> Delete(Guid id, CancellationToken cancellationToken)
{
var result = await _service.DeleteAsync(id, null, cancellationToken);
return result ? NoContent() : NotFound();
}
/// <summary>
/// Tests connection to a registry source.
/// </summary>
[HttpPost("{id:guid}/test")]
[ProducesResponseType(typeof(TestRegistrySourceResponse), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> Test(Guid id, CancellationToken cancellationToken)
{
var result = await _service.TestAsync(id, null, cancellationToken);
return Ok(result);
}
/// <summary>
/// Triggers a registry source discovery and scan run.
/// </summary>
[HttpPost("{id:guid}/trigger")]
[ProducesResponseType(typeof(RegistrySourceRun), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> Trigger(Guid id, [FromBody] TriggerRegistrySourceRequest request, CancellationToken cancellationToken)
{
try
{
var result = await _service.TriggerAsync(id, request.TriggerType, request.TriggerMetadata, null, cancellationToken);
return Ok(result);
}
catch (InvalidOperationException ex)
{
return NotFound(new { message = ex.Message });
}
}
/// <summary>
/// Pauses a registry source.
/// </summary>
[HttpPost("{id:guid}/pause")]
[ProducesResponseType(typeof(RegistrySource), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> Pause(Guid id, [FromBody] PauseRegistrySourceRequest request, CancellationToken cancellationToken)
{
var result = await _service.PauseAsync(id, request.Reason, null, cancellationToken);
return result is null ? NotFound() : Ok(result);
}
/// <summary>
/// Resumes a paused registry source.
/// </summary>
[HttpPost("{id:guid}/resume")]
[ProducesResponseType(typeof(RegistrySource), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> Resume(Guid id, CancellationToken cancellationToken)
{
var result = await _service.ResumeAsync(id, null, cancellationToken);
return result is null ? NotFound() : Ok(result);
}
/// <summary>
/// Gets run history for a registry source.
/// </summary>
[HttpGet("{id:guid}/runs")]
[ProducesResponseType(typeof(IReadOnlyList<RegistrySourceRun>), StatusCodes.Status200OK)]
public async Task<IActionResult> GetRunHistory(Guid id, [FromQuery] int limit = 50, CancellationToken cancellationToken = default)
{
var result = await _service.GetRunHistoryAsync(id, limit, cancellationToken);
return Ok(result);
}
/// <summary>
/// Discovers repositories from a registry source.
/// </summary>
[HttpGet("{id:guid}/discover/repositories")]
[ProducesResponseType(typeof(DiscoveryResult), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> DiscoverRepositories(
Guid id,
[FromServices] IRegistryDiscoveryService discoveryService,
CancellationToken cancellationToken)
{
var result = await discoveryService.DiscoverRepositoriesAsync(id.ToString(), cancellationToken);
if (!result.Success && result.Error == "Source not found")
{
return NotFound(new { error = result.Error });
}
return Ok(result);
}
/// <summary>
/// Discovers tags for a specific repository in a registry source.
/// </summary>
[HttpGet("{id:guid}/discover/tags/{repository}")]
[ProducesResponseType(typeof(TagDiscoveryResult), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> DiscoverTags(
Guid id,
string repository,
[FromServices] IRegistryDiscoveryService discoveryService,
CancellationToken cancellationToken)
{
var result = await discoveryService.DiscoverTagsAsync(id.ToString(), repository, cancellationToken);
if (!result.Success && result.Error == "Source not found")
{
return NotFound(new { error = result.Error });
}
return Ok(result);
}
/// <summary>
/// Discovers all images (repositories + tags) from a registry source.
/// </summary>
[HttpGet("{id:guid}/discover/images")]
[ProducesResponseType(typeof(ImageDiscoveryResult), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> DiscoverImages(
Guid id,
[FromServices] IRegistryDiscoveryService discoveryService,
CancellationToken cancellationToken)
{
var result = await discoveryService.DiscoverImagesAsync(id.ToString(), cancellationToken);
if (!result.Success && result.Error == "Source not found")
{
return NotFound(new { error = result.Error });
}
return Ok(result);
}
/// <summary>
/// Discovers images and submits scan jobs for a registry source.
/// </summary>
[HttpPost("{id:guid}/discover-and-scan")]
[ProducesResponseType(typeof(DiscoverAndScanResult), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> DiscoverAndScan(
Guid id,
[FromServices] IRegistryDiscoveryService discoveryService,
[FromServices] IScanJobEmitterService scanEmitter,
CancellationToken cancellationToken)
{
// First discover all images
var discoveryResult = await discoveryService.DiscoverImagesAsync(id.ToString(), cancellationToken);
if (!discoveryResult.Success && discoveryResult.Error == "Source not found")
{
return NotFound(new { error = discoveryResult.Error });
}
if (!discoveryResult.Success || discoveryResult.Images.Count == 0)
{
return Ok(new DiscoverAndScanResult(
discoveryResult.Success,
discoveryResult.Error,
discoveryResult.Images.Count,
null));
}
// Submit scan jobs for discovered images
var scanResult = await scanEmitter.SubmitBatchScanAsync(
id.ToString(),
discoveryResult.Images,
cancellationToken);
return Ok(new DiscoverAndScanResult(
true,
discoveryResult.Error,
discoveryResult.Images.Count,
scanResult));
}
}
/// <summary>
/// Result of discover and scan operation.
/// </summary>
public sealed record DiscoverAndScanResult(
bool Success,
string? Error,
int ImagesDiscovered,
BatchScanResult? ScanResult);

View File

@@ -0,0 +1,222 @@
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
// SPRINT_20251229_012 REG-SRC-004: Registry webhook endpoints
using Microsoft.AspNetCore.Mvc;
using StellaOps.SbomService.Services;
namespace StellaOps.SbomService.Controllers;
/// <summary>
/// API endpoints for receiving registry webhooks.
/// Supports Harbor, DockerHub, ACR, ECR, GCR, and GHCR webhook formats.
/// </summary>
[ApiController]
[Route("api/v1/webhooks/registry")]
public class RegistryWebhookController : ControllerBase
{
private readonly IRegistryWebhookService _webhookService;
private readonly ILogger<RegistryWebhookController> _logger;
public RegistryWebhookController(
IRegistryWebhookService webhookService,
ILogger<RegistryWebhookController> logger)
{
_webhookService = webhookService;
_logger = logger;
}
/// <summary>
/// Receive a webhook notification from a registry source.
/// The provider is auto-detected from headers if not specified.
/// </summary>
/// <param name="sourceId">The registry source ID this webhook is for.</param>
/// <param name="provider">Optional provider hint (harbor, dockerhub, acr, ecr, gcr, ghcr).</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Webhook processing result.</returns>
[HttpPost("{sourceId}")]
public async Task<IActionResult> ReceiveWebhook(
string sourceId,
[FromQuery] string? provider,
CancellationToken cancellationToken)
{
// Read the raw payload
using var reader = new StreamReader(Request.Body);
var payload = await reader.ReadToEndAsync(cancellationToken);
if (string.IsNullOrWhiteSpace(payload))
{
return BadRequest(new { error = "Empty payload" });
}
// Extract signature from headers (different registries use different headers)
var signature = ExtractSignature();
// Auto-detect provider from headers if not specified
var detectedProvider = provider ?? DetectProvider();
_logger.LogInformation(
"Received webhook for source {SourceId}, provider: {Provider}",
sourceId, detectedProvider);
var result = await _webhookService.ProcessWebhookAsync(
sourceId,
detectedProvider,
payload,
signature,
cancellationToken);
if (!result.Success)
{
// Return 200 even on "failure" to prevent retries for known issues
// Only return error codes for actual processing failures
if (result.Message == "Source not found")
{
return NotFound(new { error = result.Message });
}
if (result.Message == "Invalid signature")
{
return Unauthorized(new { error = result.Message });
}
}
return Ok(new
{
success = result.Success,
message = result.Message,
runId = result.TriggeredRunId
});
}
/// <summary>
/// Generic webhook endpoint that accepts provider as path parameter.
/// </summary>
[HttpPost("{sourceId}/{provider}")]
public async Task<IActionResult> ReceiveWebhookWithProvider(
string sourceId,
string provider,
CancellationToken cancellationToken)
{
using var reader = new StreamReader(Request.Body);
var payload = await reader.ReadToEndAsync(cancellationToken);
if (string.IsNullOrWhiteSpace(payload))
{
return BadRequest(new { error = "Empty payload" });
}
var signature = ExtractSignature();
_logger.LogInformation(
"Received webhook for source {SourceId}, provider: {Provider}",
sourceId, provider);
var result = await _webhookService.ProcessWebhookAsync(
sourceId,
provider,
payload,
signature,
cancellationToken);
if (!result.Success)
{
if (result.Message == "Source not found")
{
return NotFound(new { error = result.Message });
}
if (result.Message == "Invalid signature")
{
return Unauthorized(new { error = result.Message });
}
}
return Ok(new
{
success = result.Success,
message = result.Message,
runId = result.TriggeredRunId
});
}
/// <summary>
/// Health check endpoint for webhook receiver.
/// </summary>
[HttpGet("healthz")]
public IActionResult HealthCheck()
{
return Ok(new { status = "ok", service = "registry-webhook" });
}
private string? ExtractSignature()
{
// Check common signature headers in order of preference
var signatureHeaders = new[]
{
"X-Hub-Signature-256", // GitHub/GHCR
"X-Hub-Signature", // Legacy GitHub
"X-Docker-Hub-Signature", // DockerHub
"X-Harbor-Signature", // Harbor
"X-Signature-256", // Generic
"X-Webhook-Signature", // Generic
"Authorization" // Bearer token (for some providers)
};
foreach (var header in signatureHeaders)
{
if (Request.Headers.TryGetValue(header, out var value) && !string.IsNullOrEmpty(value))
{
return value.ToString();
}
}
return null;
}
private string DetectProvider()
{
// Detect provider from request headers and user agent
var userAgent = Request.Headers.UserAgent.ToString().ToLowerInvariant();
var contentType = Request.ContentType?.ToLowerInvariant() ?? "";
// Check specific headers that identify the source
if (Request.Headers.ContainsKey("X-GitHub-Event"))
{
return "ghcr";
}
if (Request.Headers.ContainsKey("X-Hub-Signature-256") ||
Request.Headers.ContainsKey("X-Hub-Signature"))
{
// Could be GitHub or DockerHub
if (Request.Headers.ContainsKey("X-GitHub-Delivery"))
{
return "ghcr";
}
return "dockerhub";
}
if (Request.Headers.ContainsKey("X-Harbor-Signature") ||
userAgent.Contains("harbor"))
{
return "harbor";
}
// Check for cloud provider patterns
if (userAgent.Contains("azure") || userAgent.Contains("acr"))
{
return "acr";
}
if (userAgent.Contains("amazon") || userAgent.Contains("aws") || userAgent.Contains("ecr"))
{
return "ecr";
}
if (userAgent.Contains("google") || userAgent.Contains("gcr"))
{
return "gcr";
}
// Default to generic handler
return "generic";
}
}

View File

@@ -0,0 +1,242 @@
namespace StellaOps.SbomService.Models;
/// <summary>
/// Type of registry source.
/// </summary>
public enum RegistrySourceType
{
/// <summary>Docker Hub registry.</summary>
DockerHub = 1,
/// <summary>Harbor registry.</summary>
Harbor = 2,
/// <summary>AWS ECR registry.</summary>
Ecr = 3,
/// <summary>Google Container Registry / Artifact Registry.</summary>
Gcr = 4,
/// <summary>Azure Container Registry.</summary>
Acr = 5,
/// <summary>GitHub Container Registry.</summary>
Ghcr = 6,
/// <summary>GitLab Container Registry.</summary>
GitLabRegistry = 7,
/// <summary>Quay.io registry.</summary>
Quay = 8,
/// <summary>JFrog Artifactory.</summary>
Artifactory = 9,
/// <summary>Sonatype Nexus.</summary>
Nexus = 10,
/// <summary>Generic OCI-compliant registry.</summary>
OciGeneric = 99
}
/// <summary>
/// Trigger mode for registry source scanning.
/// </summary>
public enum RegistryTriggerMode
{
/// <summary>No automatic triggers; manual only.</summary>
Manual = 0,
/// <summary>Cron-based scheduled scanning.</summary>
Schedule = 1,
/// <summary>Webhook-triggered scanning.</summary>
Webhook = 2,
/// <summary>Both scheduled and webhook triggers.</summary>
Both = 3
}
/// <summary>
/// Status of a registry source.
/// </summary>
public enum RegistrySourceStatus
{
/// <summary>Just created, not verified.</summary>
Pending = 0,
/// <summary>Verified and active.</summary>
Active = 1,
/// <summary>Paused by operator.</summary>
Paused = 2,
/// <summary>Verification failed.</summary>
Failed = 3,
/// <summary>Marked for deletion.</summary>
Archived = 4
}
/// <summary>
/// Status of a registry source run.
/// </summary>
public enum RegistryRunStatus
{
/// <summary>Run is queued.</summary>
Queued = 0,
/// <summary>Run is in progress.</summary>
Running = 1,
/// <summary>Run completed successfully.</summary>
Completed = 2,
/// <summary>Run failed.</summary>
Failed = 3,
/// <summary>Run was cancelled.</summary>
Cancelled = 4
}
/// <summary>
/// Registry source entity representing a container registry to scan.
/// </summary>
public sealed class RegistrySource
{
public required Guid Id { get; init; }
/// <summary>Human-readable name for the source.</summary>
public required string Name { get; set; }
/// <summary>Optional description.</summary>
public string? Description { get; set; }
/// <summary>Type of registry.</summary>
public required RegistrySourceType Type { get; init; }
/// <summary>Registry base URL (e.g., https://harbor.example.com).</summary>
public required string RegistryUrl { get; set; }
/// <summary>AuthRef URI for credentials.</summary>
public string? AuthRefUri { get; set; }
/// <summary>Credential reference URI for authentication.</summary>
public string? CredentialRef { get; set; }
/// <summary>Linked integration ID from Integration Catalog.</summary>
public Guid? IntegrationId { get; set; }
/// <summary>Repository filter patterns (glob, e.g., "library/*", "myorg/**").</summary>
public List<string> RepoFilters { get; set; } = [];
/// <summary>Repository allowlist patterns (glob, e.g., "library/*"). If non-empty, only matching repos are processed.</summary>
public List<string> RepositoryAllowlist { get; set; } = [];
/// <summary>Repository denylist patterns. Matching repos are skipped even if they match allowlist.</summary>
public List<string> RepositoryDenylist { get; set; } = [];
/// <summary>Tag filter patterns (glob, e.g., "v*", "latest").</summary>
public List<string> TagFilters { get; set; } = [];
/// <summary>Tag allowlist patterns. If non-empty, only matching tags are processed.</summary>
public List<string> TagAllowlist { get; set; } = [];
/// <summary>Tag denylist patterns. Matching tags are skipped even if they match allowlist.</summary>
public List<string> TagDenylist { get; set; } = [];
/// <summary>Trigger mode for scanning.</summary>
public RegistryTriggerMode TriggerMode { get; set; } = RegistryTriggerMode.Manual;
/// <summary>Cron expression for scheduled scans (when TriggerMode includes Schedule).</summary>
public string? ScheduleCron { get; set; }
/// <summary>Webhook secret for signature verification.</summary>
public string? WebhookSecretRefUri { get; set; }
/// <summary>Current status.</summary>
public RegistrySourceStatus Status { get; set; } = RegistrySourceStatus.Pending;
/// <summary>Last successful run timestamp.</summary>
public DateTimeOffset? LastRunAt { get; set; }
/// <summary>Last successful run status.</summary>
public RegistryRunStatus? LastRunStatus { get; set; }
/// <summary>Number of images discovered in last run.</summary>
public int LastDiscoveredCount { get; set; }
/// <summary>Number of images scanned in last run.</summary>
public int LastScannedCount { get; set; }
/// <summary>Creation timestamp.</summary>
public DateTimeOffset CreatedAt { get; init; } = DateTimeOffset.UtcNow;
/// <summary>Last update timestamp.</summary>
public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow;
/// <summary>Creator user/system.</summary>
public string? CreatedBy { get; init; }
/// <summary>Last updater user/system.</summary>
public string? UpdatedBy { get; set; }
/// <summary>Tenant isolation ID.</summary>
public string? TenantId { get; init; }
/// <summary>Tags for filtering.</summary>
public List<string> Tags { get; set; } = [];
/// <summary>Soft-delete marker.</summary>
public bool IsDeleted { get; set; }
}
/// <summary>
/// Registry source run history record.
/// </summary>
public sealed class RegistrySourceRun
{
public required Guid Id { get; init; }
/// <summary>Parent source ID.</summary>
public required Guid SourceId { get; init; }
/// <summary>Run status.</summary>
public RegistryRunStatus Status { get; set; } = RegistryRunStatus.Queued;
/// <summary>Trigger type (manual, schedule, webhook).</summary>
public required string TriggerType { get; init; }
/// <summary>Trigger metadata (webhook payload ID, cron tick, etc.).</summary>
public string? TriggerMetadata { get; set; }
/// <summary>Number of repositories discovered.</summary>
public int ReposDiscovered { get; set; }
/// <summary>Number of images discovered.</summary>
public int ImagesDiscovered { get; set; }
/// <summary>Number of images scanned.</summary>
public int ImagesScanned { get; set; }
/// <summary>Number of scan jobs submitted.</summary>
public int JobsSubmitted { get; set; }
/// <summary>Number of scan jobs completed.</summary>
public int JobsCompleted { get; set; }
/// <summary>Number of scan jobs failed.</summary>
public int JobsFailed { get; set; }
/// <summary>Error message if failed.</summary>
public string? ErrorMessage { get; set; }
/// <summary>Run start timestamp.</summary>
public DateTimeOffset StartedAt { get; init; } = DateTimeOffset.UtcNow;
/// <summary>Run completion timestamp.</summary>
public DateTimeOffset? CompletedAt { get; set; }
/// <summary>Duration of the run.</summary>
public TimeSpan? Duration => CompletedAt.HasValue ? CompletedAt.Value - StartedAt : null;
}

View File

@@ -104,6 +104,16 @@ builder.Services.AddSingleton<IReplayVerificationService, ReplayVerificationServ
builder.Services.Configure<CompareCacheOptions>(builder.Configuration.GetSection("SbomService:CompareCache"));
builder.Services.AddSingleton<ILineageCompareCache, InMemoryLineageCompareCache>();
// REG-SRC: Registry source management (SPRINT_20251229_012)
builder.Services.AddSingleton<IRegistrySourceRepository, InMemoryRegistrySourceRepository>();
builder.Services.AddSingleton<IRegistrySourceRunRepository, InMemoryRegistrySourceRunRepository>();
builder.Services.AddSingleton<IRegistrySourceService, RegistrySourceService>();
builder.Services.AddSingleton<IRegistryWebhookService, RegistryWebhookService>();
builder.Services.AddHttpClient("RegistryDiscovery");
builder.Services.AddHttpClient("Scanner");
builder.Services.AddSingleton<IRegistryDiscoveryService, RegistryDiscoveryService>();
builder.Services.AddSingleton<IScanJobEmitterService, ScanJobEmitterService>();
builder.Services.AddSingleton<IProjectionRepository>(sp =>
{
var config = sp.GetRequiredService<IConfiguration>();

View File

@@ -0,0 +1,46 @@
using StellaOps.SbomService.Models;
namespace StellaOps.SbomService.Repositories;
/// <summary>
/// Repository interface for registry source persistence.
/// </summary>
public interface IRegistrySourceRepository
{
Task<RegistrySource?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
Task<IReadOnlyList<RegistrySource>> GetAllAsync(RegistrySourceQuery query, CancellationToken cancellationToken = default);
Task<int> CountAsync(RegistrySourceQuery query, CancellationToken cancellationToken = default);
Task<RegistrySource> CreateAsync(RegistrySource source, CancellationToken cancellationToken = default);
Task<RegistrySource> UpdateAsync(RegistrySource source, CancellationToken cancellationToken = default);
Task DeleteAsync(Guid id, CancellationToken cancellationToken = default);
Task<IReadOnlyList<RegistrySource>> GetByIntegrationIdAsync(Guid integrationId, CancellationToken cancellationToken = default);
Task<IReadOnlyList<RegistrySource>> GetActiveByTriggerModeAsync(RegistryTriggerMode triggerMode, CancellationToken cancellationToken = default);
}
/// <summary>
/// Repository interface for registry source run history.
/// </summary>
public interface IRegistrySourceRunRepository
{
Task<RegistrySourceRun?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
Task<IReadOnlyList<RegistrySourceRun>> GetBySourceIdAsync(Guid sourceId, int limit = 50, CancellationToken cancellationToken = default);
Task<RegistrySourceRun> CreateAsync(RegistrySourceRun run, CancellationToken cancellationToken = default);
Task<RegistrySourceRun> UpdateAsync(RegistrySourceRun run, CancellationToken cancellationToken = default);
Task<RegistrySourceRun?> GetLatestBySourceIdAsync(Guid sourceId, CancellationToken cancellationToken = default);
}
/// <summary>
/// Query parameters for registry sources.
/// </summary>
public sealed record RegistrySourceQuery(
RegistrySourceType? Type = null,
RegistrySourceStatus? Status = null,
RegistryTriggerMode? TriggerMode = null,
string? Search = null,
Guid? IntegrationId = null,
string? TenantId = null,
bool IncludeDeleted = false,
int Skip = 0,
int Take = 20,
string SortBy = "name",
bool SortDescending = false);

View File

@@ -0,0 +1,279 @@
using StellaOps.SbomService.Models;
namespace StellaOps.SbomService.Repositories;
/// <summary>
/// In-memory implementation of registry source repository for development.
/// Replace with PostgreSQL implementation for production.
/// </summary>
public sealed class InMemoryRegistrySourceRepository : IRegistrySourceRepository
{
private readonly Dictionary<Guid, RegistrySource> _sources = new();
private readonly object _lock = new();
public Task<RegistrySource?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default)
{
lock (_lock)
{
return Task.FromResult(
_sources.TryGetValue(id, out var source) && !source.IsDeleted
? CloneSource(source)
: null);
}
}
public Task<IReadOnlyList<RegistrySource>> GetAllAsync(RegistrySourceQuery query, CancellationToken cancellationToken = default)
{
lock (_lock)
{
var results = _sources.Values.AsEnumerable();
if (!query.IncludeDeleted)
{
results = results.Where(s => !s.IsDeleted);
}
if (query.TenantId is not null)
{
results = results.Where(s => s.TenantId == query.TenantId);
}
if (query.Type.HasValue)
{
results = results.Where(s => s.Type == query.Type.Value);
}
if (query.Status.HasValue)
{
results = results.Where(s => s.Status == query.Status.Value);
}
if (query.TriggerMode.HasValue)
{
results = results.Where(s => s.TriggerMode == query.TriggerMode.Value);
}
if (query.IntegrationId.HasValue)
{
results = results.Where(s => s.IntegrationId == query.IntegrationId.Value);
}
if (!string.IsNullOrWhiteSpace(query.Search))
{
var searchLower = query.Search.ToLowerInvariant();
results = results.Where(s =>
s.Name.ToLowerInvariant().Contains(searchLower) ||
(s.Description?.ToLowerInvariant().Contains(searchLower) ?? false) ||
s.RegistryUrl.ToLowerInvariant().Contains(searchLower));
}
results = query.SortBy.ToLowerInvariant() switch
{
"name" => query.SortDescending ? results.OrderByDescending(s => s.Name) : results.OrderBy(s => s.Name),
"createdat" => query.SortDescending ? results.OrderByDescending(s => s.CreatedAt) : results.OrderBy(s => s.CreatedAt),
"updatedat" => query.SortDescending ? results.OrderByDescending(s => s.UpdatedAt) : results.OrderBy(s => s.UpdatedAt),
"lastrunat" => query.SortDescending ? results.OrderByDescending(s => s.LastRunAt) : results.OrderBy(s => s.LastRunAt),
_ => results.OrderBy(s => s.Name)
};
var list = results.Skip(query.Skip).Take(query.Take).Select(CloneSource).ToList();
return Task.FromResult<IReadOnlyList<RegistrySource>>(list);
}
}
public Task<int> CountAsync(RegistrySourceQuery query, CancellationToken cancellationToken = default)
{
lock (_lock)
{
var results = _sources.Values.AsEnumerable();
if (!query.IncludeDeleted) results = results.Where(s => !s.IsDeleted);
if (query.TenantId is not null) results = results.Where(s => s.TenantId == query.TenantId);
if (query.Type.HasValue) results = results.Where(s => s.Type == query.Type.Value);
if (query.Status.HasValue) results = results.Where(s => s.Status == query.Status.Value);
if (query.TriggerMode.HasValue) results = results.Where(s => s.TriggerMode == query.TriggerMode.Value);
if (query.IntegrationId.HasValue) results = results.Where(s => s.IntegrationId == query.IntegrationId.Value);
return Task.FromResult(results.Count());
}
}
public Task<RegistrySource> CreateAsync(RegistrySource source, CancellationToken cancellationToken = default)
{
lock (_lock)
{
_sources[source.Id] = source;
return Task.FromResult(CloneSource(source));
}
}
public Task<RegistrySource> UpdateAsync(RegistrySource source, CancellationToken cancellationToken = default)
{
lock (_lock)
{
if (!_sources.ContainsKey(source.Id))
{
throw new InvalidOperationException($"Registry source {source.Id} not found");
}
_sources[source.Id] = source;
return Task.FromResult(CloneSource(source));
}
}
public Task DeleteAsync(Guid id, CancellationToken cancellationToken = default)
{
lock (_lock)
{
if (_sources.TryGetValue(id, out var source))
{
source.IsDeleted = true;
source.Status = RegistrySourceStatus.Archived;
source.UpdatedAt = DateTimeOffset.UtcNow;
}
return Task.CompletedTask;
}
}
public Task<IReadOnlyList<RegistrySource>> GetByIntegrationIdAsync(Guid integrationId, CancellationToken cancellationToken = default)
{
lock (_lock)
{
var list = _sources.Values
.Where(s => s.IntegrationId == integrationId && !s.IsDeleted)
.Select(CloneSource)
.ToList();
return Task.FromResult<IReadOnlyList<RegistrySource>>(list);
}
}
public Task<IReadOnlyList<RegistrySource>> GetActiveByTriggerModeAsync(RegistryTriggerMode triggerMode, CancellationToken cancellationToken = default)
{
lock (_lock)
{
var list = _sources.Values
.Where(s => !s.IsDeleted &&
s.Status == RegistrySourceStatus.Active &&
(s.TriggerMode == triggerMode || s.TriggerMode == RegistryTriggerMode.Both))
.Select(CloneSource)
.ToList();
return Task.FromResult<IReadOnlyList<RegistrySource>>(list);
}
}
private static RegistrySource CloneSource(RegistrySource source)
{
return new RegistrySource
{
Id = source.Id,
Name = source.Name,
Description = source.Description,
Type = source.Type,
RegistryUrl = source.RegistryUrl,
AuthRefUri = source.AuthRefUri,
IntegrationId = source.IntegrationId,
RepoFilters = new List<string>(source.RepoFilters),
TagFilters = new List<string>(source.TagFilters),
TriggerMode = source.TriggerMode,
ScheduleCron = source.ScheduleCron,
WebhookSecretRefUri = source.WebhookSecretRefUri,
Status = source.Status,
LastRunAt = source.LastRunAt,
LastRunStatus = source.LastRunStatus,
LastDiscoveredCount = source.LastDiscoveredCount,
LastScannedCount = source.LastScannedCount,
CreatedAt = source.CreatedAt,
UpdatedAt = source.UpdatedAt,
CreatedBy = source.CreatedBy,
UpdatedBy = source.UpdatedBy,
TenantId = source.TenantId,
Tags = new List<string>(source.Tags),
IsDeleted = source.IsDeleted
};
}
}
/// <summary>
/// In-memory implementation of registry source run repository for development.
/// </summary>
public sealed class InMemoryRegistrySourceRunRepository : IRegistrySourceRunRepository
{
private readonly Dictionary<Guid, RegistrySourceRun> _runs = new();
private readonly object _lock = new();
public Task<RegistrySourceRun?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default)
{
lock (_lock)
{
return Task.FromResult(_runs.TryGetValue(id, out var run) ? CloneRun(run) : null);
}
}
public Task<IReadOnlyList<RegistrySourceRun>> GetBySourceIdAsync(Guid sourceId, int limit = 50, CancellationToken cancellationToken = default)
{
lock (_lock)
{
var list = _runs.Values
.Where(r => r.SourceId == sourceId)
.OrderByDescending(r => r.StartedAt)
.Take(limit)
.Select(CloneRun)
.ToList();
return Task.FromResult<IReadOnlyList<RegistrySourceRun>>(list);
}
}
public Task<RegistrySourceRun> CreateAsync(RegistrySourceRun run, CancellationToken cancellationToken = default)
{
lock (_lock)
{
_runs[run.Id] = run;
return Task.FromResult(CloneRun(run));
}
}
public Task<RegistrySourceRun> UpdateAsync(RegistrySourceRun run, CancellationToken cancellationToken = default)
{
lock (_lock)
{
if (!_runs.ContainsKey(run.Id))
{
throw new InvalidOperationException($"Registry source run {run.Id} not found");
}
_runs[run.Id] = run;
return Task.FromResult(CloneRun(run));
}
}
public Task<RegistrySourceRun?> GetLatestBySourceIdAsync(Guid sourceId, CancellationToken cancellationToken = default)
{
lock (_lock)
{
var latest = _runs.Values
.Where(r => r.SourceId == sourceId)
.OrderByDescending(r => r.StartedAt)
.FirstOrDefault();
return Task.FromResult(latest is null ? null : CloneRun(latest));
}
}
private static RegistrySourceRun CloneRun(RegistrySourceRun run)
{
return new RegistrySourceRun
{
Id = run.Id,
SourceId = run.SourceId,
Status = run.Status,
TriggerType = run.TriggerType,
TriggerMetadata = run.TriggerMetadata,
ReposDiscovered = run.ReposDiscovered,
ImagesDiscovered = run.ImagesDiscovered,
ImagesScanned = run.ImagesScanned,
JobsSubmitted = run.JobsSubmitted,
JobsCompleted = run.JobsCompleted,
JobsFailed = run.JobsFailed,
ErrorMessage = run.ErrorMessage,
StartedAt = run.StartedAt,
CompletedAt = run.CompletedAt
};
}
}

View File

@@ -0,0 +1,450 @@
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
// SPRINT_20251229_012 REG-SRC-005: Registry discovery service
using System.Net.Http.Headers;
using System.Text.Json;
using System.Text.RegularExpressions;
using StellaOps.SbomService.Models;
using StellaOps.SbomService.Repositories;
namespace StellaOps.SbomService.Services;
/// <summary>
/// Service for discovering repositories and tags from container registries.
/// Supports OCI Distribution Spec compliant registries.
/// </summary>
public interface IRegistryDiscoveryService
{
/// <summary>
/// Discover repositories in a registry source.
/// </summary>
Task<DiscoveryResult> DiscoverRepositoriesAsync(
string sourceId,
CancellationToken cancellationToken = default);
/// <summary>
/// Discover tags for a specific repository.
/// </summary>
Task<TagDiscoveryResult> DiscoverTagsAsync(
string sourceId,
string repository,
CancellationToken cancellationToken = default);
/// <summary>
/// Discover all images (repositories + tags) matching the source's filters.
/// </summary>
Task<ImageDiscoveryResult> DiscoverImagesAsync(
string sourceId,
CancellationToken cancellationToken = default);
}
public class RegistryDiscoveryService : IRegistryDiscoveryService
{
private readonly IRegistrySourceRepository _sourceRepo;
private readonly IHttpClientFactory _httpClientFactory;
private readonly ILogger<RegistryDiscoveryService> _logger;
public RegistryDiscoveryService(
IRegistrySourceRepository sourceRepo,
IHttpClientFactory httpClientFactory,
ILogger<RegistryDiscoveryService> logger)
{
_sourceRepo = sourceRepo;
_httpClientFactory = httpClientFactory;
_logger = logger;
}
public async Task<DiscoveryResult> DiscoverRepositoriesAsync(
string sourceId,
CancellationToken cancellationToken = default)
{
if (!Guid.TryParse(sourceId, out var sourceGuid))
{
return new DiscoveryResult(false, "Invalid source ID format", []);
}
var source = await _sourceRepo.GetByIdAsync(sourceGuid, cancellationToken);
if (source is null)
{
return new DiscoveryResult(false, "Source not found", []);
}
try
{
var client = CreateHttpClient(source);
var repositories = new List<string>();
var nextLink = $"{NormalizeRegistryUrl(source.RegistryUrl)}/v2/_catalog";
// Paginate through repository list
while (!string.IsNullOrEmpty(nextLink))
{
var response = await client.GetAsync(nextLink, cancellationToken);
if (!response.IsSuccessStatusCode)
{
var error = await response.Content.ReadAsStringAsync(cancellationToken);
_logger.LogWarning("Failed to list repositories for {SourceId}: {Status} - {Error}",
sourceId, response.StatusCode, error);
return new DiscoveryResult(false, $"Registry returned {response.StatusCode}", repositories);
}
var content = await response.Content.ReadAsStringAsync(cancellationToken);
var catalog = JsonDocument.Parse(content);
if (catalog.RootElement.TryGetProperty("repositories", out var repos))
{
foreach (var repo in repos.EnumerateArray())
{
var repoName = repo.GetString();
if (!string.IsNullOrEmpty(repoName) && MatchesRepositoryFilters(repoName, source))
{
repositories.Add(repoName);
}
}
}
// Check for pagination link
nextLink = ExtractNextLink(response.Headers);
}
_logger.LogInformation("Discovered {Count} repositories for source {SourceId}",
repositories.Count, sourceId);
return new DiscoveryResult(true, null, repositories);
}
catch (HttpRequestException ex)
{
_logger.LogError(ex, "Network error discovering repositories for source {SourceId}", sourceId);
return new DiscoveryResult(false, $"Network error: {ex.Message}", []);
}
catch (Exception ex)
{
_logger.LogError(ex, "Unexpected error discovering repositories for source {SourceId}", sourceId);
return new DiscoveryResult(false, $"Unexpected error: {ex.Message}", []);
}
}
public async Task<TagDiscoveryResult> DiscoverTagsAsync(
string sourceId,
string repository,
CancellationToken cancellationToken = default)
{
if (!Guid.TryParse(sourceId, out var sourceGuid))
{
return new TagDiscoveryResult(false, "Invalid source ID format", repository, []);
}
var source = await _sourceRepo.GetByIdAsync(sourceGuid, cancellationToken);
if (source is null)
{
return new TagDiscoveryResult(false, "Source not found", repository, []);
}
try
{
var client = CreateHttpClient(source);
var tags = new List<TagInfo>();
var nextLink = $"{NormalizeRegistryUrl(source.RegistryUrl)}/v2/{repository}/tags/list";
while (!string.IsNullOrEmpty(nextLink))
{
var response = await client.GetAsync(nextLink, cancellationToken);
if (!response.IsSuccessStatusCode)
{
var error = await response.Content.ReadAsStringAsync(cancellationToken);
_logger.LogWarning("Failed to list tags for {Repository} in source {SourceId}: {Status} - {Error}",
repository, sourceId, response.StatusCode, error);
return new TagDiscoveryResult(false, $"Registry returned {response.StatusCode}", repository, tags);
}
var content = await response.Content.ReadAsStringAsync(cancellationToken);
var tagList = JsonDocument.Parse(content);
if (tagList.RootElement.TryGetProperty("tags", out var tagsElement) && tagsElement.ValueKind == JsonValueKind.Array)
{
foreach (var tag in tagsElement.EnumerateArray())
{
var tagName = tag.GetString();
if (!string.IsNullOrEmpty(tagName) && MatchesTagFilters(tagName, source))
{
// Get manifest digest for each tag
var digest = await GetManifestDigestAsync(client, source, repository, tagName, cancellationToken);
tags.Add(new TagInfo(tagName, digest));
}
}
}
nextLink = ExtractNextLink(response.Headers);
}
_logger.LogInformation("Discovered {Count} tags for {Repository} in source {SourceId}",
tags.Count, repository, sourceId);
return new TagDiscoveryResult(true, null, repository, tags);
}
catch (HttpRequestException ex)
{
_logger.LogError(ex, "Network error discovering tags for {Repository} in source {SourceId}", repository, sourceId);
return new TagDiscoveryResult(false, $"Network error: {ex.Message}", repository, []);
}
catch (Exception ex)
{
_logger.LogError(ex, "Unexpected error discovering tags for {Repository} in source {SourceId}", repository, sourceId);
return new TagDiscoveryResult(false, $"Unexpected error: {ex.Message}", repository, []);
}
}
public async Task<ImageDiscoveryResult> DiscoverImagesAsync(
string sourceId,
CancellationToken cancellationToken = default)
{
var repoResult = await DiscoverRepositoriesAsync(sourceId, cancellationToken);
if (!repoResult.Success)
{
return new ImageDiscoveryResult(false, repoResult.Error, []);
}
var images = new List<DiscoveredImage>();
var errors = new List<string>();
foreach (var repo in repoResult.Repositories)
{
var tagResult = await DiscoverTagsAsync(sourceId, repo, cancellationToken);
if (!tagResult.Success)
{
errors.Add($"{repo}: {tagResult.Error}");
continue;
}
foreach (var tag in tagResult.Tags)
{
images.Add(new DiscoveredImage(repo, tag.Name, tag.Digest));
}
}
var message = errors.Count > 0
? $"Completed with {errors.Count} errors: {string.Join("; ", errors.Take(3))}"
: null;
_logger.LogInformation("Discovered {Count} images across {RepoCount} repositories for source {SourceId}",
images.Count, repoResult.Repositories.Count, sourceId);
return new ImageDiscoveryResult(errors.Count == 0 || images.Count > 0, message, images);
}
private HttpClient CreateHttpClient(RegistrySource source)
{
var client = _httpClientFactory.CreateClient("RegistryDiscovery");
client.Timeout = TimeSpan.FromSeconds(30);
// Set default headers
client.DefaultRequestHeaders.Accept.Add(
new MediaTypeWithQualityHeaderValue("application/vnd.docker.distribution.manifest.v2+json"));
client.DefaultRequestHeaders.Accept.Add(
new MediaTypeWithQualityHeaderValue("application/vnd.oci.image.manifest.v1+json"));
// TODO: In production, resolve AuthRef to get actual credentials
// For now, handle basic auth if credential ref looks like "basic:user:pass"
if (!string.IsNullOrEmpty(source.CredentialRef) &&
!source.CredentialRef.StartsWith("authref://", StringComparison.OrdinalIgnoreCase))
{
if (source.CredentialRef.StartsWith("basic:", StringComparison.OrdinalIgnoreCase))
{
var parts = source.CredentialRef[6..].Split(':', 2);
if (parts.Length == 2)
{
var credentials = Convert.ToBase64String(
System.Text.Encoding.UTF8.GetBytes($"{parts[0]}:{parts[1]}"));
client.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Basic", credentials);
}
}
else if (source.CredentialRef.StartsWith("bearer:", StringComparison.OrdinalIgnoreCase))
{
client.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", source.CredentialRef[7..]);
}
}
return client;
}
private async Task<string?> GetManifestDigestAsync(
HttpClient client,
RegistrySource source,
string repository,
string tag,
CancellationToken cancellationToken)
{
try
{
var url = $"{NormalizeRegistryUrl(source.RegistryUrl)}/v2/{repository}/manifests/{tag}";
var request = new HttpRequestMessage(HttpMethod.Head, url);
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/vnd.docker.distribution.manifest.v2+json"));
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/vnd.oci.image.manifest.v1+json"));
var response = await client.SendAsync(request, cancellationToken);
if (response.IsSuccessStatusCode &&
response.Headers.TryGetValues("Docker-Content-Digest", out var digests))
{
return digests.FirstOrDefault();
}
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Failed to get manifest digest for {Repository}:{Tag}", repository, tag);
}
return null;
}
private static string NormalizeRegistryUrl(string url)
{
url = url.TrimEnd('/');
if (!url.StartsWith("http://", StringComparison.OrdinalIgnoreCase) &&
!url.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
{
url = "https://" + url;
}
return url;
}
private static string? ExtractNextLink(HttpResponseHeaders headers)
{
if (!headers.TryGetValues("Link", out var linkHeaders))
{
return null;
}
foreach (var link in linkHeaders)
{
// Parse Link header format: </v2/_catalog?last=repo&n=100>; rel="next"
var match = Regex.Match(link, @"<([^>]+)>;\s*rel=""?next""?");
if (match.Success)
{
return match.Groups[1].Value;
}
}
return null;
}
private static bool MatchesRepositoryFilters(string repository, RegistrySource source)
{
// If no filters, match all
if (source.RepositoryAllowlist.Count == 0 && source.RepositoryDenylist.Count == 0)
{
return true;
}
// Check denylist first
if (source.RepositoryDenylist.Count > 0 && MatchesPatterns(repository, source.RepositoryDenylist))
{
return false;
}
// If allowlist exists, must match
if (source.RepositoryAllowlist.Count > 0 && !MatchesPatterns(repository, source.RepositoryAllowlist))
{
return false;
}
return true;
}
private static bool MatchesTagFilters(string tag, RegistrySource source)
{
// If no filters, match all
if (source.TagAllowlist.Count == 0 && source.TagDenylist.Count == 0)
{
return true;
}
// Check denylist first
if (source.TagDenylist.Count > 0 && MatchesPatterns(tag, source.TagDenylist))
{
return false;
}
// If allowlist exists, must match
if (source.TagAllowlist.Count > 0 && !MatchesPatterns(tag, source.TagAllowlist))
{
return false;
}
return true;
}
private static bool MatchesPatterns(string value, IReadOnlyList<string> patterns)
{
foreach (var pattern in patterns)
{
if (MatchesGlobPattern(value, pattern))
{
return true;
}
}
return false;
}
private static bool MatchesGlobPattern(string value, string pattern)
{
if (pattern == "*")
{
return true;
}
if (!pattern.Contains('*'))
{
return value.Equals(pattern, StringComparison.OrdinalIgnoreCase);
}
var regexPattern = "^" + Regex.Escape(pattern).Replace("\\*", ".*") + "$";
return Regex.IsMatch(value, regexPattern, RegexOptions.IgnoreCase);
}
}
/// <summary>
/// Result of repository discovery.
/// </summary>
public sealed record DiscoveryResult(
bool Success,
string? Error,
IReadOnlyList<string> Repositories);
/// <summary>
/// Result of tag discovery for a repository.
/// </summary>
public sealed record TagDiscoveryResult(
bool Success,
string? Error,
string Repository,
IReadOnlyList<TagInfo> Tags);
/// <summary>
/// Information about a discovered tag.
/// </summary>
public sealed record TagInfo(
string Name,
string? Digest);
/// <summary>
/// Result of full image discovery.
/// </summary>
public sealed record ImageDiscoveryResult(
bool Success,
string? Error,
IReadOnlyList<DiscoveredImage> Images);
/// <summary>
/// A discovered container image.
/// </summary>
public sealed record DiscoveredImage(
string Repository,
string Tag,
string? Digest)
{
public string FullReference => $"{Repository}:{Tag}";
public string? DigestReference => Digest is not null ? $"{Repository}@{Digest}" : null;
}

View File

@@ -0,0 +1,334 @@
using Microsoft.Extensions.Logging;
using StellaOps.SbomService.Models;
using StellaOps.SbomService.Repositories;
namespace StellaOps.SbomService.Services;
/// <summary>
/// Interface for registry source management service.
/// </summary>
public interface IRegistrySourceService
{
Task<RegistrySource> CreateAsync(CreateRegistrySourceRequest request, string? userId, string? tenantId, CancellationToken cancellationToken = default);
Task<RegistrySource?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
Task<PagedRegistrySourcesResponse> ListAsync(ListRegistrySourcesRequest request, string? tenantId, CancellationToken cancellationToken = default);
Task<RegistrySource?> UpdateAsync(Guid id, UpdateRegistrySourceRequest request, string? userId, CancellationToken cancellationToken = default);
Task<bool> DeleteAsync(Guid id, string? userId, CancellationToken cancellationToken = default);
Task<TestRegistrySourceResponse> TestAsync(Guid id, string? userId, CancellationToken cancellationToken = default);
Task<RegistrySourceRun> TriggerAsync(Guid id, string triggerType, string? triggerMetadata, string? userId, CancellationToken cancellationToken = default);
Task<RegistrySource?> PauseAsync(Guid id, string? reason, string? userId, CancellationToken cancellationToken = default);
Task<RegistrySource?> ResumeAsync(Guid id, string? userId, CancellationToken cancellationToken = default);
Task<IReadOnlyList<RegistrySourceRun>> GetRunHistoryAsync(Guid sourceId, int limit = 50, CancellationToken cancellationToken = default);
}
/// <summary>
/// Service for registry source management.
/// Sprint: SPRINT_20251229_012_SBOMSVC_registry_sources
/// </summary>
public sealed class RegistrySourceService : IRegistrySourceService
{
private readonly IRegistrySourceRepository _sourceRepository;
private readonly IRegistrySourceRunRepository _runRepository;
private readonly ILogger<RegistrySourceService> _logger;
public RegistrySourceService(
IRegistrySourceRepository sourceRepository,
IRegistrySourceRunRepository runRepository,
ILogger<RegistrySourceService> logger)
{
_sourceRepository = sourceRepository;
_runRepository = runRepository;
_logger = logger;
}
/// <summary>
/// Creates a new registry source.
/// </summary>
public async Task<RegistrySource> CreateAsync(CreateRegistrySourceRequest request, string? userId, string? tenantId, CancellationToken cancellationToken = default)
{
var source = new RegistrySource
{
Id = Guid.NewGuid(),
Name = request.Name,
Description = request.Description,
Type = request.Type,
RegistryUrl = request.RegistryUrl.TrimEnd('/'),
AuthRefUri = request.AuthRefUri,
IntegrationId = request.IntegrationId,
RepoFilters = request.RepoFilters?.ToList() ?? [],
TagFilters = request.TagFilters?.ToList() ?? [],
TriggerMode = request.TriggerMode,
ScheduleCron = request.ScheduleCron,
WebhookSecretRefUri = request.WebhookSecretRefUri,
Status = RegistrySourceStatus.Pending,
Tags = request.Tags?.ToList() ?? [],
CreatedBy = userId,
UpdatedBy = userId,
TenantId = tenantId
};
var created = await _sourceRepository.CreateAsync(source, cancellationToken);
_logger.LogInformation("Registry source created: {Id} ({Name}) by {User}", created.Id, created.Name, userId);
return created;
}
/// <summary>
/// Gets a registry source by ID.
/// </summary>
public async Task<RegistrySource?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default)
{
return await _sourceRepository.GetByIdAsync(id, cancellationToken);
}
/// <summary>
/// Lists registry sources with filtering and pagination.
/// </summary>
public async Task<PagedRegistrySourcesResponse> ListAsync(ListRegistrySourcesRequest request, string? tenantId, CancellationToken cancellationToken = default)
{
var query = new RegistrySourceQuery(
Type: request.Type,
Status: request.Status,
TriggerMode: request.TriggerMode,
Search: request.Search,
IntegrationId: request.IntegrationId,
TenantId: tenantId,
Skip: (request.Page - 1) * request.PageSize,
Take: request.PageSize,
SortBy: request.SortBy,
SortDescending: request.SortDescending);
var sources = await _sourceRepository.GetAllAsync(query, cancellationToken);
var totalCount = await _sourceRepository.CountAsync(query, cancellationToken);
var totalPages = (int)Math.Ceiling(totalCount / (double)request.PageSize);
return new PagedRegistrySourcesResponse(sources, totalCount, request.Page, request.PageSize, totalPages);
}
/// <summary>
/// Updates a registry source.
/// </summary>
public async Task<RegistrySource?> UpdateAsync(Guid id, UpdateRegistrySourceRequest request, string? userId, CancellationToken cancellationToken = default)
{
var source = await _sourceRepository.GetByIdAsync(id, cancellationToken);
if (source is null) return null;
if (request.Name is not null) source.Name = request.Name;
if (request.Description is not null) source.Description = request.Description;
if (request.RegistryUrl is not null) source.RegistryUrl = request.RegistryUrl.TrimEnd('/');
if (request.AuthRefUri is not null) source.AuthRefUri = request.AuthRefUri;
if (request.RepoFilters is not null) source.RepoFilters = request.RepoFilters.ToList();
if (request.TagFilters is not null) source.TagFilters = request.TagFilters.ToList();
if (request.TriggerMode.HasValue) source.TriggerMode = request.TriggerMode.Value;
if (request.ScheduleCron is not null) source.ScheduleCron = request.ScheduleCron;
if (request.WebhookSecretRefUri is not null) source.WebhookSecretRefUri = request.WebhookSecretRefUri;
if (request.Status.HasValue) source.Status = request.Status.Value;
if (request.Tags is not null) source.Tags = request.Tags.ToList();
source.UpdatedAt = DateTimeOffset.UtcNow;
source.UpdatedBy = userId;
var updated = await _sourceRepository.UpdateAsync(source, cancellationToken);
_logger.LogInformation("Registry source updated: {Id} ({Name}) by {User}", updated.Id, updated.Name, userId);
return updated;
}
/// <summary>
/// Deletes a registry source.
/// </summary>
public async Task<bool> DeleteAsync(Guid id, string? userId, CancellationToken cancellationToken = default)
{
var source = await _sourceRepository.GetByIdAsync(id, cancellationToken);
if (source is null) return false;
await _sourceRepository.DeleteAsync(id, cancellationToken);
_logger.LogInformation("Registry source deleted: {Id} ({Name}) by {User}", id, source.Name, userId);
return true;
}
/// <summary>
/// Tests connection to a registry source.
/// </summary>
public async Task<TestRegistrySourceResponse> TestAsync(Guid id, string? userId, CancellationToken cancellationToken = default)
{
var source = await _sourceRepository.GetByIdAsync(id, cancellationToken);
if (source is null)
{
return new TestRegistrySourceResponse(id, false, "Registry source not found", null, TimeSpan.Zero, DateTimeOffset.UtcNow);
}
var startTime = DateTimeOffset.UtcNow;
// TODO: Implement actual registry connection test
// For now, simulate a successful test
await Task.Delay(100, cancellationToken);
var duration = DateTimeOffset.UtcNow - startTime;
// Update source status
var newStatus = RegistrySourceStatus.Active;
if (source.Status != newStatus)
{
source.Status = newStatus;
source.UpdatedAt = DateTimeOffset.UtcNow;
await _sourceRepository.UpdateAsync(source, cancellationToken);
}
_logger.LogInformation("Registry source test successful: {Id} ({Name}) by {User}", id, source.Name, userId);
return new TestRegistrySourceResponse(
id,
true,
"Connection successful",
new Dictionary<string, string>
{
["registryUrl"] = source.RegistryUrl,
["type"] = source.Type.ToString()
},
duration,
DateTimeOffset.UtcNow);
}
/// <summary>
/// Triggers a registry source discovery and scan run.
/// </summary>
public async Task<RegistrySourceRun> TriggerAsync(Guid id, string triggerType, string? triggerMetadata, string? userId, CancellationToken cancellationToken = default)
{
var source = await _sourceRepository.GetByIdAsync(id, cancellationToken);
if (source is null)
{
throw new InvalidOperationException($"Registry source {id} not found");
}
var run = new RegistrySourceRun
{
Id = Guid.NewGuid(),
SourceId = id,
Status = RegistryRunStatus.Queued,
TriggerType = triggerType,
TriggerMetadata = triggerMetadata,
StartedAt = DateTimeOffset.UtcNow
};
var created = await _runRepository.CreateAsync(run, cancellationToken);
// TODO: Submit run to Orchestrator/Scheduler for processing
_logger.LogInformation("Registry source run triggered: {RunId} for source {SourceId} by {User}", created.Id, id, userId);
return created;
}
/// <summary>
/// Pauses a registry source.
/// </summary>
public async Task<RegistrySource?> PauseAsync(Guid id, string? reason, string? userId, CancellationToken cancellationToken = default)
{
var source = await _sourceRepository.GetByIdAsync(id, cancellationToken);
if (source is null) return null;
source.Status = RegistrySourceStatus.Paused;
source.UpdatedAt = DateTimeOffset.UtcNow;
source.UpdatedBy = userId;
var updated = await _sourceRepository.UpdateAsync(source, cancellationToken);
_logger.LogInformation("Registry source paused: {Id} ({Name}) by {User}, reason: {Reason}", id, source.Name, userId, reason);
return updated;
}
/// <summary>
/// Resumes a paused registry source.
/// </summary>
public async Task<RegistrySource?> ResumeAsync(Guid id, string? userId, CancellationToken cancellationToken = default)
{
var source = await _sourceRepository.GetByIdAsync(id, cancellationToken);
if (source is null) return null;
if (source.Status != RegistrySourceStatus.Paused)
{
return source; // No-op if not paused
}
source.Status = RegistrySourceStatus.Active;
source.UpdatedAt = DateTimeOffset.UtcNow;
source.UpdatedBy = userId;
var updated = await _sourceRepository.UpdateAsync(source, cancellationToken);
_logger.LogInformation("Registry source resumed: {Id} ({Name}) by {User}", id, source.Name, userId);
return updated;
}
/// <summary>
/// Gets run history for a registry source.
/// </summary>
public async Task<IReadOnlyList<RegistrySourceRun>> GetRunHistoryAsync(Guid sourceId, int limit = 50, CancellationToken cancellationToken = default)
{
return await _runRepository.GetBySourceIdAsync(sourceId, limit, cancellationToken);
}
}
#region Request/Response DTOs
public sealed record CreateRegistrySourceRequest(
string Name,
string? Description,
RegistrySourceType Type,
string RegistryUrl,
string? AuthRefUri,
Guid? IntegrationId,
IReadOnlyList<string>? RepoFilters,
IReadOnlyList<string>? TagFilters,
RegistryTriggerMode TriggerMode,
string? ScheduleCron,
string? WebhookSecretRefUri,
IReadOnlyList<string>? Tags);
public sealed record UpdateRegistrySourceRequest(
string? Name,
string? Description,
string? RegistryUrl,
string? AuthRefUri,
IReadOnlyList<string>? RepoFilters,
IReadOnlyList<string>? TagFilters,
RegistryTriggerMode? TriggerMode,
string? ScheduleCron,
string? WebhookSecretRefUri,
RegistrySourceStatus? Status,
IReadOnlyList<string>? Tags);
public sealed record ListRegistrySourcesRequest(
RegistrySourceType? Type = null,
RegistrySourceStatus? Status = null,
RegistryTriggerMode? TriggerMode = null,
string? Search = null,
Guid? IntegrationId = null,
int Page = 1,
int PageSize = 20,
string SortBy = "name",
bool SortDescending = false);
public sealed record PagedRegistrySourcesResponse(
IReadOnlyList<RegistrySource> Items,
int TotalCount,
int Page,
int PageSize,
int TotalPages);
public sealed record TestRegistrySourceResponse(
Guid SourceId,
bool Success,
string? Message,
IReadOnlyDictionary<string, string>? Details,
TimeSpan Duration,
DateTimeOffset TestedAt);
public sealed record TriggerRegistrySourceRequest(
string TriggerType = "manual",
string? TriggerMetadata = null);
public sealed record PauseRegistrySourceRequest(string? Reason);
#endregion

View File

@@ -0,0 +1,597 @@
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
// SPRINT_20251229_012 REG-SRC-004: Registry webhook ingestion service
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using StellaOps.SbomService.Models;
using StellaOps.SbomService.Repositories;
namespace StellaOps.SbomService.Services;
/// <summary>
/// Processes webhook payloads from container registries (Harbor, DockerHub, ACR, ECR, GCR, etc.).
/// Triggers scan jobs when new images are pushed to monitored registry sources.
/// </summary>
public interface IRegistryWebhookService
{
/// <summary>
/// Process an incoming webhook payload and trigger appropriate scans.
/// </summary>
Task<WebhookProcessResult> ProcessWebhookAsync(
string sourceId,
string provider,
string payload,
string? signature,
CancellationToken cancellationToken = default);
/// <summary>
/// Validate webhook signature using the source's configured secret.
/// </summary>
bool ValidateSignature(string payload, string? signature, string? secret, string provider);
}
public class RegistryWebhookService : IRegistryWebhookService
{
private readonly IRegistrySourceRepository _sourceRepo;
private readonly IRegistrySourceRunRepository _runRepo;
private readonly IRegistrySourceService _sourceService;
private readonly ILogger<RegistryWebhookService> _logger;
private readonly IClock _clock;
public RegistryWebhookService(
IRegistrySourceRepository sourceRepo,
IRegistrySourceRunRepository runRepo,
IRegistrySourceService sourceService,
ILogger<RegistryWebhookService> logger,
IClock clock)
{
_sourceRepo = sourceRepo;
_runRepo = runRepo;
_sourceService = sourceService;
_logger = logger;
_clock = clock;
}
public async Task<WebhookProcessResult> ProcessWebhookAsync(
string sourceId,
string provider,
string payload,
string? signature,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(sourceId);
ArgumentException.ThrowIfNullOrWhiteSpace(provider);
ArgumentException.ThrowIfNullOrWhiteSpace(payload);
if (!Guid.TryParse(sourceId, out var sourceGuid))
{
_logger.LogWarning("Invalid source ID format: {SourceId}", sourceId);
return new WebhookProcessResult(false, "Invalid source ID", null);
}
var source = await _sourceRepo.GetByIdAsync(sourceGuid, cancellationToken);
if (source is null)
{
_logger.LogWarning("Webhook received for unknown source {SourceId}", sourceId);
return new WebhookProcessResult(false, "Source not found", null);
}
if (source.Status != RegistrySourceStatus.Active)
{
_logger.LogInformation("Webhook ignored for inactive source {SourceId} (status: {Status})", sourceId, source.Status);
return new WebhookProcessResult(false, "Source is not active", null);
}
// Validate signature if webhook secret is configured
// In production, resolve AuthRef to get actual secret
if (!string.IsNullOrEmpty(source.WebhookSecretRefUri))
{
// TODO: Resolve AuthRef to get actual secret
// For now, skip validation if secret is an AuthRef URI
if (!source.WebhookSecretRefUri.StartsWith("authref://", StringComparison.OrdinalIgnoreCase))
{
if (!ValidateSignature(payload, signature, source.WebhookSecretRefUri, provider))
{
_logger.LogWarning("Invalid webhook signature for source {SourceId}", sourceId);
return new WebhookProcessResult(false, "Invalid signature", null);
}
}
}
// Parse the webhook payload based on provider
var parseResult = ParseWebhookPayload(provider, payload);
if (!parseResult.Success)
{
_logger.LogWarning("Failed to parse webhook payload for source {SourceId}: {Error}", sourceId, parseResult.Error);
return new WebhookProcessResult(false, parseResult.Error!, null);
}
// Check if the image matches the source's repository filters
if (!MatchesFilters(parseResult.ImageReference!, source))
{
_logger.LogDebug("Webhook image {Image} does not match filters for source {SourceId}", parseResult.ImageReference, sourceId);
return new WebhookProcessResult(true, "Image does not match filters", null);
}
// Trigger a scan for the pushed image
_logger.LogInformation("Triggering scan for {Image} from webhook on source {SourceId}", parseResult.ImageReference, sourceId);
var run = await _sourceService.TriggerAsync(
sourceGuid,
"webhook",
$"Webhook push: {parseResult.ImageReference}",
null,
cancellationToken);
return new WebhookProcessResult(true, "Scan triggered", run.Id.ToString());
}
public bool ValidateSignature(string payload, string? signature, string? secret, string provider)
{
if (string.IsNullOrEmpty(secret))
{
return true; // No secret configured, skip validation
}
if (string.IsNullOrEmpty(signature))
{
return false; // Secret configured but no signature provided
}
return provider.ToLowerInvariant() switch
{
"harbor" => ValidateHarborSignature(payload, signature, secret),
"dockerhub" => ValidateDockerHubSignature(payload, signature, secret),
"acr" => ValidateAcrSignature(payload, signature, secret),
"gcr" => ValidateGcrSignature(payload, signature, secret),
"ecr" => true, // ECR uses IAM authentication, no HMAC signature
"ghcr" => ValidateGitHubSignature(payload, signature, secret),
_ => ValidateGenericHmacSignature(payload, signature, secret)
};
}
private WebhookParseResult ParseWebhookPayload(string provider, string payload)
{
try
{
return provider.ToLowerInvariant() switch
{
"harbor" => ParseHarborPayload(payload),
"dockerhub" => ParseDockerHubPayload(payload),
"acr" => ParseAcrPayload(payload),
"gcr" => ParseGcrPayload(payload),
"ecr" => ParseEcrPayload(payload),
"ghcr" => ParseGitHubPayload(payload),
_ => ParseGenericPayload(payload)
};
}
catch (JsonException ex)
{
return new WebhookParseResult(false, $"JSON parse error: {ex.Message}", null, null);
}
}
private WebhookParseResult ParseHarborPayload(string payload)
{
// Harbor v2 webhook payload structure
var doc = JsonDocument.Parse(payload);
var root = doc.RootElement;
if (!root.TryGetProperty("type", out var typeElement))
{
return new WebhookParseResult(false, "Missing 'type' field", null, null);
}
var eventType = typeElement.GetString();
if (eventType != "PUSH_ARTIFACT" && eventType != "pushImage")
{
return new WebhookParseResult(true, "Event type not a push", null, eventType);
}
if (!root.TryGetProperty("event_data", out var eventData))
{
return new WebhookParseResult(false, "Missing 'event_data' field", null, eventType);
}
// Extract repository and tag/digest
var repository = eventData.TryGetProperty("repository", out var repoElement)
? repoElement.TryGetProperty("repo_full_name", out var fullName)
? fullName.GetString()
: repoElement.TryGetProperty("name", out var name)
? name.GetString()
: null
: null;
var tag = eventData.TryGetProperty("resources", out var resources) && resources.GetArrayLength() > 0
? resources[0].TryGetProperty("tag", out var tagElement)
? tagElement.GetString()
: resources[0].TryGetProperty("digest", out var digestElement)
? digestElement.GetString()
: null
: null;
if (string.IsNullOrEmpty(repository))
{
return new WebhookParseResult(false, "Could not extract repository", null, eventType);
}
var imageRef = string.IsNullOrEmpty(tag) ? repository : $"{repository}:{tag}";
return new WebhookParseResult(true, null, imageRef, eventType);
}
private WebhookParseResult ParseDockerHubPayload(string payload)
{
var doc = JsonDocument.Parse(payload);
var root = doc.RootElement;
if (!root.TryGetProperty("push_data", out var pushData) ||
!root.TryGetProperty("repository", out var repository))
{
return new WebhookParseResult(false, "Missing required fields", null, null);
}
var repoName = repository.TryGetProperty("repo_name", out var repoNameElement)
? repoNameElement.GetString()
: null;
var tag = pushData.TryGetProperty("tag", out var tagElement)
? tagElement.GetString()
: "latest";
if (string.IsNullOrEmpty(repoName))
{
return new WebhookParseResult(false, "Could not extract repository name", null, "push");
}
return new WebhookParseResult(true, null, $"{repoName}:{tag}", "push");
}
private WebhookParseResult ParseAcrPayload(string payload)
{
// Azure Container Registry uses CloudEvents format
var doc = JsonDocument.Parse(payload);
var root = doc.RootElement;
var eventType = root.TryGetProperty("action", out var actionElement)
? actionElement.GetString()
: null;
if (eventType != "push")
{
return new WebhookParseResult(true, "Event type not a push", null, eventType);
}
if (!root.TryGetProperty("target", out var target))
{
return new WebhookParseResult(false, "Missing 'target' field", null, eventType);
}
var repository = target.TryGetProperty("repository", out var repoElement)
? repoElement.GetString()
: null;
var tag = target.TryGetProperty("tag", out var tagElement)
? tagElement.GetString()
: target.TryGetProperty("digest", out var digestElement)
? digestElement.GetString()
: null;
if (string.IsNullOrEmpty(repository))
{
return new WebhookParseResult(false, "Could not extract repository", null, eventType);
}
var imageRef = string.IsNullOrEmpty(tag) ? repository : $"{repository}:{tag}";
return new WebhookParseResult(true, null, imageRef, eventType);
}
private WebhookParseResult ParseGcrPayload(string payload)
{
// Google Cloud Pub/Sub notification format
var doc = JsonDocument.Parse(payload);
var root = doc.RootElement;
// GCR sends base64-encoded message in Pub/Sub format
if (root.TryGetProperty("message", out var message) &&
message.TryGetProperty("data", out var dataElement))
{
var data = dataElement.GetString();
if (!string.IsNullOrEmpty(data))
{
var decoded = Encoding.UTF8.GetString(Convert.FromBase64String(data));
var innerDoc = JsonDocument.Parse(decoded);
root = innerDoc.RootElement;
}
}
var action = root.TryGetProperty("action", out var actionElement)
? actionElement.GetString()
: null;
if (action != "INSERT")
{
return new WebhookParseResult(true, "Event type not an insert", null, action);
}
var digest = root.TryGetProperty("digest", out var digestElement)
? digestElement.GetString()
: null;
var tag = root.TryGetProperty("tag", out var tagElement)
? tagElement.GetString()
: null;
var imageRef = tag ?? digest;
if (string.IsNullOrEmpty(imageRef))
{
return new WebhookParseResult(false, "Could not extract image reference", null, action);
}
return new WebhookParseResult(true, null, imageRef, action);
}
private WebhookParseResult ParseEcrPayload(string payload)
{
// AWS ECR uses EventBridge/CloudWatch Events format
var doc = JsonDocument.Parse(payload);
var root = doc.RootElement;
var detailType = root.TryGetProperty("detail-type", out var detailTypeElement)
? detailTypeElement.GetString()
: null;
if (detailType != "ECR Image Action")
{
return new WebhookParseResult(true, "Event type not an image action", null, detailType);
}
if (!root.TryGetProperty("detail", out var detail))
{
return new WebhookParseResult(false, "Missing 'detail' field", null, detailType);
}
var actionType = detail.TryGetProperty("action-type", out var actionTypeElement)
? actionTypeElement.GetString()
: null;
if (actionType != "PUSH")
{
return new WebhookParseResult(true, "Action type not a push", null, actionType);
}
var repository = detail.TryGetProperty("repository-name", out var repoElement)
? repoElement.GetString()
: null;
var tag = detail.TryGetProperty("image-tag", out var tagElement)
? tagElement.GetString()
: detail.TryGetProperty("image-digest", out var digestElement)
? digestElement.GetString()
: null;
if (string.IsNullOrEmpty(repository))
{
return new WebhookParseResult(false, "Could not extract repository", null, actionType);
}
var imageRef = string.IsNullOrEmpty(tag) ? repository : $"{repository}:{tag}";
return new WebhookParseResult(true, null, imageRef, actionType);
}
private WebhookParseResult ParseGitHubPayload(string payload)
{
// GitHub Container Registry (GHCR) uses GitHub's package webhook format
var doc = JsonDocument.Parse(payload);
var root = doc.RootElement;
var action = root.TryGetProperty("action", out var actionElement)
? actionElement.GetString()
: null;
if (action != "published")
{
return new WebhookParseResult(true, "Action type not published", null, action);
}
if (!root.TryGetProperty("package", out var package))
{
return new WebhookParseResult(false, "Missing 'package' field", null, action);
}
var packageName = package.TryGetProperty("name", out var nameElement)
? nameElement.GetString()
: null;
var version = package.TryGetProperty("package_version", out var versionElement) &&
versionElement.TryGetProperty("version", out var versionStrElement)
? versionStrElement.GetString()
: null;
if (string.IsNullOrEmpty(packageName))
{
return new WebhookParseResult(false, "Could not extract package name", null, action);
}
var imageRef = string.IsNullOrEmpty(version) ? packageName : $"{packageName}:{version}";
return new WebhookParseResult(true, null, imageRef, action);
}
private WebhookParseResult ParseGenericPayload(string payload)
{
// Try to extract common fields from unknown providers
var doc = JsonDocument.Parse(payload);
var root = doc.RootElement;
// Look for common field names
string? repository = null;
string? tag = null;
var commonRepoFields = new[] { "repository", "repo", "repo_name", "image", "name" };
var commonTagFields = new[] { "tag", "version", "digest", "ref" };
foreach (var field in commonRepoFields)
{
if (root.TryGetProperty(field, out var element))
{
if (element.ValueKind == JsonValueKind.String)
{
repository = element.GetString();
break;
}
if (element.ValueKind == JsonValueKind.Object && element.TryGetProperty("name", out var nameEl))
{
repository = nameEl.GetString();
break;
}
}
}
foreach (var field in commonTagFields)
{
if (root.TryGetProperty(field, out var element) && element.ValueKind == JsonValueKind.String)
{
tag = element.GetString();
break;
}
}
if (string.IsNullOrEmpty(repository))
{
return new WebhookParseResult(false, "Could not extract repository from generic payload", null, null);
}
var imageRef = string.IsNullOrEmpty(tag) ? repository : $"{repository}:{tag}";
return new WebhookParseResult(true, null, imageRef, "generic");
}
private bool MatchesFilters(string imageReference, RegistrySource source)
{
// If no filters configured, match all
if (source.RepositoryAllowlist.Count == 0 && source.RepositoryDenylist.Count == 0 &&
source.TagAllowlist.Count == 0 && source.TagDenylist.Count == 0)
{
return true;
}
// Parse image reference into repo and tag
var parts = imageReference.Split(':', 2);
var repo = parts[0];
var tag = parts.Length > 1 ? parts[1] : "latest";
// Check repository filters
if (source.RepositoryDenylist.Count > 0 && MatchesPatterns(repo, source.RepositoryDenylist))
{
return false;
}
if (source.RepositoryAllowlist.Count > 0 && !MatchesPatterns(repo, source.RepositoryAllowlist))
{
return false;
}
// Check tag filters
if (source.TagDenylist.Count > 0 && MatchesPatterns(tag, source.TagDenylist))
{
return false;
}
if (source.TagAllowlist.Count > 0 && !MatchesPatterns(tag, source.TagAllowlist))
{
return false;
}
return true;
}
private static bool MatchesPatterns(string value, IReadOnlyList<string> patterns)
{
foreach (var pattern in patterns)
{
if (MatchesGlobPattern(value, pattern))
{
return true;
}
}
return false;
}
private static bool MatchesGlobPattern(string value, string pattern)
{
// Simple glob matching with * wildcards
if (pattern == "*")
{
return true;
}
if (!pattern.Contains('*'))
{
return value.Equals(pattern, StringComparison.OrdinalIgnoreCase);
}
// Convert glob to regex
var regexPattern = "^" + System.Text.RegularExpressions.Regex.Escape(pattern)
.Replace("\\*", ".*") + "$";
return System.Text.RegularExpressions.Regex.IsMatch(value, regexPattern, System.Text.RegularExpressions.RegexOptions.IgnoreCase);
}
private bool ValidateHarborSignature(string payload, string signature, string secret)
{
// Harbor uses HMAC-SHA256
return ValidateHmacSha256(payload, signature, secret, "sha256=");
}
private bool ValidateDockerHubSignature(string payload, string signature, string secret)
{
// DockerHub uses HMAC-SHA256
return ValidateHmacSha256(payload, signature, secret, "sha256=");
}
private bool ValidateAcrSignature(string payload, string signature, string secret)
{
// ACR uses HMAC-SHA256
return ValidateHmacSha256(payload, signature, secret, "sha256=");
}
private bool ValidateGcrSignature(string payload, string signature, string secret)
{
// GCR typically uses Pub/Sub push authentication rather than HMAC
// For now, trust if any signature is provided
return !string.IsNullOrEmpty(signature);
}
private bool ValidateGitHubSignature(string payload, string signature, string secret)
{
// GitHub uses HMAC-SHA256 with "sha256=" prefix
return ValidateHmacSha256(payload, signature, secret, "sha256=");
}
private bool ValidateGenericHmacSignature(string payload, string signature, string secret)
{
// Try common HMAC formats
return ValidateHmacSha256(payload, signature, secret, "sha256=") ||
ValidateHmacSha256(payload, signature, secret, "");
}
private bool ValidateHmacSha256(string payload, string signature, string secret, string prefix)
{
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(payload));
var expected = prefix + Convert.ToHexString(hash).ToLowerInvariant();
return signature.Equals(expected, StringComparison.OrdinalIgnoreCase);
}
}
/// <summary>
/// Result of processing a registry webhook.
/// </summary>
public sealed record WebhookProcessResult(
bool Success,
string? Message,
string? TriggeredRunId);
/// <summary>
/// Internal result of parsing a webhook payload.
/// </summary>
internal sealed record WebhookParseResult(
bool Success,
string? Error,
string? ImageReference,
string? EventType);

View File

@@ -0,0 +1,289 @@
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
// SPRINT_20251229_012 REG-SRC-006: Scan job emission service
using System.Net.Http.Json;
using System.Text.Json;
using System.Text.Json.Serialization;
using StellaOps.SbomService.Models;
using StellaOps.SbomService.Repositories;
namespace StellaOps.SbomService.Services;
/// <summary>
/// Service for emitting scan jobs to the Scanner/Orchestrator.
/// </summary>
public interface IScanJobEmitterService
{
/// <summary>
/// Submit a scan job for an image.
/// </summary>
Task<ScanJobResult> SubmitScanAsync(
ScanJobRequest request,
CancellationToken cancellationToken = default);
/// <summary>
/// Submit scan jobs for all images discovered from a registry source.
/// </summary>
Task<BatchScanResult> SubmitBatchScanAsync(
string sourceId,
IReadOnlyList<DiscoveredImage> images,
CancellationToken cancellationToken = default);
/// <summary>
/// Get the status of a scan job.
/// </summary>
Task<ScanJobStatus?> GetJobStatusAsync(
string jobId,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Default implementation that submits to the Scanner service via HTTP.
/// </summary>
public class ScanJobEmitterService : IScanJobEmitterService
{
private readonly IHttpClientFactory _httpClientFactory;
private readonly IConfiguration _configuration;
private readonly ILogger<ScanJobEmitterService> _logger;
private static readonly JsonSerializerOptions s_jsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true,
WriteIndented = false,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
public ScanJobEmitterService(
IHttpClientFactory httpClientFactory,
IConfiguration configuration,
ILogger<ScanJobEmitterService> logger)
{
_httpClientFactory = httpClientFactory;
_configuration = configuration;
_logger = logger;
}
public async Task<ScanJobResult> SubmitScanAsync(
ScanJobRequest request,
CancellationToken cancellationToken = default)
{
var scannerUrl = _configuration.GetValue<string>("SbomService:ScannerUrl") ?? "http://localhost:5100";
var client = _httpClientFactory.CreateClient("Scanner");
var submission = new
{
target = new
{
reference = request.ImageReference,
digest = request.Digest,
platform = request.Platform ?? "linux/amd64"
},
force = request.Force,
clientRequestId = request.ClientRequestId,
metadata = new Dictionary<string, string>
{
["source_id"] = request.SourceId ?? "",
["source_type"] = "registry",
["trigger_type"] = request.TriggerType ?? "manual"
}
};
try
{
var response = await client.PostAsJsonAsync(
$"{scannerUrl}/api/v1/scans",
submission,
s_jsonOptions,
cancellationToken);
if (!response.IsSuccessStatusCode)
{
var error = await response.Content.ReadAsStringAsync(cancellationToken);
_logger.LogWarning("Failed to submit scan for {Image}: {Status} - {Error}",
request.ImageReference, response.StatusCode, error);
return new ScanJobResult(
false,
$"Scanner returned {response.StatusCode}",
null,
null);
}
var result = await response.Content.ReadFromJsonAsync<ScannerResponse>(s_jsonOptions, cancellationToken);
_logger.LogInformation("Submitted scan job {JobId} for {Image}",
result?.Snapshot?.Id, request.ImageReference);
return new ScanJobResult(
true,
null,
result?.Snapshot?.Id,
result?.Created ?? true);
}
catch (HttpRequestException ex)
{
_logger.LogError(ex, "Network error submitting scan for {Image}", request.ImageReference);
return new ScanJobResult(false, $"Network error: {ex.Message}", null, null);
}
catch (Exception ex)
{
_logger.LogError(ex, "Unexpected error submitting scan for {Image}", request.ImageReference);
return new ScanJobResult(false, $"Unexpected error: {ex.Message}", null, null);
}
}
public async Task<BatchScanResult> SubmitBatchScanAsync(
string sourceId,
IReadOnlyList<DiscoveredImage> images,
CancellationToken cancellationToken = default)
{
var submitted = new List<ScanJobResult>();
var failed = new List<string>();
var skipped = 0;
// Rate limit batch submissions
var batchSize = _configuration.GetValue<int>("SbomService:BatchScanSize", 10);
var delayMs = _configuration.GetValue<int>("SbomService:BatchScanDelayMs", 100);
foreach (var image in images)
{
cancellationToken.ThrowIfCancellationRequested();
var request = new ScanJobRequest(
ImageReference: image.FullReference,
Digest: image.Digest,
Platform: null,
Force: false,
ClientRequestId: $"registry-{sourceId}-{Guid.NewGuid():N}",
SourceId: sourceId,
TriggerType: "discovery");
var result = await SubmitScanAsync(request, cancellationToken);
if (result.Success)
{
submitted.Add(result);
// Check if this was a skip (job already exists)
if (result.Created == false)
{
skipped++;
}
}
else
{
failed.Add($"{image.FullReference}: {result.Error}");
}
// Small delay between submissions to avoid overwhelming the scanner
if (delayMs > 0 && images.Count > 1)
{
await Task.Delay(delayMs, cancellationToken);
}
}
_logger.LogInformation(
"Batch scan for source {SourceId}: {Submitted} submitted, {Skipped} skipped, {Failed} failed out of {Total}",
sourceId, submitted.Count - skipped, skipped, failed.Count, images.Count);
return new BatchScanResult(
TotalRequested: images.Count,
Submitted: submitted.Count - skipped,
Skipped: skipped,
Failed: failed.Count,
Errors: failed.Count > 0 ? failed : null);
}
public async Task<ScanJobStatus?> GetJobStatusAsync(
string jobId,
CancellationToken cancellationToken = default)
{
var scannerUrl = _configuration.GetValue<string>("SbomService:ScannerUrl") ?? "http://localhost:5100";
var client = _httpClientFactory.CreateClient("Scanner");
try
{
var response = await client.GetAsync(
$"{scannerUrl}/api/v1/scans/{jobId}",
cancellationToken);
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
{
return null;
}
if (!response.IsSuccessStatusCode)
{
_logger.LogWarning("Failed to get scan status for {JobId}: {Status}",
jobId, response.StatusCode);
return null;
}
var result = await response.Content.ReadFromJsonAsync<ScanStatusResponse>(s_jsonOptions, cancellationToken);
return result is not null
? new ScanJobStatus(result.Id, result.Status, result.Progress, result.Error)
: null;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error getting scan status for {JobId}", jobId);
return null;
}
}
}
/// <summary>
/// Request to submit a scan job.
/// </summary>
public sealed record ScanJobRequest(
string ImageReference,
string? Digest,
string? Platform,
bool Force,
string? ClientRequestId,
string? SourceId,
string? TriggerType);
/// <summary>
/// Result of submitting a scan job.
/// </summary>
public sealed record ScanJobResult(
bool Success,
string? Error,
string? JobId,
bool? Created);
/// <summary>
/// Result of batch scan submission.
/// </summary>
public sealed record BatchScanResult(
int TotalRequested,
int Submitted,
int Skipped,
int Failed,
IReadOnlyList<string>? Errors);
/// <summary>
/// Status of a scan job.
/// </summary>
public sealed record ScanJobStatus(
string Id,
string Status,
int? Progress,
string? Error);
/// <summary>
/// Internal response model for Scanner API.
/// </summary>
internal sealed record ScannerResponse(
ScannerSnapshot? Snapshot,
bool Created);
internal sealed record ScannerSnapshot(
string Id,
string Status);
internal sealed record ScanStatusResponse(
string Id,
string Status,
int? Progress,
string? Error);

View File

@@ -26,12 +26,12 @@ public sealed class PostgresEntrypointRepositoryTests : IAsyncLifetime
_repository = new PostgresEntrypointRepository(dataSource, NullLogger<PostgresEntrypointRepository>.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]
@@ -109,3 +109,6 @@ public sealed class PostgresEntrypointRepositoryTests : IAsyncLifetime
fetched.Should().BeEmpty();
}
}

View File

@@ -26,12 +26,12 @@ public sealed class PostgresOrchestratorControlRepositoryTests : IAsyncLifetime
_repository = new PostgresOrchestratorControlRepository(dataSource, NullLogger<PostgresOrchestratorControlRepository>.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]
@@ -106,3 +106,6 @@ public sealed class PostgresOrchestratorControlRepositoryTests : IAsyncLifetime
states.Should().HaveCountGreaterThanOrEqualTo(2);
}
}

View File

@@ -10,7 +10,6 @@ using FluentAssertions;
using StellaOps.SbomService.Models;
using StellaOps.TestKit;
using Xunit;
using Xunit.Abstractions;
namespace StellaOps.SbomService.Tests.Lineage;