audit, advisories and doctors/setup work
This commit is contained in:
@@ -0,0 +1,34 @@
|
||||
namespace StellaOps.Scanner.Storage.Oci;
|
||||
|
||||
public interface IOciImageInspector
|
||||
{
|
||||
/// <summary>
|
||||
/// Inspects an OCI image reference.
|
||||
/// </summary>
|
||||
/// <param name="reference">Image reference (e.g., "nginx:latest", "ghcr.io/org/app@sha256:...").</param>
|
||||
/// <param name="options">Inspection options.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Inspection result or null if not found.</returns>
|
||||
Task<StellaOps.Scanner.Contracts.ImageInspectionResult?> InspectAsync(
|
||||
string reference,
|
||||
ImageInspectionOptions? options = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public sealed record ImageInspectionOptions
|
||||
{
|
||||
/// <summary>Resolve multi-arch index to platform manifests (default: true).</summary>
|
||||
public bool ResolveIndex { get; init; } = true;
|
||||
|
||||
/// <summary>Include layer details (default: true).</summary>
|
||||
public bool IncludeLayers { get; init; } = true;
|
||||
|
||||
/// <summary>Filter to specific platform (e.g., "linux/amd64").</summary>
|
||||
public string? PlatformFilter { get; init; }
|
||||
|
||||
/// <summary>Maximum platforms to inspect (default: unlimited).</summary>
|
||||
public int? MaxPlatforms { get; init; }
|
||||
|
||||
/// <summary>Request timeout.</summary>
|
||||
public TimeSpan? Timeout { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,882 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Immutable;
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Scanner.Contracts;
|
||||
|
||||
namespace StellaOps.Scanner.Storage.Oci;
|
||||
|
||||
public sealed class OciImageInspector : IOciImageInspector
|
||||
{
|
||||
public const string HttpClientName = "stellaops-oci-registry";
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
private static readonly string[] ManifestAccept =
|
||||
[
|
||||
OciMediaTypes.ImageIndex,
|
||||
OciMediaTypes.DockerManifestList,
|
||||
OciMediaTypes.ImageManifest,
|
||||
OciMediaTypes.DockerManifest,
|
||||
OciMediaTypes.ArtifactManifest
|
||||
];
|
||||
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
private readonly OciRegistryOptions _options;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<OciImageInspector> _logger;
|
||||
private readonly ConcurrentDictionary<string, string> _tokenCache = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public OciImageInspector(
|
||||
IHttpClientFactory httpClientFactory,
|
||||
OciRegistryOptions options,
|
||||
ILogger<OciImageInspector> logger,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory));
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public async Task<ImageInspectionResult?> InspectAsync(
|
||||
string reference,
|
||||
ImageInspectionOptions? options = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(reference))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
options ??= new ImageInspectionOptions();
|
||||
|
||||
var parsedReference = OciImageReference.Parse(reference, _options.DefaultRegistry);
|
||||
if (parsedReference is null)
|
||||
{
|
||||
_logger.LogWarning("Invalid OCI reference: {Reference}", reference);
|
||||
return null;
|
||||
}
|
||||
|
||||
using var timeoutCts = CreateTimeoutCts(options.Timeout, cancellationToken);
|
||||
var effectiveToken = timeoutCts?.Token ?? cancellationToken;
|
||||
|
||||
var auth = OciRegistryAuthorization.FromOptions(parsedReference.Registry, _options.Auth);
|
||||
var warnings = new List<string>();
|
||||
|
||||
var tagOrDigest = parsedReference.Digest ?? parsedReference.Tag ?? "latest";
|
||||
var manifestFetch = await FetchManifestAsync(parsedReference, tagOrDigest, auth, warnings, effectiveToken)
|
||||
.ConfigureAwait(false);
|
||||
if (manifestFetch is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var resolvedDigest = ResolveDigest(parsedReference, manifestFetch.Digest, warnings);
|
||||
var mediaType = NormalizeMediaType(manifestFetch.MediaType);
|
||||
var kind = ResolveManifestKind(mediaType, manifestFetch.Json, warnings);
|
||||
|
||||
var platforms = kind == ManifestKind.Index
|
||||
? await InspectIndexAsync(parsedReference, manifestFetch, options, auth, warnings, effectiveToken)
|
||||
.ConfigureAwait(false)
|
||||
: BuildSinglePlatform(await InspectManifestAsync(
|
||||
parsedReference,
|
||||
manifestFetch,
|
||||
null,
|
||||
options,
|
||||
auth,
|
||||
warnings,
|
||||
effectiveToken)
|
||||
.ConfigureAwait(false));
|
||||
|
||||
var orderedWarnings = warnings
|
||||
.Where(warning => !string.IsNullOrWhiteSpace(warning))
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.OrderBy(warning => warning, StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
|
||||
return new ImageInspectionResult
|
||||
{
|
||||
Reference = reference,
|
||||
ResolvedDigest = resolvedDigest,
|
||||
MediaType = mediaType,
|
||||
IsMultiArch = kind == ManifestKind.Index,
|
||||
Platforms = platforms,
|
||||
InspectedAt = _timeProvider.GetUtcNow(),
|
||||
InspectorVersion = ResolveInspectorVersion(),
|
||||
Registry = parsedReference.Registry,
|
||||
Repository = parsedReference.Repository,
|
||||
Warnings = orderedWarnings
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<ImmutableArray<PlatformManifest>> InspectIndexAsync(
|
||||
OciImageReference reference,
|
||||
ManifestFetchResult manifest,
|
||||
ImageInspectionOptions options,
|
||||
OciRegistryAuthorization auth,
|
||||
List<string> warnings,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var index = Deserialize<OciIndexDocument>(manifest.Json);
|
||||
if (index?.Manifests is null)
|
||||
{
|
||||
warnings.Add("Index document did not include manifests.");
|
||||
return ImmutableArray<PlatformManifest>.Empty;
|
||||
}
|
||||
|
||||
var descriptors = index.Manifests
|
||||
.Where(item => !string.IsNullOrWhiteSpace(item.Digest))
|
||||
.Select(item => BuildPlatformDescriptor(item, warnings))
|
||||
.ToList();
|
||||
|
||||
var platformFilter = ParsePlatformFilter(options.PlatformFilter, warnings);
|
||||
if (platformFilter is not null)
|
||||
{
|
||||
descriptors = descriptors
|
||||
.Where(descriptor => MatchesPlatform(descriptor, platformFilter))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
descriptors = descriptors
|
||||
.OrderBy(descriptor => descriptor.Os, StringComparer.Ordinal)
|
||||
.ThenBy(descriptor => descriptor.Architecture, StringComparer.Ordinal)
|
||||
.ThenBy(descriptor => descriptor.Variant ?? string.Empty, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
if (options.MaxPlatforms is > 0 && descriptors.Count > options.MaxPlatforms.Value)
|
||||
{
|
||||
descriptors = descriptors.Take(options.MaxPlatforms.Value).ToList();
|
||||
}
|
||||
else if (options.MaxPlatforms is <= 0)
|
||||
{
|
||||
warnings.Add("MaxPlatforms must be greater than zero when specified.");
|
||||
descriptors = [];
|
||||
}
|
||||
|
||||
if (!options.ResolveIndex)
|
||||
{
|
||||
warnings.Add("Index resolution disabled; manifest details omitted.");
|
||||
return descriptors
|
||||
.Select(descriptor => new PlatformManifest
|
||||
{
|
||||
Os = descriptor.Os,
|
||||
Architecture = descriptor.Architecture,
|
||||
Variant = descriptor.Variant,
|
||||
OsVersion = descriptor.OsVersion,
|
||||
ManifestDigest = descriptor.Digest,
|
||||
ManifestMediaType = descriptor.MediaType,
|
||||
ConfigDigest = string.Empty,
|
||||
Layers = ImmutableArray<LayerInfo>.Empty,
|
||||
TotalSize = 0
|
||||
})
|
||||
.ToImmutableArray();
|
||||
}
|
||||
|
||||
var results = new List<PlatformManifest>(descriptors.Count);
|
||||
foreach (var descriptor in descriptors)
|
||||
{
|
||||
var platform = await InspectManifestAsync(
|
||||
reference,
|
||||
manifestOverride: null,
|
||||
descriptor,
|
||||
options,
|
||||
auth,
|
||||
warnings,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (platform is not null)
|
||||
{
|
||||
results.Add(platform);
|
||||
}
|
||||
}
|
||||
|
||||
return results.ToImmutableArray();
|
||||
}
|
||||
|
||||
private async Task<PlatformManifest?> InspectManifestAsync(
|
||||
OciImageReference reference,
|
||||
ManifestFetchResult? manifestOverride,
|
||||
PlatformDescriptor? descriptor,
|
||||
ImageInspectionOptions options,
|
||||
OciRegistryAuthorization auth,
|
||||
List<string> warnings,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var manifestFetch = manifestOverride;
|
||||
if (manifestFetch is null)
|
||||
{
|
||||
if (descriptor is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
manifestFetch = await FetchManifestAsync(reference, descriptor.Digest, auth, warnings, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (manifestFetch is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var document = Deserialize<OciManifestDocument>(manifestFetch.Json);
|
||||
if (document?.Config is null)
|
||||
{
|
||||
warnings.Add($"Manifest {manifestFetch.Digest} missing config descriptor.");
|
||||
return null;
|
||||
}
|
||||
|
||||
var configDigest = document.Config.Digest ?? string.Empty;
|
||||
if (string.IsNullOrWhiteSpace(configDigest))
|
||||
{
|
||||
warnings.Add($"Manifest {manifestFetch.Digest} missing config digest.");
|
||||
}
|
||||
|
||||
var config = string.IsNullOrWhiteSpace(configDigest)
|
||||
? null
|
||||
: await FetchConfigAsync(reference, configDigest, auth, warnings, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var platform = ResolvePlatform(descriptor, config);
|
||||
var layers = options.IncludeLayers
|
||||
? BuildLayers(document.Layers, warnings)
|
||||
: ImmutableArray<LayerInfo>.Empty;
|
||||
|
||||
var totalSize = layers.Sum(layer => layer.Size);
|
||||
|
||||
return new PlatformManifest
|
||||
{
|
||||
Os = platform.Os,
|
||||
Architecture = platform.Architecture,
|
||||
Variant = platform.Variant,
|
||||
OsVersion = platform.OsVersion,
|
||||
ManifestDigest = manifestFetch.Digest,
|
||||
ManifestMediaType = NormalizeMediaType(manifestFetch.MediaType),
|
||||
ConfigDigest = configDigest,
|
||||
Layers = layers,
|
||||
TotalSize = totalSize
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<ManifestFetchResult?> FetchManifestAsync(
|
||||
OciImageReference reference,
|
||||
string tagOrDigest,
|
||||
OciRegistryAuthorization auth,
|
||||
List<string> warnings,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var headRequest = BuildManifestRequest(reference, tagOrDigest, HttpMethod.Head);
|
||||
using var headResponse = await SendWithAuthAsync(reference, headRequest, auth, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (headResponse.StatusCode == HttpStatusCode.NotFound)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var headMediaType = headResponse.Content.Headers.ContentType?.MediaType;
|
||||
var headDigest = TryGetDigest(headResponse);
|
||||
|
||||
if (!headResponse.IsSuccessStatusCode)
|
||||
{
|
||||
warnings.Add($"Manifest HEAD returned {headResponse.StatusCode}.");
|
||||
}
|
||||
|
||||
var getRequest = BuildManifestRequest(reference, tagOrDigest, HttpMethod.Get);
|
||||
using var getResponse = await SendWithAuthAsync(reference, getRequest, auth, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (getResponse.StatusCode == HttpStatusCode.NotFound)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!getResponse.IsSuccessStatusCode)
|
||||
{
|
||||
warnings.Add($"Manifest GET returned {getResponse.StatusCode}.");
|
||||
}
|
||||
|
||||
var json = await getResponse.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
var mediaType = getResponse.Content.Headers.ContentType?.MediaType ?? headMediaType ?? string.Empty;
|
||||
var digest = TryGetDigest(getResponse) ?? headDigest ?? string.Empty;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(mediaType))
|
||||
{
|
||||
warnings.Add("Manifest media type missing; falling back to JSON sniffing.");
|
||||
}
|
||||
|
||||
return new ManifestFetchResult(json, mediaType, digest);
|
||||
}
|
||||
|
||||
private async Task<OciImageConfig?> FetchConfigAsync(
|
||||
OciImageReference reference,
|
||||
string digest,
|
||||
OciRegistryAuthorization auth,
|
||||
List<string> warnings,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var uri = BuildRegistryUri(reference, $"blobs/{digest}");
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, uri);
|
||||
using var response = await SendWithAuthAsync(reference, request, auth, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
warnings.Add($"Config fetch failed for {digest}: {response.StatusCode}.");
|
||||
return null;
|
||||
}
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
return Deserialize<OciImageConfig>(json);
|
||||
}
|
||||
|
||||
private async Task<HttpResponseMessage> SendWithAuthAsync(
|
||||
OciImageReference reference,
|
||||
HttpRequestMessage request,
|
||||
OciRegistryAuthorization auth,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
auth.ApplyTo(request);
|
||||
var client = _httpClientFactory.CreateClient(HttpClientName);
|
||||
var response = await client.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
if (response.StatusCode != HttpStatusCode.Unauthorized)
|
||||
{
|
||||
return response;
|
||||
}
|
||||
|
||||
var challenge = response.Headers.WwwAuthenticate.FirstOrDefault(header =>
|
||||
header.Scheme.Equals("Bearer", StringComparison.OrdinalIgnoreCase));
|
||||
if (challenge is not null)
|
||||
{
|
||||
var token = await GetTokenAsync(reference, challenge, auth, cancellationToken).ConfigureAwait(false);
|
||||
if (!string.IsNullOrWhiteSpace(token))
|
||||
{
|
||||
response.Dispose();
|
||||
var retry = CloneRequest(request);
|
||||
retry.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||
return await client.SendAsync(retry, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (auth.AllowAnonymousFallback && auth.Mode != OciRegistryAuthMode.Anonymous)
|
||||
{
|
||||
response.Dispose();
|
||||
var retry = CloneRequest(request);
|
||||
retry.Headers.Authorization = null;
|
||||
return await client.SendAsync(retry, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
private async Task<string?> GetTokenAsync(
|
||||
OciImageReference reference,
|
||||
AuthenticationHeaderValue challenge,
|
||||
OciRegistryAuthorization auth,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var parameters = ParseChallengeParameters(challenge.Parameter);
|
||||
if (!parameters.TryGetValue("realm", out var realm) || string.IsNullOrWhiteSpace(realm))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var service = parameters.GetValueOrDefault("service");
|
||||
var scope = parameters.GetValueOrDefault("scope") ?? $"repository:{reference.Repository}:pull";
|
||||
var cacheKey = $"{realm}|{service}|{scope}";
|
||||
|
||||
if (_tokenCache.TryGetValue(cacheKey, out var cached))
|
||||
{
|
||||
return cached;
|
||||
}
|
||||
|
||||
var tokenUri = BuildTokenUri(realm, service, scope);
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, tokenUri);
|
||||
var authHeader = BuildBasicAuthHeader(auth);
|
||||
if (authHeader is not null)
|
||||
{
|
||||
request.Headers.Authorization = authHeader;
|
||||
}
|
||||
|
||||
var client = _httpClientFactory.CreateClient(HttpClientName);
|
||||
using var response = await client.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogWarning("OCI token request failed: {StatusCode}", response.StatusCode);
|
||||
return null;
|
||||
}
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
using var document = JsonDocument.Parse(json);
|
||||
if (!document.RootElement.TryGetProperty("token", out var tokenElement) &&
|
||||
!document.RootElement.TryGetProperty("access_token", out tokenElement))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var token = tokenElement.GetString();
|
||||
if (!string.IsNullOrWhiteSpace(token))
|
||||
{
|
||||
_tokenCache.TryAdd(cacheKey, token);
|
||||
}
|
||||
|
||||
return token;
|
||||
}
|
||||
|
||||
private static AuthenticationHeaderValue? BuildBasicAuthHeader(OciRegistryAuthorization auth)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(auth.Username) || auth.Password is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var token = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes($"{auth.Username}:{auth.Password}"));
|
||||
return new AuthenticationHeaderValue("Basic", token);
|
||||
}
|
||||
|
||||
private static Dictionary<string, string> ParseChallengeParameters(string? parameter)
|
||||
{
|
||||
var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
if (string.IsNullOrWhiteSpace(parameter))
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
var parts = parameter.Split(',', StringSplitOptions.RemoveEmptyEntries);
|
||||
foreach (var part in parts)
|
||||
{
|
||||
var tokens = part.Split('=', 2, StringSplitOptions.RemoveEmptyEntries);
|
||||
if (tokens.Length != 2)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var key = tokens[0].Trim();
|
||||
var value = tokens[1].Trim().Trim('"');
|
||||
if (!string.IsNullOrWhiteSpace(key))
|
||||
{
|
||||
result[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static Uri BuildTokenUri(string realm, string? service, string? scope)
|
||||
{
|
||||
var builder = new UriBuilder(realm);
|
||||
var query = new List<string>();
|
||||
if (!string.IsNullOrWhiteSpace(service))
|
||||
{
|
||||
query.Add($"service={Uri.EscapeDataString(service)}");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(scope))
|
||||
{
|
||||
query.Add($"scope={Uri.EscapeDataString(scope)}");
|
||||
}
|
||||
|
||||
builder.Query = string.Join("&", query);
|
||||
return builder.Uri;
|
||||
}
|
||||
|
||||
private HttpRequestMessage BuildManifestRequest(
|
||||
OciImageReference reference,
|
||||
string tagOrDigest,
|
||||
HttpMethod method)
|
||||
{
|
||||
var uri = BuildRegistryUri(reference, $"manifests/{tagOrDigest}");
|
||||
var request = new HttpRequestMessage(method, uri);
|
||||
foreach (var accept in ManifestAccept)
|
||||
{
|
||||
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue(accept));
|
||||
}
|
||||
return request;
|
||||
}
|
||||
|
||||
private Uri BuildRegistryUri(OciImageReference reference, string path)
|
||||
{
|
||||
var scheme = reference.Scheme;
|
||||
if (_options.AllowInsecure)
|
||||
{
|
||||
scheme = "http";
|
||||
}
|
||||
|
||||
return new Uri($"{scheme}://{reference.Registry}/v2/{reference.Repository}/{path}");
|
||||
}
|
||||
|
||||
private static ManifestKind ResolveManifestKind(string mediaType, string json, List<string> warnings)
|
||||
{
|
||||
if (IsIndexMediaType(mediaType))
|
||||
{
|
||||
return ManifestKind.Index;
|
||||
}
|
||||
|
||||
if (IsManifestMediaType(mediaType))
|
||||
{
|
||||
return ManifestKind.Manifest;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var document = JsonDocument.Parse(json);
|
||||
if (document.RootElement.TryGetProperty("manifests", out var manifests) &&
|
||||
manifests.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
return ManifestKind.Index;
|
||||
}
|
||||
|
||||
if (document.RootElement.TryGetProperty("config", out _) &&
|
||||
document.RootElement.TryGetProperty("layers", out _))
|
||||
{
|
||||
return ManifestKind.Manifest;
|
||||
}
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
warnings.Add("Unable to parse manifest JSON.");
|
||||
return ManifestKind.Unknown;
|
||||
}
|
||||
|
||||
warnings.Add($"Unknown manifest media type '{mediaType}'.");
|
||||
return ManifestKind.Unknown;
|
||||
}
|
||||
|
||||
private static ImmutableArray<LayerInfo> BuildLayers(
|
||||
IReadOnlyList<OciDescriptor>? layers,
|
||||
List<string> warnings)
|
||||
{
|
||||
if (layers is null || layers.Count == 0)
|
||||
{
|
||||
return ImmutableArray<LayerInfo>.Empty;
|
||||
}
|
||||
|
||||
var builder = ImmutableArray.CreateBuilder<LayerInfo>(layers.Count);
|
||||
for (var i = 0; i < layers.Count; i++)
|
||||
{
|
||||
var layer = layers[i];
|
||||
if (string.IsNullOrWhiteSpace(layer.Digest))
|
||||
{
|
||||
warnings.Add($"Layer {i} missing digest.");
|
||||
continue;
|
||||
}
|
||||
|
||||
builder.Add(new LayerInfo
|
||||
{
|
||||
Order = i,
|
||||
Digest = layer.Digest,
|
||||
MediaType = layer.MediaType,
|
||||
Size = layer.Size,
|
||||
Annotations = NormalizeAnnotations(layer.Annotations)
|
||||
});
|
||||
}
|
||||
|
||||
return builder.ToImmutable();
|
||||
}
|
||||
|
||||
private static ImmutableDictionary<string, string>? NormalizeAnnotations(
|
||||
IReadOnlyDictionary<string, string>? annotations)
|
||||
{
|
||||
if (annotations is null || annotations.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return annotations
|
||||
.Where(kvp => !string.IsNullOrWhiteSpace(kvp.Key))
|
||||
.OrderBy(kvp => kvp.Key, StringComparer.Ordinal)
|
||||
.ToImmutableDictionary(kvp => kvp.Key, kvp => kvp.Value, StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
private static PlatformDescriptor BuildPlatformDescriptor(
|
||||
OciIndexDescriptor descriptor,
|
||||
List<string> warnings)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(descriptor.MediaType))
|
||||
{
|
||||
warnings.Add($"Index manifest {descriptor.Digest} missing mediaType.");
|
||||
}
|
||||
|
||||
var platform = descriptor.Platform;
|
||||
return new PlatformDescriptor(
|
||||
Digest: descriptor.Digest ?? string.Empty,
|
||||
MediaType: descriptor.MediaType ?? string.Empty,
|
||||
Os: platform?.Os ?? "unknown",
|
||||
Architecture: platform?.Architecture ?? "unknown",
|
||||
Variant: platform?.Variant,
|
||||
OsVersion: platform?.OsVersion,
|
||||
Annotations: descriptor.Annotations);
|
||||
}
|
||||
|
||||
private static PlatformDescriptor? ParsePlatformFilter(string? filter, List<string> warnings)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(filter))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var parts = filter.Split('/', StringSplitOptions.RemoveEmptyEntries);
|
||||
if (parts.Length < 2 || parts.Length > 3)
|
||||
{
|
||||
warnings.Add($"Invalid platform filter '{filter}'.");
|
||||
return null;
|
||||
}
|
||||
|
||||
return new PlatformDescriptor(
|
||||
Digest: string.Empty,
|
||||
MediaType: string.Empty,
|
||||
Os: parts[0],
|
||||
Architecture: parts[1],
|
||||
Variant: parts.Length == 3 ? parts[2] : null,
|
||||
OsVersion: null,
|
||||
Annotations: null);
|
||||
}
|
||||
|
||||
private static bool MatchesPlatform(PlatformDescriptor descriptor, PlatformDescriptor filter)
|
||||
{
|
||||
if (!string.Equals(descriptor.Os, filter.Os, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!string.Equals(descriptor.Architecture, filter.Architecture, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(filter.Variant))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return string.Equals(descriptor.Variant, filter.Variant, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static PlatformDescriptor ResolvePlatform(
|
||||
PlatformDescriptor? descriptor,
|
||||
OciImageConfig? config)
|
||||
{
|
||||
var os = config?.Os ?? descriptor?.Os ?? "unknown";
|
||||
var arch = config?.Architecture ?? descriptor?.Architecture ?? "unknown";
|
||||
var variant = config?.Variant ?? descriptor?.Variant;
|
||||
var osVersion = config?.OsVersion ?? descriptor?.OsVersion;
|
||||
|
||||
return new PlatformDescriptor(
|
||||
Digest: descriptor?.Digest ?? string.Empty,
|
||||
MediaType: descriptor?.MediaType ?? string.Empty,
|
||||
Os: os,
|
||||
Architecture: arch,
|
||||
Variant: variant,
|
||||
OsVersion: osVersion,
|
||||
Annotations: descriptor?.Annotations);
|
||||
}
|
||||
|
||||
private static string ResolveDigest(
|
||||
OciImageReference reference,
|
||||
string digest,
|
||||
List<string> warnings)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(digest))
|
||||
{
|
||||
return digest;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(reference.Digest))
|
||||
{
|
||||
return reference.Digest;
|
||||
}
|
||||
|
||||
warnings.Add("Resolved digest missing from registry response.");
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
private static string NormalizeMediaType(string? mediaType)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(mediaType))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var trimmed = mediaType.Trim();
|
||||
var separator = trimmed.IndexOf(';');
|
||||
return separator > 0 ? trimmed[..separator].Trim() : trimmed;
|
||||
}
|
||||
|
||||
private static bool IsIndexMediaType(string mediaType)
|
||||
=> mediaType.Equals(OciMediaTypes.ImageIndex, StringComparison.OrdinalIgnoreCase) ||
|
||||
mediaType.Equals(OciMediaTypes.DockerManifestList, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
private static bool IsManifestMediaType(string mediaType)
|
||||
=> mediaType.Equals(OciMediaTypes.ImageManifest, StringComparison.OrdinalIgnoreCase) ||
|
||||
mediaType.Equals(OciMediaTypes.DockerManifest, StringComparison.OrdinalIgnoreCase) ||
|
||||
mediaType.Equals(OciMediaTypes.ArtifactManifest, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
private static string? TryGetDigest(HttpResponseMessage response)
|
||||
{
|
||||
if (response.Headers.TryGetValues("Docker-Content-Digest", out var values))
|
||||
{
|
||||
return values.FirstOrDefault();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static CancellationTokenSource? CreateTimeoutCts(
|
||||
TimeSpan? timeout,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!timeout.HasValue)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
cts.CancelAfter(timeout.Value);
|
||||
return cts;
|
||||
}
|
||||
|
||||
private static string ResolveInspectorVersion()
|
||||
{
|
||||
var version = typeof(OciImageInspector).Assembly.GetName().Version?.ToString();
|
||||
return string.IsNullOrWhiteSpace(version) ? "stellaops-scanner" : version;
|
||||
}
|
||||
|
||||
private static HttpRequestMessage CloneRequest(HttpRequestMessage request)
|
||||
{
|
||||
var clone = new HttpRequestMessage(request.Method, request.RequestUri);
|
||||
foreach (var header in request.Headers)
|
||||
{
|
||||
clone.Headers.TryAddWithoutValidation(header.Key, header.Value);
|
||||
}
|
||||
|
||||
if (request.Content is not null)
|
||||
{
|
||||
clone.Content = request.Content;
|
||||
}
|
||||
|
||||
return clone;
|
||||
}
|
||||
|
||||
private static T? Deserialize<T>(string json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
{
|
||||
return default;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<T>(json, JsonOptions);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return default;
|
||||
}
|
||||
}
|
||||
|
||||
private static ImmutableArray<PlatformManifest> BuildSinglePlatform(PlatformManifest? platform)
|
||||
{
|
||||
return platform is null
|
||||
? ImmutableArray<PlatformManifest>.Empty
|
||||
: ImmutableArray.Create(platform);
|
||||
}
|
||||
|
||||
private sealed record ManifestFetchResult(string Json, string MediaType, string Digest);
|
||||
|
||||
private sealed record PlatformDescriptor(
|
||||
string Digest,
|
||||
string MediaType,
|
||||
string Os,
|
||||
string Architecture,
|
||||
string? Variant,
|
||||
string? OsVersion,
|
||||
IReadOnlyDictionary<string, string>? Annotations);
|
||||
|
||||
private enum ManifestKind
|
||||
{
|
||||
Unknown = 0,
|
||||
Manifest = 1,
|
||||
Index = 2
|
||||
}
|
||||
|
||||
private sealed record OciIndexDocument
|
||||
{
|
||||
[JsonPropertyName("schemaVersion")]
|
||||
public int SchemaVersion { get; init; }
|
||||
|
||||
[JsonPropertyName("mediaType")]
|
||||
public string? MediaType { get; init; }
|
||||
|
||||
[JsonPropertyName("manifests")]
|
||||
public List<OciIndexDescriptor>? Manifests { get; init; }
|
||||
}
|
||||
|
||||
private sealed record OciIndexDescriptor
|
||||
{
|
||||
[JsonPropertyName("mediaType")]
|
||||
public string? MediaType { get; init; }
|
||||
|
||||
[JsonPropertyName("digest")]
|
||||
public string? Digest { get; init; }
|
||||
|
||||
[JsonPropertyName("size")]
|
||||
public long Size { get; init; }
|
||||
|
||||
[JsonPropertyName("platform")]
|
||||
public OciPlatform? Platform { get; init; }
|
||||
|
||||
[JsonPropertyName("annotations")]
|
||||
public IReadOnlyDictionary<string, string>? Annotations { get; init; }
|
||||
}
|
||||
|
||||
private sealed record OciPlatform
|
||||
{
|
||||
[JsonPropertyName("os")]
|
||||
public string? Os { get; init; }
|
||||
|
||||
[JsonPropertyName("architecture")]
|
||||
public string? Architecture { get; init; }
|
||||
|
||||
[JsonPropertyName("variant")]
|
||||
public string? Variant { get; init; }
|
||||
|
||||
[JsonPropertyName("os.version")]
|
||||
public string? OsVersion { get; init; }
|
||||
}
|
||||
|
||||
private sealed record OciManifestDocument
|
||||
{
|
||||
[JsonPropertyName("schemaVersion")]
|
||||
public int SchemaVersion { get; init; }
|
||||
|
||||
[JsonPropertyName("mediaType")]
|
||||
public string? MediaType { get; init; }
|
||||
|
||||
[JsonPropertyName("config")]
|
||||
public OciDescriptor? Config { get; init; }
|
||||
|
||||
[JsonPropertyName("layers")]
|
||||
public List<OciDescriptor>? Layers { get; init; }
|
||||
}
|
||||
|
||||
private sealed record OciImageConfig
|
||||
{
|
||||
[JsonPropertyName("os")]
|
||||
public string? Os { get; init; }
|
||||
|
||||
[JsonPropertyName("architecture")]
|
||||
public string? Architecture { get; init; }
|
||||
|
||||
[JsonPropertyName("variant")]
|
||||
public string? Variant { get; init; }
|
||||
|
||||
[JsonPropertyName("os.version")]
|
||||
public string? OsVersion { get; init; }
|
||||
}
|
||||
}
|
||||
@@ -7,11 +7,27 @@ public static class OciMediaTypes
|
||||
/// </summary>
|
||||
public const string ImageManifest = "application/vnd.oci.image.manifest.v1+json";
|
||||
|
||||
/// <summary>
|
||||
/// OCI 1.1 image index (multi-arch manifest list).
|
||||
/// </summary>
|
||||
public const string ImageIndex = "application/vnd.oci.image.index.v1+json";
|
||||
|
||||
/// <summary>
|
||||
/// Docker manifest list (multi-arch).
|
||||
/// </summary>
|
||||
public const string DockerManifestList = "application/vnd.docker.distribution.manifest.list.v2+json";
|
||||
|
||||
/// <summary>
|
||||
/// Docker image manifest.
|
||||
/// </summary>
|
||||
public const string DockerManifest = "application/vnd.docker.distribution.manifest.v2+json";
|
||||
|
||||
/// <summary>
|
||||
/// Deprecated artifact manifest type (kept for compatibility, prefer ImageManifest).
|
||||
/// </summary>
|
||||
public const string ArtifactManifest = "application/vnd.oci.artifact.manifest.v1+json";
|
||||
|
||||
public const string ImageConfig = "application/vnd.oci.image.config.v1+json";
|
||||
public const string EmptyConfig = "application/vnd.oci.empty.v1+json";
|
||||
public const string OctetStream = "application/octet-stream";
|
||||
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace StellaOps.Scanner.Storage.Oci;
|
||||
|
||||
public static class ServiceCollectionExtensions
|
||||
{
|
||||
public static IServiceCollection AddOciImageInspector(
|
||||
this IServiceCollection services,
|
||||
Action<OciRegistryOptions> configure)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
ArgumentNullException.ThrowIfNull(configure);
|
||||
|
||||
services.AddOptions<OciRegistryOptions>().Configure(configure);
|
||||
RegisterInspectorServices(services);
|
||||
return services;
|
||||
}
|
||||
|
||||
public static IServiceCollection AddOciImageInspector(
|
||||
this IServiceCollection services,
|
||||
IConfiguration configuration)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
ArgumentNullException.ThrowIfNull(configuration);
|
||||
|
||||
services.AddOptions<OciRegistryOptions>().Bind(configuration);
|
||||
RegisterInspectorServices(services);
|
||||
return services;
|
||||
}
|
||||
|
||||
private static void RegisterInspectorServices(IServiceCollection services)
|
||||
{
|
||||
services.TryAddSingleton<TimeProvider>(TimeProvider.System);
|
||||
|
||||
services.AddHttpClient(OciImageInspector.HttpClientName)
|
||||
.ConfigurePrimaryHttpMessageHandler(sp =>
|
||||
{
|
||||
var options = sp.GetRequiredService<IOptions<OciRegistryOptions>>().Value;
|
||||
if (!options.AllowInsecure)
|
||||
{
|
||||
return new HttpClientHandler();
|
||||
}
|
||||
|
||||
return new HttpClientHandler
|
||||
{
|
||||
ServerCertificateCustomValidationCallback =
|
||||
HttpClientHandler.DangerousAcceptAnyServerCertificateValidator
|
||||
};
|
||||
});
|
||||
|
||||
services.TryAddSingleton<IOciImageInspector>(sp =>
|
||||
{
|
||||
var options = sp.GetRequiredService<IOptions<OciRegistryOptions>>().Value;
|
||||
return new OciImageInspector(
|
||||
sp.GetRequiredService<IHttpClientFactory>(),
|
||||
options,
|
||||
sp.GetRequiredService<ILogger<OciImageInspector>>(),
|
||||
sp.GetRequiredService<TimeProvider>());
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,12 @@
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" />
|
||||
</ItemGroup>
|
||||
<!-- NOTE: Reachability reference intentionally removed to break circular dependency:
|
||||
Reachability -> SmartDiff -> Storage.Oci -> Reachability
|
||||
@@ -15,6 +20,7 @@
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Cryptography\StellaOps.Cryptography.csproj" />
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Replay.Core\StellaOps.Replay.Core.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Scanner.Contracts\StellaOps.Scanner.Contracts.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Scanner.Evidence\StellaOps.Scanner.Evidence.csproj" />
|
||||
<ProjectReference Include="..\..\..\Attestor\StellaOps.Attestor.Envelope\StellaOps.Attestor.Envelope.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
Reference in New Issue
Block a user