// OciAttestationPublisher - OCI registry attachment for StellaVerdict attestations // Sprint: SPRINT_1227_0014_0001_BE_stellaverdict_consolidation // Task 6: OCI Attestation Publisher using System.Text; using System.Text.Json; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using StellaOps.Verdict.Schema; namespace StellaOps.Verdict.Oci; /// /// Service for publishing StellaVerdict attestations to OCI registries. /// public interface IOciAttestationPublisher { /// /// Publish a StellaVerdict attestation to an OCI artifact as a referrer. /// /// The verdict to publish. /// OCI image reference (registry/repo:tag@sha256:digest). /// Cancellation token. /// Result of the publish operation. Task PublishAsync( StellaVerdict verdict, string imageReference, CancellationToken cancellationToken = default); /// /// Fetch a StellaVerdict attestation from an OCI artifact. /// /// OCI image reference. /// Optional verdict ID to filter. /// Cancellation token. /// The fetched verdict or null if not found. Task FetchAsync( string imageReference, string? verdictId = null, CancellationToken cancellationToken = default); /// /// List all StellaVerdict attestations for an OCI artifact. /// Task> ListAsync( string imageReference, CancellationToken cancellationToken = default); /// /// Remove a StellaVerdict attestation from an OCI artifact. /// Task RemoveAsync( string imageReference, string verdictId, CancellationToken cancellationToken = default); } /// /// Result of publishing a verdict to OCI. /// public sealed record OciPublishResult { public required bool Success { get; init; } public string? OciDigest { get; init; } public string? ManifestDigest { get; init; } public string? ErrorMessage { get; init; } public TimeSpan Duration { get; init; } public bool WasSkippedOffline { get; init; } } /// /// Entry in the list of OCI verdict attachments. /// public sealed record OciVerdictEntry { public required string VerdictId { get; init; } public required string VulnerabilityId { get; init; } public required string Purl { get; init; } public required string OciDigest { get; init; } public required DateTimeOffset AttachedAt { get; init; } public required long SizeBytes { get; init; } } /// /// Default implementation using ORAS/OCI referrers API patterns. /// public sealed class OciAttestationPublisher : IOciAttestationPublisher { private readonly IOptionsMonitor _options; private readonly ILogger _logger; private readonly TimeProvider _timeProvider; private readonly HttpClient _httpClient; /// /// ORAS artifact type for StellaVerdict attestations. /// public const string ArtifactType = "application/vnd.stellaops.verdict+json"; /// /// Media type for DSSE envelope containing verdict. /// public const string DsseMediaType = "application/vnd.dsse.envelope.v1+json"; /// /// Media type for JSON-LD verdict. /// public const string JsonLdMediaType = "application/ld+json"; private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, WriteIndented = false }; public OciAttestationPublisher( IOptionsMonitor options, ILogger logger, HttpClient? httpClient = null, TimeProvider? timeProvider = null) { _options = options ?? throw new ArgumentNullException(nameof(options)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); _httpClient = httpClient ?? new HttpClient(); _timeProvider = timeProvider ?? TimeProvider.System; } public async Task PublishAsync( StellaVerdict verdict, string imageReference, CancellationToken cancellationToken = default) { var startTime = _timeProvider.GetUtcNow(); var opts = _options.CurrentValue; // Handle offline/air-gap mode if (opts.OfflineMode) { _logger.LogInformation( "Offline mode enabled, skipping OCI publish for verdict {VerdictId}", verdict.VerdictId); // Store locally if configured if (!string.IsNullOrEmpty(opts.OfflineStoragePath)) { await StoreOfflineAsync(verdict, opts.OfflineStoragePath, cancellationToken); } return new OciPublishResult { Success = true, WasSkippedOffline = true, Duration = _timeProvider.GetUtcNow() - startTime }; } if (!opts.Enabled) { _logger.LogDebug("OCI publishing disabled, skipping for {Reference}", imageReference); return new OciPublishResult { Success = false, ErrorMessage = "OCI publishing is disabled", Duration = _timeProvider.GetUtcNow() - startTime }; } try { // Parse reference var parsed = ParseReference(imageReference); if (parsed is null) { return new OciPublishResult { Success = false, ErrorMessage = $"Invalid OCI reference: {imageReference}", Duration = _timeProvider.GetUtcNow() - startTime }; } // Serialize verdict to JSON var verdictJson = JsonSerializer.Serialize(verdict, JsonOptions); var verdictBytes = Encoding.UTF8.GetBytes(verdictJson); var verdictDigest = ComputeSha256(verdictBytes); _logger.LogInformation( "Publishing StellaVerdict {VerdictId} to {Reference} ({Size} bytes)", verdict.VerdictId, imageReference, verdictBytes.Length); // Step 1: Push the verdict as a blob var blobDigest = await PushBlobAsync(parsed, verdictBytes, cancellationToken); if (blobDigest is null) { return new OciPublishResult { Success = false, ErrorMessage = "Failed to push verdict blob", Duration = _timeProvider.GetUtcNow() - startTime }; } // Step 2: Create and push artifact manifest with subject reference var manifestDigest = await PushArtifactManifestAsync( parsed, blobDigest, verdictBytes.Length, verdict, cancellationToken); if (manifestDigest is null) { return new OciPublishResult { Success = false, ErrorMessage = "Failed to push artifact manifest", Duration = _timeProvider.GetUtcNow() - startTime }; } // Log for audit trail _logger.LogInformation( "Successfully published StellaVerdict {VerdictId} to {Reference}, manifest={Manifest}", verdict.VerdictId, imageReference, manifestDigest); return new OciPublishResult { Success = true, OciDigest = blobDigest, ManifestDigest = manifestDigest, Duration = _timeProvider.GetUtcNow() - startTime }; } catch (Exception ex) { _logger.LogError(ex, "Failed to publish StellaVerdict to {Reference}", imageReference); return new OciPublishResult { Success = false, ErrorMessage = ex.Message, Duration = _timeProvider.GetUtcNow() - startTime }; } } public async Task FetchAsync( string imageReference, string? verdictId = null, CancellationToken cancellationToken = default) { var opts = _options.CurrentValue; if (opts.OfflineMode && !string.IsNullOrEmpty(opts.OfflineStoragePath)) { // Try offline storage first return await FetchOfflineAsync(verdictId, opts.OfflineStoragePath, cancellationToken); } if (!opts.Enabled) { _logger.LogDebug("OCI publishing disabled, skipping fetch for {Reference}", imageReference); return null; } try { var parsed = ParseReference(imageReference); if (parsed is null) { _logger.LogWarning("Invalid OCI reference: {Reference}", imageReference); return null; } // Query referrers API for verdict attestations // GET /v2/{name}/referrers/{digest}?artifactType={ArtifactType} var referrers = await FetchReferrersAsync(parsed, cancellationToken); if (referrers is null || referrers.Count == 0) { return null; } // Find matching verdict foreach (var referrer in referrers) { var verdict = await FetchVerdictBlobAsync(parsed, referrer.Digest, cancellationToken); if (verdict is not null) { if (verdictId is null || verdict.VerdictId == verdictId) { return verdict; } } } return null; } catch (Exception ex) { _logger.LogError(ex, "Failed to fetch StellaVerdict from {Reference}", imageReference); return null; } } public async Task> ListAsync( string imageReference, CancellationToken cancellationToken = default) { var opts = _options.CurrentValue; if (!opts.Enabled && !opts.OfflineMode) { return []; } try { var parsed = ParseReference(imageReference); if (parsed is null) { return []; } var referrers = await FetchReferrersAsync(parsed, cancellationToken); if (referrers is null) { return []; } var entries = new List(); foreach (var referrer in referrers) { var verdict = await FetchVerdictBlobAsync(parsed, referrer.Digest, cancellationToken); if (verdict is not null) { entries.Add(new OciVerdictEntry { VerdictId = verdict.VerdictId, VulnerabilityId = verdict.Subject.VulnerabilityId, Purl = verdict.Subject.Purl, OciDigest = referrer.Digest, AttachedAt = referrer.CreatedAt ?? _timeProvider.GetUtcNow(), SizeBytes = referrer.Size }); } } return entries; } catch (Exception ex) { _logger.LogError(ex, "Failed to list StellaVerdicts for {Reference}", imageReference); return []; } } public async Task RemoveAsync( string imageReference, string verdictId, CancellationToken cancellationToken = default) { var opts = _options.CurrentValue; if (!opts.Enabled) { return false; } try { // Find and delete the referrer manifest _logger.LogInformation( "Would remove StellaVerdict {VerdictId} from {Reference}", verdictId, imageReference); // Note: Full implementation requires finding the manifest digest // and issuing DELETE /v2/{name}/manifests/{digest} return false; } catch (Exception ex) { _logger.LogError(ex, "Failed to remove StellaVerdict from {Reference}", imageReference); return false; } } private async Task StoreOfflineAsync( StellaVerdict verdict, string storagePath, CancellationToken cancellationToken) { try { Directory.CreateDirectory(storagePath); var fileName = $"verdict-{Uri.EscapeDataString(verdict.VerdictId)}.json"; var filePath = Path.Combine(storagePath, fileName); var json = JsonSerializer.Serialize(verdict, new JsonSerializerOptions { WriteIndented = true }); await File.WriteAllTextAsync(filePath, json, cancellationToken); _logger.LogDebug("Stored verdict offline at {Path}", filePath); } catch (Exception ex) { _logger.LogWarning(ex, "Failed to store verdict offline"); } } private async Task FetchOfflineAsync( string? verdictId, string storagePath, CancellationToken cancellationToken) { try { if (!Directory.Exists(storagePath)) { return null; } if (!string.IsNullOrEmpty(verdictId)) { var fileName = $"verdict-{Uri.EscapeDataString(verdictId)}.json"; var filePath = Path.Combine(storagePath, fileName); if (File.Exists(filePath)) { var json = await File.ReadAllTextAsync(filePath, cancellationToken); return JsonSerializer.Deserialize(json, JsonOptions); } } // Return first available var files = Directory.GetFiles(storagePath, "verdict-*.json"); if (files.Length > 0) { var json = await File.ReadAllTextAsync(files[0], cancellationToken); return JsonSerializer.Deserialize(json, JsonOptions); } return null; } catch (Exception ex) { _logger.LogWarning(ex, "Failed to fetch verdict from offline storage"); return null; } } private async Task PushBlobAsync( OciReference reference, byte[] content, CancellationToken cancellationToken) { var opts = _options.CurrentValue; var digest = ComputeSha256(content); // POST /v2/{name}/blobs/uploads/ // then PUT /v2/{name}/blobs/uploads/{uuid}?digest=sha256:xxx var baseUrl = $"https://{reference.Registry}/v2/{reference.Repository}"; try { // Start upload session var initiateUrl = $"{baseUrl}/blobs/uploads/"; var initiateRequest = new HttpRequestMessage(HttpMethod.Post, initiateUrl); AddAuthHeaders(initiateRequest, opts); var initiateResponse = await _httpClient.SendAsync(initiateRequest, cancellationToken); if (!initiateResponse.IsSuccessStatusCode) { _logger.LogWarning( "Failed to initiate blob upload: {Status}", initiateResponse.StatusCode); return null; } // Get upload URL from Location header var location = initiateResponse.Headers.Location?.ToString(); if (string.IsNullOrEmpty(location)) { return null; } // Complete upload var uploadUrl = location.Contains('?') ? $"{location}&digest={digest}" : $"{location}?digest={digest}"; var uploadRequest = new HttpRequestMessage(HttpMethod.Put, uploadUrl) { Content = new ByteArrayContent(content) }; uploadRequest.Content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue(JsonLdMediaType); AddAuthHeaders(uploadRequest, opts); var uploadResponse = await _httpClient.SendAsync(uploadRequest, cancellationToken); if (uploadResponse.IsSuccessStatusCode) { return digest; } _logger.LogWarning("Failed to upload blob: {Status}", uploadResponse.StatusCode); return null; } catch (Exception ex) { _logger.LogError(ex, "Failed to push blob to registry"); return null; } } private async Task PushArtifactManifestAsync( OciReference reference, string blobDigest, long blobSize, StellaVerdict verdict, CancellationToken cancellationToken) { var opts = _options.CurrentValue; // Create OCI artifact manifest with subject reference var manifest = new { schemaVersion = 2, mediaType = "application/vnd.oci.artifact.manifest.v1+json", artifactType = ArtifactType, blobs = new[] { new { mediaType = JsonLdMediaType, digest = blobDigest, size = blobSize, annotations = new Dictionary { ["org.stellaops.verdict.id"] = verdict.VerdictId, ["org.stellaops.verdict.cve"] = verdict.Subject.VulnerabilityId, ["org.stellaops.verdict.purl"] = verdict.Subject.Purl, ["org.stellaops.verdict.status"] = verdict.Claim.Status.ToString() } } }, subject = reference.Digest is not null ? new { mediaType = "application/vnd.oci.image.manifest.v1+json", digest = reference.Digest } : null, annotations = new Dictionary { ["org.opencontainers.image.created"] = _timeProvider.GetUtcNow().ToString("O"), ["org.stellaops.verdict.version"] = verdict.Version } }; var manifestJson = JsonSerializer.Serialize(manifest, JsonOptions); var manifestBytes = Encoding.UTF8.GetBytes(manifestJson); var manifestDigest = ComputeSha256(manifestBytes); // PUT /v2/{name}/manifests/{reference} var baseUrl = $"https://{reference.Registry}/v2/{reference.Repository}"; var manifestUrl = $"{baseUrl}/manifests/{manifestDigest}"; try { var request = new HttpRequestMessage(HttpMethod.Put, manifestUrl) { Content = new ByteArrayContent(manifestBytes) }; request.Content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/vnd.oci.artifact.manifest.v1+json"); AddAuthHeaders(request, opts); var response = await _httpClient.SendAsync(request, cancellationToken); if (response.IsSuccessStatusCode) { return manifestDigest; } _logger.LogWarning("Failed to push manifest: {Status}", response.StatusCode); return null; } catch (Exception ex) { _logger.LogError(ex, "Failed to push artifact manifest"); return null; } } private async Task?> FetchReferrersAsync( OciReference reference, CancellationToken cancellationToken) { if (reference.Digest is null) { return null; } var opts = _options.CurrentValue; var baseUrl = $"https://{reference.Registry}/v2/{reference.Repository}"; var referrersUrl = $"{baseUrl}/referrers/{reference.Digest}?artifactType={Uri.EscapeDataString(ArtifactType)}"; try { var request = new HttpRequestMessage(HttpMethod.Get, referrersUrl); AddAuthHeaders(request, opts); var response = await _httpClient.SendAsync(request, cancellationToken); if (!response.IsSuccessStatusCode) { return null; } var json = await response.Content.ReadAsStringAsync(cancellationToken); var index = JsonSerializer.Deserialize(json, JsonOptions); return index?.Manifests; } catch (Exception ex) { _logger.LogWarning(ex, "Failed to fetch referrers"); return null; } } private async Task FetchVerdictBlobAsync( OciReference reference, string digest, CancellationToken cancellationToken) { var opts = _options.CurrentValue; var baseUrl = $"https://{reference.Registry}/v2/{reference.Repository}"; var blobUrl = $"{baseUrl}/blobs/{digest}"; try { var request = new HttpRequestMessage(HttpMethod.Get, blobUrl); AddAuthHeaders(request, opts); var response = await _httpClient.SendAsync(request, cancellationToken); if (!response.IsSuccessStatusCode) { return null; } var json = await response.Content.ReadAsStringAsync(cancellationToken); return JsonSerializer.Deserialize(json, JsonOptions); } catch (Exception ex) { _logger.LogWarning(ex, "Failed to fetch verdict blob"); return null; } } private static void AddAuthHeaders(HttpRequestMessage request, OciPublisherOptions opts) { if (!string.IsNullOrEmpty(opts.Auth?.BearerToken)) { request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", opts.Auth.BearerToken); } else if (!string.IsNullOrEmpty(opts.Auth?.Username) && !string.IsNullOrEmpty(opts.Auth?.Password)) { var credentials = Convert.ToBase64String( Encoding.UTF8.GetBytes($"{opts.Auth.Username}:{opts.Auth.Password}")); request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Basic", credentials); } } private static string ComputeSha256(byte[] content) { var hash = System.Security.Cryptography.SHA256.HashData(content); return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}"; } private static OciReference? ParseReference(string reference) { try { var atIdx = reference.IndexOf('@'); var colonIdx = reference.LastIndexOf(':'); string registry; string repository; string? tag = null; string? digest = null; if (atIdx > 0) { digest = reference[(atIdx + 1)..]; var beforeDigest = reference[..atIdx]; var slashIdx = beforeDigest.IndexOf('/'); if (slashIdx < 0) return null; registry = beforeDigest[..slashIdx]; repository = beforeDigest[(slashIdx + 1)..]; } else if (colonIdx > 0 && colonIdx > reference.IndexOf('/')) { tag = reference[(colonIdx + 1)..]; var beforeTag = reference[..colonIdx]; var slashIdx = beforeTag.IndexOf('/'); if (slashIdx < 0) return null; registry = beforeTag[..slashIdx]; repository = beforeTag[(slashIdx + 1)..]; } else { return null; } return new OciReference { Registry = registry, Repository = repository, Tag = tag, Digest = digest }; } catch { return null; } } private sealed record OciReference { public required string Registry { get; init; } public required string Repository { get; init; } public string? Tag { get; init; } public string? Digest { get; init; } } private sealed record ReferrersIndex { public IReadOnlyList? Manifests { get; init; } } private sealed record ReferrerEntry { public required string Digest { get; init; } public required long Size { get; init; } public string? ArtifactType { get; init; } public DateTimeOffset? CreatedAt { get; init; } } } /// /// Configuration options for OCI attestation publishing. /// public sealed class OciPublisherOptions { /// /// Configuration section key. /// public const string SectionKey = "VerdictOci"; /// /// Whether OCI publishing is enabled. /// public bool Enabled { get; set; } = false; /// /// Whether running in offline/air-gap mode. /// public bool OfflineMode { get; set; } = false; /// /// Path to store verdicts when in offline mode. /// public string? OfflineStoragePath { get; set; } /// /// Default registry URL if not specified in reference. /// public string? DefaultRegistry { get; set; } /// /// Registry authentication. /// public OciAuthConfig? Auth { get; set; } /// /// Request timeout. /// public TimeSpan Timeout { get; set; } = TimeSpan.FromSeconds(30); /// /// Whether to verify TLS certificates. /// public bool VerifyTls { get; set; } = true; } /// /// OCI registry authentication configuration. /// public sealed class OciAuthConfig { /// /// Username for basic auth. /// public string? Username { get; set; } /// /// Password or token for basic auth. /// public string? Password { get; set; } /// /// Bearer token for token auth. /// public string? BearerToken { get; set; } /// /// Path to Docker credentials file. /// public string? CredentialsFile { get; set; } }