save progress

This commit is contained in:
StellaOps Bot
2026-01-02 21:06:27 +02:00
parent f46bde5575
commit 3f197814c5
441 changed files with 21545 additions and 4306 deletions

View File

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

View File

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

View File

@@ -6,6 +6,7 @@
<Nullable>enable</Nullable>
<LangVersion>preview</LangVersion>
<RootNamespace>StellaOps.Attestor.Oci</RootNamespace>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>

View File

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