save progress
This commit is contained in:
825
src/__Libraries/StellaOps.Verdict/Oci/OciAttestationPublisher.cs
Normal file
825
src/__Libraries/StellaOps.Verdict/Oci/OciAttestationPublisher.cs
Normal file
@@ -0,0 +1,825 @@
|
||||
// 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;
|
||||
|
||||
/// <summary>
|
||||
/// Service for publishing StellaVerdict attestations to OCI registries.
|
||||
/// </summary>
|
||||
public interface IOciAttestationPublisher
|
||||
{
|
||||
/// <summary>
|
||||
/// Publish a StellaVerdict attestation to an OCI artifact as a referrer.
|
||||
/// </summary>
|
||||
/// <param name="verdict">The verdict to publish.</param>
|
||||
/// <param name="imageReference">OCI image reference (registry/repo:tag@sha256:digest).</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Result of the publish operation.</returns>
|
||||
Task<OciPublishResult> PublishAsync(
|
||||
StellaVerdict verdict,
|
||||
string imageReference,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Fetch a StellaVerdict attestation from an OCI artifact.
|
||||
/// </summary>
|
||||
/// <param name="imageReference">OCI image reference.</param>
|
||||
/// <param name="verdictId">Optional verdict ID to filter.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The fetched verdict or null if not found.</returns>
|
||||
Task<StellaVerdict?> FetchAsync(
|
||||
string imageReference,
|
||||
string? verdictId = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// List all StellaVerdict attestations for an OCI artifact.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<OciVerdictEntry>> ListAsync(
|
||||
string imageReference,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Remove a StellaVerdict attestation from an OCI artifact.
|
||||
/// </summary>
|
||||
Task<bool> RemoveAsync(
|
||||
string imageReference,
|
||||
string verdictId,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of publishing a verdict to OCI.
|
||||
/// </summary>
|
||||
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; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Entry in the list of OCI verdict attachments.
|
||||
/// </summary>
|
||||
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; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation using ORAS/OCI referrers API patterns.
|
||||
/// </summary>
|
||||
public sealed class OciAttestationPublisher : IOciAttestationPublisher
|
||||
{
|
||||
private readonly IOptionsMonitor<OciPublisherOptions> _options;
|
||||
private readonly ILogger<OciAttestationPublisher> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly HttpClient _httpClient;
|
||||
|
||||
/// <summary>
|
||||
/// ORAS artifact type for StellaVerdict attestations.
|
||||
/// </summary>
|
||||
public const string ArtifactType = "application/vnd.stellaops.verdict+json";
|
||||
|
||||
/// <summary>
|
||||
/// Media type for DSSE envelope containing verdict.
|
||||
/// </summary>
|
||||
public const string DsseMediaType = "application/vnd.dsse.envelope.v1+json";
|
||||
|
||||
/// <summary>
|
||||
/// Media type for JSON-LD verdict.
|
||||
/// </summary>
|
||||
public const string JsonLdMediaType = "application/ld+json";
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = false
|
||||
};
|
||||
|
||||
public OciAttestationPublisher(
|
||||
IOptionsMonitor<OciPublisherOptions> options,
|
||||
ILogger<OciAttestationPublisher> 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<OciPublishResult> 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<StellaVerdict?> 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<IReadOnlyList<OciVerdictEntry>> 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<OciVerdictEntry>();
|
||||
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<bool> 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<StellaVerdict?> 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<StellaVerdict>(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<StellaVerdict>(json, JsonOptions);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to fetch verdict from offline storage");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<string?> 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<string?> 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<string, string>
|
||||
{
|
||||
["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<string, string>
|
||||
{
|
||||
["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<IReadOnlyList<ReferrerEntry>?> 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<ReferrersIndex>(json, JsonOptions);
|
||||
|
||||
return index?.Manifests;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to fetch referrers");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<StellaVerdict?> 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<StellaVerdict>(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<ReferrerEntry>? 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; }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for OCI attestation publishing.
|
||||
/// </summary>
|
||||
public sealed class OciPublisherOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Configuration section key.
|
||||
/// </summary>
|
||||
public const string SectionKey = "VerdictOci";
|
||||
|
||||
/// <summary>
|
||||
/// Whether OCI publishing is enabled.
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Whether running in offline/air-gap mode.
|
||||
/// </summary>
|
||||
public bool OfflineMode { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Path to store verdicts when in offline mode.
|
||||
/// </summary>
|
||||
public string? OfflineStoragePath { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Default registry URL if not specified in reference.
|
||||
/// </summary>
|
||||
public string? DefaultRegistry { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Registry authentication.
|
||||
/// </summary>
|
||||
public OciAuthConfig? Auth { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Request timeout.
|
||||
/// </summary>
|
||||
public TimeSpan Timeout { get; set; } = TimeSpan.FromSeconds(30);
|
||||
|
||||
/// <summary>
|
||||
/// Whether to verify TLS certificates.
|
||||
/// </summary>
|
||||
public bool VerifyTls { get; set; } = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// OCI registry authentication configuration.
|
||||
/// </summary>
|
||||
public sealed class OciAuthConfig
|
||||
{
|
||||
/// <summary>
|
||||
/// Username for basic auth.
|
||||
/// </summary>
|
||||
public string? Username { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Password or token for basic auth.
|
||||
/// </summary>
|
||||
public string? Password { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Bearer token for token auth.
|
||||
/// </summary>
|
||||
public string? BearerToken { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Path to Docker credentials file.
|
||||
/// </summary>
|
||||
public string? CredentialsFile { get; set; }
|
||||
}
|
||||
Reference in New Issue
Block a user