audit, advisories and doctors/setup work

This commit is contained in:
master
2026-01-13 18:53:39 +02:00
parent 9ca7cb183e
commit d7be6ba34b
811 changed files with 54242 additions and 4056 deletions

View File

@@ -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)

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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);
}

View File

@@ -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();
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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>

View File

@@ -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; }

View File

@@ -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;
}

View File

@@ -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" }));

View File

@@ -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++;

View File

@@ -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;
}

View File

@@ -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();
}

View File

@@ -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>

View File

@@ -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);

View File

@@ -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";
}

View File

@@ -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;
}
}

View File

@@ -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

View File

@@ -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; }
}

View File

@@ -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;
}

View File

@@ -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);
}
}

View File

@@ -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());

View File

@@ -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;

View File

@@ -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,

View File

@@ -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>

View File

@@ -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" };
}