audit, advisories and doctors/setup work
This commit is contained in:
@@ -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