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:
StellaOps Bot
2025-12-22 23:21:21 +02:00
parent 3ba7157b00
commit 5146204f1b
529 changed files with 73579 additions and 5985 deletions

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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
}

View File

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

View File

@@ -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>

View File

@@ -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)
});
}
}
}

View File

@@ -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);
}
}
}

View File

@@ -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 ?? []
};
}
}

View File

@@ -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));
}
}

View File

@@ -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 */ }
}
}
}

View File

@@ -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" };
}
}

View File

@@ -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" />

View File

@@ -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";
}

View File

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

View File

@@ -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);
}

View File

@@ -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);
}

View File

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

View File

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

View File

@@ -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>