audit, advisories and doctors/setup work
This commit is contained in:
@@ -1,10 +1,12 @@
|
||||
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
|
||||
// Unit tests for RegistryDiscoveryService and ScanJobEmitterService
|
||||
|
||||
using System.Globalization;
|
||||
using System.Net;
|
||||
using System.Text.Json;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Moq;
|
||||
using Moq.Protected;
|
||||
using StellaOps.SbomService.Models;
|
||||
@@ -38,7 +40,12 @@ public class RegistryDiscoveryServiceTests
|
||||
_service = new RegistryDiscoveryService(
|
||||
_sourceRepoMock.Object,
|
||||
httpClientFactory.Object,
|
||||
NullLogger<RegistryDiscoveryService>.Instance);
|
||||
NullLogger<RegistryDiscoveryService>.Instance,
|
||||
Options.Create(new RegistryHttpOptions
|
||||
{
|
||||
AllowedHosts = new List<string> { "test-registry.example.com" },
|
||||
AllowInlineCredentials = true
|
||||
}));
|
||||
}
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
@@ -61,7 +68,7 @@ public class RegistryDiscoveryServiceTests
|
||||
public async Task DiscoverRepositoriesAsync_WithUnknownSource_ReturnsFailure()
|
||||
{
|
||||
// Arrange
|
||||
var sourceId = Guid.NewGuid();
|
||||
var sourceId = Guid.Parse("11111111-1111-1111-1111-111111111111");
|
||||
_sourceRepoMock
|
||||
.Setup(r => r.GetByIdAsync(sourceId, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((RegistrySource?)null);
|
||||
@@ -100,6 +107,26 @@ public class RegistryDiscoveryServiceTests
|
||||
result.Repositories.Should().Contain("library/nginx");
|
||||
}
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
[Fact]
|
||||
public async Task DiscoverRepositoriesAsync_WithDisallowedHost_ReturnsFailure()
|
||||
{
|
||||
// Arrange
|
||||
var source = CreateTestSource();
|
||||
source.RegistryUrl = "https://blocked.example.com";
|
||||
|
||||
_sourceRepoMock
|
||||
.Setup(r => r.GetByIdAsync(source.Id, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(source);
|
||||
|
||||
// Act
|
||||
var result = await _service.DiscoverRepositoriesAsync(source.Id.ToString());
|
||||
|
||||
// Assert
|
||||
result.Success.Should().BeFalse();
|
||||
result.Error.Should().Contain("allowlisted");
|
||||
}
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
[Fact]
|
||||
public async Task DiscoverRepositoriesAsync_WithRepositoryDenylist_ExcludesMatches()
|
||||
@@ -187,12 +214,14 @@ public class RegistryDiscoveryServiceTests
|
||||
|
||||
private static RegistrySource CreateTestSource() => new()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Id = Guid.Parse("22222222-2222-2222-2222-222222222222"),
|
||||
Name = "Test Registry",
|
||||
Type = RegistrySourceType.Harbor,
|
||||
RegistryUrl = "https://test-registry.example.com",
|
||||
AuthRefUri = "authref://vault/registry#credentials",
|
||||
Status = RegistrySourceStatus.Active
|
||||
Status = RegistrySourceStatus.Active,
|
||||
CreatedAt = DateTimeOffset.Parse("2025-12-29T12:00:00Z", CultureInfo.InvariantCulture),
|
||||
UpdatedAt = DateTimeOffset.Parse("2025-12-29T12:00:00Z", CultureInfo.InvariantCulture)
|
||||
};
|
||||
|
||||
private void SetupHttpResponse(HttpStatusCode statusCode, string content)
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
|
||||
// Unit tests for RegistrySourceService
|
||||
|
||||
using System.Globalization;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Moq;
|
||||
using StellaOps.SbomService.Models;
|
||||
using StellaOps.SbomService.Repositories;
|
||||
@@ -22,10 +24,22 @@ public class RegistrySourceServiceTests
|
||||
_sourceRepoMock = new Mock<IRegistrySourceRepository>();
|
||||
_runRepoMock = new Mock<IRegistrySourceRunRepository>();
|
||||
|
||||
var timeProvider = new FixedTimeProvider(FixedNow);
|
||||
var guidProvider = CreateGuidProvider();
|
||||
var httpOptions = Options.Create(new RegistryHttpOptions
|
||||
{
|
||||
AllowedHosts = new List<string> { "harbor.example.com", "registry.example.com", "test-registry.example.com" }
|
||||
});
|
||||
var queryOptions = Options.Create(new RegistrySourceQueryOptions());
|
||||
|
||||
_service = new RegistrySourceService(
|
||||
_sourceRepoMock.Object,
|
||||
_runRepoMock.Object,
|
||||
NullLogger<RegistrySourceService>.Instance);
|
||||
NullLogger<RegistrySourceService>.Instance,
|
||||
timeProvider,
|
||||
guidProvider,
|
||||
httpOptions,
|
||||
queryOptions);
|
||||
}
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
@@ -90,7 +104,7 @@ public class RegistrySourceServiceTests
|
||||
.Returns<RegistrySource, CancellationToken>((s, _) => Task.FromResult(s));
|
||||
|
||||
// Act
|
||||
var result = await _service.CreateAsync(request, null, null);
|
||||
var result = await _service.CreateAsync(request, null, "tenant-1");
|
||||
|
||||
// Assert
|
||||
result.RegistryUrl.Should().Be("https://registry.example.com");
|
||||
@@ -107,7 +121,7 @@ public class RegistrySourceServiceTests
|
||||
.ReturnsAsync(source);
|
||||
|
||||
// Act
|
||||
var result = await _service.GetByIdAsync(source.Id);
|
||||
var result = await _service.GetByIdAsync(source.Id, "tenant-1");
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
@@ -125,7 +139,7 @@ public class RegistrySourceServiceTests
|
||||
.ReturnsAsync((RegistrySource?)null);
|
||||
|
||||
// Act
|
||||
var result = await _service.GetByIdAsync(id);
|
||||
var result = await _service.GetByIdAsync(id, "tenant-1");
|
||||
|
||||
// Assert
|
||||
result.Should().BeNull();
|
||||
@@ -153,7 +167,7 @@ public class RegistrySourceServiceTests
|
||||
var request = new ListRegistrySourcesRequest(Type: RegistrySourceType.Harbor);
|
||||
|
||||
// Act
|
||||
var result = await _service.ListAsync(request, null);
|
||||
var result = await _service.ListAsync(request, "tenant-1");
|
||||
|
||||
// Assert
|
||||
result.Items.Should().HaveCount(2);
|
||||
@@ -188,7 +202,7 @@ public class RegistrySourceServiceTests
|
||||
Tags: null);
|
||||
|
||||
// Act
|
||||
var result = await _service.UpdateAsync(source.Id, request, "updater@example.com");
|
||||
var result = await _service.UpdateAsync(source.Id, request, "updater@example.com", "tenant-1");
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
@@ -213,7 +227,7 @@ public class RegistrySourceServiceTests
|
||||
WebhookSecretRefUri: null, Status: null, Tags: null);
|
||||
|
||||
// Act
|
||||
var result = await _service.UpdateAsync(id, request, "user");
|
||||
var result = await _service.UpdateAsync(id, request, "user", "tenant-1");
|
||||
|
||||
// Assert
|
||||
result.Should().BeNull();
|
||||
@@ -234,7 +248,7 @@ public class RegistrySourceServiceTests
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
var result = await _service.DeleteAsync(source.Id, "deleter@example.com");
|
||||
var result = await _service.DeleteAsync(source.Id, "deleter@example.com", "tenant-1");
|
||||
|
||||
// Assert
|
||||
result.Should().BeTrue();
|
||||
@@ -262,7 +276,7 @@ public class RegistrySourceServiceTests
|
||||
.Returns<RegistrySource, CancellationToken>((s, _) => Task.FromResult(s));
|
||||
|
||||
// Act
|
||||
var result = await _service.TriggerAsync(source.Id, "manual", null, "user@example.com");
|
||||
var result = await _service.TriggerAsync(source.Id, "manual", null, "user@example.com", "tenant-1");
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
@@ -288,7 +302,7 @@ public class RegistrySourceServiceTests
|
||||
.Returns<RegistrySource, CancellationToken>((s, _) => Task.FromResult(s));
|
||||
|
||||
// Act
|
||||
var result = await _service.PauseAsync(source.Id, "Maintenance", "admin@example.com");
|
||||
var result = await _service.PauseAsync(source.Id, "Maintenance", "admin@example.com", "tenant-1");
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
@@ -312,7 +326,7 @@ public class RegistrySourceServiceTests
|
||||
.Returns<RegistrySource, CancellationToken>((s, _) => Task.FromResult(s));
|
||||
|
||||
// Act
|
||||
var result = await _service.ResumeAsync(source.Id, "admin@example.com");
|
||||
var result = await _service.ResumeAsync(source.Id, "admin@example.com", "tenant-1");
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
@@ -337,7 +351,7 @@ public class RegistrySourceServiceTests
|
||||
.ReturnsAsync(runs);
|
||||
|
||||
// Act
|
||||
var result = await _service.GetRunHistoryAsync(sourceId, 50);
|
||||
var result = await _service.GetRunHistoryAsync(sourceId, 50, "tenant-1");
|
||||
|
||||
// Assert
|
||||
result.Should().HaveCount(3);
|
||||
@@ -348,23 +362,39 @@ public class RegistrySourceServiceTests
|
||||
|
||||
private static RegistrySource CreateTestSource(RegistrySourceType type = RegistrySourceType.Harbor) => new()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Id = Guid.Parse("11111111-1111-1111-1111-111111111111"),
|
||||
Name = "Test Registry",
|
||||
Type = type,
|
||||
RegistryUrl = "https://test-registry.example.com",
|
||||
Status = RegistrySourceStatus.Pending,
|
||||
TriggerMode = RegistryTriggerMode.Manual
|
||||
TriggerMode = RegistryTriggerMode.Manual,
|
||||
CreatedAt = FixedNow,
|
||||
UpdatedAt = FixedNow,
|
||||
TenantId = "tenant-1"
|
||||
};
|
||||
|
||||
private static RegistrySourceRun CreateTestRun(Guid sourceId) => new()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Id = Guid.Parse("22222222-2222-2222-2222-222222222222"),
|
||||
SourceId = sourceId,
|
||||
Status = RegistryRunStatus.Completed,
|
||||
TriggerType = "manual",
|
||||
StartedAt = DateTimeOffset.UtcNow.AddMinutes(-5),
|
||||
CompletedAt = DateTimeOffset.UtcNow
|
||||
StartedAt = FixedNow.AddMinutes(-5),
|
||||
CompletedAt = FixedNow
|
||||
};
|
||||
|
||||
private static QueueGuidProvider CreateGuidProvider()
|
||||
{
|
||||
return new QueueGuidProvider(new[]
|
||||
{
|
||||
Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"),
|
||||
Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"),
|
||||
Guid.Parse("cccccccc-cccc-cccc-cccc-cccccccccccc")
|
||||
});
|
||||
}
|
||||
|
||||
private static DateTimeOffset FixedNow =>
|
||||
DateTimeOffset.Parse("2025-12-29T12:00:00Z", CultureInfo.InvariantCulture);
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
|
||||
// Unit tests for RegistryWebhookService
|
||||
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
@@ -28,7 +29,7 @@ public class RegistryWebhookServiceTests
|
||||
_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"));
|
||||
_clockMock.Setup(c => c.UtcNow).Returns(FixedNow);
|
||||
|
||||
_service = new RegistryWebhookService(
|
||||
_sourceRepoMock.Object,
|
||||
@@ -59,7 +60,7 @@ public class RegistryWebhookServiceTests
|
||||
public async Task ProcessWebhookAsync_WithUnknownSource_ReturnsFailure()
|
||||
{
|
||||
// Arrange
|
||||
var sourceId = Guid.NewGuid();
|
||||
var sourceId = Guid.Parse("11111111-1111-1111-1111-111111111111");
|
||||
_sourceRepoMock
|
||||
.Setup(r => r.GetByIdAsync(sourceId, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((RegistrySource?)null);
|
||||
@@ -113,6 +114,7 @@ public class RegistryWebhookServiceTests
|
||||
"webhook",
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<string?>(),
|
||||
It.IsAny<string?>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(expectedRun);
|
||||
|
||||
@@ -183,27 +185,29 @@ public class RegistryWebhookServiceTests
|
||||
|
||||
private static RegistrySource CreateTestSource() => new()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Id = Guid.Parse("22222222-2222-2222-2222-222222222222"),
|
||||
Name = "Test Harbor",
|
||||
Type = RegistrySourceType.Harbor,
|
||||
RegistryUrl = "https://harbor.example.com",
|
||||
Status = RegistrySourceStatus.Active,
|
||||
TriggerMode = RegistryTriggerMode.Webhook
|
||||
TriggerMode = RegistryTriggerMode.Webhook,
|
||||
CreatedAt = FixedNow,
|
||||
UpdatedAt = FixedNow
|
||||
};
|
||||
|
||||
private static RegistrySourceRun CreateTestRun(Guid sourceId) => new()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Id = Guid.Parse("33333333-3333-3333-3333-333333333333"),
|
||||
SourceId = sourceId,
|
||||
Status = RegistryRunStatus.Running,
|
||||
TriggerType = "webhook",
|
||||
StartedAt = DateTimeOffset.UtcNow
|
||||
StartedAt = FixedNow
|
||||
};
|
||||
|
||||
private static string CreateHarborPushPayload(string repository, string tag) => JsonSerializer.Serialize(new
|
||||
{
|
||||
type = "PUSH_ARTIFACT",
|
||||
occur_at = DateTimeOffset.UtcNow.ToUnixTimeSeconds(),
|
||||
occur_at = FixedNow.ToUnixTimeSeconds(),
|
||||
@operator = "admin",
|
||||
event_data = new
|
||||
{
|
||||
@@ -225,5 +229,8 @@ public class RegistryWebhookServiceTests
|
||||
}
|
||||
});
|
||||
|
||||
private static DateTimeOffset FixedNow =>
|
||||
DateTimeOffset.Parse("2025-12-29T12:00:00Z", CultureInfo.InvariantCulture);
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
using System.Globalization;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.SbomService.Models;
|
||||
using StellaOps.SbomService.Repositories;
|
||||
using StellaOps.SbomService.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.SbomService.Tests;
|
||||
|
||||
public sealed class SbomLedgerServiceTests
|
||||
{
|
||||
[Trait("Category", "Unit")]
|
||||
[Fact]
|
||||
public async Task AddVersionAsync_UsesGuidProviderAndClock()
|
||||
{
|
||||
// Arrange
|
||||
var repository = new InMemorySbomLedgerRepository();
|
||||
var clock = new FixedClock(FixedNow);
|
||||
var guidProvider = new QueueGuidProvider(new[]
|
||||
{
|
||||
Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"),
|
||||
Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb")
|
||||
});
|
||||
|
||||
var service = new SbomLedgerService(
|
||||
repository,
|
||||
clock,
|
||||
Options.Create(new SbomLedgerOptions()),
|
||||
lineageEdgeRepository: null,
|
||||
logger: NullLogger<SbomLedgerService>.Instance,
|
||||
guidProvider: guidProvider);
|
||||
|
||||
var submission = new SbomLedgerSubmission(
|
||||
ArtifactRef: "acme/app:1.0",
|
||||
Digest: "sha256:deadbeef",
|
||||
Format: "spdx",
|
||||
FormatVersion: "2.3",
|
||||
Source: "upload",
|
||||
Provenance: null,
|
||||
Components: new List<SbomNormalizedComponent>
|
||||
{
|
||||
new("pkg:npm/lodash", "lodash", "4.17.21", "pkg:npm/lodash@4.17.21", "MIT")
|
||||
},
|
||||
ParentVersionId: null);
|
||||
|
||||
// Act
|
||||
var version = await service.AddVersionAsync(submission, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
version.ChainId.Should().Be(Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"));
|
||||
version.VersionId.Should().Be(Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"));
|
||||
version.CreatedAtUtc.Should().Be(FixedNow);
|
||||
}
|
||||
|
||||
private sealed class FixedClock : IClock
|
||||
{
|
||||
public FixedClock(DateTimeOffset utcNow)
|
||||
{
|
||||
UtcNow = utcNow;
|
||||
}
|
||||
|
||||
public DateTimeOffset UtcNow { get; }
|
||||
}
|
||||
|
||||
private static DateTimeOffset FixedNow =>
|
||||
DateTimeOffset.Parse("2025-12-29T12:00:00Z", CultureInfo.InvariantCulture);
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
using System.Net;
|
||||
using System.Text.Json;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Moq;
|
||||
using Moq.Protected;
|
||||
using StellaOps.SbomService.Models;
|
||||
using StellaOps.SbomService.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.SbomService.Tests;
|
||||
|
||||
public sealed class ScanJobEmitterServiceTests
|
||||
{
|
||||
[Trait("Category", "Unit")]
|
||||
[Fact]
|
||||
public async Task SubmitScanAsync_RejectsDisallowedScannerHost()
|
||||
{
|
||||
// Arrange
|
||||
var configuration = BuildConfiguration("http://blocked.example.com");
|
||||
var httpClientFactory = new Mock<IHttpClientFactory>();
|
||||
httpClientFactory
|
||||
.Setup(f => f.CreateClient(It.IsAny<string>()))
|
||||
.Returns(new HttpClient(new Mock<HttpMessageHandler>().Object));
|
||||
|
||||
var service = new ScanJobEmitterService(
|
||||
httpClientFactory.Object,
|
||||
configuration,
|
||||
NullLogger<ScanJobEmitterService>.Instance,
|
||||
Options.Create(new ScannerHttpOptions
|
||||
{
|
||||
AllowedHosts = new List<string> { "scanner.local" }
|
||||
}),
|
||||
new QueueGuidProvider(new[] { Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa") }));
|
||||
|
||||
var request = new ScanJobRequest(
|
||||
ImageReference: "library/nginx:latest",
|
||||
Digest: "sha256:deadbeef",
|
||||
Platform: "linux/amd64",
|
||||
Force: false,
|
||||
ClientRequestId: null,
|
||||
SourceId: "source-1",
|
||||
TriggerType: "manual");
|
||||
|
||||
// Act
|
||||
var result = await service.SubmitScanAsync(request);
|
||||
|
||||
// Assert
|
||||
result.Success.Should().BeFalse();
|
||||
result.Error.Should().Contain("allowlisted");
|
||||
}
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
[Fact]
|
||||
public async Task SubmitBatchScanAsync_UsesDeterministicRequestId()
|
||||
{
|
||||
// Arrange
|
||||
var configuration = BuildConfiguration("http://scanner.local");
|
||||
var handler = new Mock<HttpMessageHandler>();
|
||||
HttpRequestMessage? captured = null;
|
||||
|
||||
handler
|
||||
.Protected()
|
||||
.Setup<Task<HttpResponseMessage>>(
|
||||
"SendAsync",
|
||||
ItExpr.IsAny<HttpRequestMessage>(),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.Callback<HttpRequestMessage, CancellationToken>((request, _) => captured = request)
|
||||
.ReturnsAsync(new HttpResponseMessage
|
||||
{
|
||||
StatusCode = HttpStatusCode.OK,
|
||||
Content = new StringContent("{\"snapshot\":{\"id\":\"job-123\",\"status\":\"queued\"},\"created\":true}")
|
||||
});
|
||||
|
||||
var httpClientFactory = new Mock<IHttpClientFactory>();
|
||||
httpClientFactory
|
||||
.Setup(f => f.CreateClient(It.IsAny<string>()))
|
||||
.Returns(new HttpClient(handler.Object));
|
||||
|
||||
var service = new ScanJobEmitterService(
|
||||
httpClientFactory.Object,
|
||||
configuration,
|
||||
NullLogger<ScanJobEmitterService>.Instance,
|
||||
Options.Create(new ScannerHttpOptions
|
||||
{
|
||||
AllowedHosts = new List<string> { "scanner.local" }
|
||||
}),
|
||||
new QueueGuidProvider(new[] { Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb") }));
|
||||
|
||||
var images = new[]
|
||||
{
|
||||
new DiscoveredImage("library/nginx", "latest", "sha256:deadbeef")
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await service.SubmitBatchScanAsync("source-1", images);
|
||||
|
||||
// Assert
|
||||
result.Submitted.Should().Be(1);
|
||||
captured.Should().NotBeNull();
|
||||
|
||||
var payload = await captured!.Content!.ReadAsStringAsync();
|
||||
using var doc = JsonDocument.Parse(payload);
|
||||
var clientRequestId = doc.RootElement.GetProperty("clientRequestId").GetString();
|
||||
clientRequestId.Should().Be("registry-source-1-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb");
|
||||
}
|
||||
|
||||
private static IConfiguration BuildConfiguration(string scannerUrl)
|
||||
{
|
||||
return new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["SbomService:ScannerUrl"] = scannerUrl,
|
||||
["SbomService:BatchScanSize"] = "1",
|
||||
["SbomService:BatchScanDelayMs"] = "0"
|
||||
})
|
||||
.Build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
using StellaOps.SbomService.Services;
|
||||
|
||||
namespace StellaOps.SbomService.Tests;
|
||||
|
||||
internal sealed class FixedTimeProvider : TimeProvider
|
||||
{
|
||||
private DateTimeOffset _utcNow;
|
||||
private long _timestamp;
|
||||
|
||||
public FixedTimeProvider(DateTimeOffset utcNow)
|
||||
{
|
||||
_utcNow = utcNow;
|
||||
}
|
||||
|
||||
public void Advance(TimeSpan delta)
|
||||
{
|
||||
_utcNow = _utcNow.Add(delta);
|
||||
_timestamp += delta.Ticks;
|
||||
}
|
||||
|
||||
public override DateTimeOffset GetUtcNow() => _utcNow;
|
||||
|
||||
public override long GetTimestamp() => _timestamp;
|
||||
|
||||
public override long TimestampFrequency => TimeSpan.TicksPerSecond;
|
||||
}
|
||||
|
||||
internal sealed class QueueGuidProvider : IGuidProvider
|
||||
{
|
||||
private readonly Queue<Guid> _guids;
|
||||
|
||||
public QueueGuidProvider(IEnumerable<Guid> guids)
|
||||
{
|
||||
_guids = new Queue<Guid>(guids);
|
||||
}
|
||||
|
||||
public Guid NewGuid()
|
||||
{
|
||||
return _guids.Count > 0 ? _guids.Dequeue() : Guid.Empty;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
using System.Security.Claims;
|
||||
using System.Text.Encodings.Web;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace StellaOps.SbomService.Auth;
|
||||
|
||||
internal sealed class HeaderAuthenticationHandler : AuthenticationHandler<AuthenticationSchemeOptions>
|
||||
{
|
||||
public const string SchemeName = "SbomHeader";
|
||||
|
||||
#pragma warning disable CS0618 // ISystemClock obsolete; base ctor signature still requires it on this TF.
|
||||
public HeaderAuthenticationHandler(
|
||||
IOptionsMonitor<AuthenticationSchemeOptions> options,
|
||||
ILoggerFactory logger,
|
||||
UrlEncoder encoder,
|
||||
ISystemClock clock) : base(options, logger, encoder, clock)
|
||||
{
|
||||
}
|
||||
#pragma warning restore CS0618
|
||||
|
||||
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
|
||||
{
|
||||
if (!TryGetHeader("x-tenant-id", out var tenantId) &&
|
||||
!TryGetHeader("tid", out tenantId))
|
||||
{
|
||||
return Task.FromResult(AuthenticateResult.Fail("tenant_header_missing"));
|
||||
}
|
||||
|
||||
var userId = TryGetHeader("x-user-id", out var headerUser)
|
||||
? headerUser
|
||||
: "system";
|
||||
|
||||
var claims = new List<Claim>
|
||||
{
|
||||
new Claim(ClaimTypes.NameIdentifier, userId),
|
||||
new Claim("tenant", tenantId),
|
||||
new Claim("tenant_id", tenantId)
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(userId))
|
||||
{
|
||||
claims.Add(new Claim("user", userId));
|
||||
}
|
||||
|
||||
var identity = new ClaimsIdentity(claims, SchemeName);
|
||||
var principal = new ClaimsPrincipal(identity);
|
||||
var ticket = new AuthenticationTicket(principal, SchemeName);
|
||||
return Task.FromResult(AuthenticateResult.Success(ticket));
|
||||
}
|
||||
|
||||
private bool TryGetHeader(string name, out string value)
|
||||
{
|
||||
value = string.Empty;
|
||||
if (!Request.Headers.TryGetValue(name, out var headerValues))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var headerValue = headerValues.ToString().Trim();
|
||||
if (string.IsNullOrWhiteSpace(headerValue))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
value = headerValue;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,7 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Options;
|
||||
using System.Security.Claims;
|
||||
using StellaOps.SbomService.Models;
|
||||
using StellaOps.SbomService.Services;
|
||||
|
||||
@@ -10,15 +13,21 @@ namespace StellaOps.SbomService.Controllers;
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/v1/registry-sources")]
|
||||
[Authorize]
|
||||
public sealed class RegistrySourceController : ControllerBase
|
||||
{
|
||||
private readonly RegistrySourceService _service;
|
||||
private readonly ILogger<RegistrySourceController> _logger;
|
||||
private readonly RegistrySourceQueryOptions _queryOptions;
|
||||
|
||||
public RegistrySourceController(RegistrySourceService service, ILogger<RegistrySourceController> logger)
|
||||
public RegistrySourceController(
|
||||
RegistrySourceService service,
|
||||
ILogger<RegistrySourceController> logger,
|
||||
IOptions<RegistrySourceQueryOptions>? queryOptions = null)
|
||||
{
|
||||
_service = service;
|
||||
_logger = logger;
|
||||
_queryOptions = queryOptions?.Value ?? new RegistrySourceQueryOptions();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -38,11 +47,26 @@ public sealed class RegistrySourceController : ControllerBase
|
||||
[FromQuery] bool sortDescending = false,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!TryGetTenantId(out var tenantId))
|
||||
{
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
if (page <= 0)
|
||||
{
|
||||
return BadRequest(new { error = "page must be greater than 0" });
|
||||
}
|
||||
|
||||
if (pageSize <= 0 || pageSize > _queryOptions.MaxPageSize)
|
||||
{
|
||||
return BadRequest(new { error = $"pageSize must be between 1 and {_queryOptions.MaxPageSize}" });
|
||||
}
|
||||
|
||||
var request = new ListRegistrySourcesRequest(
|
||||
type, status, triggerMode, search, integrationId,
|
||||
page, pageSize, sortBy, sortDescending);
|
||||
|
||||
var result = await _service.ListAsync(request, null, cancellationToken);
|
||||
var result = await _service.ListAsync(request, tenantId, cancellationToken);
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
@@ -54,7 +78,12 @@ public sealed class RegistrySourceController : ControllerBase
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> Get(Guid id, CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await _service.GetByIdAsync(id, cancellationToken);
|
||||
if (!TryGetTenantId(out var tenantId))
|
||||
{
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
var result = await _service.GetByIdAsync(id, tenantId, cancellationToken);
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
|
||||
@@ -66,7 +95,12 @@ public sealed class RegistrySourceController : ControllerBase
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
public async Task<IActionResult> Create([FromBody] CreateRegistrySourceRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await _service.CreateAsync(request, null, null, cancellationToken);
|
||||
if (!TryGetTenantId(out var tenantId))
|
||||
{
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
var result = await _service.CreateAsync(request, GetUserId(), tenantId, cancellationToken);
|
||||
return CreatedAtAction(nameof(Get), new { id = result.Id }, result);
|
||||
}
|
||||
|
||||
@@ -78,7 +112,12 @@ public sealed class RegistrySourceController : ControllerBase
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> Update(Guid id, [FromBody] UpdateRegistrySourceRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await _service.UpdateAsync(id, request, null, cancellationToken);
|
||||
if (!TryGetTenantId(out var tenantId))
|
||||
{
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
var result = await _service.UpdateAsync(id, request, GetUserId(), tenantId, cancellationToken);
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
|
||||
@@ -90,7 +129,12 @@ public sealed class RegistrySourceController : ControllerBase
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> Delete(Guid id, CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await _service.DeleteAsync(id, null, cancellationToken);
|
||||
if (!TryGetTenantId(out var tenantId))
|
||||
{
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
var result = await _service.DeleteAsync(id, GetUserId(), tenantId, cancellationToken);
|
||||
return result ? NoContent() : NotFound();
|
||||
}
|
||||
|
||||
@@ -102,7 +146,12 @@ public sealed class RegistrySourceController : ControllerBase
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> Test(Guid id, CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await _service.TestAsync(id, null, cancellationToken);
|
||||
if (!TryGetTenantId(out var tenantId))
|
||||
{
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
var result = await _service.TestAsync(id, GetUserId(), tenantId, cancellationToken);
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
@@ -114,9 +163,14 @@ public sealed class RegistrySourceController : ControllerBase
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> Trigger(Guid id, [FromBody] TriggerRegistrySourceRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!TryGetTenantId(out var tenantId))
|
||||
{
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var result = await _service.TriggerAsync(id, request.TriggerType, request.TriggerMetadata, null, cancellationToken);
|
||||
var result = await _service.TriggerAsync(id, request.TriggerType, request.TriggerMetadata, GetUserId(), tenantId, cancellationToken);
|
||||
return Ok(result);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
@@ -133,7 +187,12 @@ public sealed class RegistrySourceController : ControllerBase
|
||||
[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);
|
||||
if (!TryGetTenantId(out var tenantId))
|
||||
{
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
var result = await _service.PauseAsync(id, request.Reason, GetUserId(), tenantId, cancellationToken);
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
|
||||
@@ -145,7 +204,12 @@ public sealed class RegistrySourceController : ControllerBase
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> Resume(Guid id, CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await _service.ResumeAsync(id, null, cancellationToken);
|
||||
if (!TryGetTenantId(out var tenantId))
|
||||
{
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
var result = await _service.ResumeAsync(id, GetUserId(), tenantId, cancellationToken);
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
|
||||
@@ -156,7 +220,17 @@ public sealed class RegistrySourceController : ControllerBase
|
||||
[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);
|
||||
if (!TryGetTenantId(out var tenantId))
|
||||
{
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
if (limit <= 0 || limit > _queryOptions.MaxRunHistoryLimit)
|
||||
{
|
||||
return BadRequest(new { error = $"limit must be between 1 and {_queryOptions.MaxRunHistoryLimit}" });
|
||||
}
|
||||
|
||||
var result = await _service.GetRunHistoryAsync(id, limit, tenantId, cancellationToken);
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
@@ -171,6 +245,16 @@ public sealed class RegistrySourceController : ControllerBase
|
||||
[FromServices] IRegistryDiscoveryService discoveryService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!TryGetTenantId(out var tenantId))
|
||||
{
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
if (await _service.GetByIdAsync(id, tenantId, cancellationToken) is null)
|
||||
{
|
||||
return NotFound(new { error = "Source not found" });
|
||||
}
|
||||
|
||||
var result = await discoveryService.DiscoverRepositoriesAsync(id.ToString(), cancellationToken);
|
||||
if (!result.Success && result.Error == "Source not found")
|
||||
{
|
||||
@@ -191,6 +275,16 @@ public sealed class RegistrySourceController : ControllerBase
|
||||
[FromServices] IRegistryDiscoveryService discoveryService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!TryGetTenantId(out var tenantId))
|
||||
{
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
if (await _service.GetByIdAsync(id, tenantId, cancellationToken) is null)
|
||||
{
|
||||
return NotFound(new { error = "Source not found" });
|
||||
}
|
||||
|
||||
var result = await discoveryService.DiscoverTagsAsync(id.ToString(), repository, cancellationToken);
|
||||
if (!result.Success && result.Error == "Source not found")
|
||||
{
|
||||
@@ -210,6 +304,16 @@ public sealed class RegistrySourceController : ControllerBase
|
||||
[FromServices] IRegistryDiscoveryService discoveryService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!TryGetTenantId(out var tenantId))
|
||||
{
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
if (await _service.GetByIdAsync(id, tenantId, cancellationToken) is null)
|
||||
{
|
||||
return NotFound(new { error = "Source not found" });
|
||||
}
|
||||
|
||||
var result = await discoveryService.DiscoverImagesAsync(id.ToString(), cancellationToken);
|
||||
if (!result.Success && result.Error == "Source not found")
|
||||
{
|
||||
@@ -230,6 +334,16 @@ public sealed class RegistrySourceController : ControllerBase
|
||||
[FromServices] IScanJobEmitterService scanEmitter,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!TryGetTenantId(out var tenantId))
|
||||
{
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
if (await _service.GetByIdAsync(id, tenantId, cancellationToken) is null)
|
||||
{
|
||||
return NotFound(new { error = "Source not found" });
|
||||
}
|
||||
|
||||
// First discover all images
|
||||
var discoveryResult = await discoveryService.DiscoverImagesAsync(id.ToString(), cancellationToken);
|
||||
if (!discoveryResult.Success && discoveryResult.Error == "Source not found")
|
||||
@@ -258,6 +372,25 @@ public sealed class RegistrySourceController : ControllerBase
|
||||
discoveryResult.Images.Count,
|
||||
scanResult));
|
||||
}
|
||||
|
||||
private bool TryGetTenantId(out string tenantId)
|
||||
{
|
||||
tenantId = string.Empty;
|
||||
var claim = User.FindFirst("tenant")?.Value;
|
||||
if (string.IsNullOrWhiteSpace(claim))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
tenantId = claim.Trim();
|
||||
return true;
|
||||
}
|
||||
|
||||
private string? GetUserId()
|
||||
{
|
||||
var claim = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
|
||||
return string.IsNullOrWhiteSpace(claim) ? null : claim.Trim();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -170,10 +170,10 @@ public sealed class RegistrySource
|
||||
public int LastScannedCount { get; set; }
|
||||
|
||||
/// <summary>Creation timestamp.</summary>
|
||||
public DateTimeOffset CreatedAt { get; init; } = DateTimeOffset.UtcNow;
|
||||
public required DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
/// <summary>Last update timestamp.</summary>
|
||||
public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||
public required DateTimeOffset UpdatedAt { get; set; }
|
||||
|
||||
/// <summary>Creator user/system.</summary>
|
||||
public string? CreatedBy { get; init; }
|
||||
@@ -232,7 +232,7 @@ public sealed class RegistrySourceRun
|
||||
public string? ErrorMessage { get; set; }
|
||||
|
||||
/// <summary>Run start timestamp.</summary>
|
||||
public DateTimeOffset StartedAt { get; init; } = DateTimeOffset.UtcNow;
|
||||
public required DateTimeOffset StartedAt { get; init; }
|
||||
|
||||
/// <summary>Run completion timestamp.</summary>
|
||||
public DateTimeOffset? CompletedAt { get; set; }
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
@@ -383,8 +384,13 @@ public sealed record SbomRetentionResult(
|
||||
|
||||
public sealed class SbomLedgerOptions
|
||||
{
|
||||
[Range(1, 10000)]
|
||||
public int MaxVersionsPerArtifact { get; init; } = 50;
|
||||
|
||||
[Range(0, 36500)]
|
||||
public int MaxAgeDays { get; init; }
|
||||
|
||||
[Range(1, 10000)]
|
||||
public int MinVersionsToKeep { get; init; } = 1;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
using System.Globalization;
|
||||
using System.Diagnostics.Metrics;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.SbomService.Auth;
|
||||
using StellaOps.SbomService.Models;
|
||||
using StellaOps.SbomService.Services;
|
||||
using StellaOps.SbomService.Observability;
|
||||
@@ -17,6 +19,12 @@ builder.Configuration
|
||||
|
||||
builder.Services.AddOptions();
|
||||
builder.Services.AddLogging();
|
||||
builder.Services.AddSingleton<TimeProvider>(TimeProvider.System);
|
||||
builder.Services.AddSingleton<IGuidProvider>(SystemGuidProvider.Instance);
|
||||
|
||||
builder.Services.AddAuthentication(HeaderAuthenticationHandler.SchemeName)
|
||||
.AddScheme<AuthenticationSchemeOptions, HeaderAuthenticationHandler>(HeaderAuthenticationHandler.SchemeName, _ => { });
|
||||
builder.Services.AddAuthorization();
|
||||
|
||||
// Register SBOM query services using file-backed fixtures when present; fallback to in-memory seeds.
|
||||
builder.Services.AddSingleton<IComponentLookupRepository>(sp =>
|
||||
@@ -50,7 +58,7 @@ builder.Services.AddSingleton<ICatalogRepository>(sp =>
|
||||
? new FileCatalogRepository(candidate)
|
||||
: new InMemoryCatalogRepository();
|
||||
});
|
||||
builder.Services.AddSingleton<IClock, SystemClock>();
|
||||
builder.Services.AddSingleton<IClock, StellaOps.SbomService.Services.SystemClock>();
|
||||
builder.Services.AddSingleton<ISbomEventStore, InMemorySbomEventStore>();
|
||||
builder.Services.AddSingleton<ISbomEventPublisher>(sp => sp.GetRequiredService<ISbomEventStore>());
|
||||
builder.Services.AddSingleton<ISbomQueryService, InMemorySbomQueryService>();
|
||||
@@ -63,7 +71,23 @@ builder.Services.AddSingleton<IOrchestratorControlService>(sp =>
|
||||
SbomMetrics.Meter));
|
||||
builder.Services.AddSingleton<IWatermarkService, InMemoryWatermarkService>();
|
||||
builder.Services.AddOptions<SbomLedgerOptions>()
|
||||
.Bind(builder.Configuration.GetSection("SbomService:Ledger"));
|
||||
.Bind(builder.Configuration.GetSection("SbomService:Ledger"))
|
||||
.ValidateDataAnnotations()
|
||||
.Validate(options => options.MaxVersionsPerArtifact <= 0 || options.MinVersionsToKeep <= options.MaxVersionsPerArtifact,
|
||||
"MinVersionsToKeep must be less than or equal to MaxVersionsPerArtifact.")
|
||||
.ValidateOnStart();
|
||||
builder.Services.AddOptions<RegistryHttpOptions>()
|
||||
.Bind(builder.Configuration.GetSection($"SbomService:{RegistryHttpOptions.SectionName}"))
|
||||
.ValidateDataAnnotations()
|
||||
.ValidateOnStart();
|
||||
builder.Services.AddOptions<ScannerHttpOptions>()
|
||||
.Bind(builder.Configuration.GetSection($"SbomService:{ScannerHttpOptions.SectionName}"))
|
||||
.ValidateDataAnnotations()
|
||||
.ValidateOnStart();
|
||||
builder.Services.AddOptions<RegistrySourceQueryOptions>()
|
||||
.Bind(builder.Configuration.GetSection($"SbomService:{RegistrySourceQueryOptions.SectionName}"))
|
||||
.ValidateDataAnnotations()
|
||||
.ValidateOnStart();
|
||||
builder.Services.AddSingleton<ISbomLedgerRepository, InMemorySbomLedgerRepository>();
|
||||
builder.Services.AddSingleton<ISbomNormalizationService, SbomNormalizationService>();
|
||||
builder.Services.AddSingleton<ISbomQualityScorer, SbomQualityScorer>();
|
||||
@@ -76,6 +100,11 @@ builder.Services.AddSingleton<ISbomLineageEdgeRepository, InMemorySbomLineageEdg
|
||||
|
||||
// LIN-BE-015: Hover card cache for <150ms response times
|
||||
// Use distributed cache if configured, otherwise in-memory
|
||||
builder.Services.AddOptions<LineageHoverCacheOptions>()
|
||||
.Bind(builder.Configuration.GetSection("SbomService:HoverCache"))
|
||||
.ValidateDataAnnotations()
|
||||
.Validate(options => options.Ttl > TimeSpan.Zero, "Hover cache TTL must be positive.")
|
||||
.ValidateOnStart();
|
||||
var hoverCacheConfig = builder.Configuration.GetSection("SbomService:HoverCache");
|
||||
if (hoverCacheConfig.GetValue<bool>("UseDistributed"))
|
||||
{
|
||||
@@ -101,7 +130,10 @@ builder.Services.AddSingleton<IReplayHashService, ReplayHashService>();
|
||||
builder.Services.AddSingleton<IReplayVerificationService, ReplayVerificationService>();
|
||||
|
||||
// LIN-BE-034: Compare cache with TTL and VEX invalidation
|
||||
builder.Services.Configure<CompareCacheOptions>(builder.Configuration.GetSection("SbomService:CompareCache"));
|
||||
builder.Services.AddOptions<CompareCacheOptions>()
|
||||
.Bind(builder.Configuration.GetSection("SbomService:CompareCache"))
|
||||
.ValidateDataAnnotations()
|
||||
.ValidateOnStart();
|
||||
builder.Services.AddSingleton<ILineageCompareCache, InMemoryLineageCompareCache>();
|
||||
|
||||
// REG-SRC: Registry source management (SPRINT_20251229_012)
|
||||
@@ -109,8 +141,22 @@ builder.Services.AddSingleton<IRegistrySourceRepository, InMemoryRegistrySourceR
|
||||
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.AddHttpClient("RegistryDiscovery", (sp, client) =>
|
||||
{
|
||||
var options = sp.GetRequiredService<IOptions<RegistryHttpOptions>>().Value;
|
||||
if (options.Timeout > TimeSpan.Zero && options.Timeout != Timeout.InfiniteTimeSpan)
|
||||
{
|
||||
client.Timeout = options.Timeout;
|
||||
}
|
||||
});
|
||||
builder.Services.AddHttpClient("Scanner", (sp, client) =>
|
||||
{
|
||||
var options = sp.GetRequiredService<IOptions<ScannerHttpOptions>>().Value;
|
||||
if (options.Timeout > TimeSpan.Zero && options.Timeout != Timeout.InfiniteTimeSpan)
|
||||
{
|
||||
client.Timeout = options.Timeout;
|
||||
}
|
||||
});
|
||||
builder.Services.AddSingleton<IRegistryDiscoveryService, RegistryDiscoveryService>();
|
||||
builder.Services.AddSingleton<IScanJobEmitterService, ScanJobEmitterService>();
|
||||
|
||||
@@ -186,21 +232,12 @@ var app = builder.Build();
|
||||
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
app.Use(async (context, next) =>
|
||||
{
|
||||
try
|
||||
{
|
||||
await next();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[dev-exception] {ex}");
|
||||
throw;
|
||||
}
|
||||
});
|
||||
app.UseDeveloperExceptionPage();
|
||||
}
|
||||
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
|
||||
app.MapGet("/healthz", () => Results.Ok(new { status = "ok" }));
|
||||
app.MapGet("/readyz", () => Results.Ok(new { status = "warming" }));
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
using System.Collections.Concurrent;
|
||||
using StellaOps.SbomService.Models;
|
||||
using StellaOps.SbomService.Services;
|
||||
|
||||
namespace StellaOps.SbomService.Repositories;
|
||||
|
||||
@@ -17,6 +18,16 @@ public sealed class InMemorySbomLineageEdgeRepository : ISbomLineageEdgeReposito
|
||||
{
|
||||
private readonly ConcurrentDictionary<Guid, SbomLineageEdgeEntity> _edges = new();
|
||||
private readonly object _lock = new();
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly IGuidProvider _guidProvider;
|
||||
|
||||
public InMemorySbomLineageEdgeRepository(
|
||||
TimeProvider? timeProvider = null,
|
||||
IGuidProvider? guidProvider = null)
|
||||
{
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_guidProvider = guidProvider ?? SystemGuidProvider.Instance;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask<SbomLineageEdgeEntity> AddAsync(
|
||||
@@ -38,8 +49,8 @@ public sealed class InMemorySbomLineageEdgeRepository : ISbomLineageEdgeReposito
|
||||
|
||||
var newEdge = edge with
|
||||
{
|
||||
Id = edge.Id == Guid.Empty ? Guid.NewGuid() : edge.Id,
|
||||
CreatedAt = edge.CreatedAt == default ? DateTimeOffset.UtcNow : edge.CreatedAt
|
||||
Id = edge.Id == Guid.Empty ? _guidProvider.NewGuid() : edge.Id,
|
||||
CreatedAt = edge.CreatedAt == default ? _timeProvider.GetUtcNow() : edge.CreatedAt
|
||||
};
|
||||
|
||||
_edges[newEdge.Id] = newEdge;
|
||||
@@ -66,8 +77,8 @@ public sealed class InMemorySbomLineageEdgeRepository : ISbomLineageEdgeReposito
|
||||
{
|
||||
var newEdge = edge with
|
||||
{
|
||||
Id = edge.Id == Guid.Empty ? Guid.NewGuid() : edge.Id,
|
||||
CreatedAt = edge.CreatedAt == default ? DateTimeOffset.UtcNow : edge.CreatedAt
|
||||
Id = edge.Id == Guid.Empty ? _guidProvider.NewGuid() : edge.Id,
|
||||
CreatedAt = edge.CreatedAt == default ? _timeProvider.GetUtcNow() : edge.CreatedAt
|
||||
};
|
||||
_edges[newEdge.Id] = newEdge;
|
||||
count++;
|
||||
|
||||
@@ -10,6 +10,12 @@ public sealed class InMemoryRegistrySourceRepository : IRegistrySourceRepository
|
||||
{
|
||||
private readonly Dictionary<Guid, RegistrySource> _sources = new();
|
||||
private readonly object _lock = new();
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public InMemoryRegistrySourceRepository(TimeProvider? timeProvider = null)
|
||||
{
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public Task<RegistrySource?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
@@ -128,7 +134,7 @@ public sealed class InMemoryRegistrySourceRepository : IRegistrySourceRepository
|
||||
{
|
||||
source.IsDeleted = true;
|
||||
source.Status = RegistrySourceStatus.Archived;
|
||||
source.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
source.UpdatedAt = _timeProvider.GetUtcNow();
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
namespace StellaOps.SbomService.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Abstraction for GUID generation to support deterministic tests.
|
||||
/// </summary>
|
||||
public interface IGuidProvider
|
||||
{
|
||||
Guid NewGuid();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Default GUID provider using Guid.NewGuid().
|
||||
/// </summary>
|
||||
public sealed class SystemGuidProvider : IGuidProvider
|
||||
{
|
||||
public static readonly SystemGuidProvider Instance = new();
|
||||
|
||||
public Guid NewGuid() => Guid.NewGuid();
|
||||
}
|
||||
@@ -6,6 +6,7 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Concurrent;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Diagnostics;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
@@ -310,11 +311,13 @@ public sealed class CompareCacheOptions
|
||||
/// <summary>
|
||||
/// Default TTL in minutes. Default: 10.
|
||||
/// </summary>
|
||||
[Range(1, 1440)]
|
||||
public int DefaultTtlMinutes { get; set; } = 10;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum number of entries. Default: 10000.
|
||||
/// </summary>
|
||||
[Range(1, 1000000)]
|
||||
public int MaxEntries { get; set; } = 10000;
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -22,18 +22,21 @@ internal sealed class LineageExportService : ILineageExportService
|
||||
private readonly IReplayHashService? _replayHashService;
|
||||
private readonly ILogger<LineageExportService> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly IGuidProvider _guidProvider;
|
||||
private const long MaxExportSizeBytes = 50 * 1024 * 1024; // 50MB limit
|
||||
|
||||
public LineageExportService(
|
||||
ISbomLineageGraphService lineageService,
|
||||
ILogger<LineageExportService> logger,
|
||||
IReplayHashService? replayHashService = null,
|
||||
TimeProvider? timeProvider = null)
|
||||
TimeProvider? timeProvider = null,
|
||||
IGuidProvider? guidProvider = null)
|
||||
{
|
||||
_lineageService = lineageService;
|
||||
_logger = logger;
|
||||
_replayHashService = replayHashService;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_guidProvider = guidProvider ?? SystemGuidProvider.Instance;
|
||||
}
|
||||
|
||||
public async Task<LineageExportResponse?> ExportAsync(
|
||||
@@ -92,7 +95,7 @@ internal sealed class LineageExportService : ILineageExportService
|
||||
}
|
||||
|
||||
// Generate export ID and URL
|
||||
var exportId = Guid.NewGuid().ToString("N");
|
||||
var exportId = _guidProvider.NewGuid().ToString("N");
|
||||
var downloadUrl = $"/api/v1/lineage/export/{exportId}/download";
|
||||
var expiresAt = _timeProvider.GetUtcNow().AddHours(24);
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Diagnostics;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Caching.Distributed;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@@ -49,11 +50,13 @@ public sealed record LineageHoverCacheOptions
|
||||
/// Time-to-live for hover card cache entries.
|
||||
/// Default: 5 minutes.
|
||||
/// </summary>
|
||||
[Range(typeof(TimeSpan), "00:00:01", "1.00:00:00", ErrorMessage = "Hover cache TTL must be between 1 second and 1 day.")]
|
||||
public TimeSpan Ttl { get; init; } = TimeSpan.FromMinutes(5);
|
||||
|
||||
/// <summary>
|
||||
/// Key prefix for hover cache entries.
|
||||
/// </summary>
|
||||
[MinLength(1)]
|
||||
public string KeyPrefix { get; init; } = "lineage:hover";
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
namespace StellaOps.SbomService.Services;
|
||||
|
||||
internal static class OutboundUrlPolicy
|
||||
{
|
||||
private static readonly string[] DefaultSchemes = ["https", "http"];
|
||||
|
||||
internal static IReadOnlySet<string> NormalizeSchemes(IReadOnlyCollection<string>? schemes)
|
||||
{
|
||||
var items = schemes ?? Array.Empty<string>();
|
||||
var normalized = items
|
||||
.Where(s => !string.IsNullOrWhiteSpace(s))
|
||||
.Select(s => s.Trim().ToLowerInvariant())
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
if (normalized.Count == 0)
|
||||
{
|
||||
foreach (var scheme in DefaultSchemes)
|
||||
{
|
||||
normalized.Add(scheme);
|
||||
}
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
internal static IReadOnlyList<string> NormalizeHosts(
|
||||
IReadOnlyCollection<string>? hosts,
|
||||
out bool allowAllHosts)
|
||||
{
|
||||
var normalized = (hosts ?? Array.Empty<string>())
|
||||
.Where(h => !string.IsNullOrWhiteSpace(h))
|
||||
.Select(h => h.Trim().ToLowerInvariant())
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
allowAllHosts = normalized.Any(h => string.Equals(h, "*", StringComparison.Ordinal));
|
||||
return normalized;
|
||||
}
|
||||
|
||||
internal static bool IsHostAllowed(
|
||||
string host,
|
||||
IReadOnlyList<string> allowedHosts,
|
||||
bool allowAllHosts)
|
||||
{
|
||||
if (allowAllHosts || allowedHosts.Count == 0)
|
||||
{
|
||||
return allowAllHosts;
|
||||
}
|
||||
|
||||
var normalizedHost = host.Trim().ToLowerInvariant();
|
||||
foreach (var entry in allowedHosts)
|
||||
{
|
||||
if (string.Equals(entry, "*", StringComparison.Ordinal))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (entry.StartsWith("*.", StringComparison.Ordinal))
|
||||
{
|
||||
var suffix = entry[1..];
|
||||
if (normalizedHost.EndsWith(suffix, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
else if (string.Equals(normalizedHost, entry, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
internal static bool TryNormalizeUri(
|
||||
string raw,
|
||||
IReadOnlySet<string> allowedSchemes,
|
||||
IReadOnlyList<string> allowedHosts,
|
||||
bool allowAllHosts,
|
||||
bool allowMissingScheme,
|
||||
string defaultScheme,
|
||||
out Uri uri,
|
||||
out string error)
|
||||
{
|
||||
uri = null!;
|
||||
error = string.Empty;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(raw))
|
||||
{
|
||||
error = "URL is required.";
|
||||
return false;
|
||||
}
|
||||
|
||||
var candidate = raw.Trim();
|
||||
if (allowMissingScheme && !candidate.Contains("://", StringComparison.Ordinal))
|
||||
{
|
||||
candidate = $"{defaultScheme}://{candidate}";
|
||||
}
|
||||
|
||||
if (!Uri.TryCreate(candidate, UriKind.Absolute, out var parsedUri))
|
||||
{
|
||||
uri = null!;
|
||||
error = "URL must be absolute.";
|
||||
return false;
|
||||
}
|
||||
|
||||
uri = parsedUri;
|
||||
|
||||
if (!allowedSchemes.Contains(uri.Scheme))
|
||||
{
|
||||
error = $"URL scheme '{uri.Scheme}' is not allowlisted.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!allowAllHosts && !IsHostAllowed(uri.Host, allowedHosts, allowAllHosts))
|
||||
{
|
||||
error = $"URL host '{uri.Host}' is not allowlisted.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(uri.UserInfo))
|
||||
{
|
||||
error = "URL must not include user info.";
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.SbomService.Models;
|
||||
using StellaOps.SbomService.Repositories;
|
||||
|
||||
@@ -43,15 +44,23 @@ public class RegistryDiscoveryService : IRegistryDiscoveryService
|
||||
private readonly IRegistrySourceRepository _sourceRepo;
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
private readonly ILogger<RegistryDiscoveryService> _logger;
|
||||
private readonly RegistryHttpOptions _httpOptions;
|
||||
private readonly IReadOnlySet<string> _allowedSchemes;
|
||||
private readonly IReadOnlyList<string> _allowedHosts;
|
||||
private readonly bool _allowAllHosts;
|
||||
|
||||
public RegistryDiscoveryService(
|
||||
IRegistrySourceRepository sourceRepo,
|
||||
IHttpClientFactory httpClientFactory,
|
||||
ILogger<RegistryDiscoveryService> logger)
|
||||
ILogger<RegistryDiscoveryService> logger,
|
||||
IOptions<RegistryHttpOptions>? httpOptions = null)
|
||||
{
|
||||
_sourceRepo = sourceRepo;
|
||||
_httpClientFactory = httpClientFactory;
|
||||
_logger = logger;
|
||||
_httpOptions = httpOptions?.Value ?? new RegistryHttpOptions();
|
||||
_allowedSchemes = OutboundUrlPolicy.NormalizeSchemes(_httpOptions.AllowedSchemes);
|
||||
_allowedHosts = OutboundUrlPolicy.NormalizeHosts(_httpOptions.AllowedHosts, out _allowAllHosts);
|
||||
}
|
||||
|
||||
public async Task<DiscoveryResult> DiscoverRepositoriesAsync(
|
||||
@@ -71,12 +80,22 @@ public class RegistryDiscoveryService : IRegistryDiscoveryService
|
||||
|
||||
try
|
||||
{
|
||||
var client = CreateHttpClient(source);
|
||||
if (!TryGetRegistryBaseUri(source.RegistryUrl, out var registryUri, out var urlError))
|
||||
{
|
||||
_logger.LogWarning("Registry URL rejected for source {SourceId}: {Error}", sourceId, urlError);
|
||||
return new DiscoveryResult(false, urlError, []);
|
||||
}
|
||||
|
||||
if (!TryCreateHttpClient(source, registryUri, out var client, out var credentialError))
|
||||
{
|
||||
_logger.LogWarning("Registry credentials rejected for source {SourceId}: {Error}", sourceId, credentialError);
|
||||
return new DiscoveryResult(false, credentialError ?? "Invalid registry credentials", []);
|
||||
}
|
||||
var repositories = new List<string>();
|
||||
var nextLink = $"{NormalizeRegistryUrl(source.RegistryUrl)}/v2/_catalog";
|
||||
Uri? nextLink = new Uri(registryUri, "/v2/_catalog");
|
||||
|
||||
// Paginate through repository list
|
||||
while (!string.IsNullOrEmpty(nextLink))
|
||||
while (nextLink is not null)
|
||||
{
|
||||
var response = await client.GetAsync(nextLink, cancellationToken);
|
||||
|
||||
@@ -104,7 +123,12 @@ public class RegistryDiscoveryService : IRegistryDiscoveryService
|
||||
}
|
||||
|
||||
// Check for pagination link
|
||||
nextLink = ExtractNextLink(response.Headers);
|
||||
nextLink = ResolveNextLink(registryUri, ExtractNextLink(response.Headers), out var linkError);
|
||||
if (linkError is not null)
|
||||
{
|
||||
_logger.LogWarning("Registry pagination link rejected for {SourceId}: {Error}", sourceId, linkError);
|
||||
return new DiscoveryResult(false, linkError, repositories);
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation("Discovered {Count} repositories for source {SourceId}",
|
||||
@@ -142,11 +166,21 @@ public class RegistryDiscoveryService : IRegistryDiscoveryService
|
||||
|
||||
try
|
||||
{
|
||||
var client = CreateHttpClient(source);
|
||||
var tags = new List<TagInfo>();
|
||||
var nextLink = $"{NormalizeRegistryUrl(source.RegistryUrl)}/v2/{repository}/tags/list";
|
||||
if (!TryGetRegistryBaseUri(source.RegistryUrl, out var registryUri, out var urlError))
|
||||
{
|
||||
_logger.LogWarning("Registry URL rejected for source {SourceId}: {Error}", sourceId, urlError);
|
||||
return new TagDiscoveryResult(false, urlError, repository, []);
|
||||
}
|
||||
|
||||
while (!string.IsNullOrEmpty(nextLink))
|
||||
if (!TryCreateHttpClient(source, registryUri, out var client, out var credentialError))
|
||||
{
|
||||
_logger.LogWarning("Registry credentials rejected for source {SourceId}: {Error}", sourceId, credentialError);
|
||||
return new TagDiscoveryResult(false, credentialError ?? "Invalid registry credentials", repository, []);
|
||||
}
|
||||
var tags = new List<TagInfo>();
|
||||
Uri? nextLink = new Uri(registryUri, $"/v2/{repository}/tags/list");
|
||||
|
||||
while (nextLink is not null)
|
||||
{
|
||||
var response = await client.GetAsync(nextLink, cancellationToken);
|
||||
|
||||
@@ -169,13 +203,18 @@ public class RegistryDiscoveryService : IRegistryDiscoveryService
|
||||
if (!string.IsNullOrEmpty(tagName) && MatchesTagFilters(tagName, source))
|
||||
{
|
||||
// Get manifest digest for each tag
|
||||
var digest = await GetManifestDigestAsync(client, source, repository, tagName, cancellationToken);
|
||||
var digest = await GetManifestDigestAsync(client, registryUri, repository, tagName, cancellationToken);
|
||||
tags.Add(new TagInfo(tagName, digest));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
nextLink = ExtractNextLink(response.Headers);
|
||||
nextLink = ResolveNextLink(registryUri, ExtractNextLink(response.Headers), out var linkError);
|
||||
if (linkError is not null)
|
||||
{
|
||||
_logger.LogWarning("Registry pagination link rejected for {SourceId}: {Error}", sourceId, linkError);
|
||||
return new TagDiscoveryResult(false, linkError, repository, tags);
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation("Discovered {Count} tags for {Repository} in source {SourceId}",
|
||||
@@ -233,10 +272,26 @@ public class RegistryDiscoveryService : IRegistryDiscoveryService
|
||||
return new ImageDiscoveryResult(errors.Count == 0 || images.Count > 0, message, images);
|
||||
}
|
||||
|
||||
private HttpClient CreateHttpClient(RegistrySource source)
|
||||
private bool TryCreateHttpClient(
|
||||
RegistrySource source,
|
||||
Uri baseUri,
|
||||
out HttpClient client,
|
||||
out string? error)
|
||||
{
|
||||
var client = _httpClientFactory.CreateClient("RegistryDiscovery");
|
||||
client.Timeout = TimeSpan.FromSeconds(30);
|
||||
client = _httpClientFactory.CreateClient("RegistryDiscovery");
|
||||
client.BaseAddress = baseUri;
|
||||
error = null;
|
||||
|
||||
if (_httpOptions.Timeout <= TimeSpan.Zero || _httpOptions.Timeout == Timeout.InfiniteTimeSpan)
|
||||
{
|
||||
error = "Registry timeout must be a positive, non-infinite duration.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (client.Timeout == Timeout.InfiniteTimeSpan || client.Timeout > _httpOptions.Timeout)
|
||||
{
|
||||
client.Timeout = _httpOptions.Timeout;
|
||||
}
|
||||
|
||||
// Set default headers
|
||||
client.DefaultRequestHeaders.Accept.Add(
|
||||
@@ -246,40 +301,24 @@ public class RegistryDiscoveryService : IRegistryDiscoveryService
|
||||
|
||||
// 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 (!TryApplyCredentials(client, source, out error))
|
||||
{
|
||||
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 false;
|
||||
}
|
||||
|
||||
return client;
|
||||
return true;
|
||||
}
|
||||
|
||||
private async Task<string?> GetManifestDigestAsync(
|
||||
HttpClient client,
|
||||
RegistrySource source,
|
||||
Uri registryUri,
|
||||
string repository,
|
||||
string tag,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var url = $"{NormalizeRegistryUrl(source.RegistryUrl)}/v2/{repository}/manifests/{tag}";
|
||||
var url = new Uri(registryUri, $"/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"));
|
||||
@@ -299,15 +338,74 @@ public class RegistryDiscoveryService : IRegistryDiscoveryService
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string NormalizeRegistryUrl(string url)
|
||||
private bool TryGetRegistryBaseUri(string raw, out Uri registryUri, out string error)
|
||||
{
|
||||
url = url.TrimEnd('/');
|
||||
if (!url.StartsWith("http://", StringComparison.OrdinalIgnoreCase) &&
|
||||
!url.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
|
||||
return OutboundUrlPolicy.TryNormalizeUri(
|
||||
raw,
|
||||
_allowedSchemes,
|
||||
_allowedHosts,
|
||||
_allowAllHosts,
|
||||
allowMissingScheme: true,
|
||||
defaultScheme: "https",
|
||||
out registryUri!,
|
||||
out error);
|
||||
}
|
||||
|
||||
private bool TryApplyCredentials(HttpClient client, RegistrySource source, out string? error)
|
||||
{
|
||||
error = null;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(source.CredentialRef))
|
||||
{
|
||||
url = "https://" + url;
|
||||
return true;
|
||||
}
|
||||
return url;
|
||||
|
||||
var credentialRef = source.CredentialRef.Trim();
|
||||
if (credentialRef.StartsWith("authref://", StringComparison.OrdinalIgnoreCase) ||
|
||||
credentialRef.StartsWith("secret://", StringComparison.OrdinalIgnoreCase) ||
|
||||
credentialRef.StartsWith("vault://", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!_httpOptions.AllowInlineCredentials)
|
||||
{
|
||||
error = "Inline credentials are disabled.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (credentialRef.StartsWith("basic:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var parts = credentialRef[6..].Split(':', 2);
|
||||
if (parts.Length != 2 || string.IsNullOrWhiteSpace(parts[0]))
|
||||
{
|
||||
error = "Invalid basic credential format.";
|
||||
return false;
|
||||
}
|
||||
|
||||
var credentials = Convert.ToBase64String(
|
||||
System.Text.Encoding.UTF8.GetBytes($"{parts[0]}:{parts[1]}"));
|
||||
client.DefaultRequestHeaders.Authorization =
|
||||
new AuthenticationHeaderValue("Basic", credentials);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (credentialRef.StartsWith("bearer:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var token = credentialRef[7..];
|
||||
if (string.IsNullOrWhiteSpace(token))
|
||||
{
|
||||
error = "Invalid bearer credential format.";
|
||||
return false;
|
||||
}
|
||||
|
||||
client.DefaultRequestHeaders.Authorization =
|
||||
new AuthenticationHeaderValue("Bearer", token);
|
||||
return true;
|
||||
}
|
||||
|
||||
error = "Unsupported credential reference scheme.";
|
||||
return false;
|
||||
}
|
||||
|
||||
private static string? ExtractNextLink(HttpResponseHeaders headers)
|
||||
@@ -330,6 +428,43 @@ public class RegistryDiscoveryService : IRegistryDiscoveryService
|
||||
return null;
|
||||
}
|
||||
|
||||
private Uri? ResolveNextLink(Uri baseUri, string? linkValue, out string? error)
|
||||
{
|
||||
error = null;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(linkValue))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (Uri.TryCreate(linkValue, UriKind.Absolute, out var absolute))
|
||||
{
|
||||
if (!OutboundUrlPolicy.TryNormalizeUri(
|
||||
absolute.ToString(),
|
||||
_allowedSchemes,
|
||||
_allowedHosts,
|
||||
_allowAllHosts,
|
||||
allowMissingScheme: false,
|
||||
defaultScheme: "https",
|
||||
out var normalized,
|
||||
out var linkError))
|
||||
{
|
||||
error = linkError;
|
||||
return null;
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
if (Uri.TryCreate(linkValue, UriKind.Relative, out var relative))
|
||||
{
|
||||
return new Uri(baseUri, relative);
|
||||
}
|
||||
|
||||
error = "Invalid registry pagination link.";
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool MatchesRepositoryFilters(string repository, RegistrySource source)
|
||||
{
|
||||
// If no filters, match all
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace StellaOps.SbomService.Services;
|
||||
|
||||
/// <summary>
|
||||
/// HTTP options for registry discovery and access.
|
||||
/// </summary>
|
||||
public sealed class RegistryHttpOptions
|
||||
{
|
||||
public const string SectionName = "RegistryHttp";
|
||||
|
||||
[Range(typeof(TimeSpan), "00:00:01", "00:05:00", ErrorMessage = "Registry timeout must be between 1 second and 5 minutes.")]
|
||||
public TimeSpan Timeout { get; set; } = TimeSpan.FromSeconds(30);
|
||||
|
||||
public List<string> AllowedHosts { get; set; } = new() { "localhost", "127.0.0.1", "::1" };
|
||||
|
||||
public List<string> AllowedSchemes { get; set; } = new() { "https", "http" };
|
||||
|
||||
public bool AllowInlineCredentials { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace StellaOps.SbomService.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Query bounds for registry source endpoints.
|
||||
/// </summary>
|
||||
public sealed class RegistrySourceQueryOptions
|
||||
{
|
||||
public const string SectionName = "RegistrySources";
|
||||
|
||||
[Range(1, 500)]
|
||||
public int DefaultPageSize { get; set; } = 20;
|
||||
|
||||
[Range(1, 500)]
|
||||
public int MaxPageSize { get; set; } = 200;
|
||||
|
||||
[Range(1, 500)]
|
||||
public int MaxRunHistoryLimit { get; set; } = 200;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.SbomService.Models;
|
||||
using StellaOps.SbomService.Repositories;
|
||||
|
||||
@@ -10,15 +11,15 @@ namespace StellaOps.SbomService.Services;
|
||||
public interface IRegistrySourceService
|
||||
{
|
||||
Task<RegistrySource> CreateAsync(CreateRegistrySourceRequest request, string? userId, string? tenantId, CancellationToken cancellationToken = default);
|
||||
Task<RegistrySource?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
Task<RegistrySource?> GetByIdAsync(Guid id, string? tenantId, 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);
|
||||
Task<RegistrySource?> UpdateAsync(Guid id, UpdateRegistrySourceRequest request, string? userId, string? tenantId, CancellationToken cancellationToken = default);
|
||||
Task<bool> DeleteAsync(Guid id, string? userId, string? tenantId, CancellationToken cancellationToken = default);
|
||||
Task<TestRegistrySourceResponse> TestAsync(Guid id, string? userId, string? tenantId, CancellationToken cancellationToken = default);
|
||||
Task<RegistrySourceRun> TriggerAsync(Guid id, string triggerType, string? triggerMetadata, string? userId, string? tenantId, CancellationToken cancellationToken = default);
|
||||
Task<RegistrySource?> PauseAsync(Guid id, string? reason, string? userId, string? tenantId, CancellationToken cancellationToken = default);
|
||||
Task<RegistrySource?> ResumeAsync(Guid id, string? userId, string? tenantId, CancellationToken cancellationToken = default);
|
||||
Task<IReadOnlyList<RegistrySourceRun>> GetRunHistoryAsync(Guid sourceId, int limit = 50, string? tenantId = null, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -31,17 +32,31 @@ public sealed class RegistrySourceService : IRegistrySourceService
|
||||
private readonly IRegistrySourceRunRepository _runRepository;
|
||||
private readonly ILogger<RegistrySourceService> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly IGuidProvider _guidProvider;
|
||||
private readonly RegistryHttpOptions _httpOptions;
|
||||
private readonly RegistrySourceQueryOptions _queryOptions;
|
||||
private readonly IReadOnlySet<string> _allowedSchemes;
|
||||
private readonly IReadOnlyList<string> _allowedHosts;
|
||||
private readonly bool _allowAllHosts;
|
||||
|
||||
public RegistrySourceService(
|
||||
IRegistrySourceRepository sourceRepository,
|
||||
IRegistrySourceRunRepository runRepository,
|
||||
ILogger<RegistrySourceService> logger,
|
||||
TimeProvider? timeProvider = null)
|
||||
TimeProvider? timeProvider = null,
|
||||
IGuidProvider? guidProvider = null,
|
||||
IOptions<RegistryHttpOptions>? httpOptions = null,
|
||||
IOptions<RegistrySourceQueryOptions>? queryOptions = null)
|
||||
{
|
||||
_sourceRepository = sourceRepository;
|
||||
_runRepository = runRepository;
|
||||
_logger = logger;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_guidProvider = guidProvider ?? SystemGuidProvider.Instance;
|
||||
_httpOptions = httpOptions?.Value ?? new RegistryHttpOptions();
|
||||
_queryOptions = queryOptions?.Value ?? new RegistrySourceQueryOptions();
|
||||
_allowedSchemes = OutboundUrlPolicy.NormalizeSchemes(_httpOptions.AllowedSchemes);
|
||||
_allowedHosts = OutboundUrlPolicy.NormalizeHosts(_httpOptions.AllowedHosts, out _allowAllHosts);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -49,13 +64,19 @@ public sealed class RegistrySourceService : IRegistrySourceService
|
||||
/// </summary>
|
||||
public async Task<RegistrySource> CreateAsync(CreateRegistrySourceRequest request, string? userId, string? tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!TryNormalizeRegistryUrl(request.RegistryUrl, out var registryUrl, out var error))
|
||||
{
|
||||
throw new ArgumentException(error, nameof(request.RegistryUrl));
|
||||
}
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var source = new RegistrySource
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Id = _guidProvider.NewGuid(),
|
||||
Name = request.Name,
|
||||
Description = request.Description,
|
||||
Type = request.Type,
|
||||
RegistryUrl = request.RegistryUrl.TrimEnd('/'),
|
||||
RegistryUrl = registryUrl,
|
||||
AuthRefUri = request.AuthRefUri,
|
||||
IntegrationId = request.IntegrationId,
|
||||
RepoFilters = request.RepoFilters?.ToList() ?? [],
|
||||
@@ -65,6 +86,8 @@ public sealed class RegistrySourceService : IRegistrySourceService
|
||||
WebhookSecretRefUri = request.WebhookSecretRefUri,
|
||||
Status = RegistrySourceStatus.Pending,
|
||||
Tags = request.Tags?.ToList() ?? [],
|
||||
CreatedAt = now,
|
||||
UpdatedAt = now,
|
||||
CreatedBy = userId,
|
||||
UpdatedBy = userId,
|
||||
TenantId = tenantId
|
||||
@@ -79,9 +102,10 @@ public sealed class RegistrySourceService : IRegistrySourceService
|
||||
/// <summary>
|
||||
/// Gets a registry source by ID.
|
||||
/// </summary>
|
||||
public async Task<RegistrySource?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default)
|
||||
public async Task<RegistrySource?> GetByIdAsync(Guid id, string? tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _sourceRepository.GetByIdAsync(id, cancellationToken);
|
||||
var source = await _sourceRepository.GetByIdAsync(id, cancellationToken);
|
||||
return source is not null && TenantMatches(source, tenantId) ? source : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -89,6 +113,14 @@ public sealed class RegistrySourceService : IRegistrySourceService
|
||||
/// </summary>
|
||||
public async Task<PagedRegistrySourcesResponse> ListAsync(ListRegistrySourcesRequest request, string? tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
return new PagedRegistrySourcesResponse([], 0, 1, _queryOptions.DefaultPageSize, 0);
|
||||
}
|
||||
|
||||
var page = request.Page <= 0 ? 1 : request.Page;
|
||||
var pageSize = NormalizePageSize(request.PageSize);
|
||||
|
||||
var query = new RegistrySourceQuery(
|
||||
Type: request.Type,
|
||||
Status: request.Status,
|
||||
@@ -96,29 +128,36 @@ public sealed class RegistrySourceService : IRegistrySourceService
|
||||
Search: request.Search,
|
||||
IntegrationId: request.IntegrationId,
|
||||
TenantId: tenantId,
|
||||
Skip: (request.Page - 1) * request.PageSize,
|
||||
Take: request.PageSize,
|
||||
Skip: (page - 1) * pageSize,
|
||||
Take: 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);
|
||||
var totalPages = pageSize == 0 ? 0 : (int)Math.Ceiling(totalCount / (double)pageSize);
|
||||
|
||||
return new PagedRegistrySourcesResponse(sources, totalCount, request.Page, request.PageSize, totalPages);
|
||||
return new PagedRegistrySourcesResponse(sources, totalCount, page, pageSize, totalPages);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates a registry source.
|
||||
/// </summary>
|
||||
public async Task<RegistrySource?> UpdateAsync(Guid id, UpdateRegistrySourceRequest request, string? userId, CancellationToken cancellationToken = default)
|
||||
public async Task<RegistrySource?> UpdateAsync(Guid id, UpdateRegistrySourceRequest request, string? userId, string? tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var source = await _sourceRepository.GetByIdAsync(id, cancellationToken);
|
||||
if (source is null) return null;
|
||||
if (source is null || !TenantMatches(source, tenantId)) 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.RegistryUrl is not null)
|
||||
{
|
||||
if (!TryNormalizeRegistryUrl(request.RegistryUrl, out var registryUrl, out var error))
|
||||
{
|
||||
throw new ArgumentException(error, nameof(request.RegistryUrl));
|
||||
}
|
||||
source.RegistryUrl = registryUrl;
|
||||
}
|
||||
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();
|
||||
@@ -140,10 +179,10 @@ public sealed class RegistrySourceService : IRegistrySourceService
|
||||
/// <summary>
|
||||
/// Deletes a registry source.
|
||||
/// </summary>
|
||||
public async Task<bool> DeleteAsync(Guid id, string? userId, CancellationToken cancellationToken = default)
|
||||
public async Task<bool> DeleteAsync(Guid id, string? userId, string? tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var source = await _sourceRepository.GetByIdAsync(id, cancellationToken);
|
||||
if (source is null) return false;
|
||||
if (source is null || !TenantMatches(source, tenantId)) return false;
|
||||
|
||||
await _sourceRepository.DeleteAsync(id, cancellationToken);
|
||||
_logger.LogInformation("Registry source deleted: {Id} ({Name}) by {User}", id, source.Name, userId);
|
||||
@@ -154,10 +193,10 @@ public sealed class RegistrySourceService : IRegistrySourceService
|
||||
/// <summary>
|
||||
/// Tests connection to a registry source.
|
||||
/// </summary>
|
||||
public async Task<TestRegistrySourceResponse> TestAsync(Guid id, string? userId, CancellationToken cancellationToken = default)
|
||||
public async Task<TestRegistrySourceResponse> TestAsync(Guid id, string? userId, string? tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var source = await _sourceRepository.GetByIdAsync(id, cancellationToken);
|
||||
if (source is null)
|
||||
if (source is null || !TenantMatches(source, tenantId))
|
||||
{
|
||||
return new TestRegistrySourceResponse(id, false, "Registry source not found", null, TimeSpan.Zero, _timeProvider.GetUtcNow());
|
||||
}
|
||||
@@ -176,6 +215,7 @@ public sealed class RegistrySourceService : IRegistrySourceService
|
||||
{
|
||||
source.Status = newStatus;
|
||||
source.UpdatedAt = _timeProvider.GetUtcNow();
|
||||
source.UpdatedBy = userId;
|
||||
await _sourceRepository.UpdateAsync(source, cancellationToken);
|
||||
}
|
||||
|
||||
@@ -197,17 +237,17 @@ public sealed class RegistrySourceService : IRegistrySourceService
|
||||
/// <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)
|
||||
public async Task<RegistrySourceRun> TriggerAsync(Guid id, string triggerType, string? triggerMetadata, string? userId, string? tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var source = await _sourceRepository.GetByIdAsync(id, cancellationToken);
|
||||
if (source is null)
|
||||
if (source is null || !TenantMatches(source, tenantId))
|
||||
{
|
||||
throw new InvalidOperationException($"Registry source {id} not found");
|
||||
}
|
||||
|
||||
var run = new RegistrySourceRun
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Id = _guidProvider.NewGuid(),
|
||||
SourceId = id,
|
||||
Status = RegistryRunStatus.Queued,
|
||||
TriggerType = triggerType,
|
||||
@@ -226,10 +266,10 @@ public sealed class RegistrySourceService : IRegistrySourceService
|
||||
/// <summary>
|
||||
/// Pauses a registry source.
|
||||
/// </summary>
|
||||
public async Task<RegistrySource?> PauseAsync(Guid id, string? reason, string? userId, CancellationToken cancellationToken = default)
|
||||
public async Task<RegistrySource?> PauseAsync(Guid id, string? reason, string? userId, string? tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var source = await _sourceRepository.GetByIdAsync(id, cancellationToken);
|
||||
if (source is null) return null;
|
||||
if (source is null || !TenantMatches(source, tenantId)) return null;
|
||||
|
||||
source.Status = RegistrySourceStatus.Paused;
|
||||
source.UpdatedAt = _timeProvider.GetUtcNow();
|
||||
@@ -244,10 +284,10 @@ public sealed class RegistrySourceService : IRegistrySourceService
|
||||
/// <summary>
|
||||
/// Resumes a paused registry source.
|
||||
/// </summary>
|
||||
public async Task<RegistrySource?> ResumeAsync(Guid id, string? userId, CancellationToken cancellationToken = default)
|
||||
public async Task<RegistrySource?> ResumeAsync(Guid id, string? userId, string? tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var source = await _sourceRepository.GetByIdAsync(id, cancellationToken);
|
||||
if (source is null) return null;
|
||||
if (source is null || !TenantMatches(source, tenantId)) return null;
|
||||
|
||||
if (source.Status != RegistrySourceStatus.Paused)
|
||||
{
|
||||
@@ -267,9 +307,71 @@ public sealed class RegistrySourceService : IRegistrySourceService
|
||||
/// <summary>
|
||||
/// Gets run history for a registry source.
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyList<RegistrySourceRun>> GetRunHistoryAsync(Guid sourceId, int limit = 50, CancellationToken cancellationToken = default)
|
||||
public async Task<IReadOnlyList<RegistrySourceRun>> GetRunHistoryAsync(Guid sourceId, int limit = 50, string? tenantId = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _runRepository.GetBySourceIdAsync(sourceId, limit, cancellationToken);
|
||||
var source = await _sourceRepository.GetByIdAsync(sourceId, cancellationToken);
|
||||
if (source is null || !TenantMatches(source, tenantId))
|
||||
{
|
||||
return Array.Empty<RegistrySourceRun>();
|
||||
}
|
||||
|
||||
var normalizedLimit = NormalizeRunHistoryLimit(limit);
|
||||
return await _runRepository.GetBySourceIdAsync(sourceId, normalizedLimit, cancellationToken);
|
||||
}
|
||||
|
||||
private bool TryNormalizeRegistryUrl(string raw, out string normalized, out string error)
|
||||
{
|
||||
if (!OutboundUrlPolicy.TryNormalizeUri(
|
||||
raw,
|
||||
_allowedSchemes,
|
||||
_allowedHosts,
|
||||
_allowAllHosts,
|
||||
allowMissingScheme: true,
|
||||
defaultScheme: "https",
|
||||
out var uri,
|
||||
out error))
|
||||
{
|
||||
normalized = string.Empty;
|
||||
return false;
|
||||
}
|
||||
|
||||
normalized = uri.ToString().TrimEnd('/');
|
||||
return true;
|
||||
}
|
||||
|
||||
private int NormalizePageSize(int requested)
|
||||
{
|
||||
if (requested <= 0)
|
||||
{
|
||||
return _queryOptions.DefaultPageSize;
|
||||
}
|
||||
|
||||
return Math.Min(requested, _queryOptions.MaxPageSize);
|
||||
}
|
||||
|
||||
private int NormalizeRunHistoryLimit(int requested)
|
||||
{
|
||||
if (requested <= 0)
|
||||
{
|
||||
return Math.Min(50, _queryOptions.MaxRunHistoryLimit);
|
||||
}
|
||||
|
||||
return Math.Min(requested, _queryOptions.MaxRunHistoryLimit);
|
||||
}
|
||||
|
||||
private static bool TenantMatches(RegistrySource source, string? tenantId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(source.TenantId))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return string.Equals(source.TenantId, tenantId, StringComparison.Ordinal);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -122,7 +122,8 @@ public class RegistryWebhookService : IRegistryWebhookService
|
||||
sourceGuid,
|
||||
"webhook",
|
||||
$"Webhook push: {parseResult.ImageReference}",
|
||||
null,
|
||||
"webhook",
|
||||
source.TenantId,
|
||||
cancellationToken);
|
||||
|
||||
return new WebhookProcessResult(true, "Scan triggered", run.Id.ToString());
|
||||
|
||||
@@ -15,16 +15,18 @@ internal sealed class InMemorySbomAnalysisTrigger : ISbomAnalysisTrigger
|
||||
{
|
||||
private readonly ISbomLedgerRepository _repository;
|
||||
private readonly IClock _clock;
|
||||
private readonly IGuidProvider _guidProvider;
|
||||
|
||||
public InMemorySbomAnalysisTrigger(ISbomLedgerRepository repository, IClock clock)
|
||||
public InMemorySbomAnalysisTrigger(ISbomLedgerRepository repository, IClock clock, IGuidProvider? guidProvider = null)
|
||||
{
|
||||
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
|
||||
_clock = clock ?? throw new ArgumentNullException(nameof(clock));
|
||||
_guidProvider = guidProvider ?? SystemGuidProvider.Instance;
|
||||
}
|
||||
|
||||
public async Task<SbomAnalysisJob> TriggerAsync(string artifactRef, Guid versionId, CancellationToken cancellationToken)
|
||||
{
|
||||
var jobId = Guid.NewGuid().ToString("n");
|
||||
var jobId = _guidProvider.NewGuid().ToString("n");
|
||||
var job = new SbomAnalysisJob(jobId, artifactRef, versionId, _clock.UtcNow, "queued");
|
||||
await _repository.AddAnalysisJobAsync(job, cancellationToken).ConfigureAwait(false);
|
||||
return job;
|
||||
|
||||
@@ -18,19 +18,22 @@ internal sealed class SbomLedgerService : ISbomLedgerService
|
||||
private readonly IClock _clock;
|
||||
private readonly SbomLedgerOptions _options;
|
||||
private readonly ILogger<SbomLedgerService>? _logger;
|
||||
private readonly IGuidProvider _guidProvider;
|
||||
|
||||
public SbomLedgerService(
|
||||
ISbomLedgerRepository repository,
|
||||
IClock clock,
|
||||
IOptions<SbomLedgerOptions> options,
|
||||
ISbomLineageEdgeRepository? lineageEdgeRepository = null,
|
||||
ILogger<SbomLedgerService>? logger = null)
|
||||
ILogger<SbomLedgerService>? logger = null,
|
||||
IGuidProvider? guidProvider = null)
|
||||
{
|
||||
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
|
||||
_clock = clock ?? throw new ArgumentNullException(nameof(clock));
|
||||
_options = options?.Value ?? new SbomLedgerOptions();
|
||||
_lineageEdgeRepository = lineageEdgeRepository;
|
||||
_logger = logger;
|
||||
_guidProvider = guidProvider ?? SystemGuidProvider.Instance;
|
||||
}
|
||||
|
||||
public async Task<SbomLedgerVersion> AddVersionAsync(SbomLedgerSubmission submission, CancellationToken cancellationToken)
|
||||
@@ -39,11 +42,11 @@ internal sealed class SbomLedgerService : ISbomLedgerService
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var artifact = submission.ArtifactRef.Trim();
|
||||
var chainId = await _repository.GetChainIdAsync(artifact, cancellationToken).ConfigureAwait(false) ?? Guid.NewGuid();
|
||||
var chainId = await _repository.GetChainIdAsync(artifact, cancellationToken).ConfigureAwait(false) ?? _guidProvider.NewGuid();
|
||||
var existing = await _repository.GetVersionsAsync(artifact, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var sequence = existing.Count + 1;
|
||||
var versionId = Guid.NewGuid();
|
||||
var versionId = _guidProvider.NewGuid();
|
||||
var createdAt = _clock.UtcNow;
|
||||
|
||||
// LIN-BE-003: Resolve parent from ParentVersionId or ParentArtifactDigest
|
||||
@@ -124,7 +127,7 @@ internal sealed class SbomLedgerService : ISbomLedgerService
|
||||
{
|
||||
edges.Add(new SbomLineageEdgeEntity
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Id = _guidProvider.NewGuid(),
|
||||
ParentDigest = version.ParentArtifactDigest,
|
||||
ChildDigest = version.Digest,
|
||||
Relationship = LineageRelationship.Parent,
|
||||
@@ -138,7 +141,7 @@ internal sealed class SbomLedgerService : ISbomLedgerService
|
||||
{
|
||||
edges.Add(new SbomLineageEdgeEntity
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Id = _guidProvider.NewGuid(),
|
||||
ParentDigest = version.BaseImageDigest,
|
||||
ChildDigest = version.Digest,
|
||||
Relationship = LineageRelationship.Base,
|
||||
@@ -164,7 +167,7 @@ internal sealed class SbomLedgerService : ISbomLedgerService
|
||||
{
|
||||
edges.Add(new SbomLineageEdgeEntity
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Id = _guidProvider.NewGuid(),
|
||||
ParentDigest = sibling.Digest,
|
||||
ChildDigest = version.Digest,
|
||||
Relationship = LineageRelationship.Build,
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.SbomService.Models;
|
||||
using StellaOps.SbomService.Repositories;
|
||||
|
||||
@@ -45,6 +46,11 @@ public class ScanJobEmitterService : IScanJobEmitterService
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
private readonly IConfiguration _configuration;
|
||||
private readonly ILogger<ScanJobEmitterService> _logger;
|
||||
private readonly ScannerHttpOptions _scannerOptions;
|
||||
private readonly IReadOnlySet<string> _allowedSchemes;
|
||||
private readonly IReadOnlyList<string> _allowedHosts;
|
||||
private readonly bool _allowAllHosts;
|
||||
private readonly IGuidProvider _guidProvider;
|
||||
|
||||
private static readonly JsonSerializerOptions s_jsonOptions = new()
|
||||
{
|
||||
@@ -57,18 +63,29 @@ public class ScanJobEmitterService : IScanJobEmitterService
|
||||
public ScanJobEmitterService(
|
||||
IHttpClientFactory httpClientFactory,
|
||||
IConfiguration configuration,
|
||||
ILogger<ScanJobEmitterService> logger)
|
||||
ILogger<ScanJobEmitterService> logger,
|
||||
IOptions<ScannerHttpOptions>? scannerOptions = null,
|
||||
IGuidProvider? guidProvider = null)
|
||||
{
|
||||
_httpClientFactory = httpClientFactory;
|
||||
_configuration = configuration;
|
||||
_logger = logger;
|
||||
_scannerOptions = scannerOptions?.Value ?? new ScannerHttpOptions();
|
||||
_allowedSchemes = OutboundUrlPolicy.NormalizeSchemes(_scannerOptions.AllowedSchemes);
|
||||
_allowedHosts = OutboundUrlPolicy.NormalizeHosts(_scannerOptions.AllowedHosts, out _allowAllHosts);
|
||||
_guidProvider = guidProvider ?? SystemGuidProvider.Instance;
|
||||
}
|
||||
|
||||
public async Task<ScanJobResult> SubmitScanAsync(
|
||||
ScanJobRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var scannerUrl = _configuration.GetValue<string>("SbomService:ScannerUrl") ?? "http://localhost:5100";
|
||||
if (!TryGetScannerBaseUri(out var scannerUri, out var error))
|
||||
{
|
||||
_logger.LogWarning("Scanner URL rejected: {Error}", error);
|
||||
return new ScanJobResult(false, error, null, null);
|
||||
}
|
||||
|
||||
var client = _httpClientFactory.CreateClient("Scanner");
|
||||
|
||||
var submission = new
|
||||
@@ -92,16 +109,16 @@ public class ScanJobEmitterService : IScanJobEmitterService
|
||||
try
|
||||
{
|
||||
var response = await client.PostAsJsonAsync(
|
||||
$"{scannerUrl}/api/v1/scans",
|
||||
new Uri(scannerUri, "/api/v1/scans"),
|
||||
submission,
|
||||
s_jsonOptions,
|
||||
cancellationToken);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var error = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
var errorMessage = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
_logger.LogWarning("Failed to submit scan for {Image}: {Status} - {Error}",
|
||||
request.ImageReference, response.StatusCode, error);
|
||||
request.ImageReference, response.StatusCode, errorMessage);
|
||||
return new ScanJobResult(
|
||||
false,
|
||||
$"Scanner returned {response.StatusCode}",
|
||||
@@ -141,19 +158,20 @@ public class ScanJobEmitterService : IScanJobEmitterService
|
||||
var skipped = 0;
|
||||
|
||||
// Rate limit batch submissions
|
||||
var batchSize = _configuration.GetValue<int>("SbomService:BatchScanSize", 10);
|
||||
var delayMs = _configuration.GetValue<int>("SbomService:BatchScanDelayMs", 100);
|
||||
var batchSize = NormalizeBatchSize(_configuration.GetValue<int>("SbomService:BatchScanSize", 10));
|
||||
var delayMs = NormalizeBatchDelay(_configuration.GetValue<int>("SbomService:BatchScanDelayMs", 100));
|
||||
|
||||
foreach (var image in images)
|
||||
for (var index = 0; index < images.Count; index++)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var image = images[index];
|
||||
var request = new ScanJobRequest(
|
||||
ImageReference: image.FullReference,
|
||||
Digest: image.Digest,
|
||||
Platform: null,
|
||||
Force: false,
|
||||
ClientRequestId: $"registry-{sourceId}-{Guid.NewGuid():N}",
|
||||
ClientRequestId: $"registry-{sourceId}-{_guidProvider.NewGuid():N}",
|
||||
SourceId: sourceId,
|
||||
TriggerType: "discovery");
|
||||
|
||||
@@ -175,7 +193,7 @@ public class ScanJobEmitterService : IScanJobEmitterService
|
||||
}
|
||||
|
||||
// Small delay between submissions to avoid overwhelming the scanner
|
||||
if (delayMs > 0 && images.Count > 1)
|
||||
if (delayMs > 0 && images.Count > 1 && batchSize > 0 && (index + 1) % batchSize == 0 && index + 1 < images.Count)
|
||||
{
|
||||
await Task.Delay(delayMs, cancellationToken);
|
||||
}
|
||||
@@ -197,13 +215,18 @@ public class ScanJobEmitterService : IScanJobEmitterService
|
||||
string jobId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var scannerUrl = _configuration.GetValue<string>("SbomService:ScannerUrl") ?? "http://localhost:5100";
|
||||
if (!TryGetScannerBaseUri(out var scannerUri, out var error))
|
||||
{
|
||||
_logger.LogWarning("Scanner URL rejected: {Error}", error);
|
||||
return null;
|
||||
}
|
||||
|
||||
var client = _httpClientFactory.CreateClient("Scanner");
|
||||
|
||||
try
|
||||
{
|
||||
var response = await client.GetAsync(
|
||||
$"{scannerUrl}/api/v1/scans/{jobId}",
|
||||
new Uri(scannerUri, $"/api/v1/scans/{jobId}"),
|
||||
cancellationToken);
|
||||
|
||||
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
|
||||
@@ -229,6 +252,40 @@ public class ScanJobEmitterService : IScanJobEmitterService
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private bool TryGetScannerBaseUri(out Uri scannerUri, out string error)
|
||||
{
|
||||
var raw = _configuration.GetValue<string>("SbomService:ScannerUrl") ?? "http://localhost:5100";
|
||||
return OutboundUrlPolicy.TryNormalizeUri(
|
||||
raw,
|
||||
_allowedSchemes,
|
||||
_allowedHosts,
|
||||
_allowAllHosts,
|
||||
allowMissingScheme: true,
|
||||
defaultScheme: "http",
|
||||
out scannerUri!,
|
||||
out error);
|
||||
}
|
||||
|
||||
private static int NormalizeBatchSize(int requested)
|
||||
{
|
||||
if (requested <= 0)
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
|
||||
return Math.Min(requested, 200);
|
||||
}
|
||||
|
||||
private static int NormalizeBatchDelay(int requested)
|
||||
{
|
||||
if (requested < 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
return Math.Min(requested, 60000);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace StellaOps.SbomService.Services;
|
||||
|
||||
/// <summary>
|
||||
/// HTTP options for Scanner connectivity.
|
||||
/// </summary>
|
||||
public sealed class ScannerHttpOptions
|
||||
{
|
||||
public const string SectionName = "ScannerHttp";
|
||||
|
||||
[Range(typeof(TimeSpan), "00:00:01", "00:05:00", ErrorMessage = "Scanner timeout must be between 1 second and 5 minutes.")]
|
||||
public TimeSpan Timeout { get; set; } = TimeSpan.FromSeconds(30);
|
||||
|
||||
public List<string> AllowedHosts { get; set; } = new() { "localhost", "127.0.0.1", "::1" };
|
||||
|
||||
public List<string> AllowedSchemes { get; set; } = new() { "https", "http" };
|
||||
}
|
||||
Reference in New Issue
Block a user