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

@@ -19,6 +19,7 @@ namespace StellaOps.Attestor.Infrastructure.Rekor;
internal sealed class HttpRekorClient : IRekorClient
{
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web);
private static readonly string[] IndexLookupPaths = ["api/v2/index/retrieve", "api/v1/index/retrieve"];
private readonly HttpClient _httpClient;
private readonly ILogger<HttpRekorClient> _logger;
@@ -31,6 +32,16 @@ internal sealed class HttpRekorClient : IRekorClient
public async Task<RekorSubmissionResponse> SubmitAsync(AttestorSubmissionRequest request, RekorBackend backend, CancellationToken cancellationToken = default)
{
var existing = await TryGetExistingEntryAsync(request, backend, cancellationToken).ConfigureAwait(false);
if (existing is not null)
{
_logger.LogInformation(
"Rekor hash pre-check hit for bundle {BundleSha}; reusing existing entry {Uuid}",
request.Meta.BundleSha256,
existing.Uuid);
return existing;
}
var submissionUri = BuildUri(backend.Url, "api/v2/log/entries");
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, submissionUri)
@@ -65,10 +76,252 @@ internal sealed class HttpRekorClient : IRekorClient
Index = index,
LogUrl = root.TryGetProperty("logURL", out var urlElement) ? urlElement.GetString() ?? backend.Url.ToString() : backend.Url.ToString(),
Status = root.TryGetProperty("status", out var statusElement) ? statusElement.GetString() ?? "included" : "included",
Proof = TryParseProof(root.TryGetProperty("proof", out var proofElement) ? proofElement : default)
Proof = TryParseProof(root.TryGetProperty("proof", out var proofElement) ? proofElement : default),
IntegratedTime = root.TryGetProperty("integratedTime", out var integratedTimeElement) && integratedTimeElement.TryGetInt64(out var integratedTime)
? integratedTime
: null
};
}
private async Task<RekorSubmissionResponse?> TryGetExistingEntryAsync(
AttestorSubmissionRequest request,
RekorBackend backend,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(request.Meta.BundleSha256))
{
return null;
}
foreach (var path in IndexLookupPaths)
{
foreach (var hashCandidate in GetHashCandidates(request.Meta.BundleSha256))
{
var lookupUri = BuildUri(backend.Url, path);
try
{
using var lookupRequest = new HttpRequestMessage(HttpMethod.Post, lookupUri)
{
Content = JsonContent.Create(new { hash = hashCandidate }, options: SerializerOptions)
};
using var lookupResponse = await _httpClient.SendAsync(lookupRequest, cancellationToken).ConfigureAwait(false);
if (lookupResponse.StatusCode is HttpStatusCode.NotFound or HttpStatusCode.MethodNotAllowed)
{
break;
}
if (!lookupResponse.IsSuccessStatusCode)
{
_logger.LogDebug(
"Rekor hash pre-check returned {StatusCode} on {Path}",
lookupResponse.StatusCode,
path);
continue;
}
await using var stream = await lookupResponse.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
using var document = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken).ConfigureAwait(false);
var existingUuid = ExtractFirstUuid(document.RootElement);
if (string.IsNullOrWhiteSpace(existingUuid))
{
continue;
}
return await GetExistingEntryAsync(existingUuid, backend, cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
_logger.LogDebug(
ex,
"Rekor hash pre-check failed on {Path}; falling back to submit",
path);
}
}
}
return null;
}
private async Task<RekorSubmissionResponse> GetExistingEntryAsync(
string uuid,
RekorBackend backend,
CancellationToken cancellationToken)
{
var entryUri = BuildUri(backend.Url, $"api/v2/log/entries/{uuid}");
using var request = new HttpRequestMessage(HttpMethod.Get, entryUri);
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
return new RekorSubmissionResponse
{
Uuid = uuid,
LogUrl = backend.Url.ToString(),
Status = "included"
};
}
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
using var document = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken).ConfigureAwait(false);
var root = document.RootElement;
return new RekorSubmissionResponse
{
Uuid = TryGetString(root, "uuid") ?? uuid,
Index = TryGetLong(root, "logIndex") ?? TryGetLong(root, "index"),
LogUrl = TryGetString(root, "logURL") ?? backend.Url.ToString(),
Status = TryGetString(root, "status") ?? "included",
Proof = TryParseProof(root.TryGetProperty("proof", out var proofElement) ? proofElement : default),
IntegratedTime = TryGetLong(root, "integratedTime")
};
}
private static string[] GetHashCandidates(string bundleSha256)
{
var normalized = bundleSha256.Trim().ToLowerInvariant();
if (normalized.StartsWith("sha256:", StringComparison.Ordinal))
{
normalized = normalized["sha256:".Length..];
}
return [$"sha256:{normalized}", normalized];
}
private static string? ExtractFirstUuid(JsonElement element)
{
switch (element.ValueKind)
{
case JsonValueKind.String:
{
var value = element.GetString();
return LooksLikeUuid(value) ? value : null;
}
case JsonValueKind.Array:
foreach (var item in element.EnumerateArray())
{
var match = ExtractFirstUuid(item);
if (!string.IsNullOrWhiteSpace(match))
{
return match;
}
}
return null;
case JsonValueKind.Object:
foreach (var property in element.EnumerateObject())
{
if (property.Value.ValueKind == JsonValueKind.String &&
string.Equals(property.Name, "uuid", StringComparison.OrdinalIgnoreCase))
{
var direct = property.Value.GetString();
if (LooksLikeUuid(direct))
{
return direct;
}
}
if (LooksLikeUuid(property.Name))
{
return property.Name;
}
var nested = ExtractFirstUuid(property.Value);
if (!string.IsNullOrWhiteSpace(nested))
{
return nested;
}
}
return null;
default:
return null;
}
}
private static bool LooksLikeUuid(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return false;
}
var trimmed = value.Trim();
if (trimmed.StartsWith("sha256:", StringComparison.OrdinalIgnoreCase))
{
return false;
}
if (Guid.TryParse(trimmed, out _))
{
return true;
}
return trimmed.Length == 64 && trimmed.All(static ch => Uri.IsHexDigit(ch));
}
private static string? TryGetString(JsonElement element, string propertyName)
{
if (element.ValueKind != JsonValueKind.Object)
{
return null;
}
foreach (var property in element.EnumerateObject())
{
if (string.Equals(property.Name, propertyName, StringComparison.OrdinalIgnoreCase))
{
return property.Value.ValueKind == JsonValueKind.String ? property.Value.GetString() : null;
}
if (property.Value.ValueKind == JsonValueKind.Object)
{
var nested = TryGetString(property.Value, propertyName);
if (!string.IsNullOrWhiteSpace(nested))
{
return nested;
}
}
}
return null;
}
private static long? TryGetLong(JsonElement element, string propertyName)
{
if (element.ValueKind != JsonValueKind.Object)
{
return null;
}
foreach (var property in element.EnumerateObject())
{
if (string.Equals(property.Name, propertyName, StringComparison.OrdinalIgnoreCase) &&
property.Value.TryGetInt64(out var value))
{
return value;
}
if (property.Value.ValueKind == JsonValueKind.Object)
{
var nested = TryGetLong(property.Value, propertyName);
if (nested.HasValue)
{
return nested;
}
}
}
return null;
}
public async Task<RekorProofResponse?> GetProofAsync(string rekorUuid, RekorBackend backend, CancellationToken cancellationToken = default)
{
var proofUri = BuildUri(backend.Url, $"api/v2/log/entries/{rekorUuid}/proof");

View File

@@ -9,3 +9,4 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
| AUDIT-0055-T | DONE | Revalidated 2026-01-06. |
| AUDIT-0055-A | DONE | Applied determinism, backend resolver, and Rekor client fixes 2026-01-08. |
| VAL-SMOKE-001 | DONE | Fixed continuation token behavior; unit tests pass. |
| SPRINT-20260208-060-IDEMP-001 | DONE | Added Rekor search-by-hash pre-check before submission with existing-entry reuse and deterministic fallback behavior (2026-02-08). |

View File

@@ -16,6 +16,7 @@ using Org.BouncyCastle.Crypto.Signers;
using Org.BouncyCastle.Math;
using Org.BouncyCastle.X509;
using StellaOps.Attestor.Core.Rekor;
using StellaOps.Attestor.Core.Submission;
using StellaOps.Attestor.Core.Verification;
using StellaOps.Attestor.Infrastructure.Rekor;
using StellaOps.TestKit;
@@ -25,6 +26,40 @@ namespace StellaOps.Attestor.Infrastructure.Tests;
public sealed class HttpRekorClientTests
{
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task SubmitAsync_HashPrecheckHit_ReusesExistingEntryWithoutNewSubmission()
{
var handler = new HashPrecheckHitHandler();
var client = CreateClient(handler);
var backend = CreateBackend();
var response = await client.SubmitAsync(CreateSubmissionRequest(), backend, CancellationToken.None);
response.Uuid.Should().Be("11111111-1111-1111-1111-111111111111");
response.Index.Should().Be(777);
response.Status.Should().Be("included");
handler.SubmitCalls.Should().Be(0);
handler.IndexLookupCalls.Should().BeGreaterThan(0);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task SubmitAsync_HashPrecheckUnsupported_FallsBackToSubmission()
{
var handler = new HashPrecheckUnsupportedHandler();
var client = CreateClient(handler);
var backend = CreateBackend();
var response = await client.SubmitAsync(CreateSubmissionRequest(), backend, CancellationToken.None);
response.Uuid.Should().Be("new-uuid");
response.Index.Should().Be(42);
response.Status.Should().Be("included");
handler.SubmitCalls.Should().Be(1);
handler.IndexLookupCalls.Should().BeGreaterThan(0);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task VerifyInclusionAsync_MissingLogIndex_ReturnsFailure()
@@ -201,6 +236,40 @@ public sealed class HttpRekorClientTests
return new HttpRekorClient(httpClient, NullLogger<HttpRekorClient>.Instance);
}
private static AttestorSubmissionRequest CreateSubmissionRequest()
{
return new AttestorSubmissionRequest
{
Bundle = new AttestorSubmissionRequest.SubmissionBundle
{
Dsse = new AttestorSubmissionRequest.DsseEnvelope
{
PayloadType = "application/vnd.in-toto+json",
PayloadBase64 = Convert.ToBase64String(Encoding.UTF8.GetBytes("{}")),
Signatures =
{
new AttestorSubmissionRequest.DsseSignature
{
KeyId = "test-key",
Signature = "sig"
}
}
}
},
Meta = new AttestorSubmissionRequest.SubmissionMeta
{
BundleSha256 = new string('a', 64),
Artifact = new AttestorSubmissionRequest.ArtifactInfo
{
Sha256 = new string('b', 64),
Kind = "sbom"
},
LogPreference = "primary",
Archive = false
}
};
}
private static RekorBackend CreateBackend()
{
return new RekorBackend
@@ -308,6 +377,63 @@ public sealed class HttpRekorClientTests
}
}
private sealed class HashPrecheckHitHandler : HttpMessageHandler
{
public int IndexLookupCalls { get; private set; }
public int SubmitCalls { get; private set; }
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
var path = request.RequestUri?.AbsolutePath ?? string.Empty;
if (request.Method == HttpMethod.Post && path.EndsWith("/api/v2/index/retrieve", StringComparison.Ordinal))
{
IndexLookupCalls++;
return Task.FromResult(BuildResponse("""["11111111-1111-1111-1111-111111111111"]"""));
}
if (request.Method == HttpMethod.Get && path.EndsWith("/api/v2/log/entries/11111111-1111-1111-1111-111111111111", StringComparison.Ordinal))
{
return Task.FromResult(BuildResponse("""{"uuid":"11111111-1111-1111-1111-111111111111","logIndex":777,"status":"included","integratedTime":1735689600}"""));
}
if (request.Method == HttpMethod.Post && path.EndsWith("/api/v2/log/entries", StringComparison.Ordinal))
{
SubmitCalls++;
return Task.FromResult(BuildResponse("""{"uuid":"should-not-submit","index":1,"status":"included"}"""));
}
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound));
}
}
private sealed class HashPrecheckUnsupportedHandler : HttpMessageHandler
{
public int IndexLookupCalls { get; private set; }
public int SubmitCalls { get; private set; }
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
var path = request.RequestUri?.AbsolutePath ?? string.Empty;
if (request.Method == HttpMethod.Post &&
(path.EndsWith("/api/v2/index/retrieve", StringComparison.Ordinal) ||
path.EndsWith("/api/v1/index/retrieve", StringComparison.Ordinal)))
{
IndexLookupCalls++;
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound));
}
if (request.Method == HttpMethod.Post && path.EndsWith("/api/v2/log/entries", StringComparison.Ordinal))
{
SubmitCalls++;
return Task.FromResult(BuildResponse("""{"uuid":"new-uuid","index":42,"status":"included","integratedTime":1735689600}"""));
}
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound));
}
}
private sealed class ProofOnlyHandler : HttpMessageHandler
{
private readonly string _proofJson;

View File

@@ -12,3 +12,4 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
| VAL-SMOKE-001 | DONE | Removed xUnit v2 references and verified unit tests pass. |
| AUDIT-0208-T | DONE | Revalidated 2026-01-08 (raw string + xUnit1051 fixes). |
| AUDIT-0208-A | DONE | Applied fixes 2026-01-08 (raw string + xUnit1051 fixes). |
| SPRINT-20260208-060-IDEMP-001 | DONE | Added deterministic unit coverage for Rekor hash pre-check and submission fallback behavior; `HttpRekorClientTests` passing (2026-02-08). |

View File

@@ -65,6 +65,10 @@ public sealed class EnvironmentAuthoritySettings
[JsonPropertyName("redirectUri")]
public required string RedirectUri { get; init; }
[JsonPropertyName("silentRefreshRedirectUri")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? SilentRefreshRedirectUri { get; init; }
[JsonPropertyName("postLogoutRedirectUri")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? PostLogoutRedirectUri { get; init; }

View File

@@ -74,6 +74,7 @@ public static class EnvironmentSettingsEndpoints
?? $"{platform.Authority.Issuer}/connect/token",
LogoutEndpoint = env.LogoutEndpoint,
RedirectUri = env.RedirectUri,
SilentRefreshRedirectUri = env.SilentRefreshRedirectUri,
PostLogoutRedirectUri = env.PostLogoutRedirectUri,
Scope = env.Scope,
Audience = env.Audience

View File

@@ -175,6 +175,7 @@ public sealed class PlatformEnvironmentSettingsOptions
public string? TokenEndpoint { get; set; }
public string? LogoutEndpoint { get; set; }
public string RedirectUri { get; set; } = string.Empty;
public string? SilentRefreshRedirectUri { get; set; }
public string? PostLogoutRedirectUri { get; set; }
public string Scope { get; set; } = "openid profile email ui.read authority:tenants.read graph:read sbom:read scanner:read policy:read policy:simulate policy:author policy:review policy:approve orch:read analytics.read advisory:read vex:read exceptions:read exceptions:approve aoc:verify findings:read release:read scheduler:read";
public string? Audience { get; set; }

View File

@@ -43,6 +43,7 @@ public sealed class EnvironmentSettingsComposer
TokenEndpoint = source.TokenEndpoint,
LogoutEndpoint = source.LogoutEndpoint,
RedirectUri = source.RedirectUri,
SilentRefreshRedirectUri = source.SilentRefreshRedirectUri,
PostLogoutRedirectUri = source.PostLogoutRedirectUri,
Scope = source.Scope,
Audience = source.Audience,
@@ -90,6 +91,8 @@ public sealed class EnvironmentSettingsComposer
options.LogoutEndpoint = value;
else if (string.Equals(key, "RedirectUri", StringComparison.OrdinalIgnoreCase))
options.RedirectUri = value;
else if (string.Equals(key, "SilentRefreshRedirectUri", StringComparison.OrdinalIgnoreCase))
options.SilentRefreshRedirectUri = value;
else if (string.Equals(key, "PostLogoutRedirectUri", StringComparison.OrdinalIgnoreCase))
options.PostLogoutRedirectUri = value;
else if (string.Equals(key, "Scope", StringComparison.OrdinalIgnoreCase))

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

View File

@@ -15,6 +15,7 @@ import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
class AuthorityAuthServiceStub {
beginLogin = jasmine.createSpy('beginLogin');
logout = jasmine.createSpy('logout');
trySilentRefresh = jasmine.createSpy('trySilentRefresh').and.returnValue(Promise.resolve(false));
}
describe('AppComponent', () => {

View File

@@ -73,6 +73,10 @@ export class AppComponent {
// Initialize branding on app start
this.brandingService.fetchBranding().subscribe();
// Attempt to silently restore the auth session if the user was
// previously logged in (session cookie may still be active at the Authority).
void this.auth.trySilentRefresh();
// Initialize legacy route telemetry tracking (ROUTE-002)
this.legacyRouteTelemetry.initialize();
}

View File

@@ -1,7 +1,8 @@
import { HTTP_INTERCEPTORS, provideHttpClient, withInterceptorsFromDi } from '@angular/common/http';
import { ApplicationConfig, inject, provideAppInitializer } from '@angular/core';
import { provideAnimationsAsync } from '@angular/platform-browser/animations/async';
import { provideRouter } from '@angular/router';
import { provideRouter, TitleStrategy } from '@angular/router';
import { PageTitleStrategy } from './core/navigation/page-title.strategy';
import { routes } from './app.routes';
import { CONCELIER_EXPORTER_API_BASE_URL } from './core/api/concelier-exporter.client';
@@ -145,6 +146,7 @@ export const appConfig: ApplicationConfig = {
providers: [
provideRouter(routes),
provideAnimationsAsync(),
{ provide: TitleStrategy, useClass: PageTitleStrategy },
provideHttpClient(withInterceptorsFromDi()),
provideAppInitializer(() => {
const initializerFn = ((configService: AppConfigService, probeService: BackendProbeService) => async () => {

View File

@@ -26,6 +26,7 @@ export const routes: Routes = [
{
path: '',
pathMatch: 'full',
title: 'Control Plane',
canMatch: [requireConfigGuard, requireBackendsReachableGuard, () => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
loadChildren: () =>
import('./features/control-plane/control-plane.routes').then(
@@ -36,6 +37,7 @@ export const routes: Routes = [
// Approvals - promotion decision cockpit
{
path: 'approvals',
title: 'Approvals',
canMatch: [requireConfigGuard, requireBackendsReachableGuard, () => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
loadChildren: () =>
import('./features/approvals/approvals.routes').then(
@@ -46,6 +48,7 @@ export const routes: Routes = [
// Security - consolidated security analysis (SEC-005, SEC-006)
{
path: 'security',
title: 'Security Overview',
canMatch: [requireConfigGuard, requireBackendsReachableGuard, () => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
loadChildren: () =>
import('./features/security/security.routes').then(
@@ -56,6 +59,7 @@ export const routes: Routes = [
// Analytics - SBOM and attestation insights (SPRINT_20260120_031)
{
path: 'analytics',
title: 'Analytics',
canMatch: [requireConfigGuard, requireBackendsReachableGuard, requireAnalyticsViewerGuard],
loadChildren: () =>
import('./features/analytics/analytics.routes').then(
@@ -66,6 +70,7 @@ export const routes: Routes = [
// Policy - governance and exceptions (SEC-007)
{
path: 'policy',
title: 'Policy',
canMatch: [requireConfigGuard, requireBackendsReachableGuard, () => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
loadChildren: () =>
import('./features/policy/policy.routes').then(
@@ -76,6 +81,7 @@ export const routes: Routes = [
// Settings - consolidated configuration (SPRINT_20260118_002)
{
path: 'settings',
title: 'Settings',
canMatch: [requireConfigGuard, requireBackendsReachableGuard, () => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
loadChildren: () =>
import('./features/settings/settings.routes').then(
@@ -453,6 +459,13 @@ export const routes: Routes = [
(m) => m.AuthCallbackComponent
),
},
{
path: 'auth/silent-refresh',
loadComponent: () =>
import('./features/auth/silent-refresh.component').then(
(m) => m.SilentRefreshComponent
),
},
// Exceptions route
{
path: 'exceptions',

View File

@@ -55,8 +55,11 @@ interface AccessTokenMetadata {
providedIn: 'root',
})
export class AuthorityAuthService {
private static readonly SILENT_REFRESH_TIMEOUT_MS = 10_000;
private refreshTimer: ReturnType<typeof setTimeout> | null = null;
private refreshInFlight: Promise<void> | null = null;
private silentRefreshInFlight: Promise<boolean> | null = null;
private lastError: AuthErrorReason | null = null;
constructor(
@@ -101,6 +104,120 @@ export class AuthorityAuthService {
window.location.assign(authorizeUrl);
}
/**
* Attempts to silently restore the session after a page reload by
* performing an OAuth2 authorize request with `prompt=none` in a
* hidden iframe. If the Authority still has an active session cookie
* the iframe will redirect back with a new authorization code.
*
* @returns `true` if session was restored, `false` otherwise.
*/
async trySilentRefresh(): Promise<boolean> {
// Already authenticated — nothing to do.
if (this.sessionStore.session()) {
return true;
}
// No persisted metadata — user was never logged in.
const metadata = this.sessionStore.subjectHint();
if (!metadata) {
return false;
}
// Deduplicate concurrent calls.
if (this.silentRefreshInFlight) {
return this.silentRefreshInFlight;
}
this.sessionStore.setStatus('loading');
this.silentRefreshInFlight = this.executeSilentRefresh()
.finally(() => {
this.silentRefreshInFlight = null;
});
return this.silentRefreshInFlight;
}
private async executeSilentRefresh(): Promise<boolean> {
const authority = this.config.authority;
const silentRedirectUri = this.resolveSilentRefreshRedirectUri(authority);
const pkce = await createPkcePair();
const state = crypto.randomUUID ? crypto.randomUUID() : createRandomId();
const nonce = crypto.randomUUID ? crypto.randomUUID() : createRandomId();
// Ensure DPoP key pair is available (persisted in IndexedDB).
await this.dpop.getThumbprint();
this.storage.savePendingLogin({
state,
codeVerifier: pkce.verifier,
createdAtEpochMs: Date.now(),
nonce,
});
const authorizeUrl = this.buildAuthorizeUrl(authority, {
state,
nonce,
codeChallenge: pkce.challenge,
codeChallengeMethod: pkce.method,
});
// Append prompt=none and override redirect_uri for the iframe.
const url = new URL(authorizeUrl);
url.searchParams.set('prompt', 'none');
url.searchParams.set('redirect_uri', new URL(silentRedirectUri, window.location.origin).toString());
return new Promise<boolean>((resolve) => {
const iframe = document.createElement('iframe');
iframe.style.display = 'none';
iframe.setAttribute('aria-hidden', 'true');
let settled = false;
const cleanup = () => {
if (settled) return;
settled = true;
window.removeEventListener('message', onMessage);
clearTimeout(timer);
iframe.remove();
};
const onMessage = (event: MessageEvent) => {
if (event.origin !== window.location.origin) return;
const data = event.data;
if (!data || typeof data.type !== 'string') return;
if (data.type === 'silent-refresh-success') {
cleanup();
resolve(true);
} else if (data.type === 'silent-refresh-error') {
cleanup();
this.sessionStore.setStatus('unauthenticated');
resolve(false);
}
};
const timer = setTimeout(() => {
cleanup();
this.sessionStore.setStatus('unauthenticated');
resolve(false);
}, AuthorityAuthService.SILENT_REFRESH_TIMEOUT_MS);
window.addEventListener('message', onMessage);
document.body.appendChild(iframe);
iframe.src = url.toString();
});
}
private resolveSilentRefreshRedirectUri(authority: AuthorityConfig): string {
if (authority.silentRefreshRedirectUri) {
return authority.silentRefreshRedirectUri;
}
// Default: derive from redirectUri by replacing the last path segment.
const base = authority.redirectUri;
const lastSlash = base.lastIndexOf('/');
return (lastSlash >= 0 ? base.substring(0, lastSlash) : '') + '/silent-refresh';
}
/**
* Completes the authorization code flow after the Authority redirects back with ?code & ?state.
*/

View File

@@ -18,6 +18,7 @@ export interface AuthorityConfig {
readonly tokenEndpoint: string;
readonly logoutEndpoint?: string;
readonly redirectUri: string;
readonly silentRefreshRedirectUri?: string;
readonly postLogoutRedirectUri?: string;
readonly scope: string;
readonly audience: string;

View File

@@ -0,0 +1,64 @@
import { Injectable, inject } from '@angular/core';
import { Title } from '@angular/platform-browser';
import { RouterStateSnapshot, TitleStrategy } from '@angular/router';
import { BrandingService } from '../branding/branding.service';
const APP_NAME = 'Stella Ops';
/** Path segments that should be excluded from the generated title. */
const NOISE_SEGMENTS = new Set(['', 'admin', 'ops', 'console', 'api', 'v1']);
/**
* Custom TitleStrategy that builds page titles from route data.
*
* Resolution order:
* 1. Route `title` property (set explicitly in route config)
* 2. Auto-generated from the URL path segments
*
* All titles are prefixed with the branding title (default: "Stella Ops").
*/
@Injectable()
export class PageTitleStrategy extends TitleStrategy {
private readonly title = inject(Title);
private readonly branding = inject(BrandingService);
override updateTitle(snapshot: RouterStateSnapshot): void {
const routeTitle = this.buildTitle(snapshot);
const prefix = this.branding.getTitle() || APP_NAME;
if (routeTitle) {
this.title.setTitle(`${routeTitle} - ${prefix}`);
} else {
const generated = this.generateTitleFromUrl(snapshot.url);
if (generated) {
this.title.setTitle(`${generated} - ${prefix}`);
} else {
this.title.setTitle(prefix);
}
}
}
private generateTitleFromUrl(url: string): string | null {
const path = url.split('?')[0].split('#')[0];
const segments = path
.split('/')
.filter((s) => s && !NOISE_SEGMENTS.has(s))
// Skip dynamic segments (UUIDs, IDs)
.filter((s) => !/^[0-9a-f-]{8,}$/i.test(s) && !/^\d+$/.test(s));
if (segments.length === 0) {
return null;
}
// Take the last meaningful segment and humanize it
const raw = segments[segments.length - 1];
return this.humanize(raw);
}
private humanize(segment: string): string {
return segment
.replace(/[-_]/g, ' ')
.replace(/\b\w/g, (c) => c.toUpperCase());
}
}

View File

@@ -0,0 +1,76 @@
/**
* Silent Refresh Component
*
* Loaded inside a hidden iframe during silent token renewal.
* Receives the authorization code from the Authority redirect,
* completes the token exchange, and posts the result back to
* the parent window via postMessage.
*/
import { Component, OnInit, inject } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { AuthorityAuthService } from '../../core/auth/authority-auth.service';
/** Message posted to the parent window on success. */
export interface SilentRefreshSuccess {
readonly type: 'silent-refresh-success';
}
/** Message posted to the parent window on failure. */
export interface SilentRefreshError {
readonly type: 'silent-refresh-error';
readonly error: string;
}
export type SilentRefreshMessage = SilentRefreshSuccess | SilentRefreshError;
@Component({
selector: 'app-silent-refresh',
standalone: true,
template: '',
})
export class SilentRefreshComponent implements OnInit {
private readonly route = inject(ActivatedRoute);
private readonly auth = inject(AuthorityAuthService);
async ngOnInit(): Promise<void> {
// If not in an iframe, do nothing (prevent accidental direct navigation).
if (window.parent === window) {
return;
}
const params = this.route.snapshot.queryParamMap;
// Authority may return an error (e.g. login_required, consent_required).
const error = params.get('error');
if (error) {
this.postToParent({
type: 'silent-refresh-error',
error,
});
return;
}
const searchParams = new URLSearchParams();
params.keys.forEach((key) => {
const value = params.get(key);
if (value != null) {
searchParams.set(key, value);
}
});
try {
await this.auth.completeLoginFromRedirect(searchParams);
this.postToParent({ type: 'silent-refresh-success' });
} catch (err) {
this.postToParent({
type: 'silent-refresh-error',
error: err instanceof Error ? err.message : 'unknown',
});
}
}
private postToParent(message: SilentRefreshMessage): void {
window.parent.postMessage(message, window.location.origin);
}
}

View File

@@ -6,6 +6,7 @@
"tokenEndpoint": "/authority/connect/token",
"logoutEndpoint": "/authority/connect/logout",
"redirectUri": "/auth/callback",
"silentRefreshRedirectUri": "/auth/silent-refresh",
"postLogoutRedirectUri": "/",
"scope": "openid profile email ui.read authority:tenants.read graph:read sbom:read scanner:read policy:read policy:simulate policy:author policy:review policy:approve orch:read analytics.read advisory:read vex:read exceptions:read exceptions:approve aoc:verify findings:read release:read scheduler:read vuln:view vuln:investigate vuln:operate vuln:audit",
"audience": "/scanner",