826 lines
27 KiB
C#
826 lines
27 KiB
C#
// 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; }
|
|
}
|