Refactor code structure for improved readability and maintainability; optimize performance in key functions.
This commit is contained in:
@@ -0,0 +1,33 @@
|
||||
# AGENTS - Scanner Storage.Oci Library
|
||||
|
||||
## Mission
|
||||
Package and store reachability slice artifacts as OCI artifacts with deterministic manifests and offline-friendly layouts.
|
||||
|
||||
## Roles
|
||||
- Backend engineer (.NET 10, C# preview).
|
||||
- QA engineer (unit/integration tests for manifest building and push flows).
|
||||
|
||||
## Required Reading
|
||||
- `docs/README.md`
|
||||
- `docs/07_HIGH_LEVEL_ARCHITECTURE.md`
|
||||
- `docs/modules/platform/architecture-overview.md`
|
||||
- `docs/modules/scanner/architecture.md`
|
||||
- `docs/reachability/binary-reachability-schema.md`
|
||||
- `docs/24_OFFLINE_KIT.md`
|
||||
|
||||
## Working Directory & Boundaries
|
||||
- Primary scope: `src/Scanner/__Libraries/StellaOps.Scanner.Storage.Oci/`
|
||||
- Tests: `src/Scanner/__Tests/StellaOps.Scanner.Storage.Oci.Tests/`
|
||||
- Avoid cross-module edits unless explicitly noted in the sprint.
|
||||
|
||||
## Determinism & Offline Rules
|
||||
- Stable ordering for manifest layers and annotations.
|
||||
- Support OCI layout for offline export without network calls.
|
||||
|
||||
## Testing Expectations
|
||||
- Unit tests for manifest building and annotation ordering.
|
||||
- Integration tests for registry push with mocked registry endpoints.
|
||||
|
||||
## Workflow
|
||||
- Update sprint status on task transitions.
|
||||
- Log notable decisions in sprint Execution Log.
|
||||
@@ -0,0 +1,22 @@
|
||||
namespace StellaOps.Scanner.Storage.Oci;
|
||||
|
||||
/// <summary>
|
||||
/// Service for pushing OCI artifacts to registries.
|
||||
/// Sprint: SPRINT_3850_0001_0001
|
||||
/// </summary>
|
||||
public interface IOciPushService
|
||||
{
|
||||
/// <summary>
|
||||
/// Push an OCI artifact to a registry.
|
||||
/// </summary>
|
||||
Task<OciArtifactPushResult> PushAsync(
|
||||
OciArtifactPushRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Push a slice artifact to a registry.
|
||||
/// </summary>
|
||||
Task<OciArtifactPushResult> PushSliceAsync(
|
||||
SliceArtifactInput input,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace StellaOps.Scanner.Storage.Oci;
|
||||
|
||||
public static class OciAnnotations
|
||||
{
|
||||
public const string Created = "org.opencontainers.image.created";
|
||||
public const string Title = "org.opencontainers.image.title";
|
||||
public const string Description = "org.opencontainers.image.description";
|
||||
public const string BaseDigest = "org.opencontainers.image.base.digest";
|
||||
public const string BaseName = "org.opencontainers.image.base.name";
|
||||
|
||||
public const string StellaPredicateType = "org.stellaops.predicate.type";
|
||||
public const string StellaAttestationDigest = "org.stellaops.attestation.digest";
|
||||
public const string StellaBeforeDigest = "org.stellaops.delta.before.digest";
|
||||
public const string StellaAfterDigest = "org.stellaops.delta.after.digest";
|
||||
public const string StellaSbomDigest = "org.stellaops.sbom.digest";
|
||||
public const string StellaVerdictDigest = "org.stellaops.verdict.digest";
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Cryptography;
|
||||
|
||||
namespace StellaOps.Scanner.Storage.Oci;
|
||||
|
||||
public sealed class OciArtifactPusher
|
||||
{
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
WriteIndented = false
|
||||
};
|
||||
|
||||
private static readonly byte[] EmptyConfigBlob = "{}"u8.ToArray();
|
||||
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly ICryptoHash _cryptoHash;
|
||||
private readonly OciRegistryOptions _options;
|
||||
private readonly ILogger<OciArtifactPusher> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public OciArtifactPusher(
|
||||
HttpClient httpClient,
|
||||
ICryptoHash cryptoHash,
|
||||
OciRegistryOptions options,
|
||||
ILogger<OciArtifactPusher> logger,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
|
||||
_cryptoHash = cryptoHash ?? throw new ArgumentNullException(nameof(cryptoHash));
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public async Task<OciArtifactPushResult> PushAsync(
|
||||
OciArtifactPushRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(request.Reference);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(request.ArtifactType);
|
||||
|
||||
if (request.Layers.Count == 0)
|
||||
{
|
||||
return OciArtifactPushResult.Failed("No layers supplied for OCI push.");
|
||||
}
|
||||
|
||||
var reference = OciImageReference.Parse(request.Reference, _options.DefaultRegistry);
|
||||
if (reference is null)
|
||||
{
|
||||
return OciArtifactPushResult.Failed($"Invalid OCI reference: {request.Reference}");
|
||||
}
|
||||
|
||||
var auth = OciRegistryAuthorization.FromOptions(reference.Registry, _options.Auth);
|
||||
|
||||
try
|
||||
{
|
||||
var configDigest = await PushBlobAsync(reference, EmptyConfigBlob, OciMediaTypes.EmptyConfig, auth, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var layerDescriptors = new List<OciDescriptor>();
|
||||
var layerDigests = new List<string>();
|
||||
|
||||
foreach (var layer in request.Layers)
|
||||
{
|
||||
var digest = await PushBlobAsync(reference, layer.Content, layer.MediaType, auth, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
layerDescriptors.Add(new OciDescriptor
|
||||
{
|
||||
MediaType = layer.MediaType,
|
||||
Digest = digest,
|
||||
Size = layer.Content.Length,
|
||||
Annotations = NormalizeAnnotations(layer.Annotations)
|
||||
});
|
||||
|
||||
layerDigests.Add(digest);
|
||||
}
|
||||
|
||||
var manifest = BuildManifest(request, configDigest, layerDescriptors);
|
||||
var manifestBytes = JsonSerializer.SerializeToUtf8Bytes(manifest, SerializerOptions);
|
||||
var manifestDigest = ComputeDigest(manifestBytes);
|
||||
|
||||
var tag = reference.Tag ?? manifestDigest.Replace("sha256:", string.Empty, StringComparison.Ordinal);
|
||||
await PushManifestAsync(reference, manifestBytes, tag, auth, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var manifestReference = $"{reference.Registry}/{reference.Repository}@{manifestDigest}";
|
||||
|
||||
_logger.LogInformation("Pushed OCI artifact {Reference}", manifestReference);
|
||||
|
||||
return new OciArtifactPushResult
|
||||
{
|
||||
Success = true,
|
||||
ManifestDigest = manifestDigest,
|
||||
ManifestReference = manifestReference,
|
||||
LayerDigests = layerDigests
|
||||
};
|
||||
}
|
||||
catch (OciRegistryException ex)
|
||||
{
|
||||
_logger.LogError(ex, "OCI push failed: {Message}", ex.Message);
|
||||
return OciArtifactPushResult.Failed(ex.Message);
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
_logger.LogError(ex, "OCI push HTTP error: {Message}", ex.Message);
|
||||
return OciArtifactPushResult.Failed($"HTTP error: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private OciArtifactManifest BuildManifest(
|
||||
OciArtifactPushRequest request,
|
||||
string configDigest,
|
||||
IReadOnlyList<OciDescriptor> layers)
|
||||
{
|
||||
var annotations = NormalizeAnnotations(request.Annotations);
|
||||
if (annotations is null)
|
||||
{
|
||||
annotations = new SortedDictionary<string, string>(StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
annotations["org.opencontainers.image.created"] = _timeProvider.GetUtcNow().ToString("O");
|
||||
annotations["org.opencontainers.image.title"] = request.ArtifactType;
|
||||
|
||||
return new OciArtifactManifest
|
||||
{
|
||||
MediaType = OciMediaTypes.ArtifactManifest,
|
||||
ArtifactType = request.ArtifactType,
|
||||
Config = new OciDescriptor
|
||||
{
|
||||
MediaType = OciMediaTypes.EmptyConfig,
|
||||
Digest = configDigest,
|
||||
Size = EmptyConfigBlob.Length
|
||||
},
|
||||
Layers = layers,
|
||||
Subject = string.IsNullOrWhiteSpace(request.SubjectDigest)
|
||||
? null
|
||||
: new OciDescriptor
|
||||
{
|
||||
MediaType = OciMediaTypes.ArtifactManifest,
|
||||
Digest = request.SubjectDigest!,
|
||||
Size = 0
|
||||
},
|
||||
Annotations = annotations
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<string> PushBlobAsync(
|
||||
OciImageReference reference,
|
||||
byte[] content,
|
||||
string mediaType,
|
||||
OciRegistryAuthorization auth,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var digest = ComputeDigest(content);
|
||||
var blobUri = BuildRegistryUri(reference, $"blobs/{digest}");
|
||||
|
||||
using (var head = new HttpRequestMessage(HttpMethod.Head, blobUri))
|
||||
{
|
||||
auth.ApplyTo(head);
|
||||
using var headResponse = await _httpClient.SendAsync(head, cancellationToken).ConfigureAwait(false);
|
||||
if (headResponse.IsSuccessStatusCode)
|
||||
{
|
||||
return digest;
|
||||
}
|
||||
|
||||
if (headResponse.StatusCode != HttpStatusCode.NotFound)
|
||||
{
|
||||
throw new OciRegistryException($"Blob HEAD failed with {headResponse.StatusCode}", "ERR_OCI_BLOB_HEAD");
|
||||
}
|
||||
}
|
||||
|
||||
var startUploadUri = BuildRegistryUri(reference, "blobs/uploads/");
|
||||
using var postRequest = new HttpRequestMessage(HttpMethod.Post, startUploadUri);
|
||||
auth.ApplyTo(postRequest);
|
||||
|
||||
using var postResponse = await _httpClient.SendAsync(postRequest, cancellationToken).ConfigureAwait(false);
|
||||
if (!postResponse.IsSuccessStatusCode)
|
||||
{
|
||||
throw new OciRegistryException($"Blob upload start failed with {postResponse.StatusCode}", "ERR_OCI_UPLOAD_START");
|
||||
}
|
||||
|
||||
if (postResponse.Headers.Location is null)
|
||||
{
|
||||
throw new OciRegistryException("Blob upload start did not return a Location header.", "ERR_OCI_UPLOAD_LOCATION");
|
||||
}
|
||||
|
||||
var uploadUri = ResolveUploadUri(reference, postResponse.Headers.Location);
|
||||
uploadUri = AppendDigest(uploadUri, digest);
|
||||
|
||||
using var putRequest = new HttpRequestMessage(HttpMethod.Put, uploadUri)
|
||||
{
|
||||
Content = new ByteArrayContent(content)
|
||||
};
|
||||
putRequest.Content.Headers.ContentType = new MediaTypeHeaderValue(mediaType);
|
||||
auth.ApplyTo(putRequest);
|
||||
|
||||
using var putResponse = await _httpClient.SendAsync(putRequest, cancellationToken).ConfigureAwait(false);
|
||||
if (!putResponse.IsSuccessStatusCode)
|
||||
{
|
||||
throw new OciRegistryException($"Blob upload failed with {putResponse.StatusCode}", "ERR_OCI_UPLOAD_PUT");
|
||||
}
|
||||
|
||||
return digest;
|
||||
}
|
||||
|
||||
private async Task PushManifestAsync(
|
||||
OciImageReference reference,
|
||||
byte[] manifestBytes,
|
||||
string tag,
|
||||
OciRegistryAuthorization auth,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var manifestUri = BuildRegistryUri(reference, $"manifests/{tag}");
|
||||
using var request = new HttpRequestMessage(HttpMethod.Put, manifestUri)
|
||||
{
|
||||
Content = new ByteArrayContent(manifestBytes)
|
||||
};
|
||||
|
||||
request.Content.Headers.ContentType = new MediaTypeHeaderValue(OciMediaTypes.ArtifactManifest);
|
||||
auth.ApplyTo(request);
|
||||
|
||||
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
throw new OciRegistryException($"Manifest upload failed with {response.StatusCode}", "ERR_OCI_MANIFEST");
|
||||
}
|
||||
}
|
||||
|
||||
private string ComputeDigest(ReadOnlySpan<byte> content)
|
||||
{
|
||||
return _cryptoHash.ComputePrefixedHashForPurpose(content, HashPurpose.Interop);
|
||||
}
|
||||
|
||||
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 Uri ResolveUploadUri(OciImageReference reference, Uri location)
|
||||
{
|
||||
if (location.IsAbsoluteUri)
|
||||
{
|
||||
return location;
|
||||
}
|
||||
|
||||
return new Uri($"{reference.Scheme}://{reference.Registry}{location}");
|
||||
}
|
||||
|
||||
private static Uri AppendDigest(Uri uploadUri, string digest)
|
||||
{
|
||||
if (uploadUri.Query.Contains("digest=", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return uploadUri;
|
||||
}
|
||||
|
||||
var delimiter = string.IsNullOrEmpty(uploadUri.Query) ? "?" : "&";
|
||||
var uri = new Uri($"{uploadUri}{delimiter}digest={Uri.EscapeDataString(digest)}");
|
||||
return uri;
|
||||
}
|
||||
|
||||
private static SortedDictionary<string, string>? NormalizeAnnotations(IReadOnlyDictionary<string, string>? annotations)
|
||||
{
|
||||
if (annotations is null || annotations.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var normalized = new SortedDictionary<string, string>(StringComparer.Ordinal);
|
||||
foreach (var (key, value) in annotations)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(key) || value is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
normalized[key.Trim()] = value.Trim();
|
||||
}
|
||||
|
||||
return normalized.Count == 0 ? null : normalized;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace StellaOps.Scanner.Storage.Oci;
|
||||
|
||||
public sealed partial record OciImageReference
|
||||
{
|
||||
public required string Registry { get; init; }
|
||||
public required string Repository { get; init; }
|
||||
public string? Tag { get; init; }
|
||||
public string? Digest { get; init; }
|
||||
public string Scheme { get; init; } = "https";
|
||||
|
||||
public bool HasDigest => !string.IsNullOrEmpty(Digest);
|
||||
public bool HasTag => !string.IsNullOrEmpty(Tag);
|
||||
|
||||
public string Canonical
|
||||
{
|
||||
get
|
||||
{
|
||||
if (HasDigest)
|
||||
{
|
||||
return $"{Registry}/{Repository}@{Digest}";
|
||||
}
|
||||
|
||||
return HasTag
|
||||
? $"{Registry}/{Repository}:{Tag}"
|
||||
: $"{Registry}/{Repository}:latest";
|
||||
}
|
||||
}
|
||||
|
||||
public string RepositoryReference => $"{Registry}/{Repository}";
|
||||
|
||||
public static OciImageReference? Parse(string reference, string defaultRegistry = "docker.io")
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(reference))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
reference = reference.Trim();
|
||||
|
||||
var scheme = "https";
|
||||
if (reference.StartsWith("http://", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
scheme = "http";
|
||||
reference = reference[7..];
|
||||
}
|
||||
else if (reference.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
reference = reference[8..];
|
||||
}
|
||||
|
||||
string? digest = null;
|
||||
var digestIndex = reference.IndexOf('@');
|
||||
if (digestIndex > 0)
|
||||
{
|
||||
digest = reference[(digestIndex + 1)..];
|
||||
reference = reference[..digestIndex];
|
||||
}
|
||||
|
||||
string? tag = null;
|
||||
if (digest is null)
|
||||
{
|
||||
var tagIndex = reference.LastIndexOf(':');
|
||||
if (tagIndex > 0)
|
||||
{
|
||||
var potentialTag = reference[(tagIndex + 1)..];
|
||||
if (!potentialTag.Contains('/') && !IsPortNumber(potentialTag))
|
||||
{
|
||||
tag = potentialTag;
|
||||
reference = reference[..tagIndex];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
string registry;
|
||||
string repository;
|
||||
|
||||
var firstSlash = reference.IndexOf('/');
|
||||
if (firstSlash < 0)
|
||||
{
|
||||
registry = defaultRegistry;
|
||||
repository = reference.Contains('.') ? reference : $"library/{reference}";
|
||||
}
|
||||
else
|
||||
{
|
||||
var firstPart = reference[..firstSlash];
|
||||
if (firstPart.Contains('.') || firstPart.Contains(':') ||
|
||||
firstPart.Equals("localhost", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
registry = firstPart;
|
||||
repository = reference[(firstSlash + 1)..];
|
||||
}
|
||||
else
|
||||
{
|
||||
registry = defaultRegistry;
|
||||
repository = reference;
|
||||
}
|
||||
}
|
||||
|
||||
if (registry == "docker.io" && !repository.Contains('/'))
|
||||
{
|
||||
repository = $"library/{repository}";
|
||||
}
|
||||
|
||||
return new OciImageReference
|
||||
{
|
||||
Registry = registry,
|
||||
Repository = repository,
|
||||
Tag = tag,
|
||||
Digest = digest,
|
||||
Scheme = scheme
|
||||
};
|
||||
}
|
||||
|
||||
private static bool IsPortNumber(string value)
|
||||
=> PortRegex().IsMatch(value);
|
||||
|
||||
[GeneratedRegex("^\\d+$")]
|
||||
private static partial Regex PortRegex();
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace StellaOps.Scanner.Storage.Oci;
|
||||
|
||||
public static class OciMediaTypes
|
||||
{
|
||||
public const string ArtifactManifest = "application/vnd.oci.artifact.manifest.v1+json";
|
||||
public const string EmptyConfig = "application/vnd.oci.empty.v1+json";
|
||||
public const string OctetStream = "application/octet-stream";
|
||||
|
||||
public const string DsseEnvelope = "application/vnd.dsse.envelope.v1+json";
|
||||
public const string DeltaVerdictPredicate = "application/vnd.stellaops.delta-verdict.v1+json";
|
||||
public const string ReachabilitySubgraph = "application/vnd.stellaops.reachability-subgraph.v1+json";
|
||||
|
||||
// Sprint: SPRINT_3850_0001_0001 - Slice storage
|
||||
public const string ReachabilitySlice = "application/vnd.stellaops.slice.v1+json";
|
||||
public const string SliceConfig = "application/vnd.stellaops.slice.config.v1+json";
|
||||
public const string SliceArtifact = "application/vnd.stellaops.slice.v1+json";
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Scanner.Storage.Oci;
|
||||
|
||||
public sealed record OciDescriptor
|
||||
{
|
||||
[JsonPropertyName("mediaType")]
|
||||
public required string MediaType { get; init; }
|
||||
|
||||
[JsonPropertyName("digest")]
|
||||
public required string Digest { get; init; }
|
||||
|
||||
[JsonPropertyName("size")]
|
||||
public required long Size { get; init; }
|
||||
|
||||
[JsonPropertyName("annotations")]
|
||||
public IReadOnlyDictionary<string, string>? Annotations { get; init; }
|
||||
|
||||
[JsonPropertyName("artifactType")]
|
||||
public string? ArtifactType { get; init; }
|
||||
}
|
||||
|
||||
public sealed record OciArtifactManifest
|
||||
{
|
||||
[JsonPropertyName("schemaVersion")]
|
||||
public int SchemaVersion { get; init; } = 2;
|
||||
|
||||
[JsonPropertyName("mediaType")]
|
||||
public string MediaType { get; init; } = OciMediaTypes.ArtifactManifest;
|
||||
|
||||
[JsonPropertyName("artifactType")]
|
||||
public string? ArtifactType { get; init; }
|
||||
|
||||
[JsonPropertyName("config")]
|
||||
public required OciDescriptor Config { get; init; }
|
||||
|
||||
[JsonPropertyName("layers")]
|
||||
public IReadOnlyList<OciDescriptor> Layers { get; init; } = [];
|
||||
|
||||
[JsonPropertyName("subject")]
|
||||
public OciDescriptor? Subject { get; init; }
|
||||
|
||||
[JsonPropertyName("annotations")]
|
||||
public IReadOnlyDictionary<string, string>? Annotations { get; init; }
|
||||
}
|
||||
|
||||
public sealed record OciLayerContent
|
||||
{
|
||||
public required byte[] Content { get; init; }
|
||||
public required string MediaType { get; init; }
|
||||
public IReadOnlyDictionary<string, string>? Annotations { get; init; }
|
||||
}
|
||||
|
||||
public sealed record OciArtifactPushRequest
|
||||
{
|
||||
public required string Reference { get; init; }
|
||||
public required string ArtifactType { get; init; }
|
||||
public required IReadOnlyList<OciLayerContent> Layers { get; init; }
|
||||
public string? SubjectDigest { get; init; }
|
||||
public IReadOnlyDictionary<string, string>? Annotations { get; init; }
|
||||
}
|
||||
|
||||
public sealed record OciArtifactPushResult
|
||||
{
|
||||
public required bool Success { get; init; }
|
||||
public string? ManifestDigest { get; init; }
|
||||
public string? ManifestReference { get; init; }
|
||||
public IReadOnlyList<string>? LayerDigests { get; init; }
|
||||
public string? Error { get; init; }
|
||||
|
||||
public static OciArtifactPushResult Failed(string error)
|
||||
=> new()
|
||||
{
|
||||
Success = false,
|
||||
Error = error,
|
||||
LayerDigests = Array.Empty<string>()
|
||||
};
|
||||
}
|
||||
|
||||
public sealed class OciRegistryOptions
|
||||
{
|
||||
public string DefaultRegistry { get; set; } = "docker.io";
|
||||
public bool AllowInsecure { get; set; }
|
||||
public OciRegistryAuthOptions Auth { get; set; } = new();
|
||||
}
|
||||
|
||||
public sealed class OciRegistryAuthOptions
|
||||
{
|
||||
public string? Username { get; set; }
|
||||
public string? Password { get; set; }
|
||||
public string? Token { get; set; }
|
||||
public bool AllowAnonymousFallback { get; set; } = true;
|
||||
}
|
||||
|
||||
public sealed class OciRegistryException : Exception
|
||||
{
|
||||
public OciRegistryException(string message, string errorCode) : base(message)
|
||||
{
|
||||
ErrorCode = errorCode;
|
||||
}
|
||||
|
||||
public string ErrorCode { get; }
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
|
||||
namespace StellaOps.Scanner.Storage.Oci;
|
||||
|
||||
public enum OciRegistryAuthMode
|
||||
{
|
||||
Anonymous = 0,
|
||||
Basic = 1,
|
||||
BearerToken = 2
|
||||
}
|
||||
|
||||
public sealed record OciRegistryAuthorization
|
||||
{
|
||||
public required string Registry { get; init; }
|
||||
public required OciRegistryAuthMode Mode { get; init; }
|
||||
public string? Username { get; init; }
|
||||
public string? Password { get; init; }
|
||||
public string? Token { get; init; }
|
||||
public bool AllowAnonymousFallback { get; init; }
|
||||
|
||||
public static OciRegistryAuthorization FromOptions(string registry, OciRegistryAuthOptions options)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(options.Token))
|
||||
{
|
||||
return new OciRegistryAuthorization
|
||||
{
|
||||
Registry = registry,
|
||||
Mode = OciRegistryAuthMode.BearerToken,
|
||||
Token = options.Token,
|
||||
AllowAnonymousFallback = options.AllowAnonymousFallback
|
||||
};
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(options.Username))
|
||||
{
|
||||
return new OciRegistryAuthorization
|
||||
{
|
||||
Registry = registry,
|
||||
Mode = OciRegistryAuthMode.Basic,
|
||||
Username = options.Username,
|
||||
Password = options.Password,
|
||||
AllowAnonymousFallback = options.AllowAnonymousFallback
|
||||
};
|
||||
}
|
||||
|
||||
return new OciRegistryAuthorization
|
||||
{
|
||||
Registry = registry,
|
||||
Mode = OciRegistryAuthMode.Anonymous,
|
||||
AllowAnonymousFallback = true
|
||||
};
|
||||
}
|
||||
|
||||
public void ApplyTo(HttpRequestMessage request)
|
||||
{
|
||||
switch (Mode)
|
||||
{
|
||||
case OciRegistryAuthMode.Basic when !string.IsNullOrEmpty(Username):
|
||||
var credentials = Convert.ToBase64String(
|
||||
Encoding.UTF8.GetBytes($"{Username}:{Password ?? string.Empty}"));
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Basic", credentials);
|
||||
break;
|
||||
|
||||
case OciRegistryAuthMode.BearerToken when !string.IsNullOrEmpty(Token):
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", Token);
|
||||
break;
|
||||
|
||||
case OciRegistryAuthMode.Anonymous:
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,577 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.IO.Compression;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Scanner.Reachability.Slices;
|
||||
|
||||
namespace StellaOps.Scanner.Storage.Oci.Offline;
|
||||
|
||||
/// <summary>
|
||||
/// Options for offline bundle operations.
|
||||
/// </summary>
|
||||
public sealed record OfflineBundleOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether to include call graphs. Default: true.
|
||||
/// </summary>
|
||||
public bool IncludeGraphs { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to include SBOMs. Default: true.
|
||||
/// </summary>
|
||||
public bool IncludeSboms { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Compression level. Default: Optimal.
|
||||
/// </summary>
|
||||
public CompressionLevel CompressionLevel { get; init; } = CompressionLevel.Optimal;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to verify on import. Default: true.
|
||||
/// </summary>
|
||||
public bool VerifyOnImport { get; init; } = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bundle manifest following OCI layout conventions.
|
||||
/// </summary>
|
||||
public sealed record BundleManifest
|
||||
{
|
||||
public required string SchemaVersion { get; init; }
|
||||
public required DateTimeOffset CreatedAt { get; init; }
|
||||
public required string ScanId { get; init; }
|
||||
public required ImmutableArray<BundleArtifact> Artifacts { get; init; }
|
||||
public required BundleMetrics Metrics { get; init; }
|
||||
public required string ManifestDigest { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Artifact entry in bundle manifest.
|
||||
/// </summary>
|
||||
public sealed record BundleArtifact
|
||||
{
|
||||
public required string Digest { get; init; }
|
||||
public required string MediaType { get; init; }
|
||||
public required long Size { get; init; }
|
||||
public required string Path { get; init; }
|
||||
public ImmutableDictionary<string, string>? Annotations { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Metrics about bundle contents.
|
||||
/// </summary>
|
||||
public sealed record BundleMetrics
|
||||
{
|
||||
public int SliceCount { get; init; }
|
||||
public int GraphCount { get; init; }
|
||||
public int SbomCount { get; init; }
|
||||
public long TotalSize { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of bundle export operation.
|
||||
/// </summary>
|
||||
public sealed record BundleExportResult
|
||||
{
|
||||
public required bool Success { get; init; }
|
||||
public string? BundlePath { get; init; }
|
||||
public string? BundleDigest { get; init; }
|
||||
public BundleMetrics? Metrics { get; init; }
|
||||
public string? Error { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of bundle import operation.
|
||||
/// </summary>
|
||||
public sealed record BundleImportResult
|
||||
{
|
||||
public required bool Success { get; init; }
|
||||
public int SlicesImported { get; init; }
|
||||
public int GraphsImported { get; init; }
|
||||
public int SbomsImported { get; init; }
|
||||
public bool IntegrityVerified { get; init; }
|
||||
public string? Error { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Provider interface for slice storage operations.
|
||||
/// </summary>
|
||||
public interface ISliceStorageProvider
|
||||
{
|
||||
Task<IReadOnlyList<ReachabilitySlice>> GetSlicesForScanAsync(string scanId, CancellationToken cancellationToken = default);
|
||||
Task<byte[]?> GetGraphAsync(string digest, CancellationToken cancellationToken = default);
|
||||
Task<byte[]?> GetSbomAsync(string digest, CancellationToken cancellationToken = default);
|
||||
Task StoreSliceAsync(ReachabilitySlice slice, CancellationToken cancellationToken = default);
|
||||
Task StoreGraphAsync(string digest, byte[] data, CancellationToken cancellationToken = default);
|
||||
Task StoreSbomAsync(string digest, byte[] data, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Service for offline bundle export and import operations.
|
||||
/// Sprint: SPRINT_3850_0001_0001
|
||||
/// Task: T8
|
||||
/// </summary>
|
||||
public sealed class OfflineBundleService
|
||||
{
|
||||
private const string SchemaVersion = "1.0.0";
|
||||
private const string BlobsDirectory = "blobs/sha256";
|
||||
private const string ManifestFile = "index.json";
|
||||
|
||||
private readonly ISliceStorageProvider _storage;
|
||||
private readonly OfflineBundleOptions _options;
|
||||
private readonly ILogger<OfflineBundleService> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
WriteIndented = true
|
||||
};
|
||||
|
||||
public OfflineBundleService(
|
||||
ISliceStorageProvider storage,
|
||||
OfflineBundleOptions? options = null,
|
||||
ILogger<OfflineBundleService>? logger = null,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_storage = storage ?? throw new ArgumentNullException(nameof(storage));
|
||||
_options = options ?? new OfflineBundleOptions();
|
||||
_logger = logger ?? Microsoft.Extensions.Logging.Abstractions.NullLogger<OfflineBundleService>.Instance;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Export slices to offline bundle.
|
||||
/// </summary>
|
||||
public async Task<BundleExportResult> ExportAsync(
|
||||
string scanId,
|
||||
string outputPath,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(scanId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(outputPath);
|
||||
|
||||
try
|
||||
{
|
||||
_logger.LogInformation("Exporting slices for scan {ScanId} to {OutputPath}", scanId, outputPath);
|
||||
|
||||
// Get all slices for scan
|
||||
var slices = await _storage.GetSlicesForScanAsync(scanId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (slices.Count == 0)
|
||||
{
|
||||
return new BundleExportResult
|
||||
{
|
||||
Success = false,
|
||||
Error = $"No slices found for scan {scanId}"
|
||||
};
|
||||
}
|
||||
|
||||
// Create temp directory for bundle layout
|
||||
var tempDir = Path.Combine(Path.GetTempPath(), $"stellaops-bundle-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(tempDir);
|
||||
var blobsDir = Path.Combine(tempDir, BlobsDirectory);
|
||||
Directory.CreateDirectory(blobsDir);
|
||||
|
||||
try
|
||||
{
|
||||
var artifacts = new List<BundleArtifact>();
|
||||
var graphDigests = new HashSet<string>(StringComparer.Ordinal);
|
||||
var sbomDigests = new HashSet<string>(StringComparer.Ordinal);
|
||||
|
||||
// Export slices
|
||||
foreach (var slice in slices)
|
||||
{
|
||||
var sliceJson = JsonSerializer.Serialize(slice, JsonOptions);
|
||||
var sliceBytes = Encoding.UTF8.GetBytes(sliceJson);
|
||||
var sliceDigest = ComputeDigest(sliceBytes);
|
||||
var slicePath = Path.Combine(blobsDir, sliceDigest);
|
||||
|
||||
await File.WriteAllBytesAsync(slicePath, sliceBytes, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
artifacts.Add(new BundleArtifact
|
||||
{
|
||||
Digest = $"sha256:{sliceDigest}",
|
||||
MediaType = OciMediaTypes.ReachabilitySlice,
|
||||
Size = sliceBytes.Length,
|
||||
Path = $"{BlobsDirectory}/{sliceDigest}",
|
||||
Annotations = ImmutableDictionary<string, string>.Empty
|
||||
.Add("stellaops.slice.cveId", slice.Query?.CveId ?? "unknown")
|
||||
.Add("stellaops.slice.verdict", slice.Verdict?.Status.ToString() ?? "unknown")
|
||||
});
|
||||
|
||||
// Collect referenced graphs and SBOMs
|
||||
if (!string.IsNullOrEmpty(slice.GraphDigest))
|
||||
{
|
||||
graphDigests.Add(slice.GraphDigest);
|
||||
}
|
||||
if (!string.IsNullOrEmpty(slice.SbomDigest))
|
||||
{
|
||||
sbomDigests.Add(slice.SbomDigest);
|
||||
}
|
||||
}
|
||||
|
||||
// Export graphs if requested
|
||||
if (_options.IncludeGraphs)
|
||||
{
|
||||
foreach (var graphDigest in graphDigests)
|
||||
{
|
||||
var graphData = await _storage.GetGraphAsync(graphDigest, cancellationToken).ConfigureAwait(false);
|
||||
if (graphData != null)
|
||||
{
|
||||
var digest = ComputeDigest(graphData);
|
||||
var graphPath = Path.Combine(blobsDir, digest);
|
||||
await File.WriteAllBytesAsync(graphPath, graphData, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
artifacts.Add(new BundleArtifact
|
||||
{
|
||||
Digest = $"sha256:{digest}",
|
||||
MediaType = OciMediaTypes.ReachabilitySubgraph,
|
||||
Size = graphData.Length,
|
||||
Path = $"{BlobsDirectory}/{digest}"
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Export SBOMs if requested
|
||||
if (_options.IncludeSboms)
|
||||
{
|
||||
foreach (var sbomDigest in sbomDigests)
|
||||
{
|
||||
var sbomData = await _storage.GetSbomAsync(sbomDigest, cancellationToken).ConfigureAwait(false);
|
||||
if (sbomData != null)
|
||||
{
|
||||
var digest = ComputeDigest(sbomData);
|
||||
var sbomPath = Path.Combine(blobsDir, digest);
|
||||
await File.WriteAllBytesAsync(sbomPath, sbomData, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
artifacts.Add(new BundleArtifact
|
||||
{
|
||||
Digest = $"sha256:{digest}",
|
||||
MediaType = "application/spdx+json",
|
||||
Size = sbomData.Length,
|
||||
Path = $"{BlobsDirectory}/{digest}"
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create manifest
|
||||
var metrics = new BundleMetrics
|
||||
{
|
||||
SliceCount = slices.Count,
|
||||
GraphCount = _options.IncludeGraphs ? graphDigests.Count : 0,
|
||||
SbomCount = _options.IncludeSboms ? sbomDigests.Count : 0,
|
||||
TotalSize = artifacts.Sum(a => a.Size)
|
||||
};
|
||||
|
||||
var manifest = new BundleManifest
|
||||
{
|
||||
SchemaVersion = SchemaVersion,
|
||||
CreatedAt = _timeProvider.GetUtcNow(),
|
||||
ScanId = scanId,
|
||||
Artifacts = artifacts.ToImmutableArray(),
|
||||
Metrics = metrics,
|
||||
ManifestDigest = "" // Will be set after serialization
|
||||
};
|
||||
|
||||
var manifestJson = JsonSerializer.Serialize(manifest, JsonOptions);
|
||||
var manifestDigest = ComputeDigest(Encoding.UTF8.GetBytes(manifestJson));
|
||||
manifest = manifest with { ManifestDigest = $"sha256:{manifestDigest}" };
|
||||
manifestJson = JsonSerializer.Serialize(manifest, JsonOptions);
|
||||
|
||||
await File.WriteAllTextAsync(
|
||||
Path.Combine(tempDir, ManifestFile),
|
||||
manifestJson,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Create tar.gz
|
||||
using (var fs = File.Create(outputPath))
|
||||
using (var gzip = new GZipStream(fs, _options.CompressionLevel))
|
||||
{
|
||||
await CreateTarAsync(tempDir, gzip, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
var bundleDigest = ComputeFileDigest(outputPath);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Bundle exported: {SliceCount} slices, {GraphCount} graphs, {SbomCount} SBOMs, {TotalSize:N0} bytes",
|
||||
metrics.SliceCount, metrics.GraphCount, metrics.SbomCount, metrics.TotalSize);
|
||||
|
||||
return new BundleExportResult
|
||||
{
|
||||
Success = true,
|
||||
BundlePath = outputPath,
|
||||
BundleDigest = $"sha256:{bundleDigest}",
|
||||
Metrics = metrics
|
||||
};
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Cleanup temp directory
|
||||
try { Directory.Delete(tempDir, true); } catch { /* Ignore cleanup errors */ }
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to export bundle for scan {ScanId}", scanId);
|
||||
return new BundleExportResult
|
||||
{
|
||||
Success = false,
|
||||
Error = ex.Message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Import slices from offline bundle.
|
||||
/// </summary>
|
||||
public async Task<BundleImportResult> ImportAsync(
|
||||
string bundlePath,
|
||||
bool dryRun = false,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(bundlePath);
|
||||
|
||||
if (!File.Exists(bundlePath))
|
||||
{
|
||||
return new BundleImportResult
|
||||
{
|
||||
Success = false,
|
||||
Error = $"Bundle not found: {bundlePath}"
|
||||
};
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
_logger.LogInformation("Importing bundle from {BundlePath} (dry run: {DryRun})", bundlePath, dryRun);
|
||||
|
||||
// Extract to temp directory
|
||||
var tempDir = Path.Combine(Path.GetTempPath(), $"stellaops-import-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(tempDir);
|
||||
|
||||
try
|
||||
{
|
||||
// Extract tar.gz
|
||||
await using (var fs = File.OpenRead(bundlePath))
|
||||
await using (var gzip = new GZipStream(fs, CompressionMode.Decompress))
|
||||
{
|
||||
await ExtractTarAsync(gzip, tempDir, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// Read manifest
|
||||
var manifestPath = Path.Combine(tempDir, ManifestFile);
|
||||
if (!File.Exists(manifestPath))
|
||||
{
|
||||
return new BundleImportResult
|
||||
{
|
||||
Success = false,
|
||||
Error = "Bundle manifest not found"
|
||||
};
|
||||
}
|
||||
|
||||
var manifestJson = await File.ReadAllTextAsync(manifestPath, cancellationToken).ConfigureAwait(false);
|
||||
var manifest = JsonSerializer.Deserialize<BundleManifest>(manifestJson, JsonOptions);
|
||||
|
||||
if (manifest == null)
|
||||
{
|
||||
return new BundleImportResult
|
||||
{
|
||||
Success = false,
|
||||
Error = "Failed to parse bundle manifest"
|
||||
};
|
||||
}
|
||||
|
||||
// Verify integrity if requested
|
||||
bool integrityVerified = false;
|
||||
if (_options.VerifyOnImport)
|
||||
{
|
||||
integrityVerified = await VerifyBundleIntegrityAsync(tempDir, manifest, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (!integrityVerified)
|
||||
{
|
||||
return new BundleImportResult
|
||||
{
|
||||
Success = false,
|
||||
Error = "Bundle integrity verification failed"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (dryRun)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Dry run: would import {SliceCount} slices, {GraphCount} graphs, {SbomCount} SBOMs",
|
||||
manifest.Metrics.SliceCount,
|
||||
manifest.Metrics.GraphCount,
|
||||
manifest.Metrics.SbomCount);
|
||||
|
||||
return new BundleImportResult
|
||||
{
|
||||
Success = true,
|
||||
SlicesImported = 0,
|
||||
GraphsImported = 0,
|
||||
SbomsImported = 0,
|
||||
IntegrityVerified = integrityVerified
|
||||
};
|
||||
}
|
||||
|
||||
// Import artifacts
|
||||
int slicesImported = 0, graphsImported = 0, sbomsImported = 0;
|
||||
|
||||
foreach (var artifact in manifest.Artifacts)
|
||||
{
|
||||
var artifactPath = Path.Combine(tempDir, artifact.Path);
|
||||
if (!File.Exists(artifactPath))
|
||||
{
|
||||
_logger.LogWarning("Artifact not found in bundle: {Path}", artifact.Path);
|
||||
continue;
|
||||
}
|
||||
|
||||
var data = await File.ReadAllBytesAsync(artifactPath, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (artifact.MediaType == OciMediaTypes.ReachabilitySlice)
|
||||
{
|
||||
var slice = JsonSerializer.Deserialize<ReachabilitySlice>(data, JsonOptions);
|
||||
if (slice != null)
|
||||
{
|
||||
await _storage.StoreSliceAsync(slice, cancellationToken).ConfigureAwait(false);
|
||||
slicesImported++;
|
||||
}
|
||||
}
|
||||
else if (artifact.MediaType == OciMediaTypes.ReachabilitySubgraph)
|
||||
{
|
||||
await _storage.StoreGraphAsync(artifact.Digest, data, cancellationToken).ConfigureAwait(false);
|
||||
graphsImported++;
|
||||
}
|
||||
else if (artifact.MediaType.Contains("spdx") || artifact.MediaType.Contains("cyclonedx"))
|
||||
{
|
||||
await _storage.StoreSbomAsync(artifact.Digest, data, cancellationToken).ConfigureAwait(false);
|
||||
sbomsImported++;
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Bundle imported: {SliceCount} slices, {GraphCount} graphs, {SbomCount} SBOMs",
|
||||
slicesImported, graphsImported, sbomsImported);
|
||||
|
||||
return new BundleImportResult
|
||||
{
|
||||
Success = true,
|
||||
SlicesImported = slicesImported,
|
||||
GraphsImported = graphsImported,
|
||||
SbomsImported = sbomsImported,
|
||||
IntegrityVerified = integrityVerified
|
||||
};
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Cleanup temp directory
|
||||
try { Directory.Delete(tempDir, true); } catch { /* Ignore cleanup errors */ }
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to import bundle from {BundlePath}", bundlePath);
|
||||
return new BundleImportResult
|
||||
{
|
||||
Success = false,
|
||||
Error = ex.Message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<bool> VerifyBundleIntegrityAsync(
|
||||
string tempDir,
|
||||
BundleManifest manifest,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
foreach (var artifact in manifest.Artifacts)
|
||||
{
|
||||
var artifactPath = Path.Combine(tempDir, artifact.Path);
|
||||
if (!File.Exists(artifactPath))
|
||||
{
|
||||
_logger.LogWarning("Missing artifact: {Path}", artifact.Path);
|
||||
return false;
|
||||
}
|
||||
|
||||
var data = await File.ReadAllBytesAsync(artifactPath, cancellationToken).ConfigureAwait(false);
|
||||
var actualDigest = $"sha256:{ComputeDigest(data)}";
|
||||
|
||||
if (!string.Equals(actualDigest, artifact.Digest, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Digest mismatch for {Path}: expected {Expected}, got {Actual}",
|
||||
artifact.Path, artifact.Digest, actualDigest);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static string ComputeDigest(byte[] data)
|
||||
{
|
||||
var hash = SHA256.HashData(data);
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static string ComputeFileDigest(string path)
|
||||
{
|
||||
using var fs = File.OpenRead(path);
|
||||
var hash = SHA256.HashData(fs);
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static async Task CreateTarAsync(string sourceDir, Stream output, CancellationToken cancellationToken)
|
||||
{
|
||||
// Simplified tar creation - in production, use a proper tar library
|
||||
var files = Directory.GetFiles(sourceDir, "*", SearchOption.AllDirectories);
|
||||
using var writer = new BinaryWriter(output, Encoding.UTF8, leaveOpen: true);
|
||||
|
||||
foreach (var file in files)
|
||||
{
|
||||
var relativePath = Path.GetRelativePath(sourceDir, file).Replace('\\', '/');
|
||||
var content = await File.ReadAllBytesAsync(file, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Write simple header
|
||||
var header = Encoding.UTF8.GetBytes($"FILE:{relativePath}:{content.Length}\n");
|
||||
writer.Write(header);
|
||||
writer.Write(content);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task ExtractTarAsync(Stream input, string targetDir, CancellationToken cancellationToken)
|
||||
{
|
||||
// Simplified tar extraction - in production, use a proper tar library
|
||||
using var reader = new StreamReader(input, Encoding.UTF8, leaveOpen: true);
|
||||
using var memoryStream = new MemoryStream();
|
||||
await input.CopyToAsync(memoryStream, cancellationToken).ConfigureAwait(false);
|
||||
memoryStream.Position = 0;
|
||||
|
||||
var binaryReader = new BinaryReader(memoryStream);
|
||||
var textReader = new StreamReader(memoryStream, Encoding.UTF8, leaveOpen: true);
|
||||
|
||||
while (memoryStream.Position < memoryStream.Length)
|
||||
{
|
||||
var headerLine = textReader.ReadLine();
|
||||
if (string.IsNullOrEmpty(headerLine) || !headerLine.StartsWith("FILE:"))
|
||||
break;
|
||||
|
||||
var parts = headerLine[5..].Split(':');
|
||||
if (parts.Length != 2 || !int.TryParse(parts[1], out var size))
|
||||
break;
|
||||
|
||||
var relativePath = parts[0];
|
||||
var fullPath = Path.Combine(targetDir, relativePath);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(fullPath)!);
|
||||
|
||||
var content = new byte[size];
|
||||
_ = memoryStream.Read(content, 0, size);
|
||||
await File.WriteAllBytesAsync(fullPath, content, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.Scanner.Storage.Oci;
|
||||
|
||||
/// <summary>
|
||||
/// Builds OCI manifests for reachability slices.
|
||||
/// Sprint: SPRINT_3850_0001_0001
|
||||
/// </summary>
|
||||
public sealed class SliceOciManifestBuilder
|
||||
{
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
WriteIndented = false
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Build OCI push request for a slice artifact.
|
||||
/// </summary>
|
||||
public OciArtifactPushRequest BuildSlicePushRequest(SliceArtifactInput input)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(input);
|
||||
ArgumentNullException.ThrowIfNull(input.Slice);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(input.Reference);
|
||||
|
||||
var layers = new List<OciLayerContent>
|
||||
{
|
||||
BuildSliceLayer(input.Slice, input.SliceQuery)
|
||||
};
|
||||
|
||||
if (input.DsseEnvelope is not null)
|
||||
{
|
||||
layers.Add(BuildDsseLayer(input.DsseEnvelope));
|
||||
}
|
||||
|
||||
var annotations = BuildAnnotations(input.SliceQuery, input.Slice);
|
||||
|
||||
return new OciArtifactPushRequest
|
||||
{
|
||||
Reference = input.Reference,
|
||||
ArtifactType = OciMediaTypes.SliceArtifact,
|
||||
Layers = layers,
|
||||
SubjectDigest = input.SubjectImageDigest,
|
||||
Annotations = annotations
|
||||
};
|
||||
}
|
||||
|
||||
private OciLayerContent BuildSliceLayer(object slice, SliceQueryMetadata? query)
|
||||
{
|
||||
var sliceJson = JsonSerializer.SerializeToUtf8Bytes(slice, SerializerOptions);
|
||||
|
||||
var annotations = new Dictionary<string, string>();
|
||||
if (query is not null)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(query.CveId))
|
||||
annotations["org.stellaops.slice.cve"] = query.CveId;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(query.Purl))
|
||||
annotations["org.stellaops.slice.purl"] = query.Purl;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(query.Verdict))
|
||||
annotations["org.stellaops.slice.verdict"] = query.Verdict;
|
||||
}
|
||||
|
||||
return new OciLayerContent
|
||||
{
|
||||
Content = sliceJson,
|
||||
MediaType = OciMediaTypes.ReachabilitySlice,
|
||||
Annotations = annotations
|
||||
};
|
||||
}
|
||||
|
||||
private OciLayerContent BuildDsseLayer(byte[] dsseEnvelope)
|
||||
{
|
||||
return new OciLayerContent
|
||||
{
|
||||
Content = dsseEnvelope,
|
||||
MediaType = OciMediaTypes.DsseEnvelope,
|
||||
Annotations = new Dictionary<string, string>
|
||||
{
|
||||
["org.stellaops.attestation.type"] = "in-toto/dsse"
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private Dictionary<string, string> BuildAnnotations(SliceQueryMetadata? query, object slice)
|
||||
{
|
||||
var annotations = new Dictionary<string, string>
|
||||
{
|
||||
["org.opencontainers.image.vendor"] = "StellaOps",
|
||||
["org.stellaops.artifact.type"] = "reachability-slice"
|
||||
};
|
||||
|
||||
if (query is not null)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(query.CveId))
|
||||
annotations["org.stellaops.slice.query.cve"] = query.CveId;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(query.Purl))
|
||||
annotations["org.stellaops.slice.query.purl"] = query.Purl;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(query.ScanId))
|
||||
annotations["org.stellaops.slice.scan-id"] = query.ScanId;
|
||||
}
|
||||
|
||||
return annotations;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Input for building a slice OCI artifact.
|
||||
/// </summary>
|
||||
public sealed record SliceArtifactInput
|
||||
{
|
||||
public required string Reference { get; init; }
|
||||
public required object Slice { get; init; }
|
||||
public byte[]? DsseEnvelope { get; init; }
|
||||
public SliceQueryMetadata? SliceQuery { get; init; }
|
||||
public string? SubjectImageDigest { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Query metadata for slice annotations.
|
||||
/// </summary>
|
||||
public sealed record SliceQueryMetadata
|
||||
{
|
||||
public string? CveId { get; init; }
|
||||
public string? Purl { get; init; }
|
||||
public string? Verdict { get; init; }
|
||||
public string? ScanId { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,474 @@
|
||||
using System.Net.Http.Json;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Scanner.Reachability.Slices;
|
||||
|
||||
namespace StellaOps.Scanner.Storage.Oci;
|
||||
|
||||
/// <summary>
|
||||
/// Options for slice pulling operations.
|
||||
/// </summary>
|
||||
public sealed record SlicePullOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether to verify DSSE signature on retrieval. Default: true.
|
||||
/// </summary>
|
||||
public bool VerifySignature { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to cache pulled slices. Default: true.
|
||||
/// </summary>
|
||||
public bool EnableCache { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Cache TTL. Default: 1 hour.
|
||||
/// </summary>
|
||||
public TimeSpan CacheTtl { get; init; } = TimeSpan.FromHours(1);
|
||||
|
||||
/// <summary>
|
||||
/// Request timeout. Default: 30 seconds.
|
||||
/// </summary>
|
||||
public TimeSpan RequestTimeout { get; init; } = TimeSpan.FromSeconds(30);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of a slice pull operation.
|
||||
/// </summary>
|
||||
public sealed record SlicePullResult
|
||||
{
|
||||
public required bool Success { get; init; }
|
||||
public ReachabilitySlice? Slice { get; init; }
|
||||
public string? SliceDigest { get; init; }
|
||||
public byte[]? DsseEnvelope { get; init; }
|
||||
public string? Error { get; init; }
|
||||
public bool FromCache { get; init; }
|
||||
public bool SignatureVerified { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Service for pulling reachability slices from OCI registries.
|
||||
/// Supports content-addressed retrieval and DSSE signature verification.
|
||||
/// Sprint: SPRINT_3850_0001_0001
|
||||
/// </summary>
|
||||
public sealed class SlicePullService : IDisposable
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly OciRegistryAuthorization _authorization;
|
||||
private readonly SlicePullOptions _options;
|
||||
private readonly ILogger<SlicePullService> _logger;
|
||||
private readonly Dictionary<string, CachedSlice> _cache = new(StringComparer.Ordinal);
|
||||
private readonly Lock _cacheLock = new();
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
||||
|
||||
public SlicePullService(
|
||||
HttpClient httpClient,
|
||||
OciRegistryAuthorization authorization,
|
||||
SlicePullOptions? options = null,
|
||||
ILogger<SlicePullService>? logger = null)
|
||||
{
|
||||
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
|
||||
_authorization = authorization ?? throw new ArgumentNullException(nameof(authorization));
|
||||
_options = options ?? new SlicePullOptions();
|
||||
_logger = logger ?? Microsoft.Extensions.Logging.Abstractions.NullLogger<SlicePullService>.Instance;
|
||||
_httpClient.Timeout = _options.RequestTimeout;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pull a slice by its content-addressed digest.
|
||||
/// </summary>
|
||||
public async Task<SlicePullResult> PullByDigestAsync(
|
||||
OciImageReference reference,
|
||||
string digest,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(reference);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(digest);
|
||||
|
||||
var cacheKey = $"{reference.Registry}/{reference.Repository}@{digest}";
|
||||
|
||||
// Check cache
|
||||
if (_options.EnableCache && TryGetFromCache(cacheKey, out var cached))
|
||||
{
|
||||
_logger.LogDebug("Cache hit for slice {Digest}", digest);
|
||||
return new SlicePullResult
|
||||
{
|
||||
Success = true,
|
||||
Slice = cached!.Slice,
|
||||
SliceDigest = digest,
|
||||
DsseEnvelope = cached.DsseEnvelope,
|
||||
FromCache = true,
|
||||
SignatureVerified = cached.SignatureVerified
|
||||
};
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
_logger.LogInformation("Pulling slice {Reference}@{Digest}", reference, digest);
|
||||
|
||||
// Get manifest first
|
||||
var manifestUrl = $"https://{reference.Registry}/v2/{reference.Repository}/manifests/{digest}";
|
||||
using var manifestRequest = new HttpRequestMessage(HttpMethod.Get, manifestUrl);
|
||||
manifestRequest.Headers.Accept.ParseAdd(OciMediaTypes.ArtifactManifest);
|
||||
await _authorization.AuthorizeRequestAsync(manifestRequest, reference, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
using var manifestResponse = await _httpClient.SendAsync(manifestRequest, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (!manifestResponse.IsSuccessStatusCode)
|
||||
{
|
||||
return new SlicePullResult
|
||||
{
|
||||
Success = false,
|
||||
Error = $"Failed to fetch manifest: {manifestResponse.StatusCode}"
|
||||
};
|
||||
}
|
||||
|
||||
var manifest = await manifestResponse.Content.ReadFromJsonAsync<OciManifest>(JsonOptions, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (manifest == null)
|
||||
{
|
||||
return new SlicePullResult
|
||||
{
|
||||
Success = false,
|
||||
Error = "Failed to parse manifest"
|
||||
};
|
||||
}
|
||||
|
||||
// Find slice layer
|
||||
var sliceLayer = manifest.Layers?.FirstOrDefault(l =>
|
||||
l.MediaType == OciMediaTypes.ReachabilitySlice ||
|
||||
l.MediaType == OciMediaTypes.SliceArtifact);
|
||||
|
||||
if (sliceLayer == null)
|
||||
{
|
||||
return new SlicePullResult
|
||||
{
|
||||
Success = false,
|
||||
Error = "No slice layer found in manifest"
|
||||
};
|
||||
}
|
||||
|
||||
// Fetch slice blob
|
||||
var blobUrl = $"https://{reference.Registry}/v2/{reference.Repository}/blobs/{sliceLayer.Digest}";
|
||||
using var blobRequest = new HttpRequestMessage(HttpMethod.Get, blobUrl);
|
||||
await _authorization.AuthorizeRequestAsync(blobRequest, reference, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
using var blobResponse = await _httpClient.SendAsync(blobRequest, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (!blobResponse.IsSuccessStatusCode)
|
||||
{
|
||||
return new SlicePullResult
|
||||
{
|
||||
Success = false,
|
||||
Error = $"Failed to fetch blob: {blobResponse.StatusCode}"
|
||||
};
|
||||
}
|
||||
|
||||
var sliceBytes = await blobResponse.Content.ReadAsByteArrayAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
// Verify digest
|
||||
var computedDigest = ComputeDigest(sliceBytes);
|
||||
if (!string.Equals(computedDigest, sliceLayer.Digest, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return new SlicePullResult
|
||||
{
|
||||
Success = false,
|
||||
Error = $"Digest mismatch: expected {sliceLayer.Digest}, got {computedDigest}"
|
||||
};
|
||||
}
|
||||
|
||||
// Parse slice
|
||||
var slice = JsonSerializer.Deserialize<ReachabilitySlice>(sliceBytes, JsonOptions);
|
||||
if (slice == null)
|
||||
{
|
||||
return new SlicePullResult
|
||||
{
|
||||
Success = false,
|
||||
Error = "Failed to parse slice JSON"
|
||||
};
|
||||
}
|
||||
|
||||
// Check for DSSE envelope layer and verify if present
|
||||
byte[]? dsseEnvelope = null;
|
||||
bool signatureVerified = false;
|
||||
|
||||
var dsseLayer = manifest.Layers?.FirstOrDefault(l =>
|
||||
l.MediaType == OciMediaTypes.DsseEnvelope);
|
||||
|
||||
if (dsseLayer != null && _options.VerifySignature)
|
||||
{
|
||||
var dsseResult = await FetchAndVerifyDsseAsync(reference, dsseLayer.Digest, sliceBytes, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
dsseEnvelope = dsseResult.Envelope;
|
||||
signatureVerified = dsseResult.Verified;
|
||||
}
|
||||
|
||||
// Cache result
|
||||
if (_options.EnableCache)
|
||||
{
|
||||
AddToCache(cacheKey, new CachedSlice
|
||||
{
|
||||
Slice = slice,
|
||||
DsseEnvelope = dsseEnvelope,
|
||||
SignatureVerified = signatureVerified,
|
||||
ExpiresAt = DateTimeOffset.UtcNow.Add(_options.CacheTtl)
|
||||
});
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Successfully pulled slice {Digest} ({Size} bytes, signature verified: {Verified})",
|
||||
digest, sliceBytes.Length, signatureVerified);
|
||||
|
||||
return new SlicePullResult
|
||||
{
|
||||
Success = true,
|
||||
Slice = slice,
|
||||
SliceDigest = digest,
|
||||
DsseEnvelope = dsseEnvelope,
|
||||
FromCache = false,
|
||||
SignatureVerified = signatureVerified
|
||||
};
|
||||
}
|
||||
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException or JsonException)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to pull slice {Reference}@{Digest}", reference, digest);
|
||||
return new SlicePullResult
|
||||
{
|
||||
Success = false,
|
||||
Error = ex.Message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pull a slice by tag.
|
||||
/// </summary>
|
||||
public async Task<SlicePullResult> PullByTagAsync(
|
||||
OciImageReference reference,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(reference);
|
||||
|
||||
if (string.IsNullOrEmpty(reference.Tag))
|
||||
{
|
||||
return new SlicePullResult
|
||||
{
|
||||
Success = false,
|
||||
Error = "Tag is required"
|
||||
};
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Resolve tag to digest
|
||||
var manifestUrl = $"https://{reference.Registry}/v2/{reference.Repository}/manifests/{reference.Tag}";
|
||||
using var request = new HttpRequestMessage(HttpMethod.Head, manifestUrl);
|
||||
request.Headers.Accept.ParseAdd(OciMediaTypes.ArtifactManifest);
|
||||
await _authorization.AuthorizeRequestAsync(request, reference, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
using var response = await _httpClient.SendAsync(request, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
return new SlicePullResult
|
||||
{
|
||||
Success = false,
|
||||
Error = $"Failed to resolve tag: {response.StatusCode}"
|
||||
};
|
||||
}
|
||||
|
||||
var digest = response.Headers.GetValues("Docker-Content-Digest").FirstOrDefault();
|
||||
if (string.IsNullOrEmpty(digest))
|
||||
{
|
||||
return new SlicePullResult
|
||||
{
|
||||
Success = false,
|
||||
Error = "No digest in response headers"
|
||||
};
|
||||
}
|
||||
|
||||
return await PullByDigestAsync(reference, digest, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to pull slice by tag {Reference}", reference);
|
||||
return new SlicePullResult
|
||||
{
|
||||
Success = false,
|
||||
Error = ex.Message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// List referrers (related artifacts) for a given digest.
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyList<OciReferrer>> ListReferrersAsync(
|
||||
OciImageReference reference,
|
||||
string digest,
|
||||
string? artifactType = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(reference);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(digest);
|
||||
|
||||
try
|
||||
{
|
||||
var referrersUrl = $"https://{reference.Registry}/v2/{reference.Repository}/referrers/{digest}";
|
||||
if (!string.IsNullOrEmpty(artifactType))
|
||||
{
|
||||
referrersUrl += $"?artifactType={Uri.EscapeDataString(artifactType)}";
|
||||
}
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, referrersUrl);
|
||||
await _authorization.AuthorizeRequestAsync(request, reference, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
using var response = await _httpClient.SendAsync(request, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogWarning("Failed to list referrers for {Digest}: {Status}", digest, response.StatusCode);
|
||||
return Array.Empty<OciReferrer>();
|
||||
}
|
||||
|
||||
var index = await response.Content.ReadFromJsonAsync<OciReferrersIndex>(JsonOptions, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return index?.Manifests ?? Array.Empty<OciReferrer>();
|
||||
}
|
||||
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to list referrers for {Digest}", digest);
|
||||
return Array.Empty<OciReferrer>();
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
// HttpClient typically managed externally
|
||||
}
|
||||
|
||||
private async Task<(byte[]? Envelope, bool Verified)> FetchAndVerifyDsseAsync(
|
||||
OciImageReference reference,
|
||||
string digest,
|
||||
byte[] payload,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var blobUrl = $"https://{reference.Registry}/v2/{reference.Repository}/blobs/{digest}";
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, blobUrl);
|
||||
await _authorization.AuthorizeRequestAsync(request, reference, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
using var response = await _httpClient.SendAsync(request, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
return (null, false);
|
||||
}
|
||||
|
||||
var envelopeBytes = await response.Content.ReadAsByteArrayAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
// TODO: Actual DSSE verification using configured trust roots
|
||||
// For now, just return the envelope
|
||||
_logger.LogDebug("DSSE envelope fetched, verification pending trust root configuration");
|
||||
|
||||
return (envelopeBytes, false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to fetch/verify DSSE envelope");
|
||||
return (null, false);
|
||||
}
|
||||
}
|
||||
|
||||
private bool TryGetFromCache(string key, out CachedSlice? cached)
|
||||
{
|
||||
lock (_cacheLock)
|
||||
{
|
||||
if (_cache.TryGetValue(key, out cached))
|
||||
{
|
||||
if (cached.ExpiresAt > DateTimeOffset.UtcNow)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
_cache.Remove(key);
|
||||
}
|
||||
cached = null;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private void AddToCache(string key, CachedSlice cached)
|
||||
{
|
||||
lock (_cacheLock)
|
||||
{
|
||||
_cache[key] = cached;
|
||||
}
|
||||
}
|
||||
|
||||
private static string ComputeDigest(byte[] data)
|
||||
{
|
||||
var hash = SHA256.HashData(data);
|
||||
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
|
||||
}
|
||||
|
||||
private sealed record CachedSlice
|
||||
{
|
||||
public required ReachabilitySlice Slice { get; init; }
|
||||
public byte[]? DsseEnvelope { get; init; }
|
||||
public bool SignatureVerified { get; init; }
|
||||
public required DateTimeOffset ExpiresAt { get; init; }
|
||||
}
|
||||
|
||||
// Internal DTOs for OCI registry responses
|
||||
private sealed record OciManifest
|
||||
{
|
||||
public int SchemaVersion { get; init; }
|
||||
public string? MediaType { get; init; }
|
||||
public string? ArtifactType { get; init; }
|
||||
public OciDescriptor? Config { get; init; }
|
||||
public List<OciDescriptor>? Layers { get; init; }
|
||||
}
|
||||
|
||||
private sealed record OciDescriptor
|
||||
{
|
||||
public string? MediaType { get; init; }
|
||||
public string? Digest { get; init; }
|
||||
public long Size { get; init; }
|
||||
}
|
||||
|
||||
private sealed record OciReferrersIndex
|
||||
{
|
||||
public int SchemaVersion { get; init; }
|
||||
public string? MediaType { get; init; }
|
||||
public List<OciReferrer>? Manifests { get; init; }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// OCI referrer descriptor.
|
||||
/// </summary>
|
||||
public sealed record OciReferrer
|
||||
{
|
||||
public string? MediaType { get; init; }
|
||||
public string? Digest { get; init; }
|
||||
public long Size { get; init; }
|
||||
public string? ArtifactType { get; init; }
|
||||
public Dictionary<string, string>? Annotations { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.Scanner.Storage.Oci;
|
||||
|
||||
/// <summary>
|
||||
/// Service for pushing reachability slices to OCI registries.
|
||||
/// Supports Harbor, Zot, GHCR, and other OCI-compliant registries.
|
||||
/// Sprint: SPRINT_3850_0001_0001
|
||||
/// </summary>
|
||||
public sealed class SlicePushService : IOciPushService
|
||||
{
|
||||
private readonly OciArtifactPusher _pusher;
|
||||
private readonly SliceOciManifestBuilder _manifestBuilder;
|
||||
private readonly ILogger<SlicePushService> _logger;
|
||||
|
||||
public SlicePushService(
|
||||
OciArtifactPusher pusher,
|
||||
SliceOciManifestBuilder manifestBuilder,
|
||||
ILogger<SlicePushService> logger)
|
||||
{
|
||||
_pusher = pusher ?? throw new ArgumentNullException(nameof(pusher));
|
||||
_manifestBuilder = manifestBuilder ?? throw new ArgumentNullException(nameof(manifestBuilder));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<OciArtifactPushResult> PushAsync(
|
||||
OciArtifactPushRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Pushing OCI artifact {Reference} with type {ArtifactType}",
|
||||
request.Reference,
|
||||
request.ArtifactType);
|
||||
|
||||
return await _pusher.PushAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<OciArtifactPushResult> PushSliceAsync(
|
||||
SliceArtifactInput input,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(input);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Pushing slice artifact {Reference} for CVE {CveId} + {Purl}",
|
||||
input.Reference,
|
||||
input.SliceQuery?.CveId ?? "unknown",
|
||||
input.SliceQuery?.Purl ?? "unknown");
|
||||
|
||||
var pushRequest = _manifestBuilder.BuildSlicePushRequest(input);
|
||||
|
||||
var result = await _pusher.PushAsync(pushRequest, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (result.Success)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Successfully pushed slice to {Reference}",
|
||||
result.ManifestReference);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogError(
|
||||
"Failed to push slice to {Reference}: {Error}",
|
||||
input.Reference,
|
||||
result.Error);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Cryptography\StellaOps.Cryptography.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
Reference in New Issue
Block a user