Refactor code structure for improved readability and maintainability; optimize performance in key functions.

This commit is contained in:
master
2025-12-22 19:06:31 +02:00
parent dfaa2079aa
commit 4602ccc3a3
1444 changed files with 109919 additions and 8058 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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