feat: Implement approvals workflow and notifications integration
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled

- Added approvals orchestration with persistence and workflow scaffolding.
- Integrated notifications insights and staged resume hooks.
- Introduced approval coordinator and policy notification bridge with unit tests.
- Added approval decision API with resume requeue and persisted plan snapshots.
- Documented the Excitor consensus API beta and provided JSON sample payload.
- Created analyzers to flag usage of deprecated merge service APIs.
- Implemented logging for artifact uploads and approval decision service.
- Added tests for PackRunApprovalDecisionService and related components.
This commit is contained in:
master
2025-11-06 08:48:13 +02:00
parent 21a2759412
commit dd217b4546
98 changed files with 3883 additions and 2381 deletions

View File

@@ -84,10 +84,13 @@ internal sealed class WorkerSignatureVerifier : IVexSignatureVerifier
throw new ExcititorAocGuardException(AocGuardResult.FromViolations(new[] { violation }));
}
VexSignatureMetadata? signatureMetadata = null;
if (document.Format == VexDocumentFormat.OciAttestation && _attestationVerifier is not null)
VexSignatureMetadata? signatureMetadata = null;
VexAttestationDiagnostics? attestationDiagnostics = null;
if (document.Format == VexDocumentFormat.OciAttestation && _attestationVerifier is not null)
{
signatureMetadata = await VerifyAttestationAsync(document, metadata, cancellationToken).ConfigureAwait(false);
var attestationResult = await VerifyAttestationAsync(document, metadata, cancellationToken).ConfigureAwait(false);
signatureMetadata = attestationResult.Metadata;
attestationDiagnostics = attestationResult.Diagnostics;
}
signatureMetadata ??= ExtractSignatureMetadata(metadata);
@@ -96,31 +99,40 @@ internal sealed class WorkerSignatureVerifier : IVexSignatureVerifier
signatureMetadata = await AttachIssuerTrustAsync(signatureMetadata, metadata, cancellationToken).ConfigureAwait(false);
}
var resultLabel = signatureMetadata is null ? "skipped" : "ok";
RecordVerification(document.ProviderId, metadata, resultLabel);
if (resultLabel == "skipped")
{
if (attestationDiagnostics is not null)
{
resultLabel = attestationDiagnostics.Result ?? resultLabel;
}
if (attestationDiagnostics is null)
{
RecordVerification(document.ProviderId, metadata, resultLabel);
}
if (resultLabel == "skipped")
{
_logger.LogDebug(
"Signature verification skipped for provider {ProviderId} (no signature metadata).",
document.ProviderId);
}
else
{
_logger.LogInformation(
"Signature metadata recorded for provider {ProviderId} (type={SignatureType}, subject={Subject}, issuer={Issuer}).",
document.ProviderId,
signatureMetadata!.Type,
signatureMetadata.Subject ?? "<unknown>",
signatureMetadata.Issuer ?? "<unknown>");
}
return signatureMetadata;
}
private async ValueTask<VexSignatureMetadata?> VerifyAttestationAsync(
VexRawDocument document,
ImmutableDictionary<string, string> metadata,
CancellationToken cancellationToken)
_logger.LogInformation(
"Signature metadata recorded for provider {ProviderId} (type={SignatureType}, subject={Subject}, issuer={Issuer}, result={Result}).",
document.ProviderId,
signatureMetadata!.Type,
signatureMetadata.Subject ?? "<unknown>",
signatureMetadata.Issuer ?? "<unknown>",
resultLabel);
}
return signatureMetadata;
}
private async ValueTask<(VexSignatureMetadata Metadata, VexAttestationDiagnostics Diagnostics)> VerifyAttestationAsync(
VexRawDocument document,
ImmutableDictionary<string, string> metadata,
CancellationToken cancellationToken)
{
try
{
@@ -146,37 +158,48 @@ internal sealed class WorkerSignatureVerifier : IVexSignatureVerifier
attestationMetadata,
envelopeJson);
var verification = await _attestationVerifier!
.VerifyAsync(verificationRequest, cancellationToken)
.ConfigureAwait(false);
if (!verification.IsValid)
{
var diagnostics = string.Join(", ", verification.Diagnostics.Select(kvp => $"{kvp.Key}={kvp.Value}"));
_logger.LogError(
"Attestation verification failed for provider {ProviderId} (uri={SourceUri}) diagnostics={Diagnostics}",
document.ProviderId,
document.SourceUri,
diagnostics);
var violation = AocViolation.Create(
AocViolationCode.SignatureInvalid,
"/upstream/signature",
"Attestation verification failed.");
RecordVerification(document.ProviderId, metadata, "fail");
throw new ExcititorAocGuardException(AocGuardResult.FromViolations(new[] { violation }));
}
_logger.LogInformation(
"Attestation verification succeeded for provider {ProviderId} (predicate={PredicateType}, subject={Subject}).",
document.ProviderId,
attestationMetadata.PredicateType,
statement.Subject[0].Name ?? "<unknown>");
return BuildSignatureMetadata(statement, metadata, attestationMetadata, verification.Diagnostics);
}
catch (ExcititorAocGuardException)
{
var verification = await _attestationVerifier!
.VerifyAsync(verificationRequest, cancellationToken)
.ConfigureAwait(false);
var diagnosticsSnapshot = verification.Diagnostics;
if (!verification.IsValid)
{
var failureReason = diagnosticsSnapshot.FailureReason ?? "verification_failed";
var resultTag = diagnosticsSnapshot.Result ?? "invalid";
RecordVerification(document.ProviderId, metadata, resultTag);
_logger.LogError(
"Attestation verification failed for provider {ProviderId} (uri={SourceUri}) result={Result} failure={FailureReason} diagnostics={@Diagnostics}",
document.ProviderId,
document.SourceUri,
resultTag,
failureReason,
diagnosticsSnapshot);
var violation = AocViolation.Create(
AocViolationCode.SignatureInvalid,
"/upstream/signature",
"Attestation verification failed.");
throw new ExcititorAocGuardException(AocGuardResult.FromViolations(new[] { violation }));
}
var successResult = diagnosticsSnapshot.Result ?? "valid";
RecordVerification(document.ProviderId, metadata, successResult);
_logger.LogInformation(
"Attestation verification succeeded for provider {ProviderId} (predicate={PredicateType}, subject={Subject}, result={Result}).",
document.ProviderId,
attestationMetadata.PredicateType,
statement.Subject[0].Name ?? "<unknown>",
successResult);
var signatureMetadata = BuildSignatureMetadata(statement, metadata, attestationMetadata, diagnosticsSnapshot);
return (signatureMetadata, diagnosticsSnapshot);
}
catch (ExcititorAocGuardException)
{
throw;
}
catch (Exception ex)
@@ -192,10 +215,10 @@ internal sealed class WorkerSignatureVerifier : IVexSignatureVerifier
"/upstream/signature",
$"Attestation verification encountered an error: {ex.Message}");
RecordVerification(document.ProviderId, metadata, "fail");
throw new ExcititorAocGuardException(AocGuardResult.FromViolations(new[] { violation }));
}
}
RecordVerification(document.ProviderId, metadata, "error");
throw new ExcititorAocGuardException(AocGuardResult.FromViolations(new[] { violation }));
}
}
private VexAttestationRequest BuildAttestationRequest(VexInTotoStatement statement, VexAttestationPredicate predicate)
{
@@ -252,11 +275,11 @@ internal sealed class WorkerSignatureVerifier : IVexSignatureVerifier
signedAt);
}
private VexSignatureMetadata BuildSignatureMetadata(
VexInTotoStatement statement,
ImmutableDictionary<string, string> metadata,
VexAttestationMetadata attestationMetadata,
ImmutableDictionary<string, string> diagnostics)
private VexSignatureMetadata BuildSignatureMetadata(
VexInTotoStatement statement,
ImmutableDictionary<string, string> metadata,
VexAttestationMetadata attestationMetadata,
VexAttestationDiagnostics diagnostics)
{
metadata.TryGetValue("vex.signature.type", out var type);
metadata.TryGetValue("vex.provenance.cosign.subject", out var subject);

View File

@@ -13,16 +13,18 @@
3. Emit observability signals (logs, metrics, optional tracing) that can run offline and degrade gracefully when transparency services are unreachable.
4. Add regression tests (unit + integration) covering positive path, negative path, and offline fallback scenarios.
## 2. Deliverables
- `IVexAttestationVerifier` abstraction + `VexAttestationVerifier` implementation inside `StellaOps.Excititor.Attestation`, encapsulating DSSE validation, predicate checks, artifact digest confirmation, Rekor inclusion verification, and deterministic diagnostics.
- DI wiring (extension method) for registering verifier + instrumentation dependencies alongside the existing signer/rekor client.
- Shared `VexAttestationDiagnostics` record describing normalized diagnostic keys consumed by Worker/WebService logging.
- Metrics utility (`AttestationMetrics`) exposing counters/histograms via `System.Diagnostics.Metrics`, exported under `StellaOps.Excititor.Attestation` meter.
- Activity source (`AttestationActivitySource`) for optional tracing spans around sign/verify operations.
- Documentation updates (`EXCITITOR-ATTEST-01-003-plan.md`, `TASKS.md` notes) describing instrumentation + test expectations.
- Test coverage in `StellaOps.Excititor.Attestation.Tests` (unit) and scaffolding notes for WebService/Worker integration tests.
## 2. Deliverables
- `IVexAttestationVerifier` abstraction + `VexAttestationVerifier` implementation inside `StellaOps.Excititor.Attestation`, encapsulating DSSE validation, predicate checks, artifact digest confirmation, Rekor inclusion verification, and deterministic diagnostics.
- DI wiring (extension method) for registering verifier + instrumentation dependencies alongside the existing signer/rekor client.
- Shared `VexAttestationDiagnostics` record describing normalized diagnostic keys consumed by Worker/WebService logging.
- Metrics utility (`AttestationMetrics`) exposing counters/histograms via `System.Diagnostics.Metrics`, exported under `StellaOps.Excititor.Attestation` meter.
- Activity source (`AttestationActivitySource`) for optional tracing spans around sign/verify operations.
- 2025-11-05: Implemented `VexAttestationDiagnostics`, activity tagging via `VexAttestationActivitySource`, and updated verifier/tests to emit structured failure reasons.
- 2025-11-05 (pm): Worker attestation verifier now records structured diagnostics/metrics and logs result/failure reasons using `VexAttestationDiagnostics`; attestation success/failure labels propagate to verification counters.
- Documentation updates (`EXCITITOR-ATTEST-01-003-plan.md`, `TASKS.md` notes) describing instrumentation + test expectations.
- Test coverage in `StellaOps.Excititor.Attestation.Tests` (unit) and scaffolding notes for WebService/Worker integration tests.
## 3. Verification Flow
### 3.1 Inputs

View File

@@ -1,6 +1,7 @@
If you are working on this file you need to read docs/modules/excititor/ARCHITECTURE.md and ./AGENTS.md).
# TASKS
| Task | Owner(s) | Depends on | Notes |
|---|---|---|---|
|EXCITITOR-ATTEST-01-003 Verification suite & observability|Team Excititor Attestation|EXCITITOR-ATTEST-01-002|DOING (2025-10-22) Continuing implementation: build `IVexAttestationVerifier`, wire metrics/logging, and add regression tests. Draft plan in `EXCITITOR-ATTEST-01-003-plan.md` (2025-10-19) guides scope; updating with worknotes as progress lands.<br>2025-10-31: Verifier now tolerates duplicate source providers from AOC raw projections, downgrades offline Rekor verification to a degraded result, and enforces trusted signer registry checks with detailed diagnostics/tests.|
If you are working on this file you need to read docs/modules/excititor/ARCHITECTURE.md and ./AGENTS.md).
# TASKS
| Task | Owner(s) | Depends on | Notes |
|---|---|---|---|
|EXCITITOR-ATTEST-01-003 Verification suite & observability|Team Excititor Attestation|EXCITITOR-ATTEST-01-002|DOING (2025-10-22) Continuing implementation: build `IVexAttestationVerifier`, wire metrics/logging, and add regression tests. Draft plan in `EXCITITOR-ATTEST-01-003-plan.md` (2025-10-19) guides scope; updating with worknotes as progress lands.<br>2025-10-31: Verifier now tolerates duplicate source providers from AOC raw projections, downgrades offline Rekor verification to a degraded result, and enforces trusted signer registry checks with detailed diagnostics/tests.<br>2025-11-05 14:35Z: Picking up diagnostics record/ActivitySource work and aligning metrics dimensions before wiring verifier into WebService/Worker paths.|
> 2025-11-05 19:10Z: Worker signature verifier now emits structured diagnostics/metrics via `VexAttestationDiagnostics`; attestation verification results flow into metric labels and logs.
> Remark (2025-10-22): Added verifier implementation + metrics/tests; next steps include wiring into WebService/Worker flows and expanding negative-path coverage.

View File

@@ -0,0 +1,10 @@
using System.Diagnostics;
namespace StellaOps.Excititor.Attestation.Verification;
public static class VexAttestationActivitySource
{
public const string Name = "StellaOps.Excititor.Attestation";
public static readonly ActivitySource Value = new(Name);
}

View File

@@ -1,8 +1,8 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Diagnostics;
using System.Linq;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Diagnostics;
using System.Linq;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
@@ -59,104 +59,120 @@ internal sealed class VexAttestationVerifier : IVexAttestationVerifier
public async ValueTask<VexAttestationVerification> VerifyAsync(
VexAttestationVerificationRequest request,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);
var stopwatch = Stopwatch.StartNew();
var diagnostics = ImmutableDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
{
ArgumentNullException.ThrowIfNull(request);
var stopwatch = Stopwatch.StartNew();
var diagnostics = ImmutableDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
var resultLabel = "valid";
var rekorState = "skipped";
var component = request.IsReverify ? "worker" : "webservice";
void SetFailure(string reason) => diagnostics["failure_reason"] = reason;
using var activity = VexAttestationActivitySource.Value.StartActivity("Verify", ActivityKind.Internal);
activity?.SetTag("attestation.component", component);
activity?.SetTag("attestation.export_id", request.Attestation.ExportId);
try
{
if (string.IsNullOrWhiteSpace(request.Envelope))
{
diagnostics["envelope.state"] = "missing";
_logger.LogWarning("Attestation envelope is missing for export {ExportId}", request.Attestation.ExportId);
resultLabel = "invalid";
return BuildResult(false);
}
if (!TryDeserializeEnvelope(request.Envelope, out var envelope, diagnostics))
{
_logger.LogWarning("Failed to deserialize attestation envelope for export {ExportId}", request.Attestation.ExportId);
resultLabel = "invalid";
return BuildResult(false);
}
if (!string.Equals(envelope.PayloadType, VexDsseBuilder.PayloadType, StringComparison.OrdinalIgnoreCase))
{
diagnostics["payload.type"] = envelope.PayloadType ?? string.Empty;
_logger.LogWarning(
"Unexpected DSSE payload type {PayloadType} for export {ExportId}",
envelope.PayloadType,
request.Attestation.ExportId);
resultLabel = "invalid";
return BuildResult(false);
}
if (envelope.Signatures is null || envelope.Signatures.Count == 0)
{
diagnostics["signature.state"] = "missing";
_logger.LogWarning("Attestation envelope for export {ExportId} does not contain signatures.", request.Attestation.ExportId);
resultLabel = "invalid";
return BuildResult(false);
}
var payloadBase64 = envelope.Payload ?? string.Empty;
if (!TryDecodePayload(payloadBase64, out var payloadBytes, diagnostics))
{
_logger.LogWarning("Failed to decode attestation payload for export {ExportId}", request.Attestation.ExportId);
resultLabel = "invalid";
return BuildResult(false);
}
if (!TryDeserializeStatement(payloadBytes, out var statement, diagnostics))
{
_logger.LogWarning("Failed to deserialize DSSE statement for export {ExportId}", request.Attestation.ExportId);
resultLabel = "invalid";
return BuildResult(false);
}
if (!ValidatePredicateType(statement, request, diagnostics))
{
_logger.LogWarning("Predicate type mismatch for export {ExportId}", request.Attestation.ExportId);
resultLabel = "invalid";
return BuildResult(false);
}
if (!ValidateSubject(statement, request, diagnostics))
{
_logger.LogWarning("Subject mismatch for export {ExportId}", request.Attestation.ExportId);
resultLabel = "invalid";
return BuildResult(false);
}
if (!ValidatePredicate(statement, request, diagnostics))
{
_logger.LogWarning("Predicate payload mismatch for export {ExportId}", request.Attestation.ExportId);
resultLabel = "invalid";
return BuildResult(false);
}
if (!ValidateMetadataDigest(envelope, request.Metadata, diagnostics))
{
_logger.LogWarning("Attestation digest mismatch for export {ExportId}", request.Attestation.ExportId);
resultLabel = "invalid";
return BuildResult(false);
}
if (!ValidateSignedAt(request.Metadata, request.Attestation.CreatedAt, diagnostics))
{
_logger.LogWarning("SignedAt validation failed for export {ExportId}", request.Attestation.ExportId);
resultLabel = "invalid";
return BuildResult(false);
}
if (string.IsNullOrWhiteSpace(request.Envelope))
{
diagnostics["envelope.state"] = "missing";
SetFailure("missing_envelope");
_logger.LogWarning("Attestation envelope is missing for export {ExportId}", request.Attestation.ExportId);
resultLabel = "invalid";
return BuildResult(false);
}
if (!TryDeserializeEnvelope(request.Envelope, out var envelope, diagnostics))
{
SetFailure("invalid_envelope");
_logger.LogWarning("Failed to deserialize attestation envelope for export {ExportId}", request.Attestation.ExportId);
resultLabel = "invalid";
return BuildResult(false);
}
if (!string.Equals(envelope.PayloadType, VexDsseBuilder.PayloadType, StringComparison.OrdinalIgnoreCase))
{
diagnostics["payload.type"] = envelope.PayloadType ?? string.Empty;
SetFailure("unexpected_payload_type");
_logger.LogWarning(
"Unexpected DSSE payload type {PayloadType} for export {ExportId}",
envelope.PayloadType,
request.Attestation.ExportId);
resultLabel = "invalid";
return BuildResult(false);
}
if (envelope.Signatures is null || envelope.Signatures.Count == 0)
{
diagnostics["signature.state"] = "missing";
SetFailure("missing_signature");
_logger.LogWarning("Attestation envelope for export {ExportId} does not contain signatures.", request.Attestation.ExportId);
resultLabel = "invalid";
return BuildResult(false);
}
var payloadBase64 = envelope.Payload ?? string.Empty;
if (!TryDecodePayload(payloadBase64, out var payloadBytes, diagnostics))
{
SetFailure("payload_decode_failed");
_logger.LogWarning("Failed to decode attestation payload for export {ExportId}", request.Attestation.ExportId);
resultLabel = "invalid";
return BuildResult(false);
}
if (!TryDeserializeStatement(payloadBytes, out var statement, diagnostics))
{
SetFailure("invalid_statement");
_logger.LogWarning("Failed to deserialize DSSE statement for export {ExportId}", request.Attestation.ExportId);
resultLabel = "invalid";
return BuildResult(false);
}
if (!ValidatePredicateType(statement, request, diagnostics))
{
SetFailure("predicate_type_mismatch");
_logger.LogWarning("Predicate type mismatch for export {ExportId}", request.Attestation.ExportId);
resultLabel = "invalid";
return BuildResult(false);
}
if (!ValidateSubject(statement, request, diagnostics))
{
SetFailure("subject_mismatch");
_logger.LogWarning("Subject mismatch for export {ExportId}", request.Attestation.ExportId);
resultLabel = "invalid";
return BuildResult(false);
}
if (!ValidatePredicate(statement, request, diagnostics))
{
SetFailure("predicate_mismatch");
_logger.LogWarning("Predicate payload mismatch for export {ExportId}", request.Attestation.ExportId);
resultLabel = "invalid";
return BuildResult(false);
}
if (!ValidateMetadataDigest(envelope, request.Metadata, diagnostics))
{
SetFailure("envelope_digest_mismatch");
_logger.LogWarning("Attestation digest mismatch for export {ExportId}", request.Attestation.ExportId);
resultLabel = "invalid";
return BuildResult(false);
}
if (!ValidateSignedAt(request.Metadata, request.Attestation.CreatedAt, diagnostics))
{
SetFailure("signedat_out_of_range");
_logger.LogWarning("SignedAt validation failed for export {ExportId}", request.Attestation.ExportId);
resultLabel = "invalid";
return BuildResult(false);
}
rekorState = await VerifyTransparencyAsync(request.Metadata, diagnostics, cancellationToken).ConfigureAwait(false);
if (rekorState is "missing" or "unverified" or "client_unavailable")
{
SetFailure(rekorState);
resultLabel = "invalid";
return BuildResult(false);
}
@@ -164,6 +180,9 @@ internal sealed class VexAttestationVerifier : IVexAttestationVerifier
var signaturesVerified = await VerifySignaturesAsync(payloadBytes, envelope.Signatures, diagnostics, cancellationToken).ConfigureAwait(false);
if (!signaturesVerified)
{
diagnostics["failure_reason"] = diagnostics.TryGetValue("signature.reason", out var reason)
? reason
: "signature_verification_failed";
if (_options.RequireSignatureVerification)
{
resultLabel = "invalid";
@@ -183,13 +202,16 @@ internal sealed class VexAttestationVerifier : IVexAttestationVerifier
catch (Exception ex)
{
diagnostics["error"] = ex.GetType().Name;
diagnostics["error.message"] = ex.Message; resultLabel = "error";
_logger.LogError(ex, "Unexpected exception verifying attestation for export {ExportId}", request.Attestation.ExportId);
return BuildResult(false);
}
finally
{
stopwatch.Stop();
diagnostics["error.message"] = ex.Message; resultLabel = "error";
_logger.LogError(ex, "Unexpected exception verifying attestation for export {ExportId}", request.Attestation.ExportId);
diagnostics["failure_reason"] = diagnostics.TryGetValue("error", out var errorCode)
? errorCode
: ex.GetType().Name;
return BuildResult(false);
}
finally
{
stopwatch.Stop();
var tags = new KeyValuePair<string, object?>[]
{
new("result", resultLabel),
@@ -200,12 +222,32 @@ internal sealed class VexAttestationVerifier : IVexAttestationVerifier
_metrics.VerifyDuration.Record(stopwatch.Elapsed.TotalSeconds, tags);
}
VexAttestationVerification BuildResult(bool isValid)
{
diagnostics["result"] = resultLabel;
diagnostics["component"] = component;
VexAttestationVerification BuildResult(bool isValid)
{
diagnostics["result"] = resultLabel;
diagnostics["component"] = component;
diagnostics["rekor.state"] = rekorState;
return new VexAttestationVerification(isValid, diagnostics.ToImmutable());
var snapshot = VexAttestationDiagnostics.FromBuilder(diagnostics);
if (activity is { } currentActivity)
{
currentActivity.SetTag("attestation.result", resultLabel);
currentActivity.SetTag("attestation.rekor", rekorState);
if (!isValid)
{
var failure = snapshot.FailureReason ?? "verification_failed";
currentActivity.SetStatus(ActivityStatusCode.Error, failure);
currentActivity.SetTag("attestation.failure_reason", failure);
}
else
{
currentActivity.SetStatus(resultLabel is "degraded"
? ActivityStatusCode.Ok
: ActivityStatusCode.Ok);
}
}
return new VexAttestationVerification(isValid, snapshot);
}
}

View File

@@ -14,4 +14,4 @@
<ProjectReference Include="../../../Aoc/__Libraries/StellaOps.Aoc/StellaOps.Aoc.csproj" />
<ProjectReference Include="../../../Concelier/__Libraries/StellaOps.Concelier.RawModels/StellaOps.Concelier.RawModels.csproj" />
</ItemGroup>
</Project>
</Project>

View File

@@ -1,7 +1,8 @@
using System;
using System.Collections.Immutable;
using System.Threading;
using System.Threading.Tasks;
using System.Threading.Tasks;
using StellaOps.Excititor.Attestation.Verification;
namespace StellaOps.Excititor.Core;
@@ -33,4 +34,4 @@ public sealed record VexAttestationVerificationRequest(
public sealed record VexAttestationVerification(
bool IsValid,
ImmutableDictionary<string, string> Diagnostics);
VexAttestationDiagnostics Diagnostics);

View File

@@ -0,0 +1,57 @@
using System.Collections;
using System.Collections.Generic;
using System.Collections.Immutable;
namespace StellaOps.Excititor.Attestation.Verification;
public sealed class VexAttestationDiagnostics : IReadOnlyDictionary<string, string>
{
private readonly ImmutableDictionary<string, string> _values;
private VexAttestationDiagnostics(ImmutableDictionary<string, string> values)
{
_values = values ?? ImmutableDictionary<string, string>.Empty;
}
public static VexAttestationDiagnostics FromBuilder(ImmutableDictionary<string, string>.Builder builder)
{
ArgumentNullException.ThrowIfNull(builder);
return new(builder.ToImmutable());
}
public static VexAttestationDiagnostics Empty { get; } = new(ImmutableDictionary<string, string>.Empty);
public string? Result => TryGetValue("result", out var value) ? value : null;
public string? Component => TryGetValue("component", out var value) ? value : null;
public string? RekorState => TryGetValue("rekor.state", out var value) ? value : null;
public string? FailureReason => TryGetValue("failure_reason", out var value) ? value : null;
public string this[string key] => _values[key];
public IEnumerable<string> Keys => _values.Keys;
public IEnumerable<string> Values => _values.Values;
public int Count => _values.Count;
public bool ContainsKey(string key) => _values.ContainsKey(key);
public bool TryGetValue(string key, out string value)
{
if (_values.TryGetValue(key, out var stored))
{
value = stored;
return true;
}
value = string.Empty;
return false;
}
public IEnumerator<KeyValuePair<string, string>> GetEnumerator() => _values.GetEnumerator();
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
}

View File

@@ -85,6 +85,6 @@ public sealed class VexAttestationClientTests
private sealed class FakeVerifier : IVexAttestationVerifier
{
public ValueTask<VexAttestationVerification> VerifyAsync(VexAttestationVerificationRequest request, CancellationToken cancellationToken)
=> ValueTask.FromResult(new VexAttestationVerification(true, ImmutableDictionary<string, string>.Empty));
=> ValueTask.FromResult(new VexAttestationVerification(true, VexAttestationDiagnostics.Empty));
}
}

View File

@@ -16,42 +16,44 @@ public sealed class VexAttestationVerifierTests : IDisposable
{
private readonly VexAttestationMetrics _metrics = new();
[Fact]
public async Task VerifyAsync_ReturnsValid_WhenEnvelopeMatches()
{
var (request, metadata, envelope) = await CreateSignedAttestationAsync();
var verifier = CreateVerifier(options => options.RequireTransparencyLog = false);
[Fact]
public async Task VerifyAsync_ReturnsValid_WhenEnvelopeMatches()
{
var (request, metadata, envelope) = await CreateSignedAttestationAsync();
var verifier = CreateVerifier(options => options.RequireTransparencyLog = false);
var verification = await verifier.VerifyAsync(
new VexAttestationVerificationRequest(request, metadata, envelope),
CancellationToken.None);
Assert.True(verification.IsValid);
Assert.Equal("valid", verification.Diagnostics.Result);
Assert.Null(verification.Diagnostics.FailureReason);
}
var verification = await verifier.VerifyAsync(
new VexAttestationVerificationRequest(request, metadata, envelope),
CancellationToken.None);
[Fact]
public async Task VerifyAsync_ReturnsInvalid_WhenDigestMismatch()
{
var (request, metadata, envelope) = await CreateSignedAttestationAsync();
var verifier = CreateVerifier(options => options.RequireTransparencyLog = false);
var tamperedMetadata = new VexAttestationMetadata(
metadata.PredicateType,
metadata.Rekor,
"sha256:deadbeef",
metadata.SignedAt);
var verification = await verifier.VerifyAsync(
new VexAttestationVerificationRequest(request, tamperedMetadata, envelope),
CancellationToken.None);
Assert.False(verification.IsValid);
Assert.Equal("invalid", verification.Diagnostics.Result);
Assert.Equal("sha256:deadbeef", verification.Diagnostics["metadata.envelopeDigest"]);
Assert.Equal("envelope_digest_mismatch", verification.Diagnostics.FailureReason);
}
Assert.True(verification.IsValid);
Assert.Equal("valid", verification.Diagnostics["result"]);
}
[Fact]
public async Task VerifyAsync_ReturnsInvalid_WhenDigestMismatch()
{
var (request, metadata, envelope) = await CreateSignedAttestationAsync();
var verifier = CreateVerifier(options => options.RequireTransparencyLog = false);
var tamperedMetadata = new VexAttestationMetadata(
metadata.PredicateType,
metadata.Rekor,
"sha256:deadbeef",
metadata.SignedAt);
var verification = await verifier.VerifyAsync(
new VexAttestationVerificationRequest(request, tamperedMetadata, envelope),
CancellationToken.None);
Assert.False(verification.IsValid);
Assert.Equal("invalid", verification.Diagnostics["result"]);
Assert.Equal("sha256:deadbeef", verification.Diagnostics["metadata.envelopeDigest"]);
}
[Fact]
[Fact]
public async Task VerifyAsync_AllowsOfflineTransparency_WhenConfigured()
{
var (request, metadata, envelope) = await CreateSignedAttestationAsync(includeRekor: true);
@@ -67,47 +69,50 @@ public sealed class VexAttestationVerifierTests : IDisposable
CancellationToken.None);
Assert.True(verification.IsValid);
Assert.Equal("offline", verification.Diagnostics["rekor.state"]);
Assert.Equal("degraded", verification.Diagnostics["result"]);
Assert.Equal("offline", verification.Diagnostics.RekorState);
Assert.Equal("degraded", verification.Diagnostics.Result);
Assert.Null(verification.Diagnostics.FailureReason);
}
[Fact]
public async Task VerifyAsync_ReturnsInvalid_WhenTransparencyRequiredAndMissing()
{
var (request, metadata, envelope) = await CreateSignedAttestationAsync(includeRekor: false);
var verifier = CreateVerifier(options =>
{
options.RequireTransparencyLog = true;
options.AllowOfflineTransparency = false;
});
var verification = await verifier.VerifyAsync(
new VexAttestationVerificationRequest(request, metadata, envelope),
CancellationToken.None);
Assert.False(verification.IsValid);
Assert.Equal("missing", verification.Diagnostics["rekor.state"]);
Assert.Equal("invalid", verification.Diagnostics["result"]);
}
[Fact]
public async Task VerifyAsync_ReturnsInvalid_WhenTransparencyUnavailableAndOfflineDisallowed()
{
var (request, metadata, envelope) = await CreateSignedAttestationAsync(includeRekor: true);
var transparency = new ThrowingTransparencyLogClient();
var verifier = CreateVerifier(options =>
{
options.RequireTransparencyLog = true;
options.AllowOfflineTransparency = false;
}, transparency);
var verification = await verifier.VerifyAsync(
new VexAttestationVerificationRequest(request, metadata, envelope),
CancellationToken.None);
Assert.False(verification.IsValid);
Assert.Equal("unreachable", verification.Diagnostics["rekor.state"]);
Assert.Equal("invalid", verification.Diagnostics["result"]);
{
var (request, metadata, envelope) = await CreateSignedAttestationAsync(includeRekor: false);
var verifier = CreateVerifier(options =>
{
options.RequireTransparencyLog = true;
options.AllowOfflineTransparency = false;
});
var verification = await verifier.VerifyAsync(
new VexAttestationVerificationRequest(request, metadata, envelope),
CancellationToken.None);
Assert.False(verification.IsValid);
Assert.Equal("missing", verification.Diagnostics.RekorState);
Assert.Equal("invalid", verification.Diagnostics.Result);
Assert.Equal("missing", verification.Diagnostics.FailureReason);
}
[Fact]
public async Task VerifyAsync_ReturnsInvalid_WhenTransparencyUnavailableAndOfflineDisallowed()
{
var (request, metadata, envelope) = await CreateSignedAttestationAsync(includeRekor: true);
var transparency = new ThrowingTransparencyLogClient();
var verifier = CreateVerifier(options =>
{
options.RequireTransparencyLog = true;
options.AllowOfflineTransparency = false;
}, transparency);
var verification = await verifier.VerifyAsync(
new VexAttestationVerificationRequest(request, metadata, envelope),
CancellationToken.None);
Assert.False(verification.IsValid);
Assert.Equal("unreachable", verification.Diagnostics.RekorState);
Assert.Equal("invalid", verification.Diagnostics.Result);
Assert.Equal("unreachable", verification.Diagnostics.FailureReason);
}
[Fact]
@@ -125,7 +130,7 @@ public sealed class VexAttestationVerifierTests : IDisposable
CancellationToken.None);
Assert.True(verification.IsValid);
Assert.Equal("valid", verification.Diagnostics["result"]);
Assert.Equal("valid", verification.Diagnostics.Result);
}
[Fact]
@@ -152,6 +157,8 @@ public sealed class VexAttestationVerifierTests : IDisposable
Assert.True(verification.IsValid);
Assert.Equal("verified", verification.Diagnostics["signature.state"]);
Assert.Equal("valid", verification.Diagnostics.Result);
Assert.Null(verification.Diagnostics.FailureReason);
}
[Fact]
@@ -179,6 +186,8 @@ public sealed class VexAttestationVerifierTests : IDisposable
Assert.False(verification.IsValid);
Assert.Equal("error", verification.Diagnostics["signature.state"]);
Assert.Equal("verification_failed", verification.Diagnostics["signature.reason"]);
Assert.Equal("verification_failed", verification.Diagnostics.FailureReason);
Assert.Equal("invalid", verification.Diagnostics.Result);
}
private async Task<(VexAttestationRequest Request, VexAttestationMetadata Metadata, string Envelope)> CreateSignedAttestationAsync(

View File

@@ -6,6 +6,7 @@ using System.Globalization;
using Microsoft.Extensions.Logging.Abstractions;
using MongoDB.Driver;
using StellaOps.Excititor.Core;
using StellaOps.Excititor.Attestation.Verification;
using StellaOps.Excititor.Export;
using StellaOps.Excititor.Policy;
using StellaOps.Excititor.Storage.Mongo;
@@ -291,7 +292,7 @@ public sealed class ExportEngineTests
}
public ValueTask<VexAttestationVerification> VerifyAsync(VexAttestationVerificationRequest request, CancellationToken cancellationToken)
=> ValueTask.FromResult(new VexAttestationVerification(true, ImmutableDictionary<string, string>.Empty));
=> ValueTask.FromResult(new VexAttestationVerification(true, VexAttestationDiagnostics.Empty));
}
private sealed class RecordingCacheIndex : IVexCacheIndex

View File

@@ -4,13 +4,14 @@ using System.Collections.Immutable;
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using StellaOps.Excititor.Core;
using StellaOps.Excititor.Export;
using StellaOps.Excititor.Storage.Mongo;
using StellaOps.Excititor.WebService.Services;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using StellaOps.Excititor.Core;
using StellaOps.Excititor.Attestation.Verification;
using StellaOps.Excititor.Export;
using StellaOps.Excititor.Storage.Mongo;
using StellaOps.Excititor.WebService.Services;
using MongoDB.Driver;
using StellaOps.Excititor.Attestation.Dsse;
@@ -162,7 +163,7 @@ internal static class TestServiceOverrides
public ValueTask<VexAttestationVerification> VerifyAsync(VexAttestationVerificationRequest request, CancellationToken cancellationToken)
{
var verification = new VexAttestationVerification(true, ImmutableDictionary<string, string>.Empty);
var verification = new VexAttestationVerification(true, VexAttestationDiagnostics.Empty);
return ValueTask.FromResult(verification);
}
}

View File

@@ -504,6 +504,21 @@ public sealed class DefaultVexProviderRunnerTests
bool includeGlobal,
CancellationToken cancellationToken)
=> ValueTask.FromResult(DefaultTrust);
public ValueTask<IssuerTrustResponseModel> SetIssuerTrustAsync(
string tenantId,
string issuerId,
decimal weight,
string? reason,
CancellationToken cancellationToken)
=> ValueTask.FromResult(DefaultTrust);
public ValueTask DeleteIssuerTrustAsync(
string tenantId,
string issuerId,
string? reason,
CancellationToken cancellationToken)
=> ValueTask.CompletedTask;
}
private sealed class NoopSignatureVerifier : IVexSignatureVerifier
@@ -655,25 +670,25 @@ public sealed class DefaultVexProviderRunnerTests
}
}
private sealed class StubAttestationVerifier : IVexAttestationVerifier
{
private readonly bool _isValid;
private readonly ImmutableDictionary<string, string> _diagnostics;
public StubAttestationVerifier(bool isValid, ImmutableDictionary<string, string> diagnostics)
{
_isValid = isValid;
_diagnostics = diagnostics;
}
public int Invocations { get; private set; }
public ValueTask<VexAttestationVerification> VerifyAsync(VexAttestationVerificationRequest request, CancellationToken cancellationToken)
{
Invocations++;
return ValueTask.FromResult(new VexAttestationVerification(_isValid, _diagnostics));
}
}
private sealed class StubAttestationVerifier : IVexAttestationVerifier
{
private readonly bool _isValid;
private readonly VexAttestationDiagnostics _diagnostics;
public StubAttestationVerifier(bool isValid, ImmutableDictionary<string, string> diagnostics)
{
_isValid = isValid;
_diagnostics = VexAttestationDiagnostics.FromBuilder(diagnostics.ToBuilder());
}
public int Invocations { get; private set; }
public ValueTask<VexAttestationVerification> VerifyAsync(VexAttestationVerificationRequest request, CancellationToken cancellationToken)
{
Invocations++;
return ValueTask.FromResult(new VexAttestationVerification(_isValid, _diagnostics));
}
}
private static VexRawDocument CreateAttestationRawDocument(DateTimeOffset observedAt)
{

View File

@@ -249,19 +249,21 @@ public sealed class WorkerSignatureVerifierTests
private sealed class StubAttestationVerifier : IVexAttestationVerifier
{
private readonly bool _isValid;
private readonly ImmutableDictionary<string, string> _diagnostics;
private readonly VexAttestationDiagnostics _diagnostics;
public StubAttestationVerifier(bool isValid, ImmutableDictionary<string, string>? diagnostics = null)
{
_isValid = isValid;
_diagnostics = diagnostics ?? ImmutableDictionary<string, string>.Empty;
}
public int Invocations { get; private set; }
public ValueTask<VexAttestationVerification> VerifyAsync(VexAttestationVerificationRequest request, CancellationToken cancellationToken)
{
Invocations++;
{
_isValid = isValid;
_diagnostics = diagnostics is null
? VexAttestationDiagnostics.Empty
: VexAttestationDiagnostics.FromBuilder(diagnostics.ToBuilder());
}
public int Invocations { get; private set; }
public ValueTask<VexAttestationVerification> VerifyAsync(VexAttestationVerificationRequest request, CancellationToken cancellationToken)
{
Invocations++;
return ValueTask.FromResult(new VexAttestationVerification(_isValid, _diagnostics));
}
}
@@ -269,7 +271,7 @@ public sealed class WorkerSignatureVerifierTests
private sealed class StubIssuerDirectoryClient : IIssuerDirectoryClient
{
private readonly IReadOnlyList<IssuerKeyModel> _keys;
private readonly IssuerTrustResponseModel _trust;
private IssuerTrustResponseModel _trust;
private StubIssuerDirectoryClient(
IReadOnlyList<IssuerKeyModel> keys,
@@ -302,7 +304,7 @@ public sealed class WorkerSignatureVerifierTests
null,
null);
var now = DateTimeOffset.UtcNow;
var now = DateTimeOffset.UnixEpoch;
var overrideModel = new IssuerTrustOverrideModel(weight, "stub", now, "test", now, "test");
return new StubIssuerDirectoryClient(
new[] { key },
@@ -322,6 +324,29 @@ public sealed class WorkerSignatureVerifierTests
bool includeGlobal,
CancellationToken cancellationToken)
=> ValueTask.FromResult(_trust);
public ValueTask<IssuerTrustResponseModel> SetIssuerTrustAsync(
string tenantId,
string issuerId,
decimal weight,
string? reason,
CancellationToken cancellationToken)
{
var now = DateTimeOffset.UnixEpoch;
var overrideModel = new IssuerTrustOverrideModel(weight, "stub-set", now, "test", now, "test");
_trust = new IssuerTrustResponseModel(overrideModel, null, weight);
return ValueTask.FromResult(_trust);
}
public ValueTask DeleteIssuerTrustAsync(
string tenantId,
string issuerId,
string? reason,
CancellationToken cancellationToken)
{
_trust = new IssuerTrustResponseModel(null, null, 0m);
return ValueTask.CompletedTask;
}
}
private sealed class FixedTimeProvider : TimeProvider