semi implemented and features implemented save checkpoint

This commit is contained in:
master
2026-02-08 18:00:49 +02:00
parent 04360dff63
commit 1bf6bbf395
20895 changed files with 716795 additions and 64 deletions

View File

@@ -4,8 +4,13 @@
// Description: Stage executor for pushing verdicts as OCI referrer artifacts.
// -----------------------------------------------------------------------------
using Microsoft.Extensions.Options;
using Microsoft.Extensions.Logging;
using StellaOps.Scanner.Evidence;
using StellaOps.Scanner.Storage.Oci;
using StellaOps.Scanner.Worker.Options;
using System.Collections.Concurrent;
using System.Globalization;
namespace StellaOps.Scanner.Worker.Processing;
@@ -15,14 +20,20 @@ namespace StellaOps.Scanner.Worker.Processing;
/// </summary>
public sealed class VerdictPushStageExecutor : IScanStageExecutor
{
private readonly VerdictOciPublisher _publisher;
private static readonly TimeSpan SubmissionReceiptTtl = TimeSpan.FromMinutes(15);
private static readonly ConcurrentDictionary<string, SubmissionReceipt> SubmissionReceipts = new(StringComparer.Ordinal);
private readonly IVerdictOciPublisher _publisher;
private readonly IOptionsMonitor<ScannerWorkerOptions> _options;
private readonly ILogger<VerdictPushStageExecutor> _logger;
public VerdictPushStageExecutor(
VerdictOciPublisher publisher,
IVerdictOciPublisher publisher,
IOptionsMonitor<ScannerWorkerOptions> options,
ILogger<VerdictPushStageExecutor> logger)
{
_publisher = publisher ?? throw new ArgumentNullException(nameof(publisher));
_options = options ?? throw new ArgumentNullException(nameof(options));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
@@ -52,6 +63,17 @@ public sealed class VerdictPushStageExecutor : IScanStageExecutor
return;
}
var idempotencyKey = AttestationIdempotencyKey.FromDsseEnvelope(envelope);
if (TryGetCachedReceipt(idempotencyKey, context.TimeProvider.GetUtcNow(), out var cachedReceipt))
{
_logger.LogInformation(
"Skipping duplicate verdict push for job {JobId}; using cached receipt {ManifestDigest}.",
context.JobId,
cachedReceipt.ManifestDigest);
StorePushResult(context, idempotencyKey, cachedReceipt.ManifestDigest, cachedReceipt.ManifestReference);
return;
}
var request = new VerdictOciPublishRequest
{
Reference = options.RegistryReference,
@@ -63,31 +85,64 @@ public sealed class VerdictPushStageExecutor : IScanStageExecutor
Decision = options.Decision,
GraphRevisionId = options.GraphRevisionId,
ProofBundleDigest = options.ProofBundleDigest,
VerdictTimestamp = context.TimeProvider.GetUtcNow()
VerdictTimestamp = options.VerdictTimestamp,
IdempotencyKey = idempotencyKey
};
try
{
var result = await _publisher.PushAsync(request, cancellationToken).ConfigureAwait(false);
if (result.Success)
var retryCount = Math.Max(0, _options.CurrentValue.VerdictPush.MaxRetries);
var maxAttempts = retryCount + 1;
for (var attempt = 1; attempt <= maxAttempts; attempt++)
{
_logger.LogInformation(
"Pushed verdict for job {JobId} to {Reference} with digest {ManifestDigest}.",
context.JobId,
request.Reference,
result.ManifestDigest);
var result = await _publisher.PushAsync(request, cancellationToken).ConfigureAwait(false);
if (result.Success)
{
_logger.LogInformation(
result.AlreadyExists
? "Verdict already exists for job {JobId} at {Reference} ({ManifestDigest})."
: "Pushed verdict for job {JobId} to {Reference} with digest {ManifestDigest}.",
context.JobId,
request.Reference,
result.ManifestDigest);
// Store the push result in the analysis store for downstream consumers
context.Analysis.Set(VerdictPushAnalysisKeys.VerdictManifestDigest, result.ManifestDigest ?? string.Empty);
context.Analysis.Set(VerdictPushAnalysisKeys.VerdictManifestReference, result.ManifestReference ?? string.Empty);
}
else
{
_logger.LogError(
"Failed to push verdict for job {JobId}: {Error}",
var manifestDigest = result.ManifestDigest ?? string.Empty;
var manifestReference = result.ManifestReference ?? string.Empty;
RememberReceipt(idempotencyKey, manifestDigest, manifestReference, context.TimeProvider.GetUtcNow());
StorePushResult(context, idempotencyKey, manifestDigest, manifestReference);
return;
}
if (IsAlreadySubmittedError(result.Error))
{
_logger.LogInformation(
"Verdict push returned already-submitted signal for job {JobId} (attempt {Attempt}/{MaxAttempts}): {Error}",
context.JobId,
attempt,
maxAttempts,
result.Error);
StorePushResult(context, idempotencyKey, result.ManifestDigest ?? string.Empty, result.ManifestReference ?? string.Empty);
return;
}
if (!ShouldRetry(result.Error, attempt, maxAttempts))
{
_logger.LogError(
"Failed to push verdict for job {JobId}: {Error}",
context.JobId,
result.Error);
return;
}
var delay = ComputeRetryDelay(attempt);
_logger.LogWarning(
"Transient verdict push failure for job {JobId} (attempt {Attempt}/{MaxAttempts}): {Error}. Retrying in {Delay}.",
context.JobId,
result.Error);
attempt,
maxAttempts,
result.Error,
delay);
await Task.Delay(delay, cancellationToken).ConfigureAwait(false);
}
}
catch (Exception ex) when (ex is not OperationCanceledException)
@@ -134,6 +189,13 @@ public sealed class VerdictPushStageExecutor : IScanStageExecutor
decision = "unknown";
}
DateTimeOffset? verdictTimestamp = null;
if (metadata.TryGetValue(VerdictPushMetadataKeys.Timestamp, out var timestampRaw) &&
DateTimeOffset.TryParse(timestampRaw, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var parsedTimestamp))
{
verdictTimestamp = parsedTimestamp;
}
return new VerdictPushOptions
{
RegistryReference = registryRef!,
@@ -143,7 +205,8 @@ public sealed class VerdictPushStageExecutor : IScanStageExecutor
PolicyDigest = metadata.GetValueOrDefault(VerdictPushMetadataKeys.PolicyDigest) ?? "sha256:unknown",
Decision = decision,
GraphRevisionId = metadata.GetValueOrDefault(VerdictPushMetadataKeys.GraphRevisionId),
ProofBundleDigest = metadata.GetValueOrDefault(VerdictPushMetadataKeys.ProofBundleDigest)
ProofBundleDigest = metadata.GetValueOrDefault(VerdictPushMetadataKeys.ProofBundleDigest),
VerdictTimestamp = verdictTimestamp
};
}
@@ -186,6 +249,83 @@ public sealed class VerdictPushStageExecutor : IScanStageExecutor
return null;
}
private static bool TryGetCachedReceipt(string idempotencyKey, DateTimeOffset now, out SubmissionReceipt receipt)
{
if (SubmissionReceipts.TryGetValue(idempotencyKey, out receipt))
{
if (receipt.ExpiresAtUtc > now)
{
return true;
}
SubmissionReceipts.TryRemove(idempotencyKey, out _);
}
receipt = default;
return false;
}
private static void RememberReceipt(
string idempotencyKey,
string manifestDigest,
string manifestReference,
DateTimeOffset now)
{
SubmissionReceipts[idempotencyKey] = new SubmissionReceipt(
manifestDigest,
manifestReference,
now.Add(SubmissionReceiptTtl));
}
private static void StorePushResult(
ScanJobContext context,
string idempotencyKey,
string manifestDigest,
string manifestReference)
{
context.Analysis.Set(VerdictPushAnalysisKeys.VerdictIdempotencyKey, idempotencyKey);
context.Analysis.Set(VerdictPushAnalysisKeys.VerdictManifestDigest, manifestDigest);
context.Analysis.Set(VerdictPushAnalysisKeys.VerdictManifestReference, manifestReference);
}
private static bool IsAlreadySubmittedError(string? error)
{
if (string.IsNullOrWhiteSpace(error))
{
return false;
}
return error.Contains("409", StringComparison.OrdinalIgnoreCase) ||
error.Contains("conflict", StringComparison.OrdinalIgnoreCase) ||
error.Contains("already", StringComparison.OrdinalIgnoreCase);
}
private static bool IsTransientError(string? error)
{
if (string.IsNullOrWhiteSpace(error))
{
return false;
}
return error.Contains("429", StringComparison.OrdinalIgnoreCase) ||
error.Contains("500", StringComparison.OrdinalIgnoreCase) ||
error.Contains("502", StringComparison.OrdinalIgnoreCase) ||
error.Contains("503", StringComparison.OrdinalIgnoreCase) ||
error.Contains("504", StringComparison.OrdinalIgnoreCase) ||
error.Contains("timeout", StringComparison.OrdinalIgnoreCase) ||
error.Contains("temporar", StringComparison.OrdinalIgnoreCase) ||
error.Contains("unavailable", StringComparison.OrdinalIgnoreCase);
}
private static bool ShouldRetry(string? error, int attempt, int maxAttempts)
=> attempt < maxAttempts && IsTransientError(error);
private static TimeSpan ComputeRetryDelay(int attempt)
{
var milliseconds = Math.Min(2000, 200 * (1 << Math.Clamp(attempt - 1, 0, 4)));
return TimeSpan.FromMilliseconds(milliseconds);
}
private sealed class VerdictPushOptions
{
public required string RegistryReference { get; init; }
@@ -196,7 +336,13 @@ public sealed class VerdictPushStageExecutor : IScanStageExecutor
public required string Decision { get; init; }
public string? GraphRevisionId { get; init; }
public string? ProofBundleDigest { get; init; }
public DateTimeOffset? VerdictTimestamp { get; init; }
}
private readonly record struct SubmissionReceipt(
string ManifestDigest,
string ManifestReference,
DateTimeOffset ExpiresAtUtc);
}
/// <summary>
@@ -212,6 +358,7 @@ public static class VerdictPushMetadataKeys
public const string Decision = "verdict.decision";
public const string GraphRevisionId = "verdict.graph.revision.id";
public const string ProofBundleDigest = "verdict.proof.bundle.digest";
public const string Timestamp = "verdict.timestamp";
}
/// <summary>
@@ -221,6 +368,7 @@ public static class VerdictPushAnalysisKeys
{
public const string VerdictDsseEnvelope = "verdict.dsse.envelope";
public const string VerdictDsseEnvelopeMemory = "verdict.dsse.envelope.memory";
public const string VerdictIdempotencyKey = "verdict.push.idempotency.key";
public const string VerdictManifestDigest = "verdict.push.manifest.digest";
public const string VerdictManifestReference = "verdict.push.manifest.reference";
}

View File

@@ -1,5 +1,6 @@
using GateDetectors = StellaOps.Scanner.Reachability.Gates.Detectors;
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Hosting;
@@ -317,7 +318,7 @@ if (workerOptions.VerdictPush.Enabled)
{
client.Timeout = workerOptions.VerdictPush.Timeout;
});
builder.Services.AddSingleton<StellaOps.Scanner.Storage.Oci.VerdictOciPublisher>();
builder.Services.AddSingleton<StellaOps.Scanner.Storage.Oci.IVerdictOciPublisher, StellaOps.Scanner.Storage.Oci.VerdictOciPublisher>();
builder.Services.AddSingleton<IScanStageExecutor, VerdictPushStageExecutor>();
}

View File

@@ -9,3 +9,4 @@ Source of truth: `docs/implplan/SPRINT_20260113_001_001_SCANNER_elf_section_hash
| ELF-SECTION-DI-0001 | DONE | Register section hash extractor options and services. |
| AUDIT-HOTLIST-SCANNER-WORKER-0001 | DONE | Apply audit hotlist findings for Scanner.Worker. |
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
| SPRINT-20260208-060-IDEMP-001 | DONE | Implement idempotent verdict attestation submission (idempotency key + dedupe + retry classification + tests). |

View File

@@ -0,0 +1,43 @@
using System.Security.Cryptography;
namespace StellaOps.Scanner.Evidence;
/// <summary>
/// Creates deterministic idempotency keys for DSSE attestation payloads.
/// </summary>
public static class AttestationIdempotencyKey
{
/// <summary>
/// Computes a stable SHA-256 idempotency key for a DSSE envelope.
/// </summary>
public static string FromDsseEnvelope(ReadOnlySpan<byte> dsseEnvelopeBytes)
{
if (dsseEnvelopeBytes.IsEmpty)
{
throw new ArgumentException("DSSE envelope bytes cannot be empty.", nameof(dsseEnvelopeBytes));
}
var hash = SHA256.HashData(dsseEnvelopeBytes);
return $"sha256:{Convert.ToHexStringLower(hash)}";
}
/// <summary>
/// Converts an idempotency key into a stable OCI-safe tag.
/// </summary>
public static string ToOciTag(string idempotencyKey, string prefix = "verdict")
{
ArgumentException.ThrowIfNullOrWhiteSpace(idempotencyKey);
var normalized = idempotencyKey.StartsWith("sha256:", StringComparison.OrdinalIgnoreCase)
? idempotencyKey[7..]
: idempotencyKey;
var compact = normalized.Trim().ToLowerInvariant();
if (compact.Length > 48)
{
compact = compact[..48];
}
return $"{prefix}-{compact}";
}
}

View File

@@ -6,3 +6,4 @@ Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_sol
| --- | --- | --- |
| REMED-05 | TODO | Remediation checklist: docs/implplan/audits/csproj-standards/remediation/checklists/src/Scanner/__Libraries/StellaOps.Scanner.Evidence/StellaOps.Scanner.Evidence.md. |
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
| SPRINT-20260208-060-IDEMP-001 | DONE | Implement idempotent verdict attestation submission (idempotency key + dedupe + retry classification + tests). |

View File

@@ -0,0 +1,8 @@
namespace StellaOps.Scanner.Storage.Oci;
public interface IVerdictOciPublisher
{
Task<OciArtifactPushResult> PushAsync(
VerdictOciPublishRequest request,
CancellationToken cancellationToken = default);
}

View File

@@ -14,6 +14,7 @@ public static class OciAnnotations
public const string StellaAfterDigest = "org.stellaops.delta.after.digest";
public const string StellaSbomDigest = "org.stellaops.sbom.digest";
public const string StellaVerdictDigest = "org.stellaops.verdict.digest";
public const string StellaIdempotencyKey = "org.stellaops.idempotency.key";
// Sprint: SPRINT_4300_0001_0001 - OCI Verdict Attestation Push
/// <summary>

View File

@@ -60,16 +60,14 @@ public sealed class OciArtifactPusher
try
{
var configDigest = await PushBlobAsync(reference, EmptyConfigBlob, OciMediaTypes.EmptyConfig, auth, cancellationToken)
.ConfigureAwait(false);
var configDigest = ComputeDigest(EmptyConfigBlob);
var layerDescriptors = new List<OciDescriptor>();
var layerDigests = new List<string>();
foreach (var layer in request.Layers)
{
var digest = await PushBlobAsync(reference, layer.Content, layer.MediaType, auth, cancellationToken)
.ConfigureAwait(false);
var digest = ComputeDigest(layer.Content);
layerDescriptors.Add(new OciDescriptor
{
@@ -86,7 +84,45 @@ public sealed class OciArtifactPusher
var manifestBytes = JsonSerializer.SerializeToUtf8Bytes(manifest, SerializerOptions);
var manifestDigest = ComputeDigest(manifestBytes);
var tag = reference.Tag ?? manifestDigest.Replace("sha256:", string.Empty, StringComparison.Ordinal);
var tag = request.Tag;
if (string.IsNullOrWhiteSpace(tag))
{
tag = reference.Tag ?? manifestDigest.Replace("sha256:", string.Empty, StringComparison.Ordinal);
}
if (request.SkipIfTagExists)
{
var existingDigest = await TryGetExistingManifestDigestAsync(reference, tag, auth, cancellationToken)
.ConfigureAwait(false);
if (existingDigest is not null)
{
if (string.IsNullOrWhiteSpace(existingDigest))
{
existingDigest = manifestDigest;
}
var existingReference = $"{reference.Registry}/{reference.Repository}@{existingDigest}";
_logger.LogInformation("OCI artifact already exists for tag {Tag}: {Reference}", tag, existingReference);
return new OciArtifactPushResult
{
Success = true,
AlreadyExists = true,
ManifestDigest = existingDigest,
ManifestReference = existingReference,
LayerDigests = layerDigests
};
}
}
await PushBlobAsync(reference, EmptyConfigBlob, OciMediaTypes.EmptyConfig, auth, cancellationToken)
.ConfigureAwait(false);
foreach (var layer in request.Layers)
{
await PushBlobAsync(reference, layer.Content, layer.MediaType, auth, cancellationToken)
.ConfigureAwait(false);
}
await PushManifestAsync(reference, manifestBytes, tag, auth, cancellationToken).ConfigureAwait(false);
var manifestReference = $"{reference.Registry}/{reference.Repository}@{manifestDigest}";
@@ -94,11 +130,12 @@ public sealed class OciArtifactPusher
_logger.LogInformation("Pushed OCI artifact {Reference}", manifestReference);
return new OciArtifactPushResult
{
Success = true,
ManifestDigest = manifestDigest,
ManifestReference = manifestReference,
LayerDigests = layerDigests
{
Success = true,
AlreadyExists = false,
ManifestDigest = manifestDigest,
ManifestReference = manifestReference,
LayerDigests = layerDigests
};
}
catch (OciRegistryException ex)
@@ -113,6 +150,40 @@ public sealed class OciArtifactPusher
}
}
private async Task<string?> TryGetExistingManifestDigestAsync(
OciImageReference reference,
string tag,
OciRegistryAuthorization auth,
CancellationToken cancellationToken)
{
var manifestUri = BuildRegistryUri(reference, $"manifests/{tag}");
using var request = new HttpRequestMessage(HttpMethod.Head, manifestUri);
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue(OciMediaTypes.ImageManifest));
auth.ApplyTo(request);
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
if (response.StatusCode == HttpStatusCode.NotFound)
{
return null;
}
if (!response.IsSuccessStatusCode)
{
throw new OciRegistryException($"Manifest HEAD failed with {response.StatusCode}", "ERR_OCI_MANIFEST_HEAD");
}
if (response.Headers.TryGetValues("Docker-Content-Digest", out var digestValues))
{
var digest = digestValues.FirstOrDefault();
if (!string.IsNullOrWhiteSpace(digest))
{
return digest;
}
}
return string.Empty;
}
private OciArtifactManifest BuildManifest(
OciArtifactPushRequest request,
string configDigest,

View File

@@ -57,12 +57,15 @@ public sealed record OciArtifactPushRequest
public required string ArtifactType { get; init; }
public required IReadOnlyList<OciLayerContent> Layers { get; init; }
public string? SubjectDigest { get; init; }
public string? Tag { get; init; }
public bool SkipIfTagExists { get; init; } = true;
public IReadOnlyDictionary<string, string>? Annotations { get; init; }
}
public sealed record OciArtifactPushResult
{
public required bool Success { get; init; }
public bool AlreadyExists { get; init; }
public string? ManifestDigest { get; init; }
public string? ManifestReference { get; init; }
public IReadOnlyList<string>? LayerDigests { get; init; }

View File

@@ -6,3 +6,4 @@ Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_sol
| --- | --- | --- |
| REMED-05 | TODO | Remediation checklist: docs/implplan/audits/csproj-standards/remediation/checklists/src/Scanner/__Libraries/StellaOps.Scanner.Storage.Oci/StellaOps.Scanner.Storage.Oci.md. |
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
| SPRINT-20260208-060-IDEMP-001 | DONE | Implement idempotent verdict attestation submission (idempotency key + dedupe + retry classification + tests). |

View File

@@ -7,6 +7,7 @@
using StellaOps.Scanner.Storage.Oci.Diagnostics;
using StellaOps.Scanner.Evidence;
using System.Diagnostics;
using System.Globalization;
@@ -68,6 +69,11 @@ public sealed record VerdictOciPublishRequest
/// </summary>
public string? AttestationDigest { get; init; }
/// <summary>
/// Optional idempotency key. When omitted, computed from DSSE envelope bytes.
/// </summary>
public string? IdempotencyKey { get; init; }
/// <summary>
/// When the verdict was computed.
/// </summary>
@@ -90,7 +96,7 @@ public sealed record VerdictOciPublishRequest
/// Service for pushing risk verdict attestations as OCI referrer artifacts.
/// This enables verdicts to be portable "ship tokens" attached to container images.
/// </summary>
public sealed class VerdictOciPublisher
public sealed class VerdictOciPublisher : IVerdictOciPublisher
{
private readonly OciArtifactPusher _pusher;
@@ -128,13 +134,19 @@ public sealed class VerdictOciPublisher
try
{
var idempotencyKey = string.IsNullOrWhiteSpace(request.IdempotencyKey)
? AttestationIdempotencyKey.FromDsseEnvelope(request.DsseEnvelopeBytes)
: request.IdempotencyKey!;
var manifestTag = AttestationIdempotencyKey.ToOciTag(idempotencyKey);
var annotations = new Dictionary<string, string>(StringComparer.Ordinal)
{
[OciAnnotations.StellaPredicateType] = VerdictPredicateTypes.Verdict,
[OciAnnotations.StellaSbomDigest] = request.SbomDigest,
[OciAnnotations.StellaFeedsDigest] = request.FeedsDigest,
[OciAnnotations.StellaPolicyDigest] = request.PolicyDigest,
[OciAnnotations.StellaVerdictDecision] = request.Decision
[OciAnnotations.StellaVerdictDecision] = request.Decision,
[OciAnnotations.StellaIdempotencyKey] = idempotencyKey
};
if (!string.IsNullOrWhiteSpace(request.GraphRevisionId))
@@ -173,6 +185,8 @@ public sealed class VerdictOciPublisher
Reference = request.Reference,
ArtifactType = OciMediaTypes.VerdictAttestation,
SubjectDigest = request.ImageDigest,
Tag = manifestTag,
SkipIfTagExists = true,
Layers =
[
new OciLayerContent

View File

@@ -0,0 +1,35 @@
using FluentAssertions;
using Xunit;
using StellaOps.TestKit;
namespace StellaOps.Scanner.Evidence.Tests;
public sealed class AttestationIdempotencyKeyTests
{
[Trait("Category", TestCategories.Unit)]
[Fact]
public void FromDsseEnvelope_SamePayload_ReturnsSameKey()
{
var payload = "{\"payloadType\":\"verdict.stella/v1\",\"payload\":\"e30=\"}"u8.ToArray();
var key1 = AttestationIdempotencyKey.FromDsseEnvelope(payload);
var key2 = AttestationIdempotencyKey.FromDsseEnvelope(payload);
key1.Should().Be(key2);
key1.Should().StartWith("sha256:");
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void ToOciTag_ProducesStableSafeTag()
{
const string key = "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
var tag = AttestationIdempotencyKey.ToOciTag(key);
tag.Should().StartWith("verdict-");
tag.Should().NotContain(":");
tag.Length.Should().BeLessThanOrEqualTo(128);
}
}

View File

@@ -6,3 +6,4 @@ Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_sol
| --- | --- | --- |
| REMED-05 | TODO | Remediation checklist: docs/implplan/audits/csproj-standards/remediation/checklists/src/Scanner/__Tests/StellaOps.Scanner.Evidence.Tests/StellaOps.Scanner.Evidence.Tests.md. |
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
| SPRINT-20260208-060-IDEMP-001 | DONE | Implement idempotent verdict attestation submission (idempotency key + dedupe + retry classification + tests). |

View File

@@ -54,14 +54,65 @@ public sealed class OciArtifactPusherTests
Assert.True(annotations.TryGetProperty("org.opencontainers.image.created", out _));
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task PushAsync_ExistingTag_ReturnsAlreadyExistsWithoutManifestPut()
{
var handler = new TestRegistryHandler
{
ManifestAlreadyExists = true,
ExistingManifestDigest = "sha256:existingmanifestdigest"
};
var httpClient = new HttpClient(handler);
var pusher = new OciArtifactPusher(
httpClient,
CryptoHashFactory.CreateDefault(),
new OciRegistryOptions { DefaultRegistry = "registry.example" },
NullLogger<OciArtifactPusher>.Instance);
var request = new OciArtifactPushRequest
{
Reference = "registry.example/stellaops/delta:demo",
ArtifactType = OciMediaTypes.DeltaVerdictPredicate,
Tag = "verdict-fixed-tag",
Layers =
[
new OciLayerContent { Content = new byte[] { 0x01, 0x02 }, MediaType = OciMediaTypes.DsseEnvelope }
]
};
var result = await pusher.PushAsync(request);
Assert.True(result.Success);
Assert.True(result.AlreadyExists);
Assert.Equal("sha256:existingmanifestdigest", result.ManifestDigest);
Assert.Equal(0, handler.ManifestPutCount);
}
private sealed class TestRegistryHandler : HttpMessageHandler
{
public bool ManifestAlreadyExists { get; set; }
public string ExistingManifestDigest { get; set; } = "sha256:existing";
public int ManifestPutCount { get; private set; }
public byte[]? ManifestBytes { get; private set; }
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
var path = request.RequestUri?.AbsolutePath ?? string.Empty;
if (request.Method == HttpMethod.Head && path.Contains("/manifests/", StringComparison.Ordinal))
{
if (!ManifestAlreadyExists)
{
return new HttpResponseMessage(HttpStatusCode.NotFound);
}
var response = new HttpResponseMessage(HttpStatusCode.OK);
response.Headers.TryAddWithoutValidation("Docker-Content-Digest", ExistingManifestDigest);
return response;
}
if (request.Method == HttpMethod.Head && path.Contains("/blobs/", StringComparison.Ordinal))
{
return new HttpResponseMessage(HttpStatusCode.NotFound);
@@ -81,6 +132,7 @@ public sealed class OciArtifactPusherTests
if (request.Method == HttpMethod.Put && path.Contains("/manifests/", StringComparison.Ordinal))
{
ManifestPutCount++;
ManifestBytes = request.Content is null ? null : await request.Content.ReadAsByteArrayAsync(cancellationToken);
return new HttpResponseMessage(HttpStatusCode.Created);
}

View File

@@ -6,3 +6,4 @@ Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_sol
| --- | --- | --- |
| REMED-05 | TODO | Remediation checklist: docs/implplan/audits/csproj-standards/remediation/checklists/src/Scanner/__Tests/StellaOps.Scanner.Storage.Oci.Tests/StellaOps.Scanner.Storage.Oci.Tests.md. |
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
| SPRINT-20260208-060-IDEMP-001 | DONE | Implement idempotent verdict attestation submission (idempotency key + dedupe + retry classification + tests). |

View File

@@ -188,6 +188,41 @@ public sealed class VerdictOciPublisherTests
annotations.GetProperty(OciAnnotations.StellaGraphRevisionId).GetString());
Assert.Equal("sha256:proof_bundle_value",
annotations.GetProperty(OciAnnotations.StellaProofBundleDigest).GetString());
Assert.True(annotations.TryGetProperty(OciAnnotations.StellaIdempotencyKey, out var idempotencyKey));
Assert.StartsWith("sha256:", idempotencyKey.GetString());
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task PushAsync_UsesStableIdempotencyTag()
{
var handler = new TestRegistryHandler();
var httpClient = new HttpClient(handler);
var pusher = new OciArtifactPusher(
httpClient,
CryptoHashFactory.CreateDefault(),
new OciRegistryOptions { DefaultRegistry = "registry.example" },
NullLogger<OciArtifactPusher>.Instance);
var verdictPublisher = new VerdictOciPublisher(pusher);
var request = new VerdictOciPublishRequest
{
Reference = "registry.example/stellaops/app",
ImageDigest = "sha256:image123",
DsseEnvelopeBytes = "{\"payload\":\"aGVsbG8=\"}"u8.ToArray(),
SbomDigest = "sha256:sbom",
FeedsDigest = "sha256:feeds",
PolicyDigest = "sha256:policy",
Decision = "pass"
};
var result = await verdictPublisher.PushAsync(request);
Assert.True(result.Success);
Assert.NotNull(handler.ManifestPutPath);
Assert.Contains("/manifests/verdict-", handler.ManifestPutPath, StringComparison.Ordinal);
}
[Trait("Category", TestCategories.Unit)]
@@ -304,12 +339,18 @@ public sealed class VerdictOciPublisherTests
private sealed class TestRegistryHandler : HttpMessageHandler
{
public string? ManifestPutPath { get; private set; }
public byte[]? ManifestBytes { get; private set; }
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
var path = request.RequestUri?.AbsolutePath ?? string.Empty;
if (request.Method == HttpMethod.Head && path.Contains("/manifests/", StringComparison.Ordinal))
{
return new HttpResponseMessage(HttpStatusCode.NotFound);
}
if (request.Method == HttpMethod.Head && path.Contains("/blobs/", StringComparison.Ordinal))
{
return new HttpResponseMessage(HttpStatusCode.NotFound);
@@ -329,6 +370,7 @@ public sealed class VerdictOciPublisherTests
if (request.Method == HttpMethod.Put && path.Contains("/manifests/", StringComparison.Ordinal))
{
ManifestPutPath = path;
ManifestBytes = request.Content is null ? null : await request.Content.ReadAsByteArrayAsync(cancellationToken);
return new HttpResponseMessage(HttpStatusCode.Created);
}

View File

@@ -6,3 +6,4 @@ Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_sol
| --- | --- | --- |
| REMED-05 | TODO | Remediation checklist: docs/implplan/audits/csproj-standards/remediation/checklists/src/Scanner/__Tests/StellaOps.Scanner.Worker.Tests/StellaOps.Scanner.Worker.Tests.md. |
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
| SPRINT-20260208-060-IDEMP-001 | DONE | Implement idempotent verdict attestation submission (idempotency key + dedupe + retry classification + tests). |

View File

@@ -0,0 +1,179 @@
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using StellaOps.Scanner.Storage.Oci;
using StellaOps.Scanner.Worker.Options;
using StellaOps.Scanner.Worker.Processing;
using StellaOps.Scanner.Worker.Tests.TestInfrastructure;
using Xunit;
using StellaOps.TestKit;
namespace StellaOps.Scanner.Worker.Tests;
public sealed class VerdictPushStageExecutorTests
{
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task ExecuteAsync_PushSuccess_StoresManifestAndIdempotencyKey()
{
var publisher = new Mock<IVerdictOciPublisher>(MockBehavior.Strict);
publisher
.Setup(p => p.PushAsync(It.IsAny<VerdictOciPublishRequest>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new OciArtifactPushResult
{
Success = true,
ManifestDigest = "sha256:manifest-a",
ManifestReference = "registry.example/app@sha256:manifest-a",
LayerDigests = Array.Empty<string>()
});
var options = CreateOptions(maxRetries: 0);
var executor = new VerdictPushStageExecutor(
publisher.Object,
new StaticOptionsMonitor<ScannerWorkerOptions>(options),
NullLogger<VerdictPushStageExecutor>.Instance);
var context = CreateContext("job-a", "scan-a");
context.Analysis.Set(VerdictPushAnalysisKeys.VerdictDsseEnvelope, "{\"payload\":\"e30=\"}"u8.ToArray());
await executor.ExecuteAsync(context, TestContext.Current.CancellationToken);
Assert.True(context.Analysis.TryGet<string>(VerdictPushAnalysisKeys.VerdictManifestDigest, out var digest));
Assert.Equal("sha256:manifest-a", digest);
Assert.True(context.Analysis.TryGet<string>(VerdictPushAnalysisKeys.VerdictIdempotencyKey, out var idempotencyKey));
Assert.StartsWith("sha256:", idempotencyKey);
publisher.Verify(
p => p.PushAsync(
It.Is<VerdictOciPublishRequest>(r => !string.IsNullOrWhiteSpace(r.IdempotencyKey)),
It.IsAny<CancellationToken>()),
Times.Once);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task ExecuteAsync_TransientFailure_RetriesAndSucceeds()
{
var publisher = new Mock<IVerdictOciPublisher>(MockBehavior.Strict);
publisher
.SetupSequence(p => p.PushAsync(It.IsAny<VerdictOciPublishRequest>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(OciArtifactPushResult.Failed("503 Service Unavailable"))
.ReturnsAsync(new OciArtifactPushResult
{
Success = true,
ManifestDigest = "sha256:manifest-b",
ManifestReference = "registry.example/app@sha256:manifest-b",
LayerDigests = Array.Empty<string>()
});
var options = CreateOptions(maxRetries: 1);
var executor = new VerdictPushStageExecutor(
publisher.Object,
new StaticOptionsMonitor<ScannerWorkerOptions>(options),
NullLogger<VerdictPushStageExecutor>.Instance);
var context = CreateContext("job-b", "scan-b");
context.Analysis.Set(VerdictPushAnalysisKeys.VerdictDsseEnvelope, "{\"payload\":\"Zm9v\"}"u8.ToArray());
await executor.ExecuteAsync(context, TestContext.Current.CancellationToken);
Assert.True(context.Analysis.TryGet<string>(VerdictPushAnalysisKeys.VerdictManifestDigest, out var digest));
Assert.Equal("sha256:manifest-b", digest);
publisher.Verify(p => p.PushAsync(It.IsAny<VerdictOciPublishRequest>(), It.IsAny<CancellationToken>()), Times.Exactly(2));
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task ExecuteAsync_SameEnvelope_SecondCallUsesReceiptCache()
{
var publisher = new Mock<IVerdictOciPublisher>(MockBehavior.Strict);
publisher
.Setup(p => p.PushAsync(It.IsAny<VerdictOciPublishRequest>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new OciArtifactPushResult
{
Success = true,
ManifestDigest = "sha256:manifest-c",
ManifestReference = "registry.example/app@sha256:manifest-c",
LayerDigests = Array.Empty<string>()
});
var options = CreateOptions(maxRetries: 0);
var executor = new VerdictPushStageExecutor(
publisher.Object,
new StaticOptionsMonitor<ScannerWorkerOptions>(options),
NullLogger<VerdictPushStageExecutor>.Instance);
var envelope = "{\"payload\":\"YmFy\"}"u8.ToArray();
var first = CreateContext("job-c1", "scan-c1");
first.Analysis.Set(VerdictPushAnalysisKeys.VerdictDsseEnvelope, envelope);
await executor.ExecuteAsync(first, TestContext.Current.CancellationToken);
var second = CreateContext("job-c2", "scan-c2");
second.Analysis.Set(VerdictPushAnalysisKeys.VerdictDsseEnvelope, envelope);
await executor.ExecuteAsync(second, TestContext.Current.CancellationToken);
Assert.True(second.Analysis.TryGet<string>(VerdictPushAnalysisKeys.VerdictManifestDigest, out var digest));
Assert.Equal("sha256:manifest-c", digest);
publisher.Verify(p => p.PushAsync(It.IsAny<VerdictOciPublishRequest>(), It.IsAny<CancellationToken>()), Times.Once);
}
private static ScannerWorkerOptions CreateOptions(int maxRetries)
{
var options = new ScannerWorkerOptions();
options.VerdictPush.MaxRetries = maxRetries;
return options;
}
private static ScanJobContext CreateContext(string jobId, string scanId)
{
var metadata = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
[VerdictPushMetadataKeys.Enabled] = "true",
[VerdictPushMetadataKeys.RegistryReference] = "registry.example/team/app",
[VerdictPushMetadataKeys.Decision] = "pass",
["image.digest"] = "sha256:image"
};
var now = new DateTimeOffset(2026, 2, 8, 12, 0, 0, TimeSpan.Zero);
var timeProvider = new FixedTimeProvider(now);
var lease = new StubLease(jobId, scanId, now, metadata);
return new ScanJobContext(lease, timeProvider, now, TestContext.Current.CancellationToken);
}
private sealed class FixedTimeProvider : TimeProvider
{
private readonly DateTimeOffset _now;
public FixedTimeProvider(DateTimeOffset now) => _now = now;
public override DateTimeOffset GetUtcNow() => _now;
}
private sealed class StubLease : IScanJobLease
{
private readonly IReadOnlyDictionary<string, string> _metadata;
public StubLease(string jobId, string scanId, DateTimeOffset now, IReadOnlyDictionary<string, string> metadata)
{
JobId = jobId;
ScanId = scanId;
_metadata = metadata;
EnqueuedAtUtc = now.AddMinutes(-1);
LeasedAtUtc = now;
}
public string JobId { get; }
public string ScanId { get; }
public int Attempt { get; } = 1;
public DateTimeOffset EnqueuedAtUtc { get; }
public DateTimeOffset LeasedAtUtc { get; }
public TimeSpan LeaseDuration { get; } = TimeSpan.FromMinutes(5);
public IReadOnlyDictionary<string, string> Metadata => _metadata;
public ValueTask RenewAsync(CancellationToken cancellationToken) => ValueTask.CompletedTask;
public ValueTask CompleteAsync(CancellationToken cancellationToken) => ValueTask.CompletedTask;
public ValueTask AbandonAsync(string reason, CancellationToken cancellationToken) => ValueTask.CompletedTask;
public ValueTask PoisonAsync(string reason, CancellationToken cancellationToken) => ValueTask.CompletedTask;
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
}