Add property-based tests for SBOM/VEX document ordering and Unicode normalization determinism
- Implement `SbomVexOrderingDeterminismProperties` for testing component list and vulnerability metadata hash consistency. - Create `UnicodeNormalizationDeterminismProperties` to validate NFC normalization and Unicode string handling. - Add project file for `StellaOps.Testing.Determinism.Properties` with necessary dependencies. - Introduce CI/CD template validation tests including YAML syntax checks and documentation content verification. - Create validation script for CI/CD templates ensuring all required files and structures are present.
This commit is contained in:
@@ -0,0 +1,339 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Replay.Core;
|
||||
using StellaOps.Scanner.Evidence.Models;
|
||||
|
||||
namespace StellaOps.Scanner.Storage.Oci;
|
||||
|
||||
/// <summary>
|
||||
/// Service for publishing FuncProof documents to OCI registries as referrer artifacts.
|
||||
/// Follows the OCI referrer pattern to link FuncProof evidence to the original image.
|
||||
/// </summary>
|
||||
public interface IFuncProofOciPublisher
|
||||
{
|
||||
/// <summary>
|
||||
/// Publishes a FuncProof document to an OCI registry as a referrer artifact.
|
||||
/// </summary>
|
||||
/// <param name="request">The publish request containing FuncProof and target details.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Result containing the pushed manifest digest and reference.</returns>
|
||||
Task<FuncProofOciPublishResult> PublishAsync(FuncProofOciPublishRequest request, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to publish a FuncProof document to OCI registry.
|
||||
/// </summary>
|
||||
public sealed record FuncProofOciPublishRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// The FuncProof document to publish.
|
||||
/// </summary>
|
||||
public required FuncProof FuncProof { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional DSSE envelope containing the signed FuncProof.
|
||||
/// If provided, this is published instead of the raw FuncProof.
|
||||
/// </summary>
|
||||
public DsseEnvelope? DsseEnvelope { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Target OCI registry reference (e.g., "registry.example.com/repo:tag").
|
||||
/// </summary>
|
||||
public required string RegistryReference { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Digest of the subject image this FuncProof refers to.
|
||||
/// Used to create a referrer relationship (OCI referrer pattern).
|
||||
/// </summary>
|
||||
public required string SubjectDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional tag for the FuncProof artifact. If null, uses the proof ID.
|
||||
/// </summary>
|
||||
public string? Tag { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Additional annotations to include in the OCI manifest.
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, string>? Annotations { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of publishing a FuncProof document to OCI registry.
|
||||
/// </summary>
|
||||
public sealed record FuncProofOciPublishResult
|
||||
{
|
||||
public required bool Success { get; init; }
|
||||
public string? ManifestDigest { get; init; }
|
||||
public string? ManifestReference { get; init; }
|
||||
public string? ProofLayerDigest { get; init; }
|
||||
public string? Error { get; init; }
|
||||
|
||||
public static FuncProofOciPublishResult Failed(string error) => new()
|
||||
{
|
||||
Success = false,
|
||||
Error = error
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for FuncProof OCI publishing.
|
||||
/// </summary>
|
||||
public sealed class FuncProofOciOptions
|
||||
{
|
||||
public const string SectionName = "Scanner:FuncProof:Oci";
|
||||
|
||||
/// <summary>
|
||||
/// Whether to publish FuncProof as a referrer artifact.
|
||||
/// </summary>
|
||||
public bool EnableReferrerPublish { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to include the DSSE envelope as a separate layer.
|
||||
/// </summary>
|
||||
public bool IncludeDsseLayer { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to compress the FuncProof content before publishing.
|
||||
/// </summary>
|
||||
public bool CompressContent { get; set; } = false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of FuncProof OCI publisher.
|
||||
/// </summary>
|
||||
public sealed class FuncProofOciPublisher : IFuncProofOciPublisher
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull,
|
||||
WriteIndented = false
|
||||
};
|
||||
|
||||
private readonly IOciPushService _ociPushService;
|
||||
private readonly IOptions<FuncProofOciOptions> _options;
|
||||
private readonly ILogger<FuncProofOciPublisher> _logger;
|
||||
|
||||
public FuncProofOciPublisher(
|
||||
IOciPushService ociPushService,
|
||||
IOptions<FuncProofOciOptions> options,
|
||||
ILogger<FuncProofOciPublisher> logger)
|
||||
{
|
||||
_ociPushService = ociPushService ?? throw new ArgumentNullException(nameof(ociPushService));
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<FuncProofOciPublishResult> PublishAsync(
|
||||
FuncProofOciPublishRequest request,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
ArgumentNullException.ThrowIfNull(request.FuncProof);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(request.RegistryReference);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(request.SubjectDigest);
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
if (string.IsNullOrEmpty(request.FuncProof.ProofId))
|
||||
{
|
||||
return FuncProofOciPublishResult.Failed("FuncProof must have a valid ProofId before publishing.");
|
||||
}
|
||||
|
||||
_logger.LogDebug(
|
||||
"Publishing FuncProof {ProofId} to OCI registry {Reference}",
|
||||
request.FuncProof.ProofId,
|
||||
request.RegistryReference);
|
||||
|
||||
try
|
||||
{
|
||||
var layers = BuildLayers(request);
|
||||
var annotations = BuildAnnotations(request);
|
||||
|
||||
var pushRequest = new OciArtifactPushRequest
|
||||
{
|
||||
Reference = request.RegistryReference,
|
||||
ArtifactType = FuncProofOciMediaTypes.ArtifactType,
|
||||
Layers = layers,
|
||||
SubjectDigest = request.SubjectDigest,
|
||||
Annotations = annotations
|
||||
};
|
||||
|
||||
var result = await _ociPushService.PushAsync(pushRequest, ct).ConfigureAwait(false);
|
||||
|
||||
if (!result.Success)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Failed to publish FuncProof {ProofId}: {Error}",
|
||||
request.FuncProof.ProofId,
|
||||
result.Error);
|
||||
return FuncProofOciPublishResult.Failed(result.Error ?? "Unknown OCI push failure");
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Published FuncProof {ProofId} to {Reference} with digest {Digest}",
|
||||
request.FuncProof.ProofId,
|
||||
result.ManifestReference,
|
||||
result.ManifestDigest);
|
||||
|
||||
return new FuncProofOciPublishResult
|
||||
{
|
||||
Success = true,
|
||||
ManifestDigest = result.ManifestDigest,
|
||||
ManifestReference = result.ManifestReference,
|
||||
ProofLayerDigest = result.LayerDigests?.FirstOrDefault()
|
||||
};
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
_logger.LogError(ex, "Error publishing FuncProof {ProofId}", request.FuncProof.ProofId);
|
||||
return FuncProofOciPublishResult.Failed($"Publish error: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private List<OciLayerContent> BuildLayers(FuncProofOciPublishRequest request)
|
||||
{
|
||||
var layers = new List<OciLayerContent>();
|
||||
var opts = _options.Value;
|
||||
|
||||
// Primary FuncProof layer
|
||||
byte[] proofContent;
|
||||
string proofMediaType;
|
||||
|
||||
if (request.DsseEnvelope is not null && opts.IncludeDsseLayer)
|
||||
{
|
||||
// Use DSSE envelope as primary layer
|
||||
proofContent = JsonSerializer.SerializeToUtf8Bytes(request.DsseEnvelope, JsonOptions);
|
||||
proofMediaType = FuncProofOciMediaTypes.DsseLayer;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Use raw FuncProof
|
||||
proofContent = JsonSerializer.SerializeToUtf8Bytes(request.FuncProof, JsonOptions);
|
||||
proofMediaType = FuncProofOciMediaTypes.ProofLayer;
|
||||
}
|
||||
|
||||
if (opts.CompressContent)
|
||||
{
|
||||
proofContent = CompressGzip(proofContent);
|
||||
proofMediaType += "+gzip";
|
||||
}
|
||||
|
||||
layers.Add(new OciLayerContent
|
||||
{
|
||||
Content = proofContent,
|
||||
MediaType = proofMediaType,
|
||||
Annotations = new SortedDictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
[OciAnnotations.Title] = $"funcproof-{request.FuncProof.ProofId}",
|
||||
[FuncProofOciAnnotations.ProofId] = request.FuncProof.ProofId,
|
||||
[FuncProofOciAnnotations.BuildId] = request.FuncProof.BuildId ?? string.Empty,
|
||||
[FuncProofOciAnnotations.FunctionCount] = request.FuncProof.Functions?.Count.ToString() ?? "0"
|
||||
}
|
||||
});
|
||||
|
||||
// Add raw FuncProof as secondary layer if DSSE was primary
|
||||
if (request.DsseEnvelope is not null && opts.IncludeDsseLayer)
|
||||
{
|
||||
var rawContent = JsonSerializer.SerializeToUtf8Bytes(request.FuncProof, JsonOptions);
|
||||
if (opts.CompressContent)
|
||||
{
|
||||
rawContent = CompressGzip(rawContent);
|
||||
}
|
||||
|
||||
layers.Add(new OciLayerContent
|
||||
{
|
||||
Content = rawContent,
|
||||
MediaType = opts.CompressContent
|
||||
? FuncProofOciMediaTypes.ProofLayer + "+gzip"
|
||||
: FuncProofOciMediaTypes.ProofLayer,
|
||||
Annotations = new SortedDictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
[OciAnnotations.Title] = $"funcproof-raw-{request.FuncProof.ProofId}"
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return layers;
|
||||
}
|
||||
|
||||
private SortedDictionary<string, string> BuildAnnotations(FuncProofOciPublishRequest request)
|
||||
{
|
||||
var annotations = new SortedDictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
[OciAnnotations.Title] = $"FuncProof for {request.FuncProof.BuildId ?? request.FuncProof.ProofId}",
|
||||
[FuncProofOciAnnotations.ProofId] = request.FuncProof.ProofId,
|
||||
[FuncProofOciAnnotations.SchemaVersion] = FuncProofConstants.SchemaVersion
|
||||
};
|
||||
|
||||
if (!string.IsNullOrEmpty(request.FuncProof.BuildId))
|
||||
{
|
||||
annotations[FuncProofOciAnnotations.BuildId] = request.FuncProof.BuildId;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(request.FuncProof.FileSha256))
|
||||
{
|
||||
annotations[FuncProofOciAnnotations.FileSha256] = request.FuncProof.FileSha256;
|
||||
}
|
||||
|
||||
if (request.FuncProof.Metadata?.CreatedAt is not null)
|
||||
{
|
||||
annotations[OciAnnotations.Created] = request.FuncProof.Metadata.CreatedAt;
|
||||
}
|
||||
|
||||
// Merge user-provided annotations
|
||||
if (request.Annotations is not null)
|
||||
{
|
||||
foreach (var (key, value) in request.Annotations)
|
||||
{
|
||||
annotations[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return annotations;
|
||||
}
|
||||
|
||||
private static byte[] CompressGzip(byte[] data)
|
||||
{
|
||||
using var output = new System.IO.MemoryStream();
|
||||
using (var gzip = new System.IO.Compression.GZipStream(output, System.IO.Compression.CompressionLevel.Optimal))
|
||||
{
|
||||
gzip.Write(data, 0, data.Length);
|
||||
}
|
||||
return output.ToArray();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// OCI media types for FuncProof artifacts.
|
||||
/// </summary>
|
||||
public static class FuncProofOciMediaTypes
|
||||
{
|
||||
/// <summary>
|
||||
/// Artifact type for FuncProof OCI artifacts.
|
||||
/// </summary>
|
||||
public const string ArtifactType = "application/vnd.stellaops.funcproof";
|
||||
|
||||
/// <summary>
|
||||
/// Media type for the FuncProof JSON layer.
|
||||
/// </summary>
|
||||
public const string ProofLayer = "application/vnd.stellaops.funcproof+json";
|
||||
|
||||
/// <summary>
|
||||
/// Media type for the DSSE envelope layer containing signed FuncProof.
|
||||
/// </summary>
|
||||
public const string DsseLayer = "application/vnd.stellaops.funcproof.dsse+json";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Custom OCI annotations for FuncProof artifacts.
|
||||
/// </summary>
|
||||
public static class FuncProofOciAnnotations
|
||||
{
|
||||
public const string ProofId = "io.stellaops.funcproof.id";
|
||||
public const string BuildId = "io.stellaops.funcproof.build-id";
|
||||
public const string FileSha256 = "io.stellaops.funcproof.file-sha256";
|
||||
public const string FunctionCount = "io.stellaops.funcproof.function-count";
|
||||
public const string SchemaVersion = "io.stellaops.funcproof.schema-version";
|
||||
}
|
||||
Reference in New Issue
Block a user