Frontend gaps fill work. Testing fixes work. Auditing in progress.
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>();
|
||||
|
||||
@@ -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);
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@ using FluentAssertions;
|
||||
using StellaOps.SbomService.Models;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
namespace StellaOps.SbomService.Tests.Lineage;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user