audit, advisories and doctors/setup work
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user