save progress

This commit is contained in:
StellaOps Bot
2025-12-26 22:03:32 +02:00
parent 9a4cd2e0f7
commit e6c47c8f50
3634 changed files with 253222 additions and 56632 deletions

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