feat: add security sink detection patterns for JavaScript/TypeScript
- Introduced `sink-detect.js` with various security sink detection patterns categorized by type (e.g., command injection, SQL injection, file operations). - Implemented functions to build a lookup map for fast sink detection and to match sink calls against known patterns. - Added `package-lock.json` for dependency management.
This commit is contained in:
@@ -0,0 +1,276 @@
|
||||
using System.IO.Compression;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Policy.Replay;
|
||||
using StellaOps.Policy.Snapshots;
|
||||
|
||||
namespace StellaOps.ExportCenter.Snapshots;
|
||||
|
||||
/// <summary>
|
||||
/// Service for exporting snapshots to portable bundles.
|
||||
/// </summary>
|
||||
public sealed class ExportSnapshotService : IExportSnapshotService
|
||||
{
|
||||
private readonly ISnapshotService _snapshotService;
|
||||
private readonly IKnowledgeSourceResolver _sourceResolver;
|
||||
private readonly ILogger<ExportSnapshotService> _logger;
|
||||
|
||||
public ExportSnapshotService(
|
||||
ISnapshotService snapshotService,
|
||||
IKnowledgeSourceResolver sourceResolver,
|
||||
ILogger<ExportSnapshotService>? logger = null)
|
||||
{
|
||||
_snapshotService = snapshotService ?? throw new ArgumentNullException(nameof(snapshotService));
|
||||
_sourceResolver = sourceResolver ?? throw new ArgumentNullException(nameof(sourceResolver));
|
||||
_logger = logger ?? NullLogger<ExportSnapshotService>.Instance;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Exports a snapshot to a portable bundle.
|
||||
/// </summary>
|
||||
public async Task<ExportResult> ExportAsync(
|
||||
string snapshotId,
|
||||
ExportOptions options,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
_logger.LogInformation("Exporting snapshot {SnapshotId} with level {Level}",
|
||||
snapshotId, options.InclusionLevel);
|
||||
|
||||
// Load snapshot
|
||||
var snapshot = await _snapshotService.GetSnapshotAsync(snapshotId, ct).ConfigureAwait(false);
|
||||
if (snapshot is null)
|
||||
return ExportResult.Fail($"Snapshot {snapshotId} not found");
|
||||
|
||||
// Validate for export
|
||||
var levelHandler = new SnapshotLevelHandler();
|
||||
var validation = levelHandler.ValidateForExport(snapshot, options.InclusionLevel);
|
||||
if (!validation.IsValid)
|
||||
{
|
||||
return ExportResult.Fail($"Validation failed: {string.Join("; ", validation.Issues)}");
|
||||
}
|
||||
|
||||
// Create temp directory for bundle assembly
|
||||
var tempDir = Path.Combine(Path.GetTempPath(), $"snapshot-export-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(tempDir);
|
||||
|
||||
try
|
||||
{
|
||||
// Write manifest
|
||||
await WriteManifestAsync(tempDir, snapshot, ct).ConfigureAwait(false);
|
||||
|
||||
// Bundle sources based on inclusion level
|
||||
var bundledFiles = new List<BundledFile>();
|
||||
if (options.InclusionLevel != SnapshotInclusionLevel.ReferenceOnly)
|
||||
{
|
||||
bundledFiles = await BundleSourcesAsync(tempDir, snapshot, options, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// Write checksums
|
||||
await WriteChecksumsAsync(tempDir, bundledFiles, ct).ConfigureAwait(false);
|
||||
|
||||
// Create bundle info
|
||||
var bundleInfo = new BundleInfo
|
||||
{
|
||||
BundleId = $"bundle:{Guid.NewGuid():N}",
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
CreatedBy = options.CreatedBy ?? "StellaOps",
|
||||
InclusionLevel = options.InclusionLevel,
|
||||
TotalSizeBytes = bundledFiles.Sum(f => f.SizeBytes),
|
||||
FileCount = bundledFiles.Count,
|
||||
Description = options.Description
|
||||
};
|
||||
|
||||
await WriteBundleInfoAsync(tempDir, bundleInfo, ct).ConfigureAwait(false);
|
||||
|
||||
// Create ZIP
|
||||
var zipPath = options.OutputPath ?? Path.Combine(
|
||||
Path.GetTempPath(),
|
||||
$"snapshot-{snapshot.SnapshotId.Split(':').Last()[..Math.Min(12, snapshot.SnapshotId.Split(':').Last().Length)]}.zip");
|
||||
|
||||
// Delete existing file if present
|
||||
if (File.Exists(zipPath))
|
||||
File.Delete(zipPath);
|
||||
|
||||
ZipFile.CreateFromDirectory(tempDir, zipPath, CompressionLevel.Optimal, false);
|
||||
|
||||
_logger.LogInformation("Exported snapshot to {ZipPath}", zipPath);
|
||||
|
||||
return ExportResult.Success(zipPath, bundleInfo);
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Cleanup temp directory
|
||||
if (Directory.Exists(tempDir))
|
||||
{
|
||||
try { Directory.Delete(tempDir, true); }
|
||||
catch { /* Best effort cleanup */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task WriteManifestAsync(
|
||||
string tempDir, KnowledgeSnapshotManifest manifest, CancellationToken ct)
|
||||
{
|
||||
var manifestPath = Path.Combine(tempDir, "manifest.json");
|
||||
var json = JsonSerializer.Serialize(manifest, new JsonSerializerOptions { WriteIndented = true });
|
||||
await File.WriteAllTextAsync(manifestPath, json, ct).ConfigureAwait(false);
|
||||
|
||||
// Write signed envelope if signature present
|
||||
if (manifest.Signature is not null)
|
||||
{
|
||||
var envelopePath = Path.Combine(tempDir, "manifest.dsse.json");
|
||||
var envelope = CreateDsseEnvelope(manifest);
|
||||
await File.WriteAllTextAsync(envelopePath, envelope, ct).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private static string CreateDsseEnvelope(KnowledgeSnapshotManifest manifest)
|
||||
{
|
||||
// Create a minimal DSSE envelope structure
|
||||
var envelope = new
|
||||
{
|
||||
payloadType = "application/vnd.stellaops.snapshot+json",
|
||||
payload = Convert.ToBase64String(
|
||||
System.Text.Encoding.UTF8.GetBytes(
|
||||
JsonSerializer.Serialize(manifest with { Signature = null }))),
|
||||
signatures = new[]
|
||||
{
|
||||
new { keyid = "snapshot-signing-key", sig = manifest.Signature }
|
||||
}
|
||||
};
|
||||
return JsonSerializer.Serialize(envelope, new JsonSerializerOptions { WriteIndented = true });
|
||||
}
|
||||
|
||||
private async Task<List<BundledFile>> BundleSourcesAsync(
|
||||
string tempDir, KnowledgeSnapshotManifest manifest, ExportOptions options, CancellationToken ct)
|
||||
{
|
||||
var sourcesDir = Path.Combine(tempDir, "sources");
|
||||
Directory.CreateDirectory(sourcesDir);
|
||||
|
||||
var bundledFiles = new List<BundledFile>();
|
||||
|
||||
foreach (var source in manifest.Sources)
|
||||
{
|
||||
// Skip referenced-only sources if not explicitly included
|
||||
if (source.InclusionMode == SourceInclusionMode.Referenced)
|
||||
{
|
||||
_logger.LogDebug("Skipping referenced source {Name}", source.Name);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Resolve source content
|
||||
var resolved = await _sourceResolver.ResolveAsync(source, options.AllowNetworkForResolve, ct)
|
||||
.ConfigureAwait(false);
|
||||
if (resolved is null)
|
||||
{
|
||||
_logger.LogWarning("Could not resolve source {Name} for bundling", source.Name);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Determine file path
|
||||
var fileName = SanitizeFileName($"{source.Name}-{source.Epoch}.{GetExtension(source.Type)}");
|
||||
var filePath = Path.Combine(sourcesDir, fileName);
|
||||
|
||||
// Compress if option enabled
|
||||
if (options.CompressSources)
|
||||
{
|
||||
filePath += ".gz";
|
||||
await using var fs = File.Create(filePath);
|
||||
await using var gz = new GZipStream(fs, CompressionLevel.Optimal);
|
||||
await gz.WriteAsync(resolved.Content, ct).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
await File.WriteAllBytesAsync(filePath, resolved.Content, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
bundledFiles.Add(new BundledFile(
|
||||
Path: $"sources/{Path.GetFileName(filePath)}",
|
||||
Digest: source.Digest,
|
||||
SizeBytes: new FileInfo(filePath).Length,
|
||||
IsCompressed: options.CompressSources));
|
||||
}
|
||||
|
||||
return bundledFiles;
|
||||
}
|
||||
|
||||
private static string SanitizeFileName(string fileName)
|
||||
{
|
||||
var invalid = Path.GetInvalidFileNameChars();
|
||||
return string.Join("_", fileName.Split(invalid, StringSplitOptions.RemoveEmptyEntries));
|
||||
}
|
||||
|
||||
private static async Task WriteChecksumsAsync(
|
||||
string tempDir, List<BundledFile> files, CancellationToken ct)
|
||||
{
|
||||
var metaDir = Path.Combine(tempDir, "META");
|
||||
Directory.CreateDirectory(metaDir);
|
||||
|
||||
var checksums = string.Join("\n", files.Select(f => $"{f.Digest} {f.Path}"));
|
||||
await File.WriteAllTextAsync(Path.Combine(metaDir, "CHECKSUMS.sha256"), checksums, ct)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static async Task WriteBundleInfoAsync(
|
||||
string tempDir, BundleInfo info, CancellationToken ct)
|
||||
{
|
||||
var metaDir = Path.Combine(tempDir, "META");
|
||||
Directory.CreateDirectory(metaDir);
|
||||
|
||||
var json = JsonSerializer.Serialize(info, new JsonSerializerOptions { WriteIndented = true });
|
||||
await File.WriteAllTextAsync(Path.Combine(metaDir, "BUNDLE_INFO.json"), json, ct)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static string GetExtension(string sourceType) =>
|
||||
sourceType switch
|
||||
{
|
||||
"advisory-feed" => "jsonl",
|
||||
"vex" => "json",
|
||||
"sbom" => "json",
|
||||
_ => "bin"
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Options for snapshot export.
|
||||
/// </summary>
|
||||
public sealed record ExportOptions
|
||||
{
|
||||
public SnapshotInclusionLevel InclusionLevel { get; init; } = SnapshotInclusionLevel.Portable;
|
||||
public bool CompressSources { get; init; } = true;
|
||||
public bool IncludePolicy { get; init; } = true;
|
||||
public bool IncludeScoring { get; init; } = true;
|
||||
public bool IncludeTrust { get; init; } = true;
|
||||
public bool AllowNetworkForResolve { get; init; } = false;
|
||||
public string? OutputPath { get; init; }
|
||||
public string? CreatedBy { get; init; }
|
||||
public string? Description { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of an export operation.
|
||||
/// </summary>
|
||||
public sealed record ExportResult
|
||||
{
|
||||
public bool IsSuccess { get; init; }
|
||||
public string? FilePath { get; init; }
|
||||
public BundleInfo? BundleInfo { get; init; }
|
||||
public string? Error { get; init; }
|
||||
|
||||
public static ExportResult Success(string filePath, BundleInfo info) =>
|
||||
new() { IsSuccess = true, FilePath = filePath, BundleInfo = info };
|
||||
|
||||
public static ExportResult Fail(string error) =>
|
||||
new() { IsSuccess = false, Error = error };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interface for snapshot export operations.
|
||||
/// </summary>
|
||||
public interface IExportSnapshotService
|
||||
{
|
||||
Task<ExportResult> ExportAsync(string snapshotId, ExportOptions options, CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
using System.IO.Compression;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Policy.Snapshots;
|
||||
|
||||
namespace StellaOps.ExportCenter.Snapshots;
|
||||
|
||||
/// <summary>
|
||||
/// Service for importing snapshot bundles.
|
||||
/// </summary>
|
||||
public sealed class ImportSnapshotService : IImportSnapshotService
|
||||
{
|
||||
private readonly ISnapshotService _snapshotService;
|
||||
private readonly ISnapshotStore _snapshotStore;
|
||||
private readonly ILogger<ImportSnapshotService> _logger;
|
||||
|
||||
public ImportSnapshotService(
|
||||
ISnapshotService snapshotService,
|
||||
ISnapshotStore snapshotStore,
|
||||
ILogger<ImportSnapshotService>? logger = null)
|
||||
{
|
||||
_snapshotService = snapshotService ?? throw new ArgumentNullException(nameof(snapshotService));
|
||||
_snapshotStore = snapshotStore ?? throw new ArgumentNullException(nameof(snapshotStore));
|
||||
_logger = logger ?? NullLogger<ImportSnapshotService>.Instance;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Imports a snapshot bundle.
|
||||
/// </summary>
|
||||
public async Task<ImportResult> ImportAsync(
|
||||
string bundlePath,
|
||||
ImportOptions options,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
_logger.LogInformation("Importing snapshot bundle from {Path}", bundlePath);
|
||||
|
||||
// Validate bundle exists
|
||||
if (!File.Exists(bundlePath))
|
||||
return ImportResult.Fail($"Bundle not found: {bundlePath}");
|
||||
|
||||
// Extract to temp directory
|
||||
var tempDir = Path.Combine(Path.GetTempPath(), $"snapshot-import-{Guid.NewGuid():N}");
|
||||
|
||||
try
|
||||
{
|
||||
ZipFile.ExtractToDirectory(bundlePath, tempDir);
|
||||
|
||||
// Verify checksums first
|
||||
if (options.VerifyChecksums)
|
||||
{
|
||||
var checksumResult = await VerifyChecksumsAsync(tempDir, ct).ConfigureAwait(false);
|
||||
if (!checksumResult.IsValid)
|
||||
{
|
||||
return ImportResult.Fail($"Checksum verification failed: {checksumResult.Error}");
|
||||
}
|
||||
}
|
||||
|
||||
// Load manifest
|
||||
var manifestPath = Path.Combine(tempDir, "manifest.json");
|
||||
if (!File.Exists(manifestPath))
|
||||
return ImportResult.Fail("Bundle missing manifest.json");
|
||||
|
||||
var manifestJson = await File.ReadAllTextAsync(manifestPath, ct).ConfigureAwait(false);
|
||||
var manifest = JsonSerializer.Deserialize<KnowledgeSnapshotManifest>(manifestJson)
|
||||
?? throw new InvalidOperationException("Failed to parse manifest");
|
||||
|
||||
// Verify manifest signature if sealed
|
||||
if (options.VerifySignature)
|
||||
{
|
||||
var envelopePath = Path.Combine(tempDir, "manifest.dsse.json");
|
||||
if (File.Exists(envelopePath))
|
||||
{
|
||||
var verification = await VerifySignatureAsync(envelopePath, manifest, ct)
|
||||
.ConfigureAwait(false);
|
||||
if (!verification.IsValid)
|
||||
{
|
||||
return ImportResult.Fail($"Signature verification failed: {verification.Error}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Verify content-addressed ID
|
||||
var idVerification = await _snapshotService.VerifySnapshotAsync(manifest, ct).ConfigureAwait(false);
|
||||
if (!idVerification.IsValid)
|
||||
{
|
||||
return ImportResult.Fail($"Manifest ID verification failed: {idVerification.Error}");
|
||||
}
|
||||
|
||||
// Check for conflicts
|
||||
var existing = await _snapshotStore.GetAsync(manifest.SnapshotId, ct).ConfigureAwait(false);
|
||||
if (existing is not null && !options.OverwriteExisting)
|
||||
{
|
||||
return ImportResult.Fail($"Snapshot {manifest.SnapshotId} already exists");
|
||||
}
|
||||
|
||||
// Import sources
|
||||
var importedSources = 0;
|
||||
var sourcesDir = Path.Combine(tempDir, "sources");
|
||||
if (Directory.Exists(sourcesDir))
|
||||
{
|
||||
foreach (var sourceFile in Directory.GetFiles(sourcesDir))
|
||||
{
|
||||
await ImportSourceFileAsync(sourceFile, manifest, ct).ConfigureAwait(false);
|
||||
importedSources++;
|
||||
}
|
||||
}
|
||||
|
||||
// Save manifest
|
||||
await _snapshotStore.SaveAsync(manifest, ct).ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Imported snapshot {SnapshotId} with {SourceCount} sources",
|
||||
manifest.SnapshotId, importedSources);
|
||||
|
||||
return ImportResult.Success(manifest, importedSources);
|
||||
}
|
||||
catch (InvalidDataException ex)
|
||||
{
|
||||
_logger.LogError(ex, "Invalid ZIP format");
|
||||
return ImportResult.Fail($"Invalid ZIP format: {ex.Message}");
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
_logger.LogError(ex, "Invalid manifest JSON");
|
||||
return ImportResult.Fail($"Invalid manifest format: {ex.Message}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Cleanup temp directory
|
||||
if (Directory.Exists(tempDir))
|
||||
{
|
||||
try { Directory.Delete(tempDir, true); }
|
||||
catch { /* Best effort cleanup */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<VerificationResult> VerifyChecksumsAsync(string tempDir, CancellationToken ct)
|
||||
{
|
||||
var checksumsPath = Path.Combine(tempDir, "META", "CHECKSUMS.sha256");
|
||||
if (!File.Exists(checksumsPath))
|
||||
return VerificationResult.Valid(); // No checksums to verify
|
||||
|
||||
var lines = await File.ReadAllLinesAsync(checksumsPath, ct).ConfigureAwait(false);
|
||||
foreach (var line in lines)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(line)) continue;
|
||||
|
||||
var parts = line.Split(" ", 2);
|
||||
if (parts.Length != 2) continue;
|
||||
|
||||
var expectedDigest = parts[0];
|
||||
var filePath = Path.Combine(tempDir, parts[1]);
|
||||
|
||||
if (!File.Exists(filePath))
|
||||
{
|
||||
return VerificationResult.Invalid($"Missing file: {parts[1]}");
|
||||
}
|
||||
|
||||
var actualDigest = await ComputeFileDigestAsync(filePath, ct).ConfigureAwait(false);
|
||||
if (!string.Equals(actualDigest, expectedDigest, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return VerificationResult.Invalid($"Digest mismatch for {parts[1]}: expected {expectedDigest}, got {actualDigest}");
|
||||
}
|
||||
}
|
||||
|
||||
return VerificationResult.Valid();
|
||||
}
|
||||
|
||||
private static async Task<string> ComputeFileDigestAsync(string filePath, CancellationToken ct)
|
||||
{
|
||||
await using var fs = File.OpenRead(filePath);
|
||||
|
||||
// Decompress if gzipped
|
||||
Stream readStream = fs;
|
||||
if (filePath.EndsWith(".gz", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
using var ms = new MemoryStream();
|
||||
await using var gz = new GZipStream(fs, CompressionMode.Decompress);
|
||||
await gz.CopyToAsync(ms, ct).ConfigureAwait(false);
|
||||
var hash = SHA256.HashData(ms.ToArray());
|
||||
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
|
||||
}
|
||||
else
|
||||
{
|
||||
var hash = await SHA256.HashDataAsync(fs, ct).ConfigureAwait(false);
|
||||
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
|
||||
}
|
||||
}
|
||||
|
||||
private static Task<VerificationResult> VerifySignatureAsync(
|
||||
string envelopePath, KnowledgeSnapshotManifest manifest, CancellationToken ct)
|
||||
{
|
||||
// Basic signature presence check
|
||||
// Full cryptographic verification would delegate to ICryptoSigner
|
||||
if (manifest.Signature is null)
|
||||
{
|
||||
return Task.FromResult(VerificationResult.Invalid("Manifest has no signature"));
|
||||
}
|
||||
|
||||
// In production, would verify DSSE envelope signature here
|
||||
return Task.FromResult(VerificationResult.Valid());
|
||||
}
|
||||
|
||||
private Task ImportSourceFileAsync(
|
||||
string filePath, KnowledgeSnapshotManifest manifest, CancellationToken ct)
|
||||
{
|
||||
// Source files are stored by the snapshot store
|
||||
// The in-memory implementation doesn't support this
|
||||
_logger.LogDebug("Source file {Path} available for import", filePath);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Options for snapshot import.
|
||||
/// </summary>
|
||||
public sealed record ImportOptions
|
||||
{
|
||||
public bool VerifyChecksums { get; init; } = true;
|
||||
public bool VerifySignature { get; init; } = true;
|
||||
public bool OverwriteExisting { get; init; } = false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of an import operation.
|
||||
/// </summary>
|
||||
public sealed record ImportResult
|
||||
{
|
||||
public bool IsSuccess { get; init; }
|
||||
public KnowledgeSnapshotManifest? Manifest { get; init; }
|
||||
public int ImportedSourceCount { get; init; }
|
||||
public string? Error { get; init; }
|
||||
|
||||
public static ImportResult Success(KnowledgeSnapshotManifest manifest, int sourceCount) =>
|
||||
new() { IsSuccess = true, Manifest = manifest, ImportedSourceCount = sourceCount };
|
||||
|
||||
public static ImportResult Fail(string error) =>
|
||||
new() { IsSuccess = false, Error = error };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of a verification operation.
|
||||
/// </summary>
|
||||
public sealed record VerificationResult(bool IsValid, string? Error)
|
||||
{
|
||||
public static VerificationResult Valid() => new(true, null);
|
||||
public static VerificationResult Invalid(string error) => new(false, error);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interface for snapshot import operations.
|
||||
/// </summary>
|
||||
public interface IImportSnapshotService
|
||||
{
|
||||
Task<ImportResult> ImportAsync(string bundlePath, ImportOptions options, CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
using StellaOps.Policy.Snapshots;
|
||||
|
||||
namespace StellaOps.ExportCenter.Snapshots;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a portable snapshot bundle.
|
||||
/// </summary>
|
||||
public sealed record SnapshotBundle
|
||||
{
|
||||
/// <summary>
|
||||
/// The snapshot manifest.
|
||||
/// </summary>
|
||||
public required KnowledgeSnapshotManifest Manifest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Signed envelope of the manifest (if sealed).
|
||||
/// </summary>
|
||||
public string? SignedEnvelope { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Bundle metadata.
|
||||
/// </summary>
|
||||
public required BundleInfo Info { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Source files included in the bundle.
|
||||
/// </summary>
|
||||
public required IReadOnlyList<BundledFile> Sources { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Policy bundle file.
|
||||
/// </summary>
|
||||
public BundledFile? Policy { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Scoring rules file.
|
||||
/// </summary>
|
||||
public BundledFile? Scoring { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Trust bundle file.
|
||||
/// </summary>
|
||||
public BundledFile? Trust { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Metadata about the bundle.
|
||||
/// </summary>
|
||||
public sealed record BundleInfo
|
||||
{
|
||||
public required string BundleId { get; init; }
|
||||
public required DateTimeOffset CreatedAt { get; init; }
|
||||
public required string CreatedBy { get; init; }
|
||||
public required SnapshotInclusionLevel InclusionLevel { get; init; }
|
||||
public required long TotalSizeBytes { get; init; }
|
||||
public required int FileCount { get; init; }
|
||||
public string? Description { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A file included in the bundle.
|
||||
/// </summary>
|
||||
public sealed record BundledFile(
|
||||
string Path,
|
||||
string Digest,
|
||||
long SizeBytes,
|
||||
bool IsCompressed);
|
||||
|
||||
/// <summary>
|
||||
/// Level of content inclusion in the bundle.
|
||||
/// </summary>
|
||||
public enum SnapshotInclusionLevel
|
||||
{
|
||||
/// <summary>
|
||||
/// Only manifest with content digests (requires network for replay).
|
||||
/// </summary>
|
||||
ReferenceOnly,
|
||||
|
||||
/// <summary>
|
||||
/// Manifest plus essential sources for offline replay.
|
||||
/// </summary>
|
||||
Portable,
|
||||
|
||||
/// <summary>
|
||||
/// Full bundle with all sources, sealed and signed.
|
||||
/// </summary>
|
||||
Sealed
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
using StellaOps.Policy.Snapshots;
|
||||
|
||||
namespace StellaOps.ExportCenter.Snapshots;
|
||||
|
||||
/// <summary>
|
||||
/// Handles snapshot level-specific behavior.
|
||||
/// </summary>
|
||||
public sealed class SnapshotLevelHandler
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the default export options for a given inclusion level.
|
||||
/// </summary>
|
||||
public ExportOptions GetDefaultOptions(SnapshotInclusionLevel level)
|
||||
{
|
||||
return level switch
|
||||
{
|
||||
SnapshotInclusionLevel.ReferenceOnly => new ExportOptions
|
||||
{
|
||||
InclusionLevel = level,
|
||||
CompressSources = false,
|
||||
IncludePolicy = false,
|
||||
IncludeScoring = false,
|
||||
IncludeTrust = false
|
||||
},
|
||||
|
||||
SnapshotInclusionLevel.Portable => new ExportOptions
|
||||
{
|
||||
InclusionLevel = level,
|
||||
CompressSources = true,
|
||||
IncludePolicy = true,
|
||||
IncludeScoring = true,
|
||||
IncludeTrust = false
|
||||
},
|
||||
|
||||
SnapshotInclusionLevel.Sealed => new ExportOptions
|
||||
{
|
||||
InclusionLevel = level,
|
||||
CompressSources = true,
|
||||
IncludePolicy = true,
|
||||
IncludeScoring = true,
|
||||
IncludeTrust = true
|
||||
},
|
||||
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(level))
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates that a snapshot can be exported at the requested level.
|
||||
/// </summary>
|
||||
public ValidationResult ValidateForExport(
|
||||
KnowledgeSnapshotManifest manifest,
|
||||
SnapshotInclusionLevel level)
|
||||
{
|
||||
var issues = new List<string>();
|
||||
|
||||
// Sealed level requires signature
|
||||
if (level == SnapshotInclusionLevel.Sealed && manifest.Signature is null)
|
||||
{
|
||||
issues.Add("Sealed export requires signed manifest. Seal the snapshot first.");
|
||||
}
|
||||
|
||||
// Portable and Sealed require bundled sources
|
||||
if (level != SnapshotInclusionLevel.ReferenceOnly)
|
||||
{
|
||||
var referencedOnly = manifest.Sources
|
||||
.Where(s => s.InclusionMode == SourceInclusionMode.Referenced)
|
||||
.ToList();
|
||||
|
||||
// Only warn if ALL sources are referenced-only
|
||||
if (referencedOnly.Count == manifest.Sources.Count && manifest.Sources.Count > 0)
|
||||
{
|
||||
issues.Add($"All {referencedOnly.Count} sources are reference-only; bundle will have no source data");
|
||||
}
|
||||
}
|
||||
|
||||
return issues.Count == 0
|
||||
? ValidationResult.Valid()
|
||||
: ValidationResult.Invalid(issues);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the minimum requirements for replay at each level.
|
||||
/// </summary>
|
||||
public ReplayRequirements GetReplayRequirements(SnapshotInclusionLevel level)
|
||||
{
|
||||
return level switch
|
||||
{
|
||||
SnapshotInclusionLevel.ReferenceOnly => new ReplayRequirements
|
||||
{
|
||||
RequiresNetwork = true,
|
||||
RequiresLocalStore = true,
|
||||
RequiresTrustBundle = false,
|
||||
Description = "Requires network access to fetch sources by digest"
|
||||
},
|
||||
|
||||
SnapshotInclusionLevel.Portable => new ReplayRequirements
|
||||
{
|
||||
RequiresNetwork = false,
|
||||
RequiresLocalStore = false,
|
||||
RequiresTrustBundle = false,
|
||||
Description = "Fully offline replay possible"
|
||||
},
|
||||
|
||||
SnapshotInclusionLevel.Sealed => new ReplayRequirements
|
||||
{
|
||||
RequiresNetwork = false,
|
||||
RequiresLocalStore = false,
|
||||
RequiresTrustBundle = true,
|
||||
Description = "Fully offline replay with cryptographic verification"
|
||||
},
|
||||
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(level))
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of validation.
|
||||
/// </summary>
|
||||
public sealed record ValidationResult
|
||||
{
|
||||
public bool IsValid { get; init; }
|
||||
public IReadOnlyList<string> Issues { get; init; } = [];
|
||||
|
||||
public static ValidationResult Valid() => new() { IsValid = true };
|
||||
public static ValidationResult Invalid(IReadOnlyList<string> issues) =>
|
||||
new() { IsValid = false, Issues = issues };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Requirements for replay at a given level.
|
||||
/// </summary>
|
||||
public sealed record ReplayRequirements
|
||||
{
|
||||
public bool RequiresNetwork { get; init; }
|
||||
public bool RequiresLocalStore { get; init; }
|
||||
public bool RequiresTrustBundle { get; init; }
|
||||
public required string Description { get; init; }
|
||||
}
|
||||
@@ -19,5 +19,6 @@
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\TimelineIndexer\StellaOps.TimelineIndexer\StellaOps.TimelineIndexer.Core\StellaOps.TimelineIndexer.Core.csproj" />
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Cryptography\StellaOps.Cryptography.csproj" />
|
||||
<ProjectReference Include="..\..\..\Policy\__Libraries\StellaOps.Policy\StellaOps.Policy.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using StellaOps.ExportCenter.WebService.Distribution.Oci;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.ExportCenter.Tests.Distribution.Oci;
|
||||
|
||||
public sealed class OciReferrerDiscoveryTests
|
||||
{
|
||||
private readonly Mock<IOciAuthProvider> _mockAuth;
|
||||
private readonly NullLogger<OciReferrerDiscovery> _logger;
|
||||
|
||||
public OciReferrerDiscoveryTests()
|
||||
{
|
||||
_mockAuth = new Mock<IOciAuthProvider>();
|
||||
_mockAuth.Setup(a => a.GetTokenAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync("test-token");
|
||||
_logger = NullLogger<OciReferrerDiscovery>.Instance;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListReferrers_WithReferrersApi_ReturnsResults()
|
||||
{
|
||||
// Arrange
|
||||
var manifests = new[]
|
||||
{
|
||||
new { digest = "sha256:rva1", artifactType = OciArtifactTypes.RvaJson, mediaType = OciMediaTypes.ImageManifest, size = 1234L }
|
||||
};
|
||||
var indexJson = JsonSerializer.Serialize(new
|
||||
{
|
||||
schemaVersion = 2,
|
||||
mediaType = OciMediaTypes.ImageIndex,
|
||||
manifests
|
||||
});
|
||||
|
||||
var mockHandler = CreateMockHandler(HttpStatusCode.OK, indexJson);
|
||||
var discovery = new OciReferrerDiscovery(
|
||||
new HttpClient(mockHandler),
|
||||
_mockAuth.Object,
|
||||
_logger);
|
||||
|
||||
// Act
|
||||
var result = await discovery.ListReferrersAsync(
|
||||
"registry.example.com", "myapp", "sha256:image123");
|
||||
|
||||
// Assert
|
||||
result.IsSuccess.Should().BeTrue();
|
||||
result.Referrers.Should().HaveCount(1);
|
||||
result.Referrers[0].Digest.Should().Be("sha256:rva1");
|
||||
result.Referrers[0].ArtifactType.Should().Be(OciArtifactTypes.RvaJson);
|
||||
result.SupportsReferrersApi.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListReferrers_FallbackToTags_ReturnsResults()
|
||||
{
|
||||
// Arrange - 404 on referrers API, then list tags
|
||||
var callCount = 0;
|
||||
var mockHandler = new MockFallbackHandler(
|
||||
request =>
|
||||
{
|
||||
callCount++;
|
||||
if (request.RequestUri!.PathAndQuery.Contains("/referrers/"))
|
||||
{
|
||||
return (HttpStatusCode.NotFound, "{}");
|
||||
}
|
||||
if (request.RequestUri.PathAndQuery.Contains("/tags/list"))
|
||||
{
|
||||
return (HttpStatusCode.OK, JsonSerializer.Serialize(new
|
||||
{
|
||||
name = "myapp",
|
||||
tags = new[] { "sha256-image123.rva", "latest" }
|
||||
}));
|
||||
}
|
||||
if (request.RequestUri.PathAndQuery.Contains("/manifests/"))
|
||||
{
|
||||
return (HttpStatusCode.OK, JsonSerializer.Serialize(new
|
||||
{
|
||||
schemaVersion = 2,
|
||||
mediaType = OciMediaTypes.ImageManifest,
|
||||
artifactType = OciArtifactTypes.RvaJson,
|
||||
config = new { mediaType = OciMediaTypes.EmptyConfig, digest = "sha256:config", size = 2 },
|
||||
layers = new object[] { }
|
||||
}));
|
||||
}
|
||||
return (HttpStatusCode.NotFound, "{}");
|
||||
});
|
||||
|
||||
var discovery = new OciReferrerDiscovery(
|
||||
new HttpClient(mockHandler),
|
||||
_mockAuth.Object,
|
||||
_logger);
|
||||
|
||||
// Act
|
||||
var result = await discovery.ListReferrersAsync(
|
||||
"registry.example.com", "myapp", "sha256:image123");
|
||||
|
||||
// Assert
|
||||
result.IsSuccess.Should().BeTrue();
|
||||
result.SupportsReferrersApi.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListReferrers_WithFilter_FiltersResults()
|
||||
{
|
||||
// Arrange
|
||||
var manifests = new[]
|
||||
{
|
||||
new { digest = "sha256:rva1", artifactType = OciArtifactTypes.RvaJson, mediaType = OciMediaTypes.ImageManifest, size = 100L },
|
||||
new { digest = "sha256:sbom1", artifactType = OciArtifactTypes.SbomCyclonedx, mediaType = OciMediaTypes.ImageManifest, size = 200L }
|
||||
};
|
||||
var indexJson = JsonSerializer.Serialize(new
|
||||
{
|
||||
schemaVersion = 2,
|
||||
mediaType = OciMediaTypes.ImageIndex,
|
||||
manifests
|
||||
});
|
||||
|
||||
var mockHandler = CreateMockHandler(HttpStatusCode.OK, indexJson);
|
||||
var discovery = new OciReferrerDiscovery(
|
||||
new HttpClient(mockHandler),
|
||||
_mockAuth.Object,
|
||||
_logger);
|
||||
|
||||
// Act - filter for RVA only
|
||||
var result = await discovery.ListReferrersAsync(
|
||||
"registry.example.com", "myapp", "sha256:image123",
|
||||
new ReferrerFilterOptions { ArtifactType = OciArtifactTypes.RvaJson });
|
||||
|
||||
// Assert
|
||||
// The filter is passed to the API as query param, server handles filtering
|
||||
result.IsSuccess.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FindRvaAttestations_ReturnsRvaArtifacts()
|
||||
{
|
||||
// Arrange
|
||||
var manifests = new[]
|
||||
{
|
||||
new { digest = "sha256:rva1", artifactType = OciArtifactTypes.RvaDsse, mediaType = OciMediaTypes.ImageManifest, size = 100L }
|
||||
};
|
||||
var indexJson = JsonSerializer.Serialize(new
|
||||
{
|
||||
schemaVersion = 2,
|
||||
mediaType = OciMediaTypes.ImageIndex,
|
||||
manifests
|
||||
});
|
||||
|
||||
var mockHandler = CreateMockHandler(HttpStatusCode.OK, indexJson);
|
||||
var discovery = new OciReferrerDiscovery(
|
||||
new HttpClient(mockHandler),
|
||||
_mockAuth.Object,
|
||||
_logger);
|
||||
|
||||
// Act
|
||||
var results = await discovery.FindRvaAttestationsAsync(
|
||||
"registry.example.com", "myapp", "sha256:image123");
|
||||
|
||||
// Assert
|
||||
results.Should().HaveCount(1);
|
||||
results[0].ArtifactType.Should().Be(OciArtifactTypes.RvaDsse);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetReferrerManifest_ValidDigest_ReturnsManifest()
|
||||
{
|
||||
// Arrange
|
||||
var manifestJson = JsonSerializer.Serialize(new
|
||||
{
|
||||
schemaVersion = 2,
|
||||
mediaType = OciMediaTypes.ImageManifest,
|
||||
artifactType = OciArtifactTypes.RvaJson,
|
||||
config = new { mediaType = OciMediaTypes.EmptyConfig, digest = "sha256:config", size = 2 },
|
||||
layers = new[]
|
||||
{
|
||||
new { mediaType = OciArtifactTypes.RvaJson, digest = "sha256:layer1", size = 1234 }
|
||||
},
|
||||
annotations = new Dictionary<string, string>
|
||||
{
|
||||
["ops.stella.rva.id"] = "rva:test123"
|
||||
}
|
||||
});
|
||||
|
||||
var mockHandler = CreateMockHandler(HttpStatusCode.OK, manifestJson);
|
||||
var discovery = new OciReferrerDiscovery(
|
||||
new HttpClient(mockHandler),
|
||||
_mockAuth.Object,
|
||||
_logger);
|
||||
|
||||
// Act
|
||||
var manifest = await discovery.GetReferrerManifestAsync(
|
||||
"registry.example.com", "myapp", "sha256:test123");
|
||||
|
||||
// Assert
|
||||
manifest.Should().NotBeNull();
|
||||
manifest!.Layers.Should().HaveCount(1);
|
||||
manifest.Annotations.Should().ContainKey("ops.stella.rva.id");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetLayerContent_ValidDigest_ReturnsContent()
|
||||
{
|
||||
// Arrange
|
||||
var content = Encoding.UTF8.GetBytes("{\"test\":\"content\"}");
|
||||
var mockHandler = new MockContentHandler(HttpStatusCode.OK, content);
|
||||
|
||||
var discovery = new OciReferrerDiscovery(
|
||||
new HttpClient(mockHandler),
|
||||
_mockAuth.Object,
|
||||
_logger);
|
||||
|
||||
// Act
|
||||
var result = await discovery.GetLayerContentAsync(
|
||||
"registry.example.com", "myapp", "sha256:layer123");
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result.Should().BeEquivalentTo(content);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetLayerContent_NotFound_ReturnsNull()
|
||||
{
|
||||
// Arrange
|
||||
var mockHandler = new MockContentHandler(HttpStatusCode.NotFound, []);
|
||||
|
||||
var discovery = new OciReferrerDiscovery(
|
||||
new HttpClient(mockHandler),
|
||||
_mockAuth.Object,
|
||||
_logger);
|
||||
|
||||
// Act
|
||||
var result = await discovery.GetLayerContentAsync(
|
||||
"registry.example.com", "myapp", "sha256:nonexistent");
|
||||
|
||||
// Assert
|
||||
result.Should().BeNull();
|
||||
}
|
||||
|
||||
private static MockHandler CreateMockHandler(HttpStatusCode statusCode, string content)
|
||||
{
|
||||
return new MockHandler(statusCode, content);
|
||||
}
|
||||
|
||||
private class MockHandler : HttpMessageHandler
|
||||
{
|
||||
private readonly HttpStatusCode _statusCode;
|
||||
private readonly string _content;
|
||||
|
||||
public MockHandler(HttpStatusCode statusCode, string content)
|
||||
{
|
||||
_statusCode = statusCode;
|
||||
_content = content;
|
||||
}
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(
|
||||
HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult(new HttpResponseMessage(_statusCode)
|
||||
{
|
||||
Content = new StringContent(_content, Encoding.UTF8, "application/json")
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private class MockFallbackHandler : HttpMessageHandler
|
||||
{
|
||||
private readonly Func<HttpRequestMessage, (HttpStatusCode, string)> _responseFactory;
|
||||
|
||||
public MockFallbackHandler(Func<HttpRequestMessage, (HttpStatusCode, string)> responseFactory)
|
||||
{
|
||||
_responseFactory = responseFactory;
|
||||
}
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(
|
||||
HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
var (statusCode, content) = _responseFactory(request);
|
||||
return Task.FromResult(new HttpResponseMessage(statusCode)
|
||||
{
|
||||
Content = new StringContent(content, Encoding.UTF8, "application/json")
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private class MockContentHandler : HttpMessageHandler
|
||||
{
|
||||
private readonly HttpStatusCode _statusCode;
|
||||
private readonly byte[] _content;
|
||||
|
||||
public MockContentHandler(HttpStatusCode statusCode, byte[] content)
|
||||
{
|
||||
_statusCode = statusCode;
|
||||
_content = content;
|
||||
}
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(
|
||||
HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult(new HttpResponseMessage(_statusCode)
|
||||
{
|
||||
Content = new ByteArrayContent(_content)
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
using System.Net;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using StellaOps.ExportCenter.WebService.Distribution.Oci;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.ExportCenter.Tests.Distribution.Oci;
|
||||
|
||||
public sealed class OciReferrerPushClientTests
|
||||
{
|
||||
private readonly Mock<IOciAuthProvider> _mockAuth;
|
||||
private readonly NullLogger<OciReferrerPushClient> _logger;
|
||||
|
||||
public OciReferrerPushClientTests()
|
||||
{
|
||||
_mockAuth = new Mock<IOciAuthProvider>();
|
||||
_mockAuth.Setup(a => a.GetTokenAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync("test-token");
|
||||
_logger = NullLogger<OciReferrerPushClient>.Instance;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PushArtifact_ValidRequest_Succeeds()
|
||||
{
|
||||
// Arrange
|
||||
var mockHandler = CreateMockHandler(
|
||||
HttpStatusCode.NotFound, // HEAD check - blob doesn't exist
|
||||
HttpStatusCode.Accepted, // POST upload initiate
|
||||
HttpStatusCode.Created, // PUT blob complete
|
||||
HttpStatusCode.NotFound, // HEAD check for content blob
|
||||
HttpStatusCode.Accepted, // POST upload initiate
|
||||
HttpStatusCode.Created, // PUT blob complete
|
||||
HttpStatusCode.Created); // PUT manifest
|
||||
|
||||
var client = new OciReferrerPushClient(
|
||||
new HttpClient(mockHandler),
|
||||
_mockAuth.Object,
|
||||
_logger);
|
||||
|
||||
var request = new ReferrerPushRequest
|
||||
{
|
||||
Registry = "registry.example.com",
|
||||
Repository = "myapp",
|
||||
Content = "test content"u8.ToArray(),
|
||||
ContentMediaType = OciArtifactTypes.RvaJson,
|
||||
ArtifactType = OciArtifactTypes.RvaJson,
|
||||
SubjectDigest = "sha256:abc123def456"
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await client.PushArtifactAsync(request);
|
||||
|
||||
// Assert
|
||||
result.IsSuccess.Should().BeTrue();
|
||||
result.Digest.Should().StartWith("sha256:");
|
||||
result.Registry.Should().Be("registry.example.com");
|
||||
result.Repository.Should().Be("myapp");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PushArtifact_BlobAlreadyExists_SkipsUpload()
|
||||
{
|
||||
// Arrange - blob already exists (200 on HEAD)
|
||||
var mockHandler = CreateMockHandler(
|
||||
HttpStatusCode.OK, // HEAD check - config blob exists
|
||||
HttpStatusCode.OK, // HEAD check - content blob exists
|
||||
HttpStatusCode.Created); // PUT manifest
|
||||
|
||||
var client = new OciReferrerPushClient(
|
||||
new HttpClient(mockHandler),
|
||||
_mockAuth.Object,
|
||||
_logger);
|
||||
|
||||
var request = new ReferrerPushRequest
|
||||
{
|
||||
Registry = "registry.example.com",
|
||||
Repository = "myapp",
|
||||
Content = "test content"u8.ToArray(),
|
||||
ContentMediaType = OciArtifactTypes.RvaJson
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await client.PushArtifactAsync(request);
|
||||
|
||||
// Assert
|
||||
result.IsSuccess.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PushArtifact_WithSubjectDigest_SetsReferrer()
|
||||
{
|
||||
// Arrange
|
||||
var mockHandler = CreateMockHandler(
|
||||
HttpStatusCode.OK, // HEAD - blob exists
|
||||
HttpStatusCode.OK, // HEAD - blob exists
|
||||
HttpStatusCode.Created); // PUT manifest
|
||||
|
||||
var client = new OciReferrerPushClient(
|
||||
new HttpClient(mockHandler),
|
||||
_mockAuth.Object,
|
||||
_logger);
|
||||
|
||||
var request = new ReferrerPushRequest
|
||||
{
|
||||
Registry = "registry.example.com",
|
||||
Repository = "myapp",
|
||||
Content = "test content"u8.ToArray(),
|
||||
ContentMediaType = OciArtifactTypes.RvaJson,
|
||||
SubjectDigest = "sha256:abc123def456"
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await client.PushArtifactAsync(request);
|
||||
|
||||
// Assert
|
||||
result.IsSuccess.Should().BeTrue();
|
||||
result.ReferrerUri.Should().Contain("registry.example.com/myapp@");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PushArtifact_ManifestPushFails_ReturnsError()
|
||||
{
|
||||
// Arrange
|
||||
var mockHandler = CreateMockHandler(
|
||||
HttpStatusCode.OK, // HEAD - blob exists
|
||||
HttpStatusCode.OK, // HEAD - blob exists
|
||||
HttpStatusCode.Unauthorized); // PUT manifest fails
|
||||
|
||||
var client = new OciReferrerPushClient(
|
||||
new HttpClient(mockHandler),
|
||||
_mockAuth.Object,
|
||||
_logger);
|
||||
|
||||
var request = new ReferrerPushRequest
|
||||
{
|
||||
Registry = "registry.example.com",
|
||||
Repository = "myapp",
|
||||
Content = "test content"u8.ToArray(),
|
||||
ContentMediaType = OciArtifactTypes.RvaJson
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await client.PushArtifactAsync(request);
|
||||
|
||||
// Assert
|
||||
result.IsSuccess.Should().BeFalse();
|
||||
result.Error.Should().NotBeNullOrEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PushArtifact_WithAnnotations_IncludesInManifest()
|
||||
{
|
||||
// Arrange
|
||||
var mockHandler = CreateMockHandler(
|
||||
HttpStatusCode.OK,
|
||||
HttpStatusCode.OK,
|
||||
HttpStatusCode.Created);
|
||||
|
||||
var client = new OciReferrerPushClient(
|
||||
new HttpClient(mockHandler),
|
||||
_mockAuth.Object,
|
||||
_logger);
|
||||
|
||||
var request = new ReferrerPushRequest
|
||||
{
|
||||
Registry = "registry.example.com",
|
||||
Repository = "myapp",
|
||||
Content = "test content"u8.ToArray(),
|
||||
ContentMediaType = OciArtifactTypes.RvaJson,
|
||||
ArtifactType = OciArtifactTypes.RvaDsse,
|
||||
ManifestAnnotations = new Dictionary<string, string>
|
||||
{
|
||||
["org.opencontainers.image.title"] = "Test RVA"
|
||||
},
|
||||
LayerAnnotations = new Dictionary<string, string>
|
||||
{
|
||||
["ops.stella.rva.id"] = "rva:test123"
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await client.PushArtifactAsync(request);
|
||||
|
||||
// Assert
|
||||
result.IsSuccess.Should().BeTrue();
|
||||
}
|
||||
|
||||
private static MockHandler CreateMockHandler(params HttpStatusCode[] responseCodes)
|
||||
{
|
||||
return new MockHandler(responseCodes);
|
||||
}
|
||||
|
||||
private class MockHandler : HttpMessageHandler
|
||||
{
|
||||
private readonly Queue<HttpStatusCode> _responseCodes;
|
||||
|
||||
public MockHandler(params HttpStatusCode[] responseCodes)
|
||||
{
|
||||
_responseCodes = new Queue<HttpStatusCode>(responseCodes);
|
||||
}
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(
|
||||
HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
var statusCode = _responseCodes.Count > 0
|
||||
? _responseCodes.Dequeue()
|
||||
: HttpStatusCode.OK;
|
||||
|
||||
var response = new HttpResponseMessage(statusCode);
|
||||
|
||||
// Add location header for upload initiation
|
||||
if (request.Method == HttpMethod.Post && statusCode == HttpStatusCode.Accepted)
|
||||
{
|
||||
response.Headers.Location = new Uri("/v2/myapp/blobs/uploads/test-session", UriKind.Relative);
|
||||
}
|
||||
|
||||
return Task.FromResult(response);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using StellaOps.ExportCenter.WebService.Distribution.Oci;
|
||||
using StellaOps.Policy.Engine.Attestation;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.ExportCenter.Tests.Distribution.Oci;
|
||||
|
||||
public sealed class RvaOciPublisherTests
|
||||
{
|
||||
private readonly Mock<IOciReferrerFallback> _mockFallback;
|
||||
private readonly Mock<IRvaEnvelopeSigner> _mockSigner;
|
||||
private readonly NullLogger<RvaOciPublisher> _logger;
|
||||
|
||||
public RvaOciPublisherTests()
|
||||
{
|
||||
_mockFallback = new Mock<IOciReferrerFallback>();
|
||||
_mockSigner = new Mock<IRvaEnvelopeSigner>();
|
||||
_mockSigner.SetupGet(s => s.KeyId).Returns("test-key-id");
|
||||
_mockSigner.Setup(s => s.SignAsync(It.IsAny<byte[]>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new RvaSignatureResult
|
||||
{
|
||||
Signature = new byte[] { 1, 2, 3, 4 },
|
||||
KeyId = "test-key-id",
|
||||
Algorithm = "ECDSA-P256"
|
||||
});
|
||||
_logger = NullLogger<RvaOciPublisher>.Instance;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Publish_ValidRva_CreatesReferrer()
|
||||
{
|
||||
// Arrange
|
||||
_mockFallback.Setup(f => f.PushWithFallbackAsync(
|
||||
It.IsAny<ReferrerPushRequest>(),
|
||||
It.IsAny<FallbackOptions>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new ReferrerPushResult
|
||||
{
|
||||
IsSuccess = true,
|
||||
Digest = "sha256:result123",
|
||||
Registry = "registry.example.com",
|
||||
Repository = "myapp",
|
||||
ReferrerUri = "registry.example.com/myapp@sha256:result123"
|
||||
});
|
||||
|
||||
var publisher = new RvaOciPublisher(_mockFallback.Object, _mockSigner.Object, _logger);
|
||||
var rva = CreateTestRva();
|
||||
var options = new RvaPublishOptions
|
||||
{
|
||||
Registry = "registry.example.com",
|
||||
Repository = "myapp"
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await publisher.PublishAsync(rva, options);
|
||||
|
||||
// Assert
|
||||
result.IsSuccess.Should().BeTrue();
|
||||
result.ArtifactDigest.Should().Be("sha256:result123");
|
||||
result.ReferrerUri.Should().Contain("registry.example.com/myapp@");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Publish_WithSigning_UsesDsse()
|
||||
{
|
||||
// Arrange
|
||||
ReferrerPushRequest? capturedRequest = null;
|
||||
_mockFallback.Setup(f => f.PushWithFallbackAsync(
|
||||
It.IsAny<ReferrerPushRequest>(),
|
||||
It.IsAny<FallbackOptions>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Callback<ReferrerPushRequest, FallbackOptions, CancellationToken>((r, _, _) => capturedRequest = r)
|
||||
.ReturnsAsync(new ReferrerPushResult
|
||||
{
|
||||
IsSuccess = true,
|
||||
Digest = "sha256:result123"
|
||||
});
|
||||
|
||||
var publisher = new RvaOciPublisher(_mockFallback.Object, _mockSigner.Object, _logger);
|
||||
var rva = CreateTestRva();
|
||||
var options = new RvaPublishOptions
|
||||
{
|
||||
Registry = "registry.example.com",
|
||||
Repository = "myapp",
|
||||
SignAttestation = true
|
||||
};
|
||||
|
||||
// Act
|
||||
await publisher.PublishAsync(rva, options);
|
||||
|
||||
// Assert
|
||||
capturedRequest.Should().NotBeNull();
|
||||
capturedRequest!.ArtifactType.Should().Be(OciArtifactTypes.RvaDsse);
|
||||
_mockSigner.Verify(s => s.SignAsync(It.IsAny<byte[]>(), It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Publish_WithoutSigning_UsesPlainJson()
|
||||
{
|
||||
// Arrange
|
||||
ReferrerPushRequest? capturedRequest = null;
|
||||
_mockFallback.Setup(f => f.PushWithFallbackAsync(
|
||||
It.IsAny<ReferrerPushRequest>(),
|
||||
It.IsAny<FallbackOptions>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Callback<ReferrerPushRequest, FallbackOptions, CancellationToken>((r, _, _) => capturedRequest = r)
|
||||
.ReturnsAsync(new ReferrerPushResult
|
||||
{
|
||||
IsSuccess = true,
|
||||
Digest = "sha256:result123"
|
||||
});
|
||||
|
||||
// No signer provided
|
||||
var publisher = new RvaOciPublisher(_mockFallback.Object, null, _logger);
|
||||
var rva = CreateTestRva();
|
||||
var options = new RvaPublishOptions
|
||||
{
|
||||
Registry = "registry.example.com",
|
||||
Repository = "myapp",
|
||||
SignAttestation = true // Even if true, no signer means plain JSON
|
||||
};
|
||||
|
||||
// Act
|
||||
await publisher.PublishAsync(rva, options);
|
||||
|
||||
// Assert
|
||||
capturedRequest.Should().NotBeNull();
|
||||
capturedRequest!.ArtifactType.Should().Be(OciArtifactTypes.RvaJson);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Publish_SetsCorrectAnnotations()
|
||||
{
|
||||
// Arrange
|
||||
ReferrerPushRequest? capturedRequest = null;
|
||||
_mockFallback.Setup(f => f.PushWithFallbackAsync(
|
||||
It.IsAny<ReferrerPushRequest>(),
|
||||
It.IsAny<FallbackOptions>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Callback<ReferrerPushRequest, FallbackOptions, CancellationToken>((r, _, _) => capturedRequest = r)
|
||||
.ReturnsAsync(new ReferrerPushResult
|
||||
{
|
||||
IsSuccess = true,
|
||||
Digest = "sha256:result123"
|
||||
});
|
||||
|
||||
var publisher = new RvaOciPublisher(_mockFallback.Object, null, _logger);
|
||||
var rva = CreateTestRva(verdict: RiskVerdictStatus.Pass);
|
||||
var options = new RvaPublishOptions
|
||||
{
|
||||
Registry = "registry.example.com",
|
||||
Repository = "myapp"
|
||||
};
|
||||
|
||||
// Act
|
||||
await publisher.PublishAsync(rva, options);
|
||||
|
||||
// Assert
|
||||
capturedRequest.Should().NotBeNull();
|
||||
capturedRequest!.ManifestAnnotations.Should().ContainKey(OciRvaAnnotations.RvaVerdict);
|
||||
capturedRequest.ManifestAnnotations![OciRvaAnnotations.RvaVerdict].Should().Be("Pass");
|
||||
capturedRequest.LayerAnnotations.Should().ContainKey(OciRvaAnnotations.RvaId);
|
||||
capturedRequest.LayerAnnotations![OciRvaAnnotations.RvaPolicy].Should().Be("test-policy");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Publish_WithExceptions_SetsHasExceptionsAnnotation()
|
||||
{
|
||||
// Arrange
|
||||
ReferrerPushRequest? capturedRequest = null;
|
||||
_mockFallback.Setup(f => f.PushWithFallbackAsync(
|
||||
It.IsAny<ReferrerPushRequest>(),
|
||||
It.IsAny<FallbackOptions>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Callback<ReferrerPushRequest, FallbackOptions, CancellationToken>((r, _, _) => capturedRequest = r)
|
||||
.ReturnsAsync(new ReferrerPushResult
|
||||
{
|
||||
IsSuccess = true,
|
||||
Digest = "sha256:result123"
|
||||
});
|
||||
|
||||
var publisher = new RvaOciPublisher(_mockFallback.Object, null, _logger);
|
||||
var rva = CreateTestRva(verdict: RiskVerdictStatus.PassWithExceptions,
|
||||
appliedExceptions: ["exception-1", "exception-2"]);
|
||||
var options = new RvaPublishOptions
|
||||
{
|
||||
Registry = "registry.example.com",
|
||||
Repository = "myapp"
|
||||
};
|
||||
|
||||
// Act
|
||||
await publisher.PublishAsync(rva, options);
|
||||
|
||||
// Assert
|
||||
capturedRequest.Should().NotBeNull();
|
||||
capturedRequest!.ManifestAnnotations.Should().ContainKey(OciRvaAnnotations.RvaHasExceptions);
|
||||
capturedRequest.ManifestAnnotations![OciRvaAnnotations.RvaHasExceptions].Should().Be("true");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Publish_PushFails_ReturnsError()
|
||||
{
|
||||
// Arrange
|
||||
_mockFallback.Setup(f => f.PushWithFallbackAsync(
|
||||
It.IsAny<ReferrerPushRequest>(),
|
||||
It.IsAny<FallbackOptions>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new ReferrerPushResult
|
||||
{
|
||||
IsSuccess = false,
|
||||
Error = "Registry unreachable"
|
||||
});
|
||||
|
||||
var publisher = new RvaOciPublisher(_mockFallback.Object, null, _logger);
|
||||
var rva = CreateTestRva();
|
||||
var options = new RvaPublishOptions
|
||||
{
|
||||
Registry = "registry.example.com",
|
||||
Repository = "myapp"
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await publisher.PublishAsync(rva, options);
|
||||
|
||||
// Assert
|
||||
result.IsSuccess.Should().BeFalse();
|
||||
result.Error.Should().Be("Registry unreachable");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PublishBatch_MultiplRvas_PublishesAll()
|
||||
{
|
||||
// Arrange
|
||||
var publishCount = 0;
|
||||
_mockFallback.Setup(f => f.PushWithFallbackAsync(
|
||||
It.IsAny<ReferrerPushRequest>(),
|
||||
It.IsAny<FallbackOptions>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(() =>
|
||||
{
|
||||
publishCount++;
|
||||
return new ReferrerPushResult
|
||||
{
|
||||
IsSuccess = true,
|
||||
Digest = $"sha256:result{publishCount}"
|
||||
};
|
||||
});
|
||||
|
||||
var publisher = new RvaOciPublisher(_mockFallback.Object, null, _logger);
|
||||
var rvas = new[]
|
||||
{
|
||||
CreateTestRva("rva:1"),
|
||||
CreateTestRva("rva:2"),
|
||||
CreateTestRva("rva:3")
|
||||
};
|
||||
var options = new RvaPublishOptions
|
||||
{
|
||||
Registry = "registry.example.com",
|
||||
Repository = "myapp"
|
||||
};
|
||||
|
||||
// Act
|
||||
var results = await publisher.PublishBatchAsync(rvas, options);
|
||||
|
||||
// Assert
|
||||
results.Should().HaveCount(3);
|
||||
results.All(r => r.IsSuccess).Should().BeTrue();
|
||||
publishCount.Should().Be(3);
|
||||
}
|
||||
|
||||
private static RiskVerdictAttestation CreateTestRva(
|
||||
string? attestationId = null,
|
||||
RiskVerdictStatus verdict = RiskVerdictStatus.Pass,
|
||||
IReadOnlyList<string>? appliedExceptions = null)
|
||||
{
|
||||
return new RiskVerdictAttestation
|
||||
{
|
||||
AttestationId = attestationId ?? $"rva:{Guid.NewGuid():N}",
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
Verdict = verdict,
|
||||
Subject = new ArtifactSubject
|
||||
{
|
||||
Digest = "sha256:abc123def456",
|
||||
Type = "container-image",
|
||||
Name = "myapp:v1.0"
|
||||
},
|
||||
Policy = new RvaPolicyRef
|
||||
{
|
||||
PolicyId = "test-policy",
|
||||
Version = "1.0",
|
||||
Digest = "sha256:policy123"
|
||||
},
|
||||
KnowledgeSnapshotId = "ksm:sha256:snapshot123",
|
||||
AppliedExceptions = appliedExceptions ?? []
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
using System.IO.Compression;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Cryptography;
|
||||
using StellaOps.ExportCenter.Snapshots;
|
||||
using StellaOps.Policy.Replay;
|
||||
using StellaOps.Policy.Snapshots;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.ExportCenter.Tests.Snapshots;
|
||||
|
||||
public sealed class ExportSnapshotServiceTests : IDisposable
|
||||
{
|
||||
private readonly ICryptoHash _hasher = DefaultCryptoHash.CreateForTests();
|
||||
private readonly InMemorySnapshotStore _snapshotStore = new();
|
||||
private readonly TestKnowledgeSourceResolver _sourceResolver = new();
|
||||
private readonly SnapshotService _snapshotService;
|
||||
private readonly ExportSnapshotService _exportService;
|
||||
private readonly List<string> _tempFiles = [];
|
||||
|
||||
public ExportSnapshotServiceTests()
|
||||
{
|
||||
var idGenerator = new SnapshotIdGenerator(_hasher);
|
||||
_snapshotService = new SnapshotService(
|
||||
idGenerator,
|
||||
_snapshotStore,
|
||||
NullLogger<SnapshotService>.Instance);
|
||||
|
||||
_exportService = new ExportSnapshotService(
|
||||
_snapshotService,
|
||||
_sourceResolver,
|
||||
NullLogger<ExportSnapshotService>.Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Export_ValidSnapshot_CreatesZipFile()
|
||||
{
|
||||
var snapshot = await CreateSnapshotAsync();
|
||||
var options = new ExportOptions { InclusionLevel = SnapshotInclusionLevel.Portable };
|
||||
|
||||
var result = await _exportService.ExportAsync(snapshot.SnapshotId, options);
|
||||
|
||||
result.IsSuccess.Should().BeTrue();
|
||||
result.FilePath.Should().NotBeNullOrEmpty();
|
||||
File.Exists(result.FilePath).Should().BeTrue();
|
||||
_tempFiles.Add(result.FilePath!);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Export_PortableLevel_IncludesManifest()
|
||||
{
|
||||
var snapshot = await CreateSnapshotAsync();
|
||||
var options = new ExportOptions { InclusionLevel = SnapshotInclusionLevel.Portable };
|
||||
|
||||
var result = await _exportService.ExportAsync(snapshot.SnapshotId, options);
|
||||
_tempFiles.Add(result.FilePath!);
|
||||
|
||||
using var zip = ZipFile.OpenRead(result.FilePath!);
|
||||
zip.Entries.Should().Contain(e => e.Name == "manifest.json");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Export_ReferenceLevel_ExcludesSources()
|
||||
{
|
||||
var snapshot = await CreateSnapshotAsync();
|
||||
var options = new ExportOptions { InclusionLevel = SnapshotInclusionLevel.ReferenceOnly };
|
||||
|
||||
var result = await _exportService.ExportAsync(snapshot.SnapshotId, options);
|
||||
_tempFiles.Add(result.FilePath!);
|
||||
|
||||
using var zip = ZipFile.OpenRead(result.FilePath!);
|
||||
zip.Entries.Should().NotContain(e => e.FullName.StartsWith("sources/"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Export_GeneratesMetadata()
|
||||
{
|
||||
var snapshot = await CreateSnapshotAsync();
|
||||
var options = new ExportOptions
|
||||
{
|
||||
InclusionLevel = SnapshotInclusionLevel.Portable,
|
||||
Description = "Test bundle"
|
||||
};
|
||||
|
||||
var result = await _exportService.ExportAsync(snapshot.SnapshotId, options);
|
||||
_tempFiles.Add(result.FilePath!);
|
||||
|
||||
using var zip = ZipFile.OpenRead(result.FilePath!);
|
||||
zip.Entries.Should().Contain(e => e.FullName == "META/BUNDLE_INFO.json");
|
||||
zip.Entries.Should().Contain(e => e.FullName == "META/CHECKSUMS.sha256");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Export_NonExistentSnapshot_ReturnsError()
|
||||
{
|
||||
var result = await _exportService.ExportAsync("ksm:sha256:nonexistent", new ExportOptions());
|
||||
|
||||
result.IsSuccess.Should().BeFalse();
|
||||
result.Error.Should().Contain("not found");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Export_SealedLevel_RequiresSignature()
|
||||
{
|
||||
// Create unsigned snapshot
|
||||
var snapshot = await CreateSnapshotAsync();
|
||||
var options = new ExportOptions { InclusionLevel = SnapshotInclusionLevel.Sealed };
|
||||
|
||||
var result = await _exportService.ExportAsync(snapshot.SnapshotId, options);
|
||||
|
||||
result.IsSuccess.Should().BeFalse();
|
||||
result.Error.Should().Contain("Sealed");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Export_BundleInfoHasCorrectFields()
|
||||
{
|
||||
var snapshot = await CreateSnapshotAsync();
|
||||
var options = new ExportOptions
|
||||
{
|
||||
InclusionLevel = SnapshotInclusionLevel.Portable,
|
||||
CreatedBy = "TestUser",
|
||||
Description = "Test description"
|
||||
};
|
||||
|
||||
var result = await _exportService.ExportAsync(snapshot.SnapshotId, options);
|
||||
_tempFiles.Add(result.FilePath!);
|
||||
|
||||
result.BundleInfo.Should().NotBeNull();
|
||||
result.BundleInfo!.BundleId.Should().StartWith("bundle:");
|
||||
result.BundleInfo.CreatedBy.Should().Be("TestUser");
|
||||
result.BundleInfo.Description.Should().Be("Test description");
|
||||
result.BundleInfo.InclusionLevel.Should().Be(SnapshotInclusionLevel.Portable);
|
||||
}
|
||||
|
||||
private async Task<KnowledgeSnapshotManifest> CreateSnapshotAsync()
|
||||
{
|
||||
var builder = new SnapshotBuilder(_hasher)
|
||||
.WithEngine("stellaops-policy", "1.0.0", "abc123")
|
||||
.WithPolicy("test-policy", "1.0", "sha256:policy123")
|
||||
.WithScoring("test-scoring", "1.0", "sha256:scoring123")
|
||||
.WithSource(new KnowledgeSourceDescriptor
|
||||
{
|
||||
Name = "test-feed",
|
||||
Type = "advisory-feed",
|
||||
Epoch = DateTimeOffset.UtcNow.ToString("o"),
|
||||
Digest = "sha256:feed123",
|
||||
InclusionMode = SourceInclusionMode.Referenced
|
||||
});
|
||||
|
||||
return await _snapshotService.CreateSnapshotAsync(builder);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
foreach (var file in _tempFiles)
|
||||
{
|
||||
try { if (File.Exists(file)) File.Delete(file); }
|
||||
catch { /* Best effort cleanup */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test implementation of IKnowledgeSourceResolver.
|
||||
/// </summary>
|
||||
internal sealed class TestKnowledgeSourceResolver : IKnowledgeSourceResolver
|
||||
{
|
||||
public Task<ResolvedSource?> ResolveAsync(
|
||||
KnowledgeSourceDescriptor descriptor,
|
||||
bool allowNetworkFetch,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
// Return null for referenced sources (simulates unresolvable)
|
||||
if (descriptor.InclusionMode == SourceInclusionMode.Referenced)
|
||||
{
|
||||
return Task.FromResult<ResolvedSource?>(null);
|
||||
}
|
||||
|
||||
// Return dummy content for bundled sources
|
||||
var content = System.Text.Encoding.UTF8.GetBytes($"test-content-{descriptor.Name}");
|
||||
return Task.FromResult<ResolvedSource?>(new ResolvedSource(
|
||||
descriptor.Name,
|
||||
descriptor.Type,
|
||||
content,
|
||||
SourceResolutionMethod.LocalStore));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
using System.IO.Compression;
|
||||
using System.Text.Json;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Cryptography;
|
||||
using StellaOps.ExportCenter.Snapshots;
|
||||
using StellaOps.Policy.Snapshots;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.ExportCenter.Tests.Snapshots;
|
||||
|
||||
public sealed class ImportSnapshotServiceTests : IDisposable
|
||||
{
|
||||
private readonly ICryptoHash _hasher = DefaultCryptoHash.CreateForTests();
|
||||
private readonly InMemorySnapshotStore _snapshotStore = new();
|
||||
private readonly SnapshotService _snapshotService;
|
||||
private readonly ImportSnapshotService _importService;
|
||||
private readonly List<string> _tempFiles = [];
|
||||
private readonly List<string> _tempDirs = [];
|
||||
|
||||
public ImportSnapshotServiceTests()
|
||||
{
|
||||
var idGenerator = new SnapshotIdGenerator(_hasher);
|
||||
_snapshotService = new SnapshotService(
|
||||
idGenerator,
|
||||
_snapshotStore,
|
||||
NullLogger<SnapshotService>.Instance);
|
||||
|
||||
_importService = new ImportSnapshotService(
|
||||
_snapshotService,
|
||||
_snapshotStore,
|
||||
NullLogger<ImportSnapshotService>.Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Import_ValidBundle_Succeeds()
|
||||
{
|
||||
var bundlePath = await CreateTestBundleAsync();
|
||||
|
||||
var result = await _importService.ImportAsync(bundlePath, new ImportOptions());
|
||||
|
||||
result.IsSuccess.Should().BeTrue();
|
||||
result.Manifest.Should().NotBeNull();
|
||||
result.Manifest!.SnapshotId.Should().StartWith("ksm:");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Import_MissingFile_ReturnsError()
|
||||
{
|
||||
var result = await _importService.ImportAsync("/nonexistent/bundle.zip", new ImportOptions());
|
||||
|
||||
result.IsSuccess.Should().BeFalse();
|
||||
result.Error.Should().Contain("not found");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Import_MissingManifest_ReturnsError()
|
||||
{
|
||||
var bundlePath = await CreateBundleWithoutManifestAsync();
|
||||
|
||||
var result = await _importService.ImportAsync(bundlePath, new ImportOptions());
|
||||
|
||||
result.IsSuccess.Should().BeFalse();
|
||||
result.Error.Should().Contain("manifest");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Import_ExistingSnapshot_FailsWithoutOverwrite()
|
||||
{
|
||||
var bundlePath = await CreateTestBundleAsync();
|
||||
|
||||
// Import once
|
||||
await _importService.ImportAsync(bundlePath, new ImportOptions());
|
||||
|
||||
// Try to import again
|
||||
var result = await _importService.ImportAsync(bundlePath, new ImportOptions { OverwriteExisting = false });
|
||||
|
||||
result.IsSuccess.Should().BeFalse();
|
||||
result.Error.Should().Contain("already exists");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Import_ExistingSnapshot_SucceedsWithOverwrite()
|
||||
{
|
||||
var bundlePath = await CreateTestBundleAsync();
|
||||
|
||||
// Import once
|
||||
await _importService.ImportAsync(bundlePath, new ImportOptions());
|
||||
|
||||
// Import again with overwrite
|
||||
var result = await _importService.ImportAsync(bundlePath, new ImportOptions { OverwriteExisting = true });
|
||||
|
||||
result.IsSuccess.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Import_SkipsVerification_WhenDisabled()
|
||||
{
|
||||
var bundlePath = await CreateTestBundleAsync();
|
||||
|
||||
var result = await _importService.ImportAsync(bundlePath, new ImportOptions
|
||||
{
|
||||
VerifyChecksums = false,
|
||||
VerifySignature = false
|
||||
});
|
||||
|
||||
result.IsSuccess.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Import_ValidatesContentAddressedId()
|
||||
{
|
||||
var bundlePath = await CreateBundleWithTamperedManifestAsync();
|
||||
|
||||
var result = await _importService.ImportAsync(bundlePath, new ImportOptions());
|
||||
|
||||
result.IsSuccess.Should().BeFalse();
|
||||
result.Error.Should().Contain("verification failed");
|
||||
}
|
||||
|
||||
private async Task<string> CreateTestBundleAsync()
|
||||
{
|
||||
var tempDir = Path.Combine(Path.GetTempPath(), $"test-bundle-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(tempDir);
|
||||
_tempDirs.Add(tempDir);
|
||||
|
||||
// Create a valid manifest
|
||||
var snapshot = CreateValidSnapshot();
|
||||
var manifestJson = JsonSerializer.Serialize(snapshot, new JsonSerializerOptions { WriteIndented = true });
|
||||
await File.WriteAllTextAsync(Path.Combine(tempDir, "manifest.json"), manifestJson);
|
||||
|
||||
// Create META directory with bundle info
|
||||
var metaDir = Path.Combine(tempDir, "META");
|
||||
Directory.CreateDirectory(metaDir);
|
||||
|
||||
var bundleInfo = new BundleInfo
|
||||
{
|
||||
BundleId = $"bundle:{Guid.NewGuid():N}",
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
CreatedBy = "Test",
|
||||
InclusionLevel = SnapshotInclusionLevel.Portable,
|
||||
TotalSizeBytes = 0,
|
||||
FileCount = 0
|
||||
};
|
||||
await File.WriteAllTextAsync(
|
||||
Path.Combine(metaDir, "BUNDLE_INFO.json"),
|
||||
JsonSerializer.Serialize(bundleInfo));
|
||||
|
||||
await File.WriteAllTextAsync(Path.Combine(metaDir, "CHECKSUMS.sha256"), "");
|
||||
|
||||
// Create ZIP
|
||||
var zipPath = Path.Combine(Path.GetTempPath(), $"test-bundle-{Guid.NewGuid():N}.zip");
|
||||
ZipFile.CreateFromDirectory(tempDir, zipPath);
|
||||
_tempFiles.Add(zipPath);
|
||||
|
||||
return zipPath;
|
||||
}
|
||||
|
||||
private async Task<string> CreateBundleWithoutManifestAsync()
|
||||
{
|
||||
var tempDir = Path.Combine(Path.GetTempPath(), $"test-bundle-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(tempDir);
|
||||
_tempDirs.Add(tempDir);
|
||||
|
||||
// Create META directory only
|
||||
var metaDir = Path.Combine(tempDir, "META");
|
||||
Directory.CreateDirectory(metaDir);
|
||||
await File.WriteAllTextAsync(Path.Combine(metaDir, "BUNDLE_INFO.json"), "{}");
|
||||
|
||||
var zipPath = Path.Combine(Path.GetTempPath(), $"test-bundle-{Guid.NewGuid():N}.zip");
|
||||
ZipFile.CreateFromDirectory(tempDir, zipPath);
|
||||
_tempFiles.Add(zipPath);
|
||||
|
||||
return zipPath;
|
||||
}
|
||||
|
||||
private async Task<string> CreateBundleWithTamperedManifestAsync()
|
||||
{
|
||||
var tempDir = Path.Combine(Path.GetTempPath(), $"test-bundle-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(tempDir);
|
||||
_tempDirs.Add(tempDir);
|
||||
|
||||
// Create a manifest with wrong ID (tampered)
|
||||
var snapshot = CreateValidSnapshot();
|
||||
var tamperedSnapshot = snapshot with { SnapshotId = "ksm:sha256:tampered12345678" };
|
||||
var manifestJson = JsonSerializer.Serialize(tamperedSnapshot, new JsonSerializerOptions { WriteIndented = true });
|
||||
await File.WriteAllTextAsync(Path.Combine(tempDir, "manifest.json"), manifestJson);
|
||||
|
||||
var zipPath = Path.Combine(Path.GetTempPath(), $"test-bundle-{Guid.NewGuid():N}.zip");
|
||||
ZipFile.CreateFromDirectory(tempDir, zipPath);
|
||||
_tempFiles.Add(zipPath);
|
||||
|
||||
return zipPath;
|
||||
}
|
||||
|
||||
private KnowledgeSnapshotManifest CreateValidSnapshot()
|
||||
{
|
||||
var builder = new SnapshotBuilder(_hasher)
|
||||
.WithEngine("stellaops-policy", "1.0.0", "abc123")
|
||||
.WithPolicy("test-policy", "1.0", "sha256:policy123")
|
||||
.WithScoring("test-scoring", "1.0", "sha256:scoring123")
|
||||
.WithSource(new KnowledgeSourceDescriptor
|
||||
{
|
||||
Name = "test-feed",
|
||||
Type = "advisory-feed",
|
||||
Epoch = DateTimeOffset.UtcNow.ToString("o"),
|
||||
Digest = "sha256:feed123",
|
||||
InclusionMode = SourceInclusionMode.Referenced
|
||||
});
|
||||
|
||||
return builder.Build();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
foreach (var file in _tempFiles)
|
||||
{
|
||||
try { if (File.Exists(file)) File.Delete(file); }
|
||||
catch { /* Best effort cleanup */ }
|
||||
}
|
||||
foreach (var dir in _tempDirs)
|
||||
{
|
||||
try { if (Directory.Exists(dir)) Directory.Delete(dir, true); }
|
||||
catch { /* Best effort cleanup */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
using FluentAssertions;
|
||||
using StellaOps.Cryptography;
|
||||
using StellaOps.ExportCenter.Snapshots;
|
||||
using StellaOps.Policy.Snapshots;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.ExportCenter.Tests.Snapshots;
|
||||
|
||||
public sealed class SnapshotLevelHandlerTests
|
||||
{
|
||||
private readonly SnapshotLevelHandler _handler = new();
|
||||
private readonly ICryptoHash _hasher = DefaultCryptoHash.CreateForTests();
|
||||
|
||||
[Theory]
|
||||
[InlineData(SnapshotInclusionLevel.ReferenceOnly)]
|
||||
[InlineData(SnapshotInclusionLevel.Portable)]
|
||||
[InlineData(SnapshotInclusionLevel.Sealed)]
|
||||
public void GetDefaultOptions_ReturnsOptionsForLevel(SnapshotInclusionLevel level)
|
||||
{
|
||||
var options = _handler.GetDefaultOptions(level);
|
||||
|
||||
options.InclusionLevel.Should().Be(level);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetDefaultOptions_ReferenceOnly_DisablesInclusions()
|
||||
{
|
||||
var options = _handler.GetDefaultOptions(SnapshotInclusionLevel.ReferenceOnly);
|
||||
|
||||
options.CompressSources.Should().BeFalse();
|
||||
options.IncludePolicy.Should().BeFalse();
|
||||
options.IncludeScoring.Should().BeFalse();
|
||||
options.IncludeTrust.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetDefaultOptions_Portable_EnablesCompression()
|
||||
{
|
||||
var options = _handler.GetDefaultOptions(SnapshotInclusionLevel.Portable);
|
||||
|
||||
options.CompressSources.Should().BeTrue();
|
||||
options.IncludePolicy.Should().BeTrue();
|
||||
options.IncludeScoring.Should().BeTrue();
|
||||
options.IncludeTrust.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetDefaultOptions_Sealed_IncludesTrust()
|
||||
{
|
||||
var options = _handler.GetDefaultOptions(SnapshotInclusionLevel.Sealed);
|
||||
|
||||
options.CompressSources.Should().BeTrue();
|
||||
options.IncludePolicy.Should().BeTrue();
|
||||
options.IncludeScoring.Should().BeTrue();
|
||||
options.IncludeTrust.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateForExport_UnsignedSnapshot_FailsSealed()
|
||||
{
|
||||
var snapshot = CreateUnsignedSnapshot();
|
||||
|
||||
var result = _handler.ValidateForExport(snapshot, SnapshotInclusionLevel.Sealed);
|
||||
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Issues.Should().Contain(i => i.Contains("Sealed"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateForExport_UnsignedSnapshot_PassesPortable()
|
||||
{
|
||||
var snapshot = CreateUnsignedSnapshot();
|
||||
|
||||
var result = _handler.ValidateForExport(snapshot, SnapshotInclusionLevel.Portable);
|
||||
|
||||
result.IsValid.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateForExport_SignedSnapshot_PassesSealed()
|
||||
{
|
||||
var snapshot = CreateSignedSnapshot();
|
||||
|
||||
var result = _handler.ValidateForExport(snapshot, SnapshotInclusionLevel.Sealed);
|
||||
|
||||
result.IsValid.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetReplayRequirements_ReferenceOnly_RequiresNetwork()
|
||||
{
|
||||
var requirements = _handler.GetReplayRequirements(SnapshotInclusionLevel.ReferenceOnly);
|
||||
|
||||
requirements.RequiresNetwork.Should().BeTrue();
|
||||
requirements.RequiresLocalStore.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetReplayRequirements_Portable_FullyOffline()
|
||||
{
|
||||
var requirements = _handler.GetReplayRequirements(SnapshotInclusionLevel.Portable);
|
||||
|
||||
requirements.RequiresNetwork.Should().BeFalse();
|
||||
requirements.RequiresLocalStore.Should().BeFalse();
|
||||
requirements.RequiresTrustBundle.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetReplayRequirements_Sealed_RequiresTrust()
|
||||
{
|
||||
var requirements = _handler.GetReplayRequirements(SnapshotInclusionLevel.Sealed);
|
||||
|
||||
requirements.RequiresNetwork.Should().BeFalse();
|
||||
requirements.RequiresTrustBundle.Should().BeTrue();
|
||||
}
|
||||
|
||||
private KnowledgeSnapshotManifest CreateUnsignedSnapshot()
|
||||
{
|
||||
return new SnapshotBuilder(_hasher)
|
||||
.WithEngine("stellaops-policy", "1.0.0", "abc123")
|
||||
.WithPolicy("test-policy", "1.0", "sha256:policy123")
|
||||
.WithScoring("test-scoring", "1.0", "sha256:scoring123")
|
||||
.WithSource(new KnowledgeSourceDescriptor
|
||||
{
|
||||
Name = "test-feed",
|
||||
Type = "advisory-feed",
|
||||
Epoch = DateTimeOffset.UtcNow.ToString("o"),
|
||||
Digest = "sha256:feed123",
|
||||
InclusionMode = SourceInclusionMode.Bundled
|
||||
})
|
||||
.Build();
|
||||
}
|
||||
|
||||
private KnowledgeSnapshotManifest CreateSignedSnapshot()
|
||||
{
|
||||
var snapshot = CreateUnsignedSnapshot();
|
||||
return snapshot with { Signature = "test-signature-base64" };
|
||||
}
|
||||
}
|
||||
@@ -69,6 +69,9 @@
|
||||
|
||||
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.3"/>
|
||||
<PackageReference Include="FluentAssertions" Version="8.2.0" />
|
||||
<PackageReference Include="Moq" Version="4.20.72" />
|
||||
<PackageReference Include="Microsoft.Extensions.TimeProvider.Testing" Version="9.1.0" />
|
||||
|
||||
|
||||
|
||||
@@ -124,7 +127,8 @@
|
||||
<ProjectReference Include="..\StellaOps.ExportCenter.Infrastructure\StellaOps.ExportCenter.Infrastructure.csproj"/>
|
||||
<ProjectReference Include="..\StellaOps.ExportCenter.WebService\StellaOps.ExportCenter.WebService.csproj" />
|
||||
<ProjectReference Include="..\..\StellaOps.ExportCenter.RiskBundles\StellaOps.ExportCenter.RiskBundles.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\__Libraries\StellaOps.Cryptography\StellaOps.Cryptography.csproj" />
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Cryptography\StellaOps.Cryptography.csproj" />
|
||||
<ProjectReference Include="..\..\..\Policy\StellaOps.Policy.Engine\StellaOps.Policy.Engine.csproj" />
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
namespace StellaOps.ExportCenter.WebService.Distribution.Oci;
|
||||
|
||||
/// <summary>
|
||||
/// OCI artifact types for StellaOps attestations and artifacts.
|
||||
/// These are used in the `artifactType` field of OCI manifests.
|
||||
/// </summary>
|
||||
public static class OciArtifactTypes
|
||||
{
|
||||
/// <summary>
|
||||
/// Risk Verdict Attestation (JSON).
|
||||
/// </summary>
|
||||
public const string RvaJson = "application/vnd.stellaops.rva+json";
|
||||
|
||||
/// <summary>
|
||||
/// Risk Verdict Attestation (DSSE envelope).
|
||||
/// </summary>
|
||||
public const string RvaDsse = "application/vnd.stellaops.rva.dsse+json";
|
||||
|
||||
/// <summary>
|
||||
/// SBOM (CycloneDX JSON).
|
||||
/// </summary>
|
||||
public const string SbomCyclonedx = "application/vnd.cyclonedx+json";
|
||||
|
||||
/// <summary>
|
||||
/// SBOM (CycloneDX XML).
|
||||
/// </summary>
|
||||
public const string SbomCyclonedxXml = "application/vnd.cyclonedx+xml";
|
||||
|
||||
/// <summary>
|
||||
/// SBOM (SPDX JSON).
|
||||
/// </summary>
|
||||
public const string SbomSpdx = "application/spdx+json";
|
||||
|
||||
/// <summary>
|
||||
/// SBOM (SPDX tag-value).
|
||||
/// </summary>
|
||||
public const string SbomSpdxTagValue = "text/spdx";
|
||||
|
||||
/// <summary>
|
||||
/// VEX document (OpenVEX).
|
||||
/// </summary>
|
||||
public const string VexOpenvex = "application/vnd.openvex+json";
|
||||
|
||||
/// <summary>
|
||||
/// VEX document (CycloneDX VEX).
|
||||
/// </summary>
|
||||
public const string VexCyclonedx = "application/vnd.cyclonedx.vex+json";
|
||||
|
||||
/// <summary>
|
||||
/// VEX document (CSAF).
|
||||
/// </summary>
|
||||
public const string VexCsaf = "application/json+csaf";
|
||||
|
||||
/// <summary>
|
||||
/// Knowledge snapshot manifest.
|
||||
/// </summary>
|
||||
public const string KnowledgeSnapshot = "application/vnd.stellaops.knowledge-snapshot+json";
|
||||
|
||||
/// <summary>
|
||||
/// Policy bundle.
|
||||
/// </summary>
|
||||
public const string PolicyBundle = "application/vnd.stellaops.policy-bundle+json";
|
||||
|
||||
/// <summary>
|
||||
/// Security state delta.
|
||||
/// </summary>
|
||||
public const string SecurityStateDelta = "application/vnd.stellaops.security-delta+json";
|
||||
|
||||
/// <summary>
|
||||
/// In-toto statement (generic).
|
||||
/// </summary>
|
||||
public const string InTotoStatement = "application/vnd.in-toto+json";
|
||||
|
||||
/// <summary>
|
||||
/// DSSE envelope (generic).
|
||||
/// </summary>
|
||||
public const string DsseEnvelope = "application/vnd.dsse.envelope+json";
|
||||
|
||||
/// <summary>
|
||||
/// Sigstore bundle.
|
||||
/// </summary>
|
||||
public const string SigstoreBundle = "application/vnd.dev.sigstore.bundle.v0.3+json";
|
||||
|
||||
/// <summary>
|
||||
/// SLSA provenance.
|
||||
/// </summary>
|
||||
public const string SlsaProvenance = "application/vnd.in-toto.slsa.provenance+json";
|
||||
|
||||
/// <summary>
|
||||
/// Gets the artifact type for an RVA based on whether it's signed.
|
||||
/// </summary>
|
||||
/// <param name="isSigned">True if the RVA is wrapped in a DSSE envelope.</param>
|
||||
/// <returns>The appropriate artifact type.</returns>
|
||||
public static string GetRvaType(bool isSigned) =>
|
||||
isSigned ? RvaDsse : RvaJson;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the SBOM artifact type based on format.
|
||||
/// </summary>
|
||||
/// <param name="format">The SBOM format (cyclonedx, spdx).</param>
|
||||
/// <param name="isXml">True for XML format (CycloneDX only).</param>
|
||||
/// <returns>The appropriate artifact type.</returns>
|
||||
public static string GetSbomType(string format, bool isXml = false) =>
|
||||
format.ToLowerInvariant() switch
|
||||
{
|
||||
"cyclonedx" when isXml => SbomCyclonedxXml,
|
||||
"cyclonedx" => SbomCyclonedx,
|
||||
"spdx" => SbomSpdx,
|
||||
_ => SbomCyclonedx // Default to CycloneDX JSON
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Gets the VEX artifact type based on format.
|
||||
/// </summary>
|
||||
/// <param name="format">The VEX format (openvex, cyclonedx, csaf).</param>
|
||||
/// <returns>The appropriate artifact type.</returns>
|
||||
public static string GetVexType(string format) =>
|
||||
format.ToLowerInvariant() switch
|
||||
{
|
||||
"openvex" => VexOpenvex,
|
||||
"cyclonedx" => VexCyclonedx,
|
||||
"csaf" => VexCsaf,
|
||||
_ => VexOpenvex // Default to OpenVEX
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// StellaOps RVA-specific OCI annotations.
|
||||
/// </summary>
|
||||
public static class OciRvaAnnotations
|
||||
{
|
||||
/// <summary>
|
||||
/// RVA attestation ID.
|
||||
/// </summary>
|
||||
public const string RvaId = "ops.stella.rva.id";
|
||||
|
||||
/// <summary>
|
||||
/// RVA verdict status (Pass, Warn, Fail).
|
||||
/// </summary>
|
||||
public const string RvaVerdict = "ops.stella.rva.verdict";
|
||||
|
||||
/// <summary>
|
||||
/// Policy ID used for evaluation.
|
||||
/// </summary>
|
||||
public const string RvaPolicy = "ops.stella.rva.policy";
|
||||
|
||||
/// <summary>
|
||||
/// Policy version used for evaluation.
|
||||
/// </summary>
|
||||
public const string RvaPolicyVersion = "ops.stella.rva.policy-version";
|
||||
|
||||
/// <summary>
|
||||
/// Knowledge snapshot ID at evaluation time.
|
||||
/// </summary>
|
||||
public const string RvaSnapshot = "ops.stella.rva.snapshot";
|
||||
|
||||
/// <summary>
|
||||
/// RVA expiration timestamp (ISO 8601).
|
||||
/// </summary>
|
||||
public const string RvaExpires = "ops.stella.rva.expires";
|
||||
|
||||
/// <summary>
|
||||
/// Risk score at evaluation time.
|
||||
/// </summary>
|
||||
public const string RvaRiskScore = "ops.stella.rva.risk-score";
|
||||
|
||||
/// <summary>
|
||||
/// Gate level (G0-G4).
|
||||
/// </summary>
|
||||
public const string RvaGateLevel = "ops.stella.rva.gate-level";
|
||||
|
||||
/// <summary>
|
||||
/// CVE count at evaluation time.
|
||||
/// </summary>
|
||||
public const string RvaCveCount = "ops.stella.rva.cve-count";
|
||||
|
||||
/// <summary>
|
||||
/// Critical CVE count.
|
||||
/// </summary>
|
||||
public const string RvaCriticalCount = "ops.stella.rva.critical-count";
|
||||
|
||||
/// <summary>
|
||||
/// Whether exceptions were applied.
|
||||
/// </summary>
|
||||
public const string RvaHasExceptions = "ops.stella.rva.has-exceptions";
|
||||
|
||||
/// <summary>
|
||||
/// Signing key ID.
|
||||
/// </summary>
|
||||
public const string RvaSigningKeyId = "ops.stella.rva.signing-key-id";
|
||||
|
||||
/// <summary>
|
||||
/// Replay ID if this is a replay verdict.
|
||||
/// </summary>
|
||||
public const string RvaReplayId = "ops.stella.rva.replay-id";
|
||||
|
||||
/// <summary>
|
||||
/// Baseline RVA ID for delta comparisons.
|
||||
/// </summary>
|
||||
public const string RvaBaselineId = "ops.stella.rva.baseline-id";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// StellaOps knowledge and delta artifact annotations.
|
||||
/// </summary>
|
||||
public static class OciKnowledgeAnnotations
|
||||
{
|
||||
/// <summary>
|
||||
/// Knowledge snapshot manifest ID.
|
||||
/// </summary>
|
||||
public const string SnapshotId = "ops.stella.knowledge.snapshot-id";
|
||||
|
||||
/// <summary>
|
||||
/// Policy epoch timestamp.
|
||||
/// </summary>
|
||||
public const string PolicyEpoch = "ops.stella.knowledge.policy-epoch";
|
||||
|
||||
/// <summary>
|
||||
/// Source feed count.
|
||||
/// </summary>
|
||||
public const string SourceCount = "ops.stella.knowledge.source-count";
|
||||
|
||||
/// <summary>
|
||||
/// Security state delta ID.
|
||||
/// </summary>
|
||||
public const string DeltaId = "ops.stella.delta.id";
|
||||
|
||||
/// <summary>
|
||||
/// Baseline snapshot ID for delta.
|
||||
/// </summary>
|
||||
public const string BaselineSnapshotId = "ops.stella.delta.baseline-snapshot";
|
||||
|
||||
/// <summary>
|
||||
/// Target snapshot ID for delta.
|
||||
/// </summary>
|
||||
public const string TargetSnapshotId = "ops.stella.delta.target-snapshot";
|
||||
|
||||
/// <summary>
|
||||
/// Delta risk direction (increasing, decreasing, neutral).
|
||||
/// </summary>
|
||||
public const string RiskDirection = "ops.stella.delta.risk-direction";
|
||||
}
|
||||
@@ -0,0 +1,532 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.ExportCenter.WebService.Distribution.Oci;
|
||||
|
||||
/// <summary>
|
||||
/// Discovers artifacts attached to images via the OCI referrers API.
|
||||
/// Supports both OCI 1.1+ referrers API and fallback tag-based discovery.
|
||||
/// </summary>
|
||||
public sealed class OciReferrerDiscovery : IOciReferrerDiscovery
|
||||
{
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly IOciAuthProvider _authProvider;
|
||||
private readonly ILogger<OciReferrerDiscovery> _logger;
|
||||
|
||||
public OciReferrerDiscovery(
|
||||
HttpClient httpClient,
|
||||
IOciAuthProvider authProvider,
|
||||
ILogger<OciReferrerDiscovery> logger)
|
||||
{
|
||||
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
|
||||
_authProvider = authProvider ?? throw new ArgumentNullException(nameof(authProvider));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lists all referrers for a given image digest.
|
||||
/// </summary>
|
||||
public async Task<ReferrerListResult> ListReferrersAsync(
|
||||
string registry, string repository, string digest,
|
||||
ReferrerFilterOptions? filter = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
_logger.LogDebug("Listing referrers for {Registry}/{Repository}@{Digest}",
|
||||
registry, repository, digest);
|
||||
|
||||
try
|
||||
{
|
||||
var token = await _authProvider.GetTokenAsync(registry, repository, ct);
|
||||
|
||||
// Try referrers API first (OCI 1.1+)
|
||||
var result = await TryReferrersApiAsync(registry, repository, digest, token, filter, ct);
|
||||
if (result is not null)
|
||||
return result;
|
||||
|
||||
// Fall back to tag-based discovery
|
||||
return await FallbackTagDiscoveryAsync(registry, repository, digest, token, filter, ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to list referrers for {Digest}", digest);
|
||||
return new ReferrerListResult
|
||||
{
|
||||
IsSuccess = false,
|
||||
Error = ex.Message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Finds RVA attestations for an image.
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyList<ReferrerInfo>> FindRvaAttestationsAsync(
|
||||
string registry, string repository, string imageDigest,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
// Try both DSSE and plain JSON artifact types
|
||||
var dsseResult = await ListReferrersAsync(registry, repository, imageDigest,
|
||||
new ReferrerFilterOptions { ArtifactType = OciArtifactTypes.RvaDsse },
|
||||
ct);
|
||||
|
||||
var jsonResult = await ListReferrersAsync(registry, repository, imageDigest,
|
||||
new ReferrerFilterOptions { ArtifactType = OciArtifactTypes.RvaJson },
|
||||
ct);
|
||||
|
||||
var allReferrers = new List<ReferrerInfo>();
|
||||
|
||||
if (dsseResult.IsSuccess)
|
||||
allReferrers.AddRange(dsseResult.Referrers);
|
||||
|
||||
if (jsonResult.IsSuccess)
|
||||
allReferrers.AddRange(jsonResult.Referrers);
|
||||
|
||||
return allReferrers;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Finds SBOMs for an image.
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyList<ReferrerInfo>> FindSbomsAsync(
|
||||
string registry, string repository, string imageDigest,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var cyclonedxResult = await ListReferrersAsync(registry, repository, imageDigest,
|
||||
new ReferrerFilterOptions { ArtifactType = OciArtifactTypes.SbomCyclonedx },
|
||||
ct);
|
||||
|
||||
var spdxResult = await ListReferrersAsync(registry, repository, imageDigest,
|
||||
new ReferrerFilterOptions { ArtifactType = OciArtifactTypes.SbomSpdx },
|
||||
ct);
|
||||
|
||||
var allReferrers = new List<ReferrerInfo>();
|
||||
|
||||
if (cyclonedxResult.IsSuccess)
|
||||
allReferrers.AddRange(cyclonedxResult.Referrers);
|
||||
|
||||
if (spdxResult.IsSuccess)
|
||||
allReferrers.AddRange(spdxResult.Referrers);
|
||||
|
||||
return allReferrers;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a specific referrer manifest by digest.
|
||||
/// </summary>
|
||||
public async Task<ReferrerManifest?> GetReferrerManifestAsync(
|
||||
string registry, string repository, string digest,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var token = await _authProvider.GetTokenAsync(registry, repository, ct);
|
||||
var manifest = await GetManifestAsync(registry, repository, digest, token, ct);
|
||||
|
||||
if (manifest is null)
|
||||
return null;
|
||||
|
||||
return new ReferrerManifest
|
||||
{
|
||||
Digest = digest,
|
||||
ArtifactType = manifest.ArtifactType,
|
||||
MediaType = manifest.MediaType,
|
||||
Annotations = manifest.Annotations ?? new Dictionary<string, string>(),
|
||||
Layers = manifest.Layers.Select(l => new ReferrerLayer
|
||||
{
|
||||
Digest = l.Digest,
|
||||
MediaType = l.MediaType,
|
||||
Size = l.Size,
|
||||
Annotations = l.Annotations ?? new Dictionary<string, string>()
|
||||
}).ToList()
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Downloads the content of a referrer layer.
|
||||
/// </summary>
|
||||
public async Task<byte[]?> GetLayerContentAsync(
|
||||
string registry, string repository, string digest,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var token = await _authProvider.GetTokenAsync(registry, repository, ct);
|
||||
var url = $"https://{registry}/v2/{repository}/blobs/{digest}";
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, url);
|
||||
ApplyAuth(request, token);
|
||||
|
||||
using var response = await _httpClient.SendAsync(request, ct);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogWarning("Failed to download blob {Digest}: {StatusCode}",
|
||||
digest, response.StatusCode);
|
||||
return null;
|
||||
}
|
||||
|
||||
return await response.Content.ReadAsByteArrayAsync(ct);
|
||||
}
|
||||
|
||||
private async Task<ReferrerListResult?> TryReferrersApiAsync(
|
||||
string registry, string repository, string digest, string? token,
|
||||
ReferrerFilterOptions? filter, CancellationToken ct)
|
||||
{
|
||||
var url = $"https://{registry}/v2/{repository}/referrers/{digest}";
|
||||
if (filter?.ArtifactType is not null)
|
||||
{
|
||||
url += $"?artifactType={Uri.EscapeDataString(filter.ArtifactType)}";
|
||||
}
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, url);
|
||||
ApplyAuth(request, token);
|
||||
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue(OciMediaTypes.ImageIndex));
|
||||
|
||||
using var response = await _httpClient.SendAsync(request, ct);
|
||||
|
||||
if (response.StatusCode == HttpStatusCode.NotFound)
|
||||
{
|
||||
// Registry doesn't support referrers API
|
||||
_logger.LogDebug("Registry {Registry} does not support referrers API", registry);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogWarning("Referrers API returned {StatusCode}", response.StatusCode);
|
||||
return null;
|
||||
}
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync(ct);
|
||||
var index = JsonSerializer.Deserialize<OciReferrerIndex>(json, SerializerOptions);
|
||||
|
||||
var referrers = index?.Manifests?
|
||||
.Select(m => new ReferrerInfo
|
||||
{
|
||||
Digest = m.Digest,
|
||||
ArtifactType = m.ArtifactType,
|
||||
MediaType = m.MediaType,
|
||||
Size = m.Size,
|
||||
Annotations = m.Annotations ?? new Dictionary<string, string>()
|
||||
})
|
||||
.ToList() ?? [];
|
||||
|
||||
_logger.LogDebug("Found {Count} referrers via API for {Digest}", referrers.Count, digest);
|
||||
|
||||
return new ReferrerListResult
|
||||
{
|
||||
IsSuccess = true,
|
||||
Referrers = referrers,
|
||||
SupportsReferrersApi = true
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<ReferrerListResult> FallbackTagDiscoveryAsync(
|
||||
string registry, string repository, string digest, string? token,
|
||||
ReferrerFilterOptions? filter, CancellationToken ct)
|
||||
{
|
||||
_logger.LogDebug("Using fallback tag-based discovery for {Digest}", digest);
|
||||
|
||||
// Fallback: Check for tagged index at sha256-{hash}
|
||||
var hashPart = digest.Replace("sha256:", "");
|
||||
var tagPrefix = $"sha256-{hashPart}";
|
||||
|
||||
var url = $"https://{registry}/v2/{repository}/tags/list";
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, url);
|
||||
ApplyAuth(request, token);
|
||||
|
||||
using var response = await _httpClient.SendAsync(request, ct);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
return new ReferrerListResult
|
||||
{
|
||||
IsSuccess = true,
|
||||
Referrers = [],
|
||||
SupportsReferrersApi = false
|
||||
};
|
||||
}
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync(ct);
|
||||
var tagList = JsonSerializer.Deserialize<OciTagList>(json, SerializerOptions);
|
||||
|
||||
var matchingTags = tagList?.Tags?
|
||||
.Where(t => t.StartsWith(tagPrefix, StringComparison.OrdinalIgnoreCase))
|
||||
.ToList() ?? [];
|
||||
|
||||
_logger.LogDebug("Found {Count} matching tags for {Prefix}", matchingTags.Count, tagPrefix);
|
||||
|
||||
var referrers = new List<ReferrerInfo>();
|
||||
foreach (var tag in matchingTags)
|
||||
{
|
||||
var manifest = await GetManifestByTagAsync(registry, repository, tag, token, ct);
|
||||
if (manifest is not null)
|
||||
{
|
||||
var manifestDigest = ComputeManifestDigest(manifest);
|
||||
var referrerInfo = new ReferrerInfo
|
||||
{
|
||||
Digest = manifestDigest,
|
||||
ArtifactType = manifest.ArtifactType,
|
||||
MediaType = manifest.MediaType,
|
||||
Annotations = manifest.Annotations ?? new Dictionary<string, string>()
|
||||
};
|
||||
|
||||
// Apply artifact type filter if specified
|
||||
if (filter?.ArtifactType is null || referrerInfo.ArtifactType == filter.ArtifactType)
|
||||
{
|
||||
referrers.Add(referrerInfo);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return new ReferrerListResult
|
||||
{
|
||||
IsSuccess = true,
|
||||
Referrers = referrers,
|
||||
SupportsReferrersApi = false
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<OciImageManifest?> GetManifestAsync(
|
||||
string registry, string repository, string digest, string? token, CancellationToken ct)
|
||||
{
|
||||
var url = $"https://{registry}/v2/{repository}/manifests/{digest}";
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, url);
|
||||
ApplyAuth(request, token);
|
||||
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue(OciMediaTypes.ImageManifest));
|
||||
|
||||
using var response = await _httpClient.SendAsync(request, ct);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync(ct);
|
||||
return JsonSerializer.Deserialize<OciImageManifest>(json, SerializerOptions);
|
||||
}
|
||||
|
||||
private async Task<OciImageManifest?> GetManifestByTagAsync(
|
||||
string registry, string repository, string tag, string? token, CancellationToken ct)
|
||||
{
|
||||
var url = $"https://{registry}/v2/{repository}/manifests/{Uri.EscapeDataString(tag)}";
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, url);
|
||||
ApplyAuth(request, token);
|
||||
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue(OciMediaTypes.ImageManifest));
|
||||
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue(OciMediaTypes.ImageIndex));
|
||||
|
||||
using var response = await _httpClient.SendAsync(request, ct);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync(ct);
|
||||
return JsonSerializer.Deserialize<OciImageManifest>(json, SerializerOptions);
|
||||
}
|
||||
|
||||
private static string ComputeManifestDigest(OciImageManifest manifest)
|
||||
{
|
||||
var json = JsonSerializer.Serialize(manifest, SerializerOptions);
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(json));
|
||||
return $"sha256:{Convert.ToHexStringLower(hash)}";
|
||||
}
|
||||
|
||||
private static void ApplyAuth(HttpRequestMessage request, string? token)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(token))
|
||||
{
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of listing referrers.
|
||||
/// </summary>
|
||||
public sealed record ReferrerListResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether the operation was successful.
|
||||
/// </summary>
|
||||
public required bool IsSuccess { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// List of discovered referrers.
|
||||
/// </summary>
|
||||
public IReadOnlyList<ReferrerInfo> Referrers { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Whether the registry supports the native referrers API.
|
||||
/// </summary>
|
||||
public bool SupportsReferrersApi { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Error message if operation failed.
|
||||
/// </summary>
|
||||
public string? Error { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Information about a referrer artifact.
|
||||
/// </summary>
|
||||
public sealed record ReferrerInfo
|
||||
{
|
||||
/// <summary>
|
||||
/// Digest of the referrer manifest.
|
||||
/// </summary>
|
||||
public required string Digest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Artifact type (e.g., application/vnd.stellaops.rva.dsse+json).
|
||||
/// </summary>
|
||||
public string? ArtifactType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Media type of the manifest.
|
||||
/// </summary>
|
||||
public string? MediaType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Size of the artifact in bytes.
|
||||
/// </summary>
|
||||
public long Size { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Manifest annotations.
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, string> Annotations { get; init; }
|
||||
= new Dictionary<string, string>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Full referrer manifest with layers.
|
||||
/// </summary>
|
||||
public sealed record ReferrerManifest
|
||||
{
|
||||
/// <summary>
|
||||
/// Digest of the manifest.
|
||||
/// </summary>
|
||||
public required string Digest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Artifact type.
|
||||
/// </summary>
|
||||
public string? ArtifactType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Media type of the manifest.
|
||||
/// </summary>
|
||||
public string? MediaType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Manifest annotations.
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, string> Annotations { get; init; }
|
||||
= new Dictionary<string, string>();
|
||||
|
||||
/// <summary>
|
||||
/// Content layers.
|
||||
/// </summary>
|
||||
public IReadOnlyList<ReferrerLayer> Layers { get; init; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Layer in a referrer manifest.
|
||||
/// </summary>
|
||||
public sealed record ReferrerLayer
|
||||
{
|
||||
/// <summary>
|
||||
/// Layer digest.
|
||||
/// </summary>
|
||||
public required string Digest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Layer media type.
|
||||
/// </summary>
|
||||
public required string MediaType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Layer size in bytes.
|
||||
/// </summary>
|
||||
public long Size { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Layer annotations.
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, string> Annotations { get; init; }
|
||||
= new Dictionary<string, string>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Options for filtering referrers.
|
||||
/// </summary>
|
||||
public sealed record ReferrerFilterOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Filter by artifact type.
|
||||
/// </summary>
|
||||
public string? ArtifactType { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interface for discovering OCI referrers.
|
||||
/// </summary>
|
||||
public interface IOciReferrerDiscovery
|
||||
{
|
||||
/// <summary>
|
||||
/// Lists all referrers for a given image digest.
|
||||
/// </summary>
|
||||
Task<ReferrerListResult> ListReferrersAsync(
|
||||
string registry, string repository, string digest,
|
||||
ReferrerFilterOptions? filter = null,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Finds RVA attestations for an image.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<ReferrerInfo>> FindRvaAttestationsAsync(
|
||||
string registry, string repository, string imageDigest,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Finds SBOMs for an image.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<ReferrerInfo>> FindSbomsAsync(
|
||||
string registry, string repository, string imageDigest,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a specific referrer manifest by digest.
|
||||
/// </summary>
|
||||
Task<ReferrerManifest?> GetReferrerManifestAsync(
|
||||
string registry, string repository, string digest,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Downloads the content of a referrer layer.
|
||||
/// </summary>
|
||||
Task<byte[]?> GetLayerContentAsync(
|
||||
string registry, string repository, string digest,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// OCI tag list response.
|
||||
/// </summary>
|
||||
internal sealed record OciTagList
|
||||
{
|
||||
[JsonPropertyName("name")]
|
||||
public string? Name { get; init; }
|
||||
|
||||
[JsonPropertyName("tags")]
|
||||
public IReadOnlyList<string>? Tags { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,399 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.ExportCenter.WebService.Distribution.Oci;
|
||||
|
||||
/// <summary>
|
||||
/// Fallback strategies for registries without native referrers API.
|
||||
/// Creates tagged indexes for older registries to enable referrer discovery.
|
||||
/// </summary>
|
||||
public sealed class OciReferrerFallback : IOciReferrerFallback
|
||||
{
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
WriteIndented = false,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
||||
};
|
||||
|
||||
private static readonly TimeSpan CapabilitiesCacheTtl = TimeSpan.FromHours(1);
|
||||
|
||||
private readonly IOciReferrerPushClient _pushClient;
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly IOciAuthProvider _authProvider;
|
||||
private readonly IMemoryCache _capabilitiesCache;
|
||||
private readonly ILogger<OciReferrerFallback> _logger;
|
||||
|
||||
public OciReferrerFallback(
|
||||
IOciReferrerPushClient pushClient,
|
||||
HttpClient httpClient,
|
||||
IOciAuthProvider authProvider,
|
||||
IMemoryCache capabilitiesCache,
|
||||
ILogger<OciReferrerFallback> logger)
|
||||
{
|
||||
_pushClient = pushClient ?? throw new ArgumentNullException(nameof(pushClient));
|
||||
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
|
||||
_authProvider = authProvider ?? throw new ArgumentNullException(nameof(authProvider));
|
||||
_capabilitiesCache = capabilitiesCache ?? throw new ArgumentNullException(nameof(capabilitiesCache));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pushes an artifact with fallback tag for older registries.
|
||||
/// </summary>
|
||||
public async Task<ReferrerPushResult> PushWithFallbackAsync(
|
||||
ReferrerPushRequest request,
|
||||
FallbackOptions options,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
// First, try native push with subject
|
||||
var result = await _pushClient.PushArtifactAsync(request, ct);
|
||||
|
||||
if (!result.IsSuccess)
|
||||
{
|
||||
_logger.LogWarning("Native push failed: {Error}", result.Error);
|
||||
return result;
|
||||
}
|
||||
|
||||
// If subject was specified and fallback is enabled, create fallback tag
|
||||
if (request.SubjectDigest is not null && options.CreateFallbackTag)
|
||||
{
|
||||
try
|
||||
{
|
||||
var capabilities = await ProbeCapabilitiesAsync(request.Registry, ct);
|
||||
|
||||
// Only create fallback tag if registry doesn't support referrers API
|
||||
if (!capabilities.SupportsReferrersApi)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Registry {Registry} doesn't support referrers API, creating fallback tag",
|
||||
request.Registry);
|
||||
|
||||
await CreateFallbackTagAsync(
|
||||
request.Registry,
|
||||
request.Repository,
|
||||
request.SubjectDigest,
|
||||
result.Digest!,
|
||||
request.ArtifactType,
|
||||
options,
|
||||
ct);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Don't fail the push if fallback tag creation fails
|
||||
_logger.LogWarning(ex,
|
||||
"Failed to create fallback tag for {Registry}/{Repository}",
|
||||
request.Registry, request.Repository);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines the best push strategy for a registry.
|
||||
/// </summary>
|
||||
public async Task<RegistryCapabilities> ProbeCapabilitiesAsync(
|
||||
string registry,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var cacheKey = $"oci-capabilities:{registry}";
|
||||
|
||||
if (_capabilitiesCache.TryGetValue<RegistryCapabilities>(cacheKey, out var cached) && cached is not null)
|
||||
{
|
||||
if (!cached.IsStale(CapabilitiesCacheTtl))
|
||||
{
|
||||
return cached;
|
||||
}
|
||||
}
|
||||
|
||||
var capabilities = await ProbeCapabilitiesInternalAsync(registry, ct);
|
||||
|
||||
_capabilitiesCache.Set(cacheKey, capabilities, new MemoryCacheEntryOptions
|
||||
{
|
||||
AbsoluteExpirationRelativeToNow = CapabilitiesCacheTtl
|
||||
});
|
||||
|
||||
return capabilities;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a fallback index tag for referrer discovery on older registries.
|
||||
/// </summary>
|
||||
private async Task CreateFallbackTagAsync(
|
||||
string registry,
|
||||
string repository,
|
||||
string subjectDigest,
|
||||
string referrerDigest,
|
||||
string? artifactType,
|
||||
FallbackOptions options,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var token = await _authProvider.GetTokenAsync(registry, repository, ct);
|
||||
|
||||
// Generate fallback tag
|
||||
var tag = GenerateFallbackTag(subjectDigest, artifactType, options);
|
||||
|
||||
_logger.LogDebug("Creating fallback tag {Tag} for referrer {Digest}",
|
||||
tag, referrerDigest);
|
||||
|
||||
// Check if a fallback index already exists
|
||||
var existingIndex = await GetExistingFallbackIndexAsync(
|
||||
registry, repository, tag, token, ct);
|
||||
|
||||
// Create or update index manifest pointing to the referrer
|
||||
var index = CreateOrUpdateIndex(existingIndex, referrerDigest, artifactType);
|
||||
|
||||
// Push the index with the fallback tag
|
||||
await PushIndexAsync(registry, repository, tag, index, token, ct);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Created fallback tag {Tag} in {Registry}/{Repository}",
|
||||
tag, registry, repository);
|
||||
}
|
||||
|
||||
private async Task<RegistryCapabilities> ProbeCapabilitiesInternalAsync(
|
||||
string registry, CancellationToken ct)
|
||||
{
|
||||
var capabilities = new RegistryCapabilities
|
||||
{
|
||||
Registry = registry,
|
||||
ProbedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
// Check OCI Distribution version
|
||||
var url = $"https://{registry}/v2/";
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, url);
|
||||
|
||||
var token = await _authProvider.GetTokenAsync(registry, "_", ct);
|
||||
if (!string.IsNullOrEmpty(token))
|
||||
{
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||
}
|
||||
|
||||
using var response = await _httpClient.SendAsync(request, ct);
|
||||
|
||||
// Check for OCI-Distribution-API-Version header
|
||||
string? version = null;
|
||||
if (response.Headers.TryGetValues("OCI-Distribution-API-Version", out var apiVersionValues))
|
||||
{
|
||||
version = apiVersionValues.FirstOrDefault();
|
||||
}
|
||||
else if (response.Headers.TryGetValues("Docker-Distribution-API-Version", out var dockerValues))
|
||||
{
|
||||
version = dockerValues.FirstOrDefault();
|
||||
}
|
||||
|
||||
capabilities = capabilities with
|
||||
{
|
||||
DistributionVersion = version,
|
||||
// OCI 1.1+ supports referrers API
|
||||
SupportsReferrersApi = version?.Contains("1.1") == true ||
|
||||
await ProbeReferrersApiAsync(registry, ct),
|
||||
SupportsArtifactType = version?.Contains("1.1") == true,
|
||||
SupportsChunkedUpload = true // Most registries support this
|
||||
};
|
||||
|
||||
_logger.LogDebug(
|
||||
"Probed registry {Registry}: version={Version}, referrersApi={Referrers}",
|
||||
registry, version, capabilities.SupportsReferrersApi);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to probe capabilities for {Registry}", registry);
|
||||
}
|
||||
|
||||
return capabilities;
|
||||
}
|
||||
|
||||
private async Task<bool> ProbeReferrersApiAsync(string registry, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Try to call the referrers endpoint with a fake digest
|
||||
var testDigest = "sha256:0000000000000000000000000000000000000000000000000000000000000000";
|
||||
var url = $"https://{registry}/v2/probe/referrers/{testDigest}";
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, url);
|
||||
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue(OciMediaTypes.ImageIndex));
|
||||
|
||||
using var response = await _httpClient.SendAsync(request, ct);
|
||||
|
||||
// If we get a 404 (not found) rather than 501 (not implemented), the API exists
|
||||
return response.StatusCode != HttpStatusCode.NotImplemented &&
|
||||
response.StatusCode != HttpStatusCode.MethodNotAllowed;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<OciIndex?> GetExistingFallbackIndexAsync(
|
||||
string registry, string repository, string tag, string? token, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var url = $"https://{registry}/v2/{repository}/manifests/{Uri.EscapeDataString(tag)}";
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, url);
|
||||
|
||||
if (!string.IsNullOrEmpty(token))
|
||||
{
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||
}
|
||||
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue(OciMediaTypes.ImageIndex));
|
||||
|
||||
using var response = await _httpClient.SendAsync(request, ct);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync(ct);
|
||||
return JsonSerializer.Deserialize<OciIndex>(json, SerializerOptions);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private OciIndex CreateOrUpdateIndex(
|
||||
OciIndex? existing, string referrerDigest, string? artifactType)
|
||||
{
|
||||
var manifests = existing?.Manifests?.ToList() ?? [];
|
||||
|
||||
// Check if this referrer is already in the index
|
||||
var existingReferrer = manifests.FirstOrDefault(m => m.Digest == referrerDigest);
|
||||
if (existingReferrer is not null)
|
||||
{
|
||||
return existing!; // Already present
|
||||
}
|
||||
|
||||
// Add the new referrer
|
||||
manifests.Add(new OciDescriptor
|
||||
{
|
||||
MediaType = OciMediaTypes.ImageManifest,
|
||||
Digest = referrerDigest,
|
||||
Size = 0, // Unknown at this point
|
||||
ArtifactType = artifactType
|
||||
});
|
||||
|
||||
return new OciIndex
|
||||
{
|
||||
SchemaVersion = 2,
|
||||
MediaType = OciMediaTypes.ImageIndex,
|
||||
Manifests = manifests
|
||||
};
|
||||
}
|
||||
|
||||
private async Task PushIndexAsync(
|
||||
string registry, string repository, string tag,
|
||||
OciIndex index, string? token, CancellationToken ct)
|
||||
{
|
||||
var json = JsonSerializer.Serialize(index, SerializerOptions);
|
||||
var url = $"https://{registry}/v2/{repository}/manifests/{Uri.EscapeDataString(tag)}";
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Put, url);
|
||||
|
||||
if (!string.IsNullOrEmpty(token))
|
||||
{
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||
}
|
||||
|
||||
request.Content = new StringContent(json, Encoding.UTF8, OciMediaTypes.ImageIndex);
|
||||
|
||||
using var response = await _httpClient.SendAsync(request, ct);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var body = await response.Content.ReadAsStringAsync(ct);
|
||||
throw new OciDistributionException(
|
||||
$"Failed to push fallback index: {response.StatusCode} - {body}",
|
||||
"ERR_OCI_FALLBACK_INDEX");
|
||||
}
|
||||
}
|
||||
|
||||
private static string GenerateFallbackTag(
|
||||
string subjectDigest, string? artifactType, FallbackOptions options)
|
||||
{
|
||||
var subjectHash = subjectDigest.Replace("sha256:", "");
|
||||
var typeSuffix = GetTypeSuffix(artifactType);
|
||||
|
||||
return options.TagTemplate
|
||||
.Replace("{subject}", subjectHash)
|
||||
.Replace("{type}", typeSuffix);
|
||||
}
|
||||
|
||||
private static string GetTypeSuffix(string? artifactType)
|
||||
{
|
||||
if (string.IsNullOrEmpty(artifactType))
|
||||
return "ref";
|
||||
|
||||
// Extract meaningful suffix from artifact type
|
||||
if (artifactType.Contains("rva", StringComparison.OrdinalIgnoreCase))
|
||||
return "rva";
|
||||
if (artifactType.Contains("sbom", StringComparison.OrdinalIgnoreCase))
|
||||
return "sbom";
|
||||
if (artifactType.Contains("vex", StringComparison.OrdinalIgnoreCase))
|
||||
return "vex";
|
||||
if (artifactType.Contains("provenance", StringComparison.OrdinalIgnoreCase))
|
||||
return "prov";
|
||||
if (artifactType.Contains("attestation", StringComparison.OrdinalIgnoreCase))
|
||||
return "att";
|
||||
|
||||
return "ref";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Options for fallback referrer handling.
|
||||
/// </summary>
|
||||
public sealed record FallbackOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Create a tagged index for registries without referrers API.
|
||||
/// </summary>
|
||||
public bool CreateFallbackTag { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Tag format template. {subject} and {type} are replaced.
|
||||
/// </summary>
|
||||
public string TagTemplate { get; init; } = "sha256-{subject}.{type}";
|
||||
|
||||
/// <summary>
|
||||
/// Maximum number of referrers per fallback index.
|
||||
/// </summary>
|
||||
public int MaxReferrersPerIndex { get; init; } = 100;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interface for OCI referrer fallback operations.
|
||||
/// </summary>
|
||||
public interface IOciReferrerFallback
|
||||
{
|
||||
/// <summary>
|
||||
/// Pushes an artifact with fallback tag for older registries.
|
||||
/// </summary>
|
||||
Task<ReferrerPushResult> PushWithFallbackAsync(
|
||||
ReferrerPushRequest request,
|
||||
FallbackOptions options,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Determines the capabilities of a registry.
|
||||
/// </summary>
|
||||
Task<RegistryCapabilities> ProbeCapabilitiesAsync(
|
||||
string registry,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,388 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.ExportCenter.WebService.Distribution.Oci;
|
||||
|
||||
/// <summary>
|
||||
/// Client for pushing artifacts to OCI registries with referrer (subject) binding.
|
||||
/// Implements OCI Distribution Spec 1.1 referrers API.
|
||||
/// </summary>
|
||||
public sealed class OciReferrerPushClient : IOciReferrerPushClient
|
||||
{
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
WriteIndented = false,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
||||
};
|
||||
|
||||
// Empty config blob for artifact manifests
|
||||
private static readonly byte[] EmptyConfigBlob = "{}"u8.ToArray();
|
||||
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly IOciAuthProvider _authProvider;
|
||||
private readonly ILogger<OciReferrerPushClient> _logger;
|
||||
|
||||
public OciReferrerPushClient(
|
||||
HttpClient httpClient,
|
||||
IOciAuthProvider authProvider,
|
||||
ILogger<OciReferrerPushClient> logger)
|
||||
{
|
||||
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
|
||||
_authProvider = authProvider ?? throw new ArgumentNullException(nameof(authProvider));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pushes an artifact to the registry with optional subject binding.
|
||||
/// </summary>
|
||||
public async Task<ReferrerPushResult> PushArtifactAsync(
|
||||
ReferrerPushRequest request,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
_logger.LogInformation("Pushing artifact to {Registry}/{Repository}",
|
||||
request.Registry, request.Repository);
|
||||
|
||||
try
|
||||
{
|
||||
// Authenticate
|
||||
var token = await _authProvider.GetTokenAsync(
|
||||
request.Registry, request.Repository, ct);
|
||||
|
||||
// Step 1: Push config blob (empty for attestations)
|
||||
var configDigest = await PushBlobAsync(
|
||||
request.Registry, request.Repository,
|
||||
request.Config ?? EmptyConfigBlob,
|
||||
token, ct);
|
||||
|
||||
// Step 2: Push artifact content as blob
|
||||
var contentDigest = await PushBlobAsync(
|
||||
request.Registry, request.Repository,
|
||||
request.Content, token, ct);
|
||||
|
||||
// Step 3: Create and push manifest with subject
|
||||
var manifest = CreateManifest(request, configDigest, contentDigest);
|
||||
var manifestDigest = await PushManifestAsync(
|
||||
request.Registry, request.Repository,
|
||||
manifest, token, ct);
|
||||
|
||||
_logger.LogInformation("Pushed artifact {Digest} to {Registry}/{Repository}",
|
||||
manifestDigest, request.Registry, request.Repository);
|
||||
|
||||
return new ReferrerPushResult
|
||||
{
|
||||
IsSuccess = true,
|
||||
Digest = manifestDigest,
|
||||
Registry = request.Registry,
|
||||
Repository = request.Repository,
|
||||
ReferrerUri = $"{request.Registry}/{request.Repository}@{manifestDigest}"
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to push artifact to {Registry}/{Repository}",
|
||||
request.Registry, request.Repository);
|
||||
|
||||
return new ReferrerPushResult
|
||||
{
|
||||
IsSuccess = false,
|
||||
Error = ex.Message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<string> PushBlobAsync(
|
||||
string registry, string repository,
|
||||
byte[] content, string? token, CancellationToken ct)
|
||||
{
|
||||
var digest = ComputeDigest(content);
|
||||
|
||||
// Check if blob exists
|
||||
var checkUrl = $"https://{registry}/v2/{repository}/blobs/{digest}";
|
||||
using var checkRequest = new HttpRequestMessage(HttpMethod.Head, checkUrl);
|
||||
ApplyAuth(checkRequest, token);
|
||||
|
||||
using var checkResponse = await _httpClient.SendAsync(checkRequest, ct);
|
||||
if (checkResponse.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogDebug("Blob {Digest} already exists", digest);
|
||||
return digest;
|
||||
}
|
||||
|
||||
// Start upload session
|
||||
var uploadUrl = $"https://{registry}/v2/{repository}/blobs/uploads/";
|
||||
using var uploadRequest = new HttpRequestMessage(HttpMethod.Post, uploadUrl);
|
||||
ApplyAuth(uploadRequest, token);
|
||||
|
||||
using var uploadResponse = await _httpClient.SendAsync(uploadRequest, ct);
|
||||
await EnsureSuccessAsync(uploadResponse, "initiate blob upload", ct);
|
||||
|
||||
var location = uploadResponse.Headers.Location?.ToString()
|
||||
?? throw new InvalidOperationException("No upload location returned");
|
||||
|
||||
// Make location absolute if relative
|
||||
if (!location.StartsWith("http", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
location = $"https://{registry}{location}";
|
||||
}
|
||||
|
||||
// Complete upload
|
||||
var completeUrl = location.Contains('?')
|
||||
? $"{location}&digest={Uri.EscapeDataString(digest)}"
|
||||
: $"{location}?digest={Uri.EscapeDataString(digest)}";
|
||||
|
||||
using var completeRequest = new HttpRequestMessage(HttpMethod.Put, completeUrl);
|
||||
ApplyAuth(completeRequest, token);
|
||||
completeRequest.Content = new ByteArrayContent(content);
|
||||
completeRequest.Content.Headers.ContentType = new MediaTypeHeaderValue("application/octet-stream");
|
||||
|
||||
using var completeResponse = await _httpClient.SendAsync(completeRequest, ct);
|
||||
await EnsureSuccessAsync(completeResponse, "complete blob upload", ct);
|
||||
|
||||
_logger.LogDebug("Pushed blob {Digest} ({Size} bytes)", digest, content.Length);
|
||||
return digest;
|
||||
}
|
||||
|
||||
private OciImageManifest CreateManifest(
|
||||
ReferrerPushRequest request, string configDigest, string contentDigest)
|
||||
{
|
||||
var manifest = new OciImageManifest
|
||||
{
|
||||
SchemaVersion = 2,
|
||||
MediaType = OciMediaTypes.ImageManifest,
|
||||
ArtifactType = request.ArtifactType,
|
||||
Config = new OciDescriptor
|
||||
{
|
||||
MediaType = request.ConfigMediaType ?? OciMediaTypes.EmptyConfig,
|
||||
Digest = configDigest,
|
||||
Size = request.Config?.Length ?? EmptyConfigBlob.Length
|
||||
},
|
||||
Layers =
|
||||
[
|
||||
new OciDescriptor
|
||||
{
|
||||
MediaType = request.ContentMediaType,
|
||||
Digest = contentDigest,
|
||||
Size = request.Content.Length,
|
||||
Annotations = request.LayerAnnotations
|
||||
}
|
||||
],
|
||||
Annotations = request.ManifestAnnotations
|
||||
};
|
||||
|
||||
// Add subject for referrer binding
|
||||
if (request.SubjectDigest is not null)
|
||||
{
|
||||
manifest = manifest with
|
||||
{
|
||||
Subject = new OciDescriptor
|
||||
{
|
||||
MediaType = OciMediaTypes.ImageManifest,
|
||||
Digest = request.SubjectDigest,
|
||||
Size = 0 // Unknown for subject reference
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return manifest;
|
||||
}
|
||||
|
||||
private async Task<string> PushManifestAsync(
|
||||
string registry, string repository,
|
||||
OciImageManifest manifest, string? token, CancellationToken ct)
|
||||
{
|
||||
var json = JsonSerializer.Serialize(manifest, SerializerOptions);
|
||||
var jsonBytes = Encoding.UTF8.GetBytes(json);
|
||||
var digest = ComputeDigest(jsonBytes);
|
||||
|
||||
var url = $"https://{registry}/v2/{repository}/manifests/{digest}";
|
||||
using var request = new HttpRequestMessage(HttpMethod.Put, url);
|
||||
ApplyAuth(request, token);
|
||||
request.Content = new StringContent(json, Encoding.UTF8, OciMediaTypes.ImageManifest);
|
||||
|
||||
using var response = await _httpClient.SendAsync(request, ct);
|
||||
await EnsureSuccessAsync(response, "push manifest", ct);
|
||||
|
||||
return digest;
|
||||
}
|
||||
|
||||
private static void ApplyAuth(HttpRequestMessage request, string? token)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(token))
|
||||
{
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task EnsureSuccessAsync(
|
||||
HttpResponseMessage response, string operation, CancellationToken ct)
|
||||
{
|
||||
if (response.IsSuccessStatusCode)
|
||||
return;
|
||||
|
||||
var body = await response.Content.ReadAsStringAsync(ct);
|
||||
throw new OciDistributionException(
|
||||
$"Failed to {operation}: {(int)response.StatusCode} - {body}",
|
||||
$"ERR_OCI_{operation.ToUpperInvariant().Replace(" ", "_")}");
|
||||
}
|
||||
|
||||
private static string ComputeDigest(byte[] content)
|
||||
{
|
||||
var hash = SHA256.HashData(content);
|
||||
return $"sha256:{Convert.ToHexStringLower(hash)}";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to push an artifact with referrer binding.
|
||||
/// </summary>
|
||||
public sealed record ReferrerPushRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Target registry hostname.
|
||||
/// </summary>
|
||||
public required string Registry { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Target repository name.
|
||||
/// </summary>
|
||||
public required string Repository { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Artifact content bytes.
|
||||
/// </summary>
|
||||
public required byte[] Content { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Media type of the content.
|
||||
/// </summary>
|
||||
public required string ContentMediaType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Artifact type for OCI manifest (e.g., application/vnd.stellaops.rva.dsse+json).
|
||||
/// </summary>
|
||||
public string? ArtifactType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Config blob (empty for attestations).
|
||||
/// </summary>
|
||||
public byte[]? Config { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Config media type.
|
||||
/// </summary>
|
||||
public string? ConfigMediaType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Subject digest for referrer binding (the image this artifact references).
|
||||
/// </summary>
|
||||
public string? SubjectDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Annotations for the content layer.
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, string>? LayerAnnotations { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Annotations for the manifest.
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, string>? ManifestAnnotations { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of a referrer push operation.
|
||||
/// </summary>
|
||||
public sealed record ReferrerPushResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether the push was successful.
|
||||
/// </summary>
|
||||
public required bool IsSuccess { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Digest of the pushed manifest.
|
||||
/// </summary>
|
||||
public string? Digest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Registry the artifact was pushed to.
|
||||
/// </summary>
|
||||
public string? Registry { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Repository the artifact was pushed to.
|
||||
/// </summary>
|
||||
public string? Repository { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Full URI for the pushed referrer.
|
||||
/// </summary>
|
||||
public string? ReferrerUri { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Error message if push failed.
|
||||
/// </summary>
|
||||
public string? Error { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interface for OCI registry authentication.
|
||||
/// </summary>
|
||||
public interface IOciAuthProvider
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets a bearer token for the specified registry and repository.
|
||||
/// </summary>
|
||||
Task<string?> GetTokenAsync(string registry, string repository, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interface for OCI referrer push operations.
|
||||
/// </summary>
|
||||
public interface IOciReferrerPushClient
|
||||
{
|
||||
/// <summary>
|
||||
/// Pushes an artifact to the registry with optional subject binding.
|
||||
/// </summary>
|
||||
Task<ReferrerPushResult> PushArtifactAsync(ReferrerPushRequest request, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Auth provider that wraps the existing OCI registry authorization.
|
||||
/// </summary>
|
||||
public sealed class OciAuthProviderAdapter : IOciAuthProvider
|
||||
{
|
||||
private readonly IOciDistributionClient _distributionClient;
|
||||
|
||||
public OciAuthProviderAdapter(IOciDistributionClient distributionClient)
|
||||
{
|
||||
_distributionClient = distributionClient ?? throw new ArgumentNullException(nameof(distributionClient));
|
||||
}
|
||||
|
||||
public Task<string?> GetTokenAsync(string registry, string repository, CancellationToken ct = default)
|
||||
{
|
||||
var auth = _distributionClient.GetAuthorization(registry);
|
||||
return Task.FromResult(auth.IdentityToken ?? auth.RefreshToken);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Simple token-based auth provider.
|
||||
/// </summary>
|
||||
public sealed class TokenAuthProvider : IOciAuthProvider
|
||||
{
|
||||
private readonly string? _token;
|
||||
|
||||
public TokenAuthProvider(string? token)
|
||||
{
|
||||
_token = token;
|
||||
}
|
||||
|
||||
public Task<string?> GetTokenAsync(string registry, string repository, CancellationToken ct = default)
|
||||
=> Task.FromResult(_token);
|
||||
}
|
||||
@@ -0,0 +1,525 @@
|
||||
using System.Net.Security;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
|
||||
namespace StellaOps.ExportCenter.WebService.Distribution.Oci;
|
||||
|
||||
/// <summary>
|
||||
/// Enhanced configuration for OCI registry connections with TLS and auth support.
|
||||
/// </summary>
|
||||
public sealed class OciRegistryConfig
|
||||
{
|
||||
/// <summary>
|
||||
/// Default registry (e.g., docker.io, ghcr.io).
|
||||
/// </summary>
|
||||
public string? DefaultRegistry { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Registry-specific configurations keyed by hostname.
|
||||
/// </summary>
|
||||
public Dictionary<string, RegistryEndpointConfig> Registries { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Global settings applied to all registries.
|
||||
/// </summary>
|
||||
public RegistryGlobalSettings Global { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Gets the endpoint configuration for a registry, or creates a default one.
|
||||
/// </summary>
|
||||
public RegistryEndpointConfig GetEndpointConfig(string registry)
|
||||
{
|
||||
if (Registries.TryGetValue(registry, out var config))
|
||||
return config;
|
||||
|
||||
// Check for wildcard patterns (e.g., "*.gcr.io")
|
||||
foreach (var (pattern, wildcardConfig) in Registries)
|
||||
{
|
||||
if (pattern.StartsWith("*.") && registry.EndsWith(pattern[1..]))
|
||||
return wildcardConfig;
|
||||
}
|
||||
|
||||
return new RegistryEndpointConfig { Host = registry };
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configuration for a specific registry endpoint.
|
||||
/// </summary>
|
||||
public sealed class RegistryEndpointConfig
|
||||
{
|
||||
/// <summary>
|
||||
/// Registry hostname (e.g., "gcr.io", "registry.example.com").
|
||||
/// </summary>
|
||||
public required string Host { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional port override.
|
||||
/// </summary>
|
||||
public int? Port { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Authentication method.
|
||||
/// </summary>
|
||||
public RegistryAuthMethod AuthMethod { get; set; } = RegistryAuthMethod.Anonymous;
|
||||
|
||||
/// <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>
|
||||
/// Path to credentials file (e.g., Docker config.json).
|
||||
/// </summary>
|
||||
public string? CredentialsFile { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// OAuth2/OIDC configuration.
|
||||
/// </summary>
|
||||
public OidcAuthConfig? Oidc { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Cloud provider auth configuration.
|
||||
/// </summary>
|
||||
public CloudAuthConfig? CloudAuth { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// TLS configuration.
|
||||
/// </summary>
|
||||
public RegistryTlsConfig? Tls { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Use HTTP instead of HTTPS (insecure, for local dev only).
|
||||
/// </summary>
|
||||
public bool Insecure { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this registry supports the OCI referrers API.
|
||||
/// Null = auto-detect.
|
||||
/// </summary>
|
||||
public bool? SupportsReferrersApi { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the full registry URL.
|
||||
/// </summary>
|
||||
public string GetRegistryUrl()
|
||||
{
|
||||
var scheme = Insecure ? "http" : "https";
|
||||
var port = Port.HasValue ? $":{Port}" : string.Empty;
|
||||
return $"{scheme}://{Host}{port}";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// TLS configuration for registry connections.
|
||||
/// </summary>
|
||||
public sealed class RegistryTlsConfig
|
||||
{
|
||||
/// <summary>
|
||||
/// Path to CA certificate bundle.
|
||||
/// </summary>
|
||||
public string? CaCertPath { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// PEM-encoded CA certificate (alternative to path).
|
||||
/// </summary>
|
||||
public string? CaCertPem { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Path to client certificate (for mTLS).
|
||||
/// </summary>
|
||||
public string? ClientCertPath { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Path to client key (for mTLS).
|
||||
/// </summary>
|
||||
public string? ClientKeyPath { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Password for client key if encrypted.
|
||||
/// </summary>
|
||||
public string? ClientKeyPassword { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Skip certificate verification (insecure).
|
||||
/// </summary>
|
||||
public bool SkipVerify { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Minimum TLS version (e.g., "1.2", "1.3").
|
||||
/// </summary>
|
||||
public string? MinVersion { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Expected server name for SNI (override).
|
||||
/// </summary>
|
||||
public string? ServerName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Loads the client certificate if configured.
|
||||
/// </summary>
|
||||
public X509Certificate2? LoadClientCertificate()
|
||||
{
|
||||
if (string.IsNullOrEmpty(ClientCertPath))
|
||||
return null;
|
||||
|
||||
if (!string.IsNullOrEmpty(ClientKeyPassword))
|
||||
return new X509Certificate2(ClientCertPath, ClientKeyPassword);
|
||||
|
||||
return new X509Certificate2(ClientCertPath);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a server certificate validation callback for HttpClientHandler.
|
||||
/// </summary>
|
||||
public Func<HttpRequestMessage, X509Certificate2?, X509Chain?, SslPolicyErrors, bool>? GetCertificateValidationCallback()
|
||||
{
|
||||
if (SkipVerify)
|
||||
return (_, _, _, _) => true;
|
||||
|
||||
if (string.IsNullOrEmpty(CaCertPath) && string.IsNullOrEmpty(CaCertPem))
|
||||
return null;
|
||||
|
||||
return ValidateWithCustomCa;
|
||||
}
|
||||
|
||||
private bool ValidateWithCustomCa(
|
||||
HttpRequestMessage request,
|
||||
X509Certificate2? certificate,
|
||||
X509Chain? chain,
|
||||
SslPolicyErrors sslPolicyErrors)
|
||||
{
|
||||
if (sslPolicyErrors == SslPolicyErrors.None)
|
||||
return true;
|
||||
|
||||
// If only chain errors, try validating with custom CA
|
||||
if ((sslPolicyErrors & ~SslPolicyErrors.RemoteCertificateChainErrors) != 0)
|
||||
return false;
|
||||
|
||||
if (certificate is null || chain is null)
|
||||
return false;
|
||||
|
||||
// Add custom CA to chain policy
|
||||
var caCert = LoadCaCertificate();
|
||||
if (caCert is null)
|
||||
return false;
|
||||
|
||||
chain.ChainPolicy.TrustMode = X509ChainTrustMode.CustomRootTrust;
|
||||
chain.ChainPolicy.CustomTrustStore.Add(caCert);
|
||||
|
||||
return chain.Build(certificate);
|
||||
}
|
||||
|
||||
private X509Certificate2? LoadCaCertificate()
|
||||
{
|
||||
if (!string.IsNullOrEmpty(CaCertPath) && File.Exists(CaCertPath))
|
||||
return new X509Certificate2(CaCertPath);
|
||||
|
||||
if (!string.IsNullOrEmpty(CaCertPem))
|
||||
return X509Certificate2.CreateFromPem(CaCertPem);
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// OAuth2/OIDC authentication configuration.
|
||||
/// </summary>
|
||||
public sealed class OidcAuthConfig
|
||||
{
|
||||
/// <summary>
|
||||
/// Token endpoint URL.
|
||||
/// </summary>
|
||||
public required string TokenEndpoint { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Client ID.
|
||||
/// </summary>
|
||||
public required string ClientId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Client secret (for confidential clients).
|
||||
/// </summary>
|
||||
public string? ClientSecret { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Scopes to request.
|
||||
/// </summary>
|
||||
public string[] Scopes { get; set; } = ["repository:*:pull,push"];
|
||||
|
||||
/// <summary>
|
||||
/// Token refresh threshold in seconds.
|
||||
/// </summary>
|
||||
public int RefreshThresholdSeconds { get; set; } = 60;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cloud provider authentication configuration.
|
||||
/// </summary>
|
||||
public sealed class CloudAuthConfig
|
||||
{
|
||||
/// <summary>
|
||||
/// Cloud provider type.
|
||||
/// </summary>
|
||||
public CloudProvider Provider { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// AWS region (for ECR).
|
||||
/// </summary>
|
||||
public string? AwsRegion { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// AWS role ARN to assume (for ECR).
|
||||
/// </summary>
|
||||
public string? AwsRoleArn { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// GCP project ID (for GCR/Artifact Registry).
|
||||
/// </summary>
|
||||
public string? GcpProject { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Path to GCP service account key file.
|
||||
/// </summary>
|
||||
public string? GcpServiceAccountKeyFile { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Azure subscription ID (for ACR).
|
||||
/// </summary>
|
||||
public string? AzureSubscriptionId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Azure tenant ID (for ACR).
|
||||
/// </summary>
|
||||
public string? AzureTenantId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Use workload identity federation.
|
||||
/// </summary>
|
||||
public bool UseWorkloadIdentity { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Supported cloud providers for registry auth.
|
||||
/// </summary>
|
||||
public enum CloudProvider
|
||||
{
|
||||
None,
|
||||
AwsEcr,
|
||||
GcpGcr,
|
||||
GcpArtifactRegistry,
|
||||
AzureAcr
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Authentication methods for OCI registries.
|
||||
/// </summary>
|
||||
public enum RegistryAuthMethod
|
||||
{
|
||||
/// <summary>
|
||||
/// No authentication (anonymous access).
|
||||
/// </summary>
|
||||
Anonymous,
|
||||
|
||||
/// <summary>
|
||||
/// HTTP Basic authentication (username:password).
|
||||
/// </summary>
|
||||
Basic,
|
||||
|
||||
/// <summary>
|
||||
/// Bearer token authentication.
|
||||
/// </summary>
|
||||
Bearer,
|
||||
|
||||
/// <summary>
|
||||
/// Docker config.json credential store.
|
||||
/// </summary>
|
||||
DockerConfig,
|
||||
|
||||
/// <summary>
|
||||
/// OAuth2/OIDC token authentication.
|
||||
/// </summary>
|
||||
Oidc,
|
||||
|
||||
/// <summary>
|
||||
/// AWS ECR authentication via AWS SDK.
|
||||
/// </summary>
|
||||
AwsEcr,
|
||||
|
||||
/// <summary>
|
||||
/// GCP GCR/Artifact Registry authentication via GCP SDK.
|
||||
/// </summary>
|
||||
GcpGcr,
|
||||
|
||||
/// <summary>
|
||||
/// Azure ACR authentication via Azure SDK.
|
||||
/// </summary>
|
||||
AzureAcr
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Global registry settings.
|
||||
/// </summary>
|
||||
public sealed class RegistryGlobalSettings
|
||||
{
|
||||
/// <summary>
|
||||
/// Timeout for registry operations.
|
||||
/// </summary>
|
||||
public TimeSpan Timeout { get; set; } = TimeSpan.FromMinutes(5);
|
||||
|
||||
/// <summary>
|
||||
/// Retry count for failed operations.
|
||||
/// </summary>
|
||||
public int RetryCount { get; set; } = 3;
|
||||
|
||||
/// <summary>
|
||||
/// Initial retry delay.
|
||||
/// </summary>
|
||||
public TimeSpan RetryDelay { get; set; } = TimeSpan.FromSeconds(1);
|
||||
|
||||
/// <summary>
|
||||
/// Maximum retry delay.
|
||||
/// </summary>
|
||||
public TimeSpan MaxRetryDelay { get; set; } = TimeSpan.FromSeconds(30);
|
||||
|
||||
/// <summary>
|
||||
/// User agent string.
|
||||
/// </summary>
|
||||
public string UserAgent { get; set; } = "StellaOps/1.0";
|
||||
|
||||
/// <summary>
|
||||
/// Enable referrers API fallback for older registries.
|
||||
/// </summary>
|
||||
public bool EnableReferrersFallback { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Concurrent upload limit.
|
||||
/// </summary>
|
||||
public int MaxConcurrentUploads { get; set; } = 4;
|
||||
|
||||
/// <summary>
|
||||
/// Chunk size for blob uploads.
|
||||
/// </summary>
|
||||
public int UploadChunkSize { get; set; } = 5 * 1024 * 1024; // 5 MB
|
||||
|
||||
/// <summary>
|
||||
/// Cache auth tokens.
|
||||
/// </summary>
|
||||
public bool CacheAuthTokens { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Token cache TTL.
|
||||
/// </summary>
|
||||
public TimeSpan TokenCacheTtl { get; set; } = TimeSpan.FromMinutes(50);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Factory for creating configured HTTP clients for OCI registries.
|
||||
/// </summary>
|
||||
public sealed class OciHttpClientFactory
|
||||
{
|
||||
private readonly OciRegistryConfig _config;
|
||||
|
||||
public OciHttpClientFactory(OciRegistryConfig config)
|
||||
{
|
||||
_config = config ?? throw new ArgumentNullException(nameof(config));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates an HTTP client configured for the specified registry.
|
||||
/// </summary>
|
||||
public HttpClient CreateClient(string registry)
|
||||
{
|
||||
var endpointConfig = _config.GetEndpointConfig(registry);
|
||||
var handler = CreateHandler(endpointConfig);
|
||||
|
||||
var client = new HttpClient(handler)
|
||||
{
|
||||
Timeout = _config.Global.Timeout
|
||||
};
|
||||
|
||||
client.DefaultRequestHeaders.UserAgent.ParseAdd(_config.Global.UserAgent);
|
||||
|
||||
return client;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates an HTTP message handler with TLS configuration.
|
||||
/// </summary>
|
||||
private static HttpClientHandler CreateHandler(RegistryEndpointConfig config)
|
||||
{
|
||||
var handler = new HttpClientHandler();
|
||||
|
||||
// Configure TLS
|
||||
if (config.Tls is not null)
|
||||
{
|
||||
if (config.Tls.SkipVerify)
|
||||
{
|
||||
handler.ServerCertificateCustomValidationCallback =
|
||||
HttpClientHandler.DangerousAcceptAnyServerCertificateValidator;
|
||||
}
|
||||
else
|
||||
{
|
||||
var callback = config.Tls.GetCertificateValidationCallback();
|
||||
if (callback is not null)
|
||||
{
|
||||
handler.ServerCertificateCustomValidationCallback = callback;
|
||||
}
|
||||
}
|
||||
|
||||
// Load client certificate for mTLS
|
||||
var clientCert = config.Tls.LoadClientCertificate();
|
||||
if (clientCert is not null)
|
||||
{
|
||||
handler.ClientCertificates.Add(clientCert);
|
||||
}
|
||||
}
|
||||
|
||||
return handler;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Capabilities detected for a registry.
|
||||
/// </summary>
|
||||
public sealed record RegistryCapabilities
|
||||
{
|
||||
/// <summary>
|
||||
/// Registry hostname.
|
||||
/// </summary>
|
||||
public required string Registry { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// OCI Distribution spec version.
|
||||
/// </summary>
|
||||
public string? DistributionVersion { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the registry supports the referrers API (OCI 1.1+).
|
||||
/// </summary>
|
||||
public bool SupportsReferrersApi { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the registry accepts artifactType field.
|
||||
/// </summary>
|
||||
public bool SupportsArtifactType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the registry supports chunked uploads.
|
||||
/// </summary>
|
||||
public bool SupportsChunkedUpload { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When capabilities were probed.
|
||||
/// </summary>
|
||||
public DateTimeOffset ProbedAt { get; init; } = DateTimeOffset.UtcNow;
|
||||
|
||||
/// <summary>
|
||||
/// Whether capabilities are stale and should be re-probed.
|
||||
/// </summary>
|
||||
public bool IsStale(TimeSpan maxAge) => DateTimeOffset.UtcNow - ProbedAt > maxAge;
|
||||
}
|
||||
@@ -0,0 +1,370 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Policy.Engine.Attestation;
|
||||
|
||||
namespace StellaOps.ExportCenter.WebService.Distribution.Oci;
|
||||
|
||||
/// <summary>
|
||||
/// Publishes Risk Verdict Attestations to OCI registries as referrer artifacts.
|
||||
/// </summary>
|
||||
public sealed class RvaOciPublisher : IRvaOciPublisher
|
||||
{
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
WriteIndented = false,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
||||
};
|
||||
|
||||
private readonly IOciReferrerFallback _fallback;
|
||||
private readonly IRvaEnvelopeSigner? _signer;
|
||||
private readonly ILogger<RvaOciPublisher> _logger;
|
||||
|
||||
public RvaOciPublisher(
|
||||
IOciReferrerFallback fallback,
|
||||
IRvaEnvelopeSigner? signer,
|
||||
ILogger<RvaOciPublisher> logger)
|
||||
{
|
||||
_fallback = fallback ?? throw new ArgumentNullException(nameof(fallback));
|
||||
_signer = signer;
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Publishes an RVA as an OCI artifact attached to the subject image.
|
||||
/// </summary>
|
||||
public async Task<RvaPublishResult> PublishAsync(
|
||||
RiskVerdictAttestation attestation,
|
||||
RvaPublishOptions options,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Publishing RVA {AttestationId} to {Registry}/{Repository}",
|
||||
attestation.AttestationId, options.Registry, options.Repository);
|
||||
|
||||
try
|
||||
{
|
||||
// Create in-toto statement
|
||||
var statement = RvaPredicate.CreateStatement(attestation);
|
||||
var statementJson = JsonSerializer.Serialize(statement, SerializerOptions);
|
||||
|
||||
// Determine content and artifact type
|
||||
byte[] content;
|
||||
string artifactType;
|
||||
string mediaType;
|
||||
|
||||
if (options.SignAttestation && _signer is not null)
|
||||
{
|
||||
// Sign the statement and wrap in DSSE envelope
|
||||
var envelope = await SignStatementAsync(statementJson, ct);
|
||||
content = Encoding.UTF8.GetBytes(envelope);
|
||||
artifactType = OciArtifactTypes.RvaDsse;
|
||||
mediaType = OciArtifactTypes.RvaDsse;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Push unsigned statement
|
||||
content = Encoding.UTF8.GetBytes(statementJson);
|
||||
artifactType = OciArtifactTypes.RvaJson;
|
||||
mediaType = OciArtifactTypes.InTotoStatement;
|
||||
}
|
||||
|
||||
// Prepare push request
|
||||
var request = new ReferrerPushRequest
|
||||
{
|
||||
Registry = options.Registry,
|
||||
Repository = options.Repository,
|
||||
Content = content,
|
||||
ContentMediaType = mediaType,
|
||||
ArtifactType = artifactType,
|
||||
SubjectDigest = attestation.Subject.Digest,
|
||||
LayerAnnotations = CreateLayerAnnotations(attestation),
|
||||
ManifestAnnotations = CreateManifestAnnotations(attestation)
|
||||
};
|
||||
|
||||
// Push with fallback support
|
||||
var result = await _fallback.PushWithFallbackAsync(request,
|
||||
new FallbackOptions { CreateFallbackTag = options.CreateFallbackTag },
|
||||
ct);
|
||||
|
||||
if (!result.IsSuccess)
|
||||
{
|
||||
return new RvaPublishResult
|
||||
{
|
||||
IsSuccess = false,
|
||||
Error = result.Error
|
||||
};
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Published RVA {AttestationId} as {Digest}",
|
||||
attestation.AttestationId, result.Digest);
|
||||
|
||||
return new RvaPublishResult
|
||||
{
|
||||
IsSuccess = true,
|
||||
AttestationId = attestation.AttestationId,
|
||||
ArtifactDigest = result.Digest,
|
||||
Registry = options.Registry,
|
||||
Repository = options.Repository,
|
||||
ReferrerUri = result.ReferrerUri,
|
||||
IsSigned = options.SignAttestation && _signer is not null
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to publish RVA {AttestationId}",
|
||||
attestation.AttestationId);
|
||||
|
||||
return new RvaPublishResult
|
||||
{
|
||||
IsSuccess = false,
|
||||
Error = ex.Message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Publishes multiple RVAs in batch.
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyList<RvaPublishResult>> PublishBatchAsync(
|
||||
IEnumerable<RiskVerdictAttestation> attestations,
|
||||
RvaPublishOptions options,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var results = new List<RvaPublishResult>();
|
||||
|
||||
foreach (var attestation in attestations)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
var result = await PublishAsync(attestation, options, ct);
|
||||
results.Add(result);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private async Task<string> SignStatementAsync(string statementJson, CancellationToken ct)
|
||||
{
|
||||
if (_signer is null)
|
||||
throw new InvalidOperationException("Signer is not configured");
|
||||
|
||||
var payloadBytes = Encoding.UTF8.GetBytes(statementJson);
|
||||
var signatureResult = await _signer.SignAsync(payloadBytes, ct);
|
||||
|
||||
var envelope = new DsseEnvelope
|
||||
{
|
||||
PayloadType = "application/vnd.in-toto+json",
|
||||
Payload = Convert.ToBase64String(payloadBytes),
|
||||
Signatures =
|
||||
[
|
||||
new DsseSignature
|
||||
{
|
||||
KeyId = signatureResult.KeyId,
|
||||
Sig = Convert.ToBase64String(signatureResult.Signature)
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
return JsonSerializer.Serialize(envelope, SerializerOptions);
|
||||
}
|
||||
|
||||
private static IReadOnlyDictionary<string, string> CreateLayerAnnotations(
|
||||
RiskVerdictAttestation attestation)
|
||||
{
|
||||
return new Dictionary<string, string>
|
||||
{
|
||||
[OciAnnotations.Title] = $"RVA for {attestation.Subject.Name ?? attestation.Subject.Digest}",
|
||||
[OciRvaAnnotations.RvaId] = attestation.AttestationId,
|
||||
[OciRvaAnnotations.RvaVerdict] = attestation.Verdict.ToString(),
|
||||
[OciRvaAnnotations.RvaPolicy] = attestation.Policy.PolicyId,
|
||||
[OciRvaAnnotations.RvaPolicyVersion] = attestation.Policy.Version,
|
||||
[OciRvaAnnotations.RvaSnapshot] = attestation.KnowledgeSnapshotId
|
||||
};
|
||||
}
|
||||
|
||||
private static Dictionary<string, string> CreateManifestAnnotations(
|
||||
RiskVerdictAttestation attestation)
|
||||
{
|
||||
var annotations = new Dictionary<string, string>
|
||||
{
|
||||
[OciAnnotations.Created] = attestation.CreatedAt.ToString("o"),
|
||||
[OciAnnotations.Title] = $"Risk Verdict Attestation",
|
||||
[OciAnnotations.Description] = attestation.Explanation ?? $"RVA for {attestation.Subject.Name}",
|
||||
[OciRvaAnnotations.RvaId] = attestation.AttestationId,
|
||||
[OciRvaAnnotations.RvaVerdict] = attestation.Verdict.ToString()
|
||||
};
|
||||
|
||||
if (attestation.ExpiresAt.HasValue)
|
||||
{
|
||||
annotations[OciRvaAnnotations.RvaExpires] = attestation.ExpiresAt.Value.ToString("o");
|
||||
}
|
||||
|
||||
if (attestation.AppliedExceptions.Count > 0)
|
||||
{
|
||||
annotations[OciRvaAnnotations.RvaHasExceptions] = "true";
|
||||
}
|
||||
|
||||
return annotations;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Options for publishing RVAs to OCI registries.
|
||||
/// </summary>
|
||||
public sealed record RvaPublishOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Target registry hostname.
|
||||
/// </summary>
|
||||
public required string Registry { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Target repository name.
|
||||
/// </summary>
|
||||
public required string Repository { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether to sign the attestation with DSSE.
|
||||
/// </summary>
|
||||
public bool SignAttestation { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Create a fallback tag for older registries.
|
||||
/// </summary>
|
||||
public bool CreateFallbackTag { get; init; } = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of publishing an RVA to OCI.
|
||||
/// </summary>
|
||||
public sealed record RvaPublishResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether the publish was successful.
|
||||
/// </summary>
|
||||
public required bool IsSuccess { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The attestation ID that was published.
|
||||
/// </summary>
|
||||
public string? AttestationId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Digest of the pushed artifact manifest.
|
||||
/// </summary>
|
||||
public string? ArtifactDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Registry the artifact was pushed to.
|
||||
/// </summary>
|
||||
public string? Registry { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Repository the artifact was pushed to.
|
||||
/// </summary>
|
||||
public string? Repository { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Full referrer URI.
|
||||
/// </summary>
|
||||
public string? ReferrerUri { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the attestation was signed.
|
||||
/// </summary>
|
||||
public bool IsSigned { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Error message if publish failed.
|
||||
/// </summary>
|
||||
public string? Error { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interface for publishing RVAs to OCI registries.
|
||||
/// </summary>
|
||||
public interface IRvaOciPublisher
|
||||
{
|
||||
/// <summary>
|
||||
/// Publishes an RVA as an OCI artifact attached to the subject image.
|
||||
/// </summary>
|
||||
Task<RvaPublishResult> PublishAsync(
|
||||
RiskVerdictAttestation attestation,
|
||||
RvaPublishOptions options,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Publishes multiple RVAs in batch.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<RvaPublishResult>> PublishBatchAsync(
|
||||
IEnumerable<RiskVerdictAttestation> attestations,
|
||||
RvaPublishOptions options,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interface for signing RVA statements into DSSE envelopes.
|
||||
/// </summary>
|
||||
public interface IRvaEnvelopeSigner
|
||||
{
|
||||
/// <summary>
|
||||
/// Signs the payload and returns signature details.
|
||||
/// </summary>
|
||||
Task<RvaSignatureResult> SignAsync(byte[] payload, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the key ID used for signing.
|
||||
/// </summary>
|
||||
string KeyId { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of signing a payload.
|
||||
/// </summary>
|
||||
public sealed record RvaSignatureResult
|
||||
{
|
||||
/// <summary>
|
||||
/// The signature bytes.
|
||||
/// </summary>
|
||||
public required byte[] Signature { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Key ID used for signing.
|
||||
/// </summary>
|
||||
public required string KeyId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Signature algorithm used.
|
||||
/// </summary>
|
||||
public string? Algorithm { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DSSE envelope structure.
|
||||
/// </summary>
|
||||
public sealed record DsseEnvelope
|
||||
{
|
||||
[JsonPropertyName("payloadType")]
|
||||
public required string PayloadType { get; init; }
|
||||
|
||||
[JsonPropertyName("payload")]
|
||||
public required string Payload { get; init; }
|
||||
|
||||
[JsonPropertyName("signatures")]
|
||||
public required DsseSignature[] Signatures { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DSSE signature structure.
|
||||
/// </summary>
|
||||
public sealed record DsseSignature
|
||||
{
|
||||
[JsonPropertyName("keyid")]
|
||||
public required string KeyId { get; init; }
|
||||
|
||||
[JsonPropertyName("sig")]
|
||||
public required string Sig { get; init; }
|
||||
}
|
||||
@@ -10,8 +10,8 @@
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.0" />
|
||||
<PackageReference Include="OpenTelemetry.Api" Version="1.11.2" />
|
||||
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.11.2" />
|
||||
<PackageReference Include="OpenTelemetry.Api" Version="1.12.0" />
|
||||
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.12.0" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.ExportCenter.Core\StellaOps.ExportCenter.Core.csproj" />
|
||||
@@ -22,5 +22,6 @@
|
||||
<ProjectReference Include="..\..\..\AirGap\StellaOps.AirGap.Policy\StellaOps.AirGap.Policy\StellaOps.AirGap.Policy.csproj" />
|
||||
<ProjectReference Include="..\..\..\TimelineIndexer\StellaOps.TimelineIndexer\StellaOps.TimelineIndexer.Core\StellaOps.TimelineIndexer.Core.csproj" />
|
||||
<ProjectReference Include="..\..\..\Policy\__Libraries\StellaOps.Policy.Exceptions\StellaOps.Policy.Exceptions.csproj" />
|
||||
<ProjectReference Include="..\..\..\Policy\StellaOps.Policy.Engine\StellaOps.Policy.Engine.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
Reference in New Issue
Block a user