save progress
This commit is contained in:
@@ -91,9 +91,20 @@ public sealed record OciReference
|
||||
/// <summary>
|
||||
/// Gets the full reference string.
|
||||
/// </summary>
|
||||
public string FullReference => Tag is not null
|
||||
? $"{Registry}/{Repository}:{Tag}"
|
||||
: $"{Registry}/{Repository}@{Digest}";
|
||||
public string FullReference
|
||||
{
|
||||
get
|
||||
{
|
||||
var baseRef = $"{Registry}/{Repository}";
|
||||
if (!string.IsNullOrWhiteSpace(Digest))
|
||||
{
|
||||
return $"{baseRef}@{Digest}";
|
||||
}
|
||||
|
||||
var tag = string.IsNullOrWhiteSpace(Tag) ? "latest" : Tag;
|
||||
return $"{baseRef}:{tag}";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses an OCI reference string.
|
||||
@@ -102,45 +113,43 @@ public sealed record OciReference
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(reference);
|
||||
|
||||
// Handle digest references: registry/repo@sha256:...
|
||||
string? digest = null;
|
||||
var name = reference;
|
||||
|
||||
var digestIndex = reference.IndexOf('@');
|
||||
if (digestIndex > 0)
|
||||
if (digestIndex >= 0)
|
||||
{
|
||||
var beforeDigest = reference[..digestIndex];
|
||||
var digest = reference[(digestIndex + 1)..];
|
||||
var (registry, repo) = ParseRegistryAndRepo(beforeDigest);
|
||||
return new OciReference
|
||||
{
|
||||
Registry = registry,
|
||||
Repository = repo,
|
||||
Digest = digest
|
||||
};
|
||||
}
|
||||
|
||||
// Handle tag references: registry/repo:tag
|
||||
var tagIndex = reference.LastIndexOf(':');
|
||||
if (tagIndex > 0)
|
||||
{
|
||||
var beforeTag = reference[..tagIndex];
|
||||
var tag = reference[(tagIndex + 1)..];
|
||||
|
||||
// Check if this is actually a port number
|
||||
if (!beforeTag.Contains('/') || tag.Contains('/'))
|
||||
name = reference[..digestIndex];
|
||||
digest = reference[(digestIndex + 1)..];
|
||||
if (string.IsNullOrWhiteSpace(digest))
|
||||
{
|
||||
throw new ArgumentException($"Invalid OCI reference: {reference}", nameof(reference));
|
||||
}
|
||||
|
||||
var (registry, repo) = ParseRegistryAndRepo(beforeTag);
|
||||
return new OciReference
|
||||
{
|
||||
Registry = registry,
|
||||
Repository = repo,
|
||||
Digest = string.Empty, // Will be resolved
|
||||
Tag = tag
|
||||
};
|
||||
}
|
||||
|
||||
throw new ArgumentException($"Invalid OCI reference: {reference}", nameof(reference));
|
||||
string? tag = null;
|
||||
var tagIndex = name.LastIndexOf(':');
|
||||
var slashIndex = name.LastIndexOf('/');
|
||||
if (tagIndex > slashIndex)
|
||||
{
|
||||
tag = name[(tagIndex + 1)..];
|
||||
name = name[..tagIndex];
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(tag) && string.IsNullOrWhiteSpace(digest))
|
||||
{
|
||||
tag = "latest";
|
||||
}
|
||||
|
||||
var (registry, repo) = ParseRegistryAndRepo(name);
|
||||
|
||||
return new OciReference
|
||||
{
|
||||
Registry = registry,
|
||||
Repository = repo,
|
||||
Digest = digest ?? string.Empty,
|
||||
Tag = tag
|
||||
};
|
||||
}
|
||||
|
||||
private static (string Registry, string Repo) ParseRegistryAndRepo(string reference)
|
||||
@@ -148,13 +157,35 @@ public sealed record OciReference
|
||||
var firstSlash = reference.IndexOf('/');
|
||||
if (firstSlash < 0)
|
||||
{
|
||||
throw new ArgumentException($"Invalid OCI reference: {reference}");
|
||||
return ("docker.io", NormalizeRepository("docker.io", reference));
|
||||
}
|
||||
|
||||
var registry = reference[..firstSlash];
|
||||
var repo = reference[(firstSlash + 1)..];
|
||||
var firstSegment = reference[..firstSlash];
|
||||
if (IsRegistryHost(firstSegment))
|
||||
{
|
||||
var repo = reference[(firstSlash + 1)..];
|
||||
return (firstSegment, NormalizeRepository(firstSegment, repo));
|
||||
}
|
||||
|
||||
return (registry, repo);
|
||||
return ("docker.io", NormalizeRepository("docker.io", reference));
|
||||
}
|
||||
|
||||
private static bool IsRegistryHost(string value)
|
||||
{
|
||||
return value.Contains('.', StringComparison.Ordinal)
|
||||
|| value.Contains(':', StringComparison.Ordinal)
|
||||
|| string.Equals(value, "localhost", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static string NormalizeRepository(string registry, string repository)
|
||||
{
|
||||
if (string.Equals(registry, "docker.io", StringComparison.OrdinalIgnoreCase)
|
||||
&& !repository.Contains('/', StringComparison.Ordinal))
|
||||
{
|
||||
return $"library/{repository}";
|
||||
}
|
||||
|
||||
return repository;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -19,19 +19,16 @@ public sealed class OrasAttestationAttacher : IOciAttestationAttacher
|
||||
{
|
||||
private readonly IOciRegistryClient _registryClient;
|
||||
private readonly ILogger<OrasAttestationAttacher> _logger;
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = false
|
||||
};
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public OrasAttestationAttacher(
|
||||
IOciRegistryClient registryClient,
|
||||
ILogger<OrasAttestationAttacher> logger)
|
||||
ILogger<OrasAttestationAttacher> logger,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_registryClient = registryClient ?? throw new ArgumentNullException(nameof(registryClient));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
@@ -46,6 +43,8 @@ public sealed class OrasAttestationAttacher : IOciAttestationAttacher
|
||||
|
||||
options ??= new AttachmentOptions();
|
||||
|
||||
var predicateType = ResolvePredicateType(attestation);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Attaching attestation to {Registry}/{Repository}@{Digest}",
|
||||
imageRef.Registry,
|
||||
@@ -66,18 +65,18 @@ public sealed class OrasAttestationAttacher : IOciAttestationAttacher
|
||||
{
|
||||
var existing = await FindExistingAttestationAsync(
|
||||
imageRef,
|
||||
attestation.PayloadType,
|
||||
predicateType,
|
||||
ct).ConfigureAwait(false);
|
||||
|
||||
if (existing is not null)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Attestation with predicate type {PredicateType} already exists at {Digest}",
|
||||
attestation.PayloadType,
|
||||
predicateType,
|
||||
TruncateDigest(existing.Digest));
|
||||
|
||||
throw new InvalidOperationException(
|
||||
$"Attestation with predicate type '{attestation.PayloadType}' already exists. " +
|
||||
$"Attestation with predicate type '{predicateType}' already exists. " +
|
||||
"Use ReplaceExisting=true to overwrite.");
|
||||
}
|
||||
}
|
||||
@@ -104,7 +103,8 @@ public sealed class OrasAttestationAttacher : IOciAttestationAttacher
|
||||
ct).ConfigureAwait(false);
|
||||
|
||||
// 5. Build manifest with subject reference
|
||||
var annotations = BuildAnnotations(attestation, options);
|
||||
var attachedAt = _timeProvider.GetUtcNow();
|
||||
var annotations = BuildAnnotations(attestation, predicateType, options, attachedAt);
|
||||
var manifest = new OciManifest
|
||||
{
|
||||
SchemaVersion = 2,
|
||||
@@ -131,7 +131,7 @@ public sealed class OrasAttestationAttacher : IOciAttestationAttacher
|
||||
Size = attestationBytes.Length,
|
||||
Annotations = new Dictionary<string, string>
|
||||
{
|
||||
[AnnotationKeys.PredicateType] = attestation.PayloadType
|
||||
[AnnotationKeys.PredicateType] = predicateType
|
||||
}
|
||||
}
|
||||
],
|
||||
@@ -153,11 +153,17 @@ public sealed class OrasAttestationAttacher : IOciAttestationAttacher
|
||||
TruncateDigest(imageRef.Digest),
|
||||
TruncateDigest(manifestDigest));
|
||||
|
||||
if (options.RecordInRekor)
|
||||
{
|
||||
_logger.LogWarning("RecordInRekor requested but Rekor integration is not configured for OCI attachments.");
|
||||
}
|
||||
|
||||
return new AttachmentResult
|
||||
{
|
||||
AttestationDigest = attestationDigest,
|
||||
AttestationRef = $"{imageRef.Registry}/{imageRef.Repository}@{manifestDigest}",
|
||||
AttachedAt = DateTimeOffset.UtcNow
|
||||
AttachedAt = attachedAt,
|
||||
RekorLogId = null
|
||||
};
|
||||
}
|
||||
|
||||
@@ -259,7 +265,17 @@ public sealed class OrasAttestationAttacher : IOciAttestationAttacher
|
||||
return null;
|
||||
}
|
||||
|
||||
var layerDigest = manifest.Layers[0].Digest;
|
||||
var layer = manifest.Layers.FirstOrDefault(l =>
|
||||
string.Equals(l.MediaType, MediaTypes.DsseEnvelope, StringComparison.Ordinal));
|
||||
if (layer is null)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Attestation manifest {Digest} has no DSSE envelope layer",
|
||||
TruncateDigest(target.Digest));
|
||||
return null;
|
||||
}
|
||||
|
||||
var layerDigest = layer.Digest;
|
||||
|
||||
// Fetch the attestation blob
|
||||
var blobBytes = await _registryClient.FetchBlobAsync(
|
||||
@@ -305,12 +321,14 @@ public sealed class OrasAttestationAttacher : IOciAttestationAttacher
|
||||
|
||||
private static Dictionary<string, string> BuildAnnotations(
|
||||
DsseEnvelope envelope,
|
||||
AttachmentOptions options)
|
||||
string predicateType,
|
||||
AttachmentOptions options,
|
||||
DateTimeOffset createdAt)
|
||||
{
|
||||
var annotations = new Dictionary<string, string>
|
||||
var annotations = new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
[AnnotationKeys.Created] = DateTimeOffset.UtcNow.ToString("O"),
|
||||
[AnnotationKeys.PredicateType] = envelope.PayloadType,
|
||||
[AnnotationKeys.Created] = createdAt.ToString("O"),
|
||||
[AnnotationKeys.PredicateType] = predicateType,
|
||||
[AnnotationKeys.CosignSignature] = "" // Cosign compatibility placeholder
|
||||
};
|
||||
|
||||
@@ -351,7 +369,7 @@ public sealed class OrasAttestationAttacher : IOciAttestationAttacher
|
||||
private static DsseEnvelope DeserializeEnvelope(ReadOnlyMemory<byte> bytes)
|
||||
{
|
||||
// Parse the compact DSSE envelope format
|
||||
var json = JsonDocument.Parse(bytes);
|
||||
using var json = JsonDocument.Parse(bytes);
|
||||
var root = json.RootElement;
|
||||
|
||||
var payloadType = root.GetProperty("payloadType").GetString()
|
||||
@@ -360,7 +378,15 @@ public sealed class OrasAttestationAttacher : IOciAttestationAttacher
|
||||
var payloadBase64 = root.GetProperty("payload").GetString()
|
||||
?? throw new InvalidOperationException("Missing payload");
|
||||
|
||||
var payload = Convert.FromBase64String(payloadBase64);
|
||||
byte[] payload;
|
||||
try
|
||||
{
|
||||
payload = Convert.FromBase64String(payloadBase64);
|
||||
}
|
||||
catch (FormatException ex)
|
||||
{
|
||||
throw new InvalidOperationException("Attestation payload is not valid base64.", ex);
|
||||
}
|
||||
|
||||
var signatures = new List<DsseSignature>();
|
||||
if (root.TryGetProperty("signatures", out var sigsElement))
|
||||
@@ -381,6 +407,41 @@ public sealed class OrasAttestationAttacher : IOciAttestationAttacher
|
||||
return new DsseEnvelope(payloadType, payload, signatures);
|
||||
}
|
||||
|
||||
private static string ResolvePredicateType(DsseEnvelope envelope)
|
||||
{
|
||||
if (TryGetPredicateType(envelope.Payload.Span, out var predicateType))
|
||||
{
|
||||
return predicateType;
|
||||
}
|
||||
|
||||
return envelope.PayloadType;
|
||||
}
|
||||
|
||||
private static bool TryGetPredicateType(ReadOnlySpan<byte> payload, out string predicateType)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var json = JsonDocument.Parse(payload.ToArray());
|
||||
if (json.RootElement.TryGetProperty("predicateType", out var predicateElement)
|
||||
&& predicateElement.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
var value = predicateElement.GetString();
|
||||
if (!string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
predicateType = value;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// Swallow and fallback to payload type
|
||||
}
|
||||
|
||||
predicateType = string.Empty;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static string ComputeDigest(ReadOnlySpan<byte> content)
|
||||
{
|
||||
var hash = SHA256.HashData(content);
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<RootNamespace>StellaOps.Attestor.Oci</RootNamespace>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -7,4 +7,5 @@ Source of truth: `docs/implplan/SPRINT_20251229_049_BE_csproj_audit_maint_tests.
|
||||
| --- | --- | --- |
|
||||
| AUDIT-0056-M | DONE | Maintainability audit for StellaOps.Attestor.Oci. |
|
||||
| AUDIT-0056-T | DONE | Test coverage audit for StellaOps.Attestor.Oci. |
|
||||
| AUDIT-0056-A | TODO | Pending approval for changes. |
|
||||
| AUDIT-0056-A | DONE | Applied audit remediation for OCI attacher and references. |
|
||||
| VAL-SMOKE-001 | DONE | Fixed build issue in Attestor OCI attacher. |
|
||||
|
||||
Reference in New Issue
Block a user