Add unit tests for PackRunAttestation and SealedInstallEnforcer
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
release-manifest-verify / verify (push) Has been cancelled

- Implement comprehensive tests for PackRunAttestationService, covering attestation generation, verification, and event emission.
- Add tests for SealedInstallEnforcer to validate sealed install requirements and enforcement logic.
- Introduce a MonacoLoaderService stub for testing purposes to prevent Monaco workers/styles from loading during Karma runs.
This commit is contained in:
StellaOps Bot
2025-12-06 22:25:30 +02:00
parent dd0067ea0b
commit 4042fc2184
110 changed files with 20084 additions and 639 deletions

View File

@@ -0,0 +1,576 @@
using Microsoft.Extensions.Logging;
using StellaOps.TaskRunner.Core.Events;
using System.Text;
using System.Text.Json;
namespace StellaOps.TaskRunner.Core.Attestation;
/// <summary>
/// Service for generating and verifying pack run attestations.
/// Per TASKRUN-OBS-54-001.
/// </summary>
public interface IPackRunAttestationService
{
/// <summary>
/// Generates an attestation for a pack run.
/// </summary>
Task<PackRunAttestationResult> GenerateAsync(
PackRunAttestationRequest request,
CancellationToken cancellationToken = default);
/// <summary>
/// Verifies a pack run attestation.
/// </summary>
Task<PackRunAttestationVerificationResult> VerifyAsync(
PackRunAttestationVerificationRequest request,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets an attestation by ID.
/// </summary>
Task<PackRunAttestation?> GetAsync(
Guid attestationId,
CancellationToken cancellationToken = default);
/// <summary>
/// Lists attestations for a run.
/// </summary>
Task<IReadOnlyList<PackRunAttestation>> ListByRunAsync(
string tenantId,
string runId,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets the DSSE envelope for an attestation.
/// </summary>
Task<PackRunDsseEnvelope?> GetEnvelopeAsync(
Guid attestationId,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Store for pack run attestations.
/// </summary>
public interface IPackRunAttestationStore
{
/// <summary>
/// Stores an attestation.
/// </summary>
Task StoreAsync(
PackRunAttestation attestation,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets an attestation by ID.
/// </summary>
Task<PackRunAttestation?> GetAsync(
Guid attestationId,
CancellationToken cancellationToken = default);
/// <summary>
/// Lists attestations for a run.
/// </summary>
Task<IReadOnlyList<PackRunAttestation>> ListByRunAsync(
string tenantId,
string runId,
CancellationToken cancellationToken = default);
/// <summary>
/// Updates attestation status.
/// </summary>
Task UpdateStatusAsync(
Guid attestationId,
PackRunAttestationStatus status,
string? error = null,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Signing provider for pack run attestations.
/// </summary>
public interface IPackRunAttestationSigner
{
/// <summary>
/// Signs an in-toto statement.
/// </summary>
Task<PackRunDsseEnvelope> SignAsync(
byte[] statementBytes,
CancellationToken cancellationToken = default);
/// <summary>
/// Verifies a DSSE envelope signature.
/// </summary>
Task<bool> VerifyAsync(
PackRunDsseEnvelope envelope,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets the current signing key ID.
/// </summary>
string GetKeyId();
}
/// <summary>
/// Default implementation of pack run attestation service.
/// </summary>
public sealed class PackRunAttestationService : IPackRunAttestationService
{
private readonly IPackRunAttestationStore _store;
private readonly IPackRunAttestationSigner? _signer;
private readonly IPackRunTimelineEventEmitter? _timelineEmitter;
private readonly ILogger<PackRunAttestationService> _logger;
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = false
};
public PackRunAttestationService(
IPackRunAttestationStore store,
ILogger<PackRunAttestationService> logger,
IPackRunAttestationSigner? signer = null,
IPackRunTimelineEventEmitter? timelineEmitter = null)
{
_store = store ?? throw new ArgumentNullException(nameof(store));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_signer = signer;
_timelineEmitter = timelineEmitter;
}
/// <inheritdoc />
public async Task<PackRunAttestationResult> GenerateAsync(
PackRunAttestationRequest request,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
try
{
// Build provenance predicate
var buildDefinition = new PackRunBuildDefinition(
BuildType: "https://stellaops.io/pack-run/v1",
ExternalParameters: request.ExternalParameters,
InternalParameters: new Dictionary<string, object>
{
["planHash"] = request.PlanHash
},
ResolvedDependencies: request.ResolvedDependencies);
var runDetails = new PackRunDetails(
Builder: new PackRunBuilder(
Id: request.BuilderId ?? "https://stellaops.io/task-runner",
Version: new Dictionary<string, string>
{
["stellaops.task-runner"] = GetVersion()
},
BuilderDependencies: null),
Metadata: new PackRunProvMetadata(
InvocationId: request.RunId,
StartedOn: request.StartedAt,
FinishedOn: request.CompletedAt),
Byproducts: null);
var predicate = new PackRunProvenancePredicate(
BuildDefinition: buildDefinition,
RunDetails: runDetails);
var predicateJson = JsonSerializer.Serialize(predicate, JsonOptions);
// Build in-toto statement
var statement = new PackRunInTotoStatement(
Type: InTotoStatementTypes.V1,
Subject: request.Subjects,
PredicateType: PredicateTypes.PackRunProvenance,
Predicate: predicate);
var statementJson = JsonSerializer.Serialize(statement, JsonOptions);
var statementBytes = Encoding.UTF8.GetBytes(statementJson);
// Sign if signer is available
PackRunDsseEnvelope? envelope = null;
PackRunAttestationStatus status = PackRunAttestationStatus.Pending;
string? error = null;
if (_signer is not null)
{
try
{
envelope = await _signer.SignAsync(statementBytes, cancellationToken);
status = PackRunAttestationStatus.Signed;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to sign attestation for run {RunId}", request.RunId);
error = ex.Message;
status = PackRunAttestationStatus.Failed;
}
}
// Create attestation record
var attestation = new PackRunAttestation(
AttestationId: Guid.NewGuid(),
TenantId: request.TenantId,
RunId: request.RunId,
PlanHash: request.PlanHash,
CreatedAt: DateTimeOffset.UtcNow,
Subjects: request.Subjects,
PredicateType: PredicateTypes.PackRunProvenance,
PredicateJson: predicateJson,
Envelope: envelope,
Status: status,
Error: error,
EvidenceSnapshotId: request.EvidenceSnapshotId,
Metadata: request.Metadata);
// Store attestation
await _store.StoreAsync(attestation, cancellationToken);
// Emit timeline event
if (_timelineEmitter is not null)
{
var eventType = status == PackRunAttestationStatus.Signed
? PackRunAttestationEventTypes.AttestationCreated
: PackRunAttestationEventTypes.AttestationFailed;
await _timelineEmitter.EmitAsync(
PackRunTimelineEvent.Create(
tenantId: request.TenantId,
eventType: eventType,
source: "taskrunner-attestation",
occurredAt: DateTimeOffset.UtcNow,
runId: request.RunId,
planHash: request.PlanHash,
attributes: new Dictionary<string, string>
{
["attestationId"] = attestation.AttestationId.ToString(),
["predicateType"] = attestation.PredicateType,
["subjectCount"] = request.Subjects.Count.ToString(),
["status"] = status.ToString()
},
evidencePointer: envelope is not null
? PackRunEvidencePointer.Attestation(
request.RunId,
envelope.ComputeDigest())
: null),
cancellationToken);
}
_logger.LogInformation(
"Generated attestation {AttestationId} for run {RunId} with {SubjectCount} subjects, status {Status}",
attestation.AttestationId,
request.RunId,
request.Subjects.Count,
status);
return new PackRunAttestationResult(
Success: status != PackRunAttestationStatus.Failed,
Attestation: attestation,
Error: error);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to generate attestation for run {RunId}", request.RunId);
return new PackRunAttestationResult(
Success: false,
Attestation: null,
Error: ex.Message);
}
}
/// <inheritdoc />
public async Task<PackRunAttestationVerificationResult> VerifyAsync(
PackRunAttestationVerificationRequest request,
CancellationToken cancellationToken = default)
{
var errors = new List<string>();
var signatureStatus = PackRunSignatureVerificationStatus.NotVerified;
var subjectStatus = PackRunSubjectVerificationStatus.NotVerified;
var revocationStatus = PackRunRevocationStatus.NotChecked;
var attestation = await _store.GetAsync(request.AttestationId, cancellationToken);
if (attestation is null)
{
return new PackRunAttestationVerificationResult(
Valid: false,
AttestationId: request.AttestationId,
SignatureStatus: PackRunSignatureVerificationStatus.NotVerified,
SubjectStatus: PackRunSubjectVerificationStatus.NotVerified,
RevocationStatus: PackRunRevocationStatus.NotChecked,
Errors: ["Attestation not found"],
VerifiedAt: DateTimeOffset.UtcNow);
}
// Verify signature
if (request.VerifySignature && attestation.Envelope is not null && _signer is not null)
{
try
{
var signatureValid = await _signer.VerifyAsync(attestation.Envelope, cancellationToken);
signatureStatus = signatureValid
? PackRunSignatureVerificationStatus.Valid
: PackRunSignatureVerificationStatus.Invalid;
if (!signatureValid)
{
errors.Add("Signature verification failed");
}
}
catch (Exception ex)
{
signatureStatus = PackRunSignatureVerificationStatus.Invalid;
errors.Add($"Signature verification error: {ex.Message}");
}
}
else if (request.VerifySignature && attestation.Envelope is null)
{
signatureStatus = PackRunSignatureVerificationStatus.Invalid;
errors.Add("No envelope available for signature verification");
}
// Verify subjects
if (request.VerifySubjects && request.ExpectedSubjects is not null)
{
var expectedSet = request.ExpectedSubjects
.Select(s => $"{s.Name}:{string.Join(",", s.Digest.OrderBy(d => d.Key).Select(d => $"{d.Key}={d.Value}"))}")
.ToHashSet();
var actualSet = attestation.Subjects
.Select(s => $"{s.Name}:{string.Join(",", s.Digest.OrderBy(d => d.Key).Select(d => $"{d.Key}={d.Value}"))}")
.ToHashSet();
if (expectedSet.SetEquals(actualSet))
{
subjectStatus = PackRunSubjectVerificationStatus.Match;
}
else if (expectedSet.IsSubsetOf(actualSet))
{
subjectStatus = PackRunSubjectVerificationStatus.Match;
}
else
{
var missing = expectedSet.Except(actualSet).ToList();
if (missing.Count > 0)
{
subjectStatus = PackRunSubjectVerificationStatus.Missing;
errors.Add($"Missing subjects: {string.Join(", ", missing)}");
}
else
{
subjectStatus = PackRunSubjectVerificationStatus.Mismatch;
errors.Add("Subject digest mismatch");
}
}
}
// Check revocation
if (request.CheckRevocation)
{
revocationStatus = attestation.Status == PackRunAttestationStatus.Revoked
? PackRunRevocationStatus.Revoked
: PackRunRevocationStatus.NotRevoked;
if (attestation.Status == PackRunAttestationStatus.Revoked)
{
errors.Add("Attestation has been revoked");
}
}
var valid = errors.Count == 0 &&
(signatureStatus is PackRunSignatureVerificationStatus.Valid or PackRunSignatureVerificationStatus.NotVerified) &&
(subjectStatus is PackRunSubjectVerificationStatus.Match or PackRunSubjectVerificationStatus.NotVerified) &&
(revocationStatus is PackRunRevocationStatus.NotRevoked or PackRunRevocationStatus.NotChecked);
return new PackRunAttestationVerificationResult(
Valid: valid,
AttestationId: request.AttestationId,
SignatureStatus: signatureStatus,
SubjectStatus: subjectStatus,
RevocationStatus: revocationStatus,
Errors: errors.Count > 0 ? errors : null,
VerifiedAt: DateTimeOffset.UtcNow);
}
/// <inheritdoc />
public Task<PackRunAttestation?> GetAsync(
Guid attestationId,
CancellationToken cancellationToken = default)
=> _store.GetAsync(attestationId, cancellationToken);
/// <inheritdoc />
public Task<IReadOnlyList<PackRunAttestation>> ListByRunAsync(
string tenantId,
string runId,
CancellationToken cancellationToken = default)
=> _store.ListByRunAsync(tenantId, runId, cancellationToken);
/// <inheritdoc />
public async Task<PackRunDsseEnvelope?> GetEnvelopeAsync(
Guid attestationId,
CancellationToken cancellationToken = default)
{
var attestation = await _store.GetAsync(attestationId, cancellationToken);
return attestation?.Envelope;
}
private static string GetVersion()
{
var assembly = typeof(PackRunAttestationService).Assembly;
var version = assembly.GetName().Version;
return version?.ToString() ?? "0.0.0";
}
}
/// <summary>
/// Attestation event types for timeline.
/// </summary>
public static class PackRunAttestationEventTypes
{
/// <summary>Attestation created successfully.</summary>
public const string AttestationCreated = "pack.attestation.created";
/// <summary>Attestation creation failed.</summary>
public const string AttestationFailed = "pack.attestation.failed";
/// <summary>Attestation verified.</summary>
public const string AttestationVerified = "pack.attestation.verified";
/// <summary>Attestation verification failed.</summary>
public const string AttestationVerificationFailed = "pack.attestation.verification_failed";
/// <summary>Attestation revoked.</summary>
public const string AttestationRevoked = "pack.attestation.revoked";
}
/// <summary>
/// In-memory attestation store for testing.
/// </summary>
public sealed class InMemoryPackRunAttestationStore : IPackRunAttestationStore
{
private readonly Dictionary<Guid, PackRunAttestation> _attestations = new();
private readonly object _lock = new();
/// <inheritdoc />
public Task StoreAsync(
PackRunAttestation attestation,
CancellationToken cancellationToken = default)
{
lock (_lock)
{
_attestations[attestation.AttestationId] = attestation;
}
return Task.CompletedTask;
}
/// <inheritdoc />
public Task<PackRunAttestation?> GetAsync(
Guid attestationId,
CancellationToken cancellationToken = default)
{
lock (_lock)
{
_attestations.TryGetValue(attestationId, out var attestation);
return Task.FromResult(attestation);
}
}
/// <inheritdoc />
public Task<IReadOnlyList<PackRunAttestation>> ListByRunAsync(
string tenantId,
string runId,
CancellationToken cancellationToken = default)
{
lock (_lock)
{
var results = _attestations.Values
.Where(a => a.TenantId == tenantId && a.RunId == runId)
.OrderBy(a => a.CreatedAt)
.ToList();
return Task.FromResult<IReadOnlyList<PackRunAttestation>>(results);
}
}
/// <inheritdoc />
public Task UpdateStatusAsync(
Guid attestationId,
PackRunAttestationStatus status,
string? error = null,
CancellationToken cancellationToken = default)
{
lock (_lock)
{
if (_attestations.TryGetValue(attestationId, out var attestation))
{
_attestations[attestationId] = attestation with
{
Status = status,
Error = error
};
}
}
return Task.CompletedTask;
}
/// <summary>Gets all attestations (for testing).</summary>
public IReadOnlyList<PackRunAttestation> GetAll()
{
lock (_lock) { return _attestations.Values.ToList(); }
}
/// <summary>Clears all attestations (for testing).</summary>
public void Clear()
{
lock (_lock) { _attestations.Clear(); }
}
/// <summary>Gets attestation count.</summary>
public int Count
{
get { lock (_lock) { return _attestations.Count; } }
}
}
/// <summary>
/// Stub signer for testing (does not perform real cryptographic signing).
/// </summary>
public sealed class StubPackRunAttestationSigner : IPackRunAttestationSigner
{
private readonly string _keyId;
public StubPackRunAttestationSigner(string keyId = "test-key-001")
{
_keyId = keyId;
}
/// <inheritdoc />
public Task<PackRunDsseEnvelope> SignAsync(
byte[] statementBytes,
CancellationToken cancellationToken = default)
{
var payload = Convert.ToBase64String(statementBytes);
// Create stub signature (not cryptographically valid)
var sigBytes = System.Security.Cryptography.SHA256.HashData(statementBytes);
var sig = Convert.ToBase64String(sigBytes);
var envelope = new PackRunDsseEnvelope(
PayloadType: PackRunDsseEnvelope.InTotoPayloadType,
Payload: payload,
Signatures: [new PackRunDsseSignature(_keyId, sig)]);
return Task.FromResult(envelope);
}
/// <inheritdoc />
public Task<bool> VerifyAsync(
PackRunDsseEnvelope envelope,
CancellationToken cancellationToken = default)
{
// Stub always returns true for testing
return Task.FromResult(true);
}
/// <inheritdoc />
public string GetKeyId() => _keyId;
}

View File

@@ -0,0 +1,525 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using StellaOps.TaskRunner.Core.Evidence;
namespace StellaOps.TaskRunner.Core.Attestation;
/// <summary>
/// DSSE attestation for pack run execution.
/// Per TASKRUN-OBS-54-001.
/// </summary>
public sealed record PackRunAttestation(
/// <summary>Unique attestation identifier.</summary>
Guid AttestationId,
/// <summary>Tenant scope.</summary>
string TenantId,
/// <summary>Run ID this attestation covers.</summary>
string RunId,
/// <summary>Plan hash that was executed.</summary>
string PlanHash,
/// <summary>When the attestation was created.</summary>
DateTimeOffset CreatedAt,
/// <summary>Subjects covered by this attestation (produced artifacts).</summary>
IReadOnlyList<PackRunAttestationSubject> Subjects,
/// <summary>Predicate type URI.</summary>
string PredicateType,
/// <summary>Predicate content as JSON.</summary>
string PredicateJson,
/// <summary>DSSE envelope containing signature.</summary>
PackRunDsseEnvelope? Envelope,
/// <summary>Attestation status.</summary>
PackRunAttestationStatus Status,
/// <summary>Error message if signing failed.</summary>
string? Error,
/// <summary>Reference to evidence snapshot.</summary>
Guid? EvidenceSnapshotId,
/// <summary>Attestation metadata.</summary>
IReadOnlyDictionary<string, string>? Metadata)
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
WriteIndented = false
};
/// <summary>
/// Computes the canonical statement digest.
/// </summary>
public string ComputeStatementDigest()
{
var statement = new PackRunInTotoStatement(
Type: InTotoStatementTypes.V01,
Subject: Subjects,
PredicateType: PredicateType,
Predicate: JsonSerializer.Deserialize<JsonElement>(PredicateJson, JsonOptions));
var json = JsonSerializer.Serialize(statement, JsonOptions);
var bytes = Encoding.UTF8.GetBytes(json);
var hash = SHA256.HashData(bytes);
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
}
/// <summary>
/// Serializes to JSON.
/// </summary>
public string ToJson() => JsonSerializer.Serialize(this, JsonOptions);
/// <summary>
/// Deserializes from JSON.
/// </summary>
public static PackRunAttestation? FromJson(string json)
=> JsonSerializer.Deserialize<PackRunAttestation>(json, JsonOptions);
}
/// <summary>
/// Attestation status.
/// </summary>
public enum PackRunAttestationStatus
{
/// <summary>Attestation is pending signing.</summary>
Pending,
/// <summary>Attestation is signed and valid.</summary>
Signed,
/// <summary>Attestation signing failed.</summary>
Failed,
/// <summary>Attestation signature was revoked.</summary>
Revoked
}
/// <summary>
/// Subject covered by attestation (an artifact).
/// </summary>
public sealed record PackRunAttestationSubject(
/// <summary>Subject name (artifact path or identifier).</summary>
[property: JsonPropertyName("name")]
string Name,
/// <summary>Subject digest (sha256 -> hash).</summary>
[property: JsonPropertyName("digest")]
IReadOnlyDictionary<string, string> Digest)
{
/// <summary>
/// Creates a subject from an artifact reference.
/// </summary>
public static PackRunAttestationSubject FromArtifact(PackRunArtifactReference artifact)
{
var digest = new Dictionary<string, string>();
// Parse sha256:abcdef format and extract just the hash
if (artifact.Sha256.StartsWith("sha256:", StringComparison.OrdinalIgnoreCase))
{
digest["sha256"] = artifact.Sha256[7..];
}
else
{
digest["sha256"] = artifact.Sha256;
}
return new PackRunAttestationSubject(artifact.Name, digest);
}
/// <summary>
/// Creates a subject from a material.
/// </summary>
public static PackRunAttestationSubject FromMaterial(PackRunEvidenceMaterial material)
{
var digest = new Dictionary<string, string>();
if (material.Sha256.StartsWith("sha256:", StringComparison.OrdinalIgnoreCase))
{
digest["sha256"] = material.Sha256[7..];
}
else
{
digest["sha256"] = material.Sha256;
}
return new PackRunAttestationSubject(material.CanonicalPath, digest);
}
}
/// <summary>
/// In-toto statement wrapper for pack runs.
/// </summary>
public sealed record PackRunInTotoStatement(
/// <summary>Statement type (always _type).</summary>
[property: JsonPropertyName("_type")]
string Type,
/// <summary>Subjects covered.</summary>
[property: JsonPropertyName("subject")]
IReadOnlyList<PackRunAttestationSubject> Subject,
/// <summary>Predicate type URI.</summary>
[property: JsonPropertyName("predicateType")]
string PredicateType,
/// <summary>Predicate content.</summary>
[property: JsonPropertyName("predicate")]
object Predicate);
/// <summary>
/// Standard in-toto statement type URIs.
/// </summary>
public static class InTotoStatementTypes
{
/// <summary>In-toto statement v0.1.</summary>
public const string V01 = "https://in-toto.io/Statement/v0.1";
/// <summary>In-toto statement v1.0.</summary>
public const string V1 = "https://in-toto.io/Statement/v1";
}
/// <summary>
/// Standard predicate type URIs.
/// </summary>
public static class PredicateTypes
{
/// <summary>SLSA Provenance v0.2.</summary>
public const string SlsaProvenanceV02 = "https://slsa.dev/provenance/v0.2";
/// <summary>SLSA Provenance v1.0.</summary>
public const string SlsaProvenanceV1 = "https://slsa.dev/provenance/v1";
/// <summary>StellaOps Pack Run provenance.</summary>
public const string PackRunProvenance = "https://stellaops.io/attestation/pack-run/v1";
/// <summary>StellaOps Pack Run completion.</summary>
public const string PackRunCompletion = "https://stellaops.io/attestation/pack-run-completion/v1";
}
/// <summary>
/// DSSE envelope for pack run attestation.
/// </summary>
public sealed record PackRunDsseEnvelope(
/// <summary>Payload type (usually application/vnd.in-toto+json).</summary>
[property: JsonPropertyName("payloadType")]
string PayloadType,
/// <summary>Base64-encoded payload.</summary>
[property: JsonPropertyName("payload")]
string Payload,
/// <summary>Signatures on the envelope.</summary>
[property: JsonPropertyName("signatures")]
IReadOnlyList<PackRunDsseSignature> Signatures)
{
/// <summary>Standard payload type for in-toto attestations.</summary>
public const string InTotoPayloadType = "application/vnd.in-toto+json";
/// <summary>
/// Computes the envelope digest.
/// </summary>
public string ComputeDigest()
{
var json = JsonSerializer.Serialize(this, new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = false
});
var bytes = Encoding.UTF8.GetBytes(json);
var hash = SHA256.HashData(bytes);
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
}
}
/// <summary>
/// Signature in a DSSE envelope.
/// </summary>
public sealed record PackRunDsseSignature(
/// <summary>Key identifier.</summary>
[property: JsonPropertyName("keyid")]
string? KeyId,
/// <summary>Base64-encoded signature.</summary>
[property: JsonPropertyName("sig")]
string Sig);
/// <summary>
/// Pack run provenance predicate per SLSA Provenance v1.
/// </summary>
public sealed record PackRunProvenancePredicate(
/// <summary>Build definition describing what was run.</summary>
[property: JsonPropertyName("buildDefinition")]
PackRunBuildDefinition BuildDefinition,
/// <summary>Run details describing the actual execution.</summary>
[property: JsonPropertyName("runDetails")]
PackRunDetails RunDetails);
/// <summary>
/// Build definition for pack run provenance.
/// </summary>
public sealed record PackRunBuildDefinition(
/// <summary>Build type identifier.</summary>
[property: JsonPropertyName("buildType")]
string BuildType,
/// <summary>External parameters (e.g., pack manifest URL).</summary>
[property: JsonPropertyName("externalParameters")]
IReadOnlyDictionary<string, object>? ExternalParameters,
/// <summary>Internal parameters resolved during build.</summary>
[property: JsonPropertyName("internalParameters")]
IReadOnlyDictionary<string, object>? InternalParameters,
/// <summary>Dependencies resolved during build.</summary>
[property: JsonPropertyName("resolvedDependencies")]
IReadOnlyList<PackRunDependency>? ResolvedDependencies);
/// <summary>
/// Resolved dependency in provenance.
/// </summary>
public sealed record PackRunDependency(
/// <summary>Dependency URI.</summary>
[property: JsonPropertyName("uri")]
string Uri,
/// <summary>Dependency digest.</summary>
[property: JsonPropertyName("digest")]
IReadOnlyDictionary<string, string>? Digest,
/// <summary>Dependency name.</summary>
[property: JsonPropertyName("name")]
string? Name,
/// <summary>Media type.</summary>
[property: JsonPropertyName("mediaType")]
string? MediaType);
/// <summary>
/// Run details for pack run provenance.
/// </summary>
public sealed record PackRunDetails(
/// <summary>Builder information.</summary>
[property: JsonPropertyName("builder")]
PackRunBuilder Builder,
/// <summary>Run metadata.</summary>
[property: JsonPropertyName("metadata")]
PackRunProvMetadata Metadata,
/// <summary>By-products of the run.</summary>
[property: JsonPropertyName("byproducts")]
IReadOnlyList<PackRunByproduct>? Byproducts);
/// <summary>
/// Builder information.
/// </summary>
public sealed record PackRunBuilder(
/// <summary>Builder ID (URI).</summary>
[property: JsonPropertyName("id")]
string Id,
/// <summary>Builder version.</summary>
[property: JsonPropertyName("version")]
IReadOnlyDictionary<string, string>? Version,
/// <summary>Builder dependencies.</summary>
[property: JsonPropertyName("builderDependencies")]
IReadOnlyList<PackRunDependency>? BuilderDependencies);
/// <summary>
/// Provenance metadata.
/// </summary>
public sealed record PackRunProvMetadata(
/// <summary>Invocation ID.</summary>
[property: JsonPropertyName("invocationId")]
string? InvocationId,
/// <summary>When the build started.</summary>
[property: JsonPropertyName("startedOn")]
DateTimeOffset? StartedOn,
/// <summary>When the build finished.</summary>
[property: JsonPropertyName("finishedOn")]
DateTimeOffset? FinishedOn);
/// <summary>
/// By-product of the build.
/// </summary>
public sealed record PackRunByproduct(
/// <summary>By-product URI.</summary>
[property: JsonPropertyName("uri")]
string? Uri,
/// <summary>By-product digest.</summary>
[property: JsonPropertyName("digest")]
IReadOnlyDictionary<string, string>? Digest,
/// <summary>By-product name.</summary>
[property: JsonPropertyName("name")]
string? Name,
/// <summary>By-product media type.</summary>
[property: JsonPropertyName("mediaType")]
string? MediaType);
/// <summary>
/// Request to generate an attestation for a pack run.
/// </summary>
public sealed record PackRunAttestationRequest(
/// <summary>Run ID to attest.</summary>
string RunId,
/// <summary>Tenant ID.</summary>
string TenantId,
/// <summary>Plan hash.</summary>
string PlanHash,
/// <summary>Subjects (artifacts) to attest.</summary>
IReadOnlyList<PackRunAttestationSubject> Subjects,
/// <summary>Evidence snapshot ID to link.</summary>
Guid? EvidenceSnapshotId,
/// <summary>Run started at.</summary>
DateTimeOffset StartedAt,
/// <summary>Run completed at.</summary>
DateTimeOffset? CompletedAt,
/// <summary>Builder ID.</summary>
string? BuilderId,
/// <summary>External parameters.</summary>
IReadOnlyDictionary<string, object>? ExternalParameters,
/// <summary>Resolved dependencies.</summary>
IReadOnlyList<PackRunDependency>? ResolvedDependencies,
/// <summary>Additional metadata.</summary>
IReadOnlyDictionary<string, string>? Metadata);
/// <summary>
/// Result of attestation generation.
/// </summary>
public sealed record PackRunAttestationResult(
/// <summary>Whether attestation generation succeeded.</summary>
bool Success,
/// <summary>Generated attestation.</summary>
PackRunAttestation? Attestation,
/// <summary>Error message if failed.</summary>
string? Error);
/// <summary>
/// Request to verify a pack run attestation.
/// </summary>
public sealed record PackRunAttestationVerificationRequest(
/// <summary>Attestation ID to verify.</summary>
Guid AttestationId,
/// <summary>Expected subjects to verify against.</summary>
IReadOnlyList<PackRunAttestationSubject>? ExpectedSubjects,
/// <summary>Whether to verify signature.</summary>
bool VerifySignature,
/// <summary>Whether to verify subjects match.</summary>
bool VerifySubjects,
/// <summary>Whether to check revocation status.</summary>
bool CheckRevocation);
/// <summary>
/// Result of attestation verification.
/// </summary>
public sealed record PackRunAttestationVerificationResult(
/// <summary>Whether verification passed.</summary>
bool Valid,
/// <summary>Attestation that was verified.</summary>
Guid AttestationId,
/// <summary>Signature verification status.</summary>
PackRunSignatureVerificationStatus SignatureStatus,
/// <summary>Subject verification status.</summary>
PackRunSubjectVerificationStatus SubjectStatus,
/// <summary>Revocation status.</summary>
PackRunRevocationStatus RevocationStatus,
/// <summary>Verification errors.</summary>
IReadOnlyList<string>? Errors,
/// <summary>When verification was performed.</summary>
DateTimeOffset VerifiedAt);
/// <summary>
/// Signature verification status.
/// </summary>
public enum PackRunSignatureVerificationStatus
{
/// <summary>Not verified.</summary>
NotVerified,
/// <summary>Signature is valid.</summary>
Valid,
/// <summary>Signature is invalid.</summary>
Invalid,
/// <summary>Key not found.</summary>
KeyNotFound,
/// <summary>Key expired.</summary>
KeyExpired
}
/// <summary>
/// Subject verification status.
/// </summary>
public enum PackRunSubjectVerificationStatus
{
/// <summary>Not verified.</summary>
NotVerified,
/// <summary>All subjects match.</summary>
Match,
/// <summary>Subjects do not match.</summary>
Mismatch,
/// <summary>Missing expected subjects.</summary>
Missing
}
/// <summary>
/// Revocation status.
/// </summary>
public enum PackRunRevocationStatus
{
/// <summary>Not checked.</summary>
NotChecked,
/// <summary>Not revoked.</summary>
NotRevoked,
/// <summary>Revoked.</summary>
Revoked,
/// <summary>Revocation check failed.</summary>
CheckFailed
}

View File

@@ -313,6 +313,21 @@ public static class PackRunEventTypes
/// <summary>Sealed install requirements warning.</summary>
public const string SealedInstallWarning = "pack.sealed_install.warning";
/// <summary>Attestation created successfully (per TASKRUN-OBS-54-001).</summary>
public const string AttestationCreated = "pack.attestation.created";
/// <summary>Attestation creation failed.</summary>
public const string AttestationFailed = "pack.attestation.failed";
/// <summary>Attestation verified successfully.</summary>
public const string AttestationVerified = "pack.attestation.verified";
/// <summary>Attestation verification failed.</summary>
public const string AttestationVerificationFailed = "pack.attestation.verification_failed";
/// <summary>Attestation was revoked.</summary>
public const string AttestationRevoked = "pack.attestation.revoked";
/// <summary>Checks if the event type is a pack run event.</summary>
public static bool IsPackRunEvent(string eventType) =>
eventType.StartsWith(Prefix, StringComparison.Ordinal);

View File

@@ -0,0 +1,243 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace StellaOps.TaskRunner.Core.Evidence;
/// <summary>
/// Evidence for bundle import operations.
/// Per TASKRUN-AIRGAP-58-001.
/// </summary>
public sealed record BundleImportEvidence(
/// <summary>Unique import job identifier.</summary>
string JobId,
/// <summary>Tenant that initiated the import.</summary>
string TenantId,
/// <summary>Bundle source path or URL.</summary>
string SourcePath,
/// <summary>When the import started.</summary>
DateTimeOffset StartedAt,
/// <summary>When the import completed.</summary>
DateTimeOffset? CompletedAt,
/// <summary>Final status of the import.</summary>
BundleImportStatus Status,
/// <summary>Error message if failed.</summary>
string? ErrorMessage,
/// <summary>Actor who initiated the import.</summary>
string? InitiatedBy,
/// <summary>Input bundle manifest.</summary>
BundleImportInputManifest? InputManifest,
/// <summary>Output files with hashes.</summary>
IReadOnlyList<BundleImportOutputFile> OutputFiles,
/// <summary>Import transcript log entries.</summary>
IReadOnlyList<BundleImportTranscriptEntry> Transcript,
/// <summary>Validation results.</summary>
BundleImportValidationResult? ValidationResult,
/// <summary>Computed hashes for evidence chain.</summary>
BundleImportHashChain HashChain);
/// <summary>
/// Bundle import status.
/// </summary>
public enum BundleImportStatus
{
/// <summary>Import is pending.</summary>
Pending,
/// <summary>Import is in progress.</summary>
InProgress,
/// <summary>Import completed successfully.</summary>
Completed,
/// <summary>Import failed.</summary>
Failed,
/// <summary>Import was cancelled.</summary>
Cancelled,
/// <summary>Import is partially complete.</summary>
PartiallyComplete
}
/// <summary>
/// Input bundle manifest from the import source.
/// </summary>
public sealed record BundleImportInputManifest(
/// <summary>Bundle format version.</summary>
string FormatVersion,
/// <summary>Bundle identifier.</summary>
string BundleId,
/// <summary>Bundle version.</summary>
string BundleVersion,
/// <summary>When the bundle was created.</summary>
DateTimeOffset CreatedAt,
/// <summary>Who created the bundle.</summary>
string? CreatedBy,
/// <summary>Total size in bytes.</summary>
long TotalSizeBytes,
/// <summary>Number of items in the bundle.</summary>
int ItemCount,
/// <summary>SHA-256 of the manifest.</summary>
string ManifestSha256,
/// <summary>Bundle signature if present.</summary>
string? Signature,
/// <summary>Signature verification status.</summary>
bool? SignatureValid);
/// <summary>
/// Output file from bundle import.
/// </summary>
public sealed record BundleImportOutputFile(
/// <summary>Relative path within staging directory.</summary>
string RelativePath,
/// <summary>SHA-256 hash of the file.</summary>
string Sha256,
/// <summary>Size in bytes.</summary>
long SizeBytes,
/// <summary>Media type.</summary>
string MediaType,
/// <summary>When the file was staged.</summary>
DateTimeOffset StagedAt,
/// <summary>Source item identifier in the bundle.</summary>
string? SourceItemId);
/// <summary>
/// Transcript entry for bundle import.
/// </summary>
public sealed record BundleImportTranscriptEntry(
/// <summary>When the entry was recorded.</summary>
DateTimeOffset Timestamp,
/// <summary>Log level.</summary>
string Level,
/// <summary>Event type.</summary>
string EventType,
/// <summary>Message.</summary>
string Message,
/// <summary>Additional data.</summary>
IReadOnlyDictionary<string, string>? Data);
/// <summary>
/// Bundle import validation result.
/// </summary>
public sealed record BundleImportValidationResult(
/// <summary>Whether validation passed.</summary>
bool Valid,
/// <summary>Checksum verification passed.</summary>
bool ChecksumValid,
/// <summary>Signature verification passed.</summary>
bool? SignatureValid,
/// <summary>Format validation passed.</summary>
bool FormatValid,
/// <summary>Validation errors.</summary>
IReadOnlyList<string>? Errors,
/// <summary>Validation warnings.</summary>
IReadOnlyList<string>? Warnings);
/// <summary>
/// Hash chain for bundle import evidence.
/// </summary>
public sealed record BundleImportHashChain(
/// <summary>Hash of all input files.</summary>
string InputsHash,
/// <summary>Hash of all output files.</summary>
string OutputsHash,
/// <summary>Hash of the transcript.</summary>
string TranscriptHash,
/// <summary>Combined root hash.</summary>
string RootHash,
/// <summary>Algorithm used.</summary>
string Algorithm)
{
/// <summary>
/// Computes hash chain from import evidence data.
/// </summary>
public static BundleImportHashChain Compute(
BundleImportInputManifest? input,
IReadOnlyList<BundleImportOutputFile> outputs,
IReadOnlyList<BundleImportTranscriptEntry> transcript)
{
// Compute input hash
var inputJson = input is not null
? JsonSerializer.Serialize(input, JsonOptions)
: "null";
var inputsHash = ComputeSha256(inputJson);
// Compute outputs hash (sorted for determinism)
var sortedOutputs = outputs
.OrderBy(o => o.RelativePath, StringComparer.Ordinal)
.Select(o => o.Sha256)
.ToList();
var outputsJson = JsonSerializer.Serialize(sortedOutputs, JsonOptions);
var outputsHash = ComputeSha256(outputsJson);
// Compute transcript hash
var transcriptJson = JsonSerializer.Serialize(transcript, JsonOptions);
var transcriptHash = ComputeSha256(transcriptJson);
// Compute root hash
var combined = $"{inputsHash}|{outputsHash}|{transcriptHash}";
var rootHash = ComputeSha256(combined);
return new BundleImportHashChain(
InputsHash: inputsHash,
OutputsHash: outputsHash,
TranscriptHash: transcriptHash,
RootHash: rootHash,
Algorithm: "sha256");
}
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
WriteIndented = false
};
private static string ComputeSha256(string content)
{
var bytes = Encoding.UTF8.GetBytes(content);
var hash = SHA256.HashData(bytes);
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
}
}

View File

@@ -0,0 +1,381 @@
using Microsoft.Extensions.Logging;
using StellaOps.TaskRunner.Core.Events;
namespace StellaOps.TaskRunner.Core.Evidence;
/// <summary>
/// Service for capturing bundle import evidence.
/// Per TASKRUN-AIRGAP-58-001.
/// </summary>
public interface IBundleImportEvidenceService
{
/// <summary>
/// Captures evidence for a bundle import operation.
/// </summary>
Task<BundleImportEvidenceResult> CaptureAsync(
BundleImportEvidence evidence,
CancellationToken cancellationToken = default);
/// <summary>
/// Exports evidence to a portable bundle format.
/// </summary>
Task<PortableEvidenceBundleResult> ExportToPortableBundleAsync(
string jobId,
string outputPath,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets evidence for a bundle import job.
/// </summary>
Task<BundleImportEvidence?> GetAsync(
string jobId,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Result of capturing bundle import evidence.
/// </summary>
public sealed record BundleImportEvidenceResult(
/// <summary>Whether capture was successful.</summary>
bool Success,
/// <summary>The captured snapshot.</summary>
PackRunEvidenceSnapshot? Snapshot,
/// <summary>Evidence pointer for linking.</summary>
PackRunEvidencePointer? EvidencePointer,
/// <summary>Error message if capture failed.</summary>
string? Error);
/// <summary>
/// Result of exporting to portable bundle.
/// </summary>
public sealed record PortableEvidenceBundleResult(
/// <summary>Whether export was successful.</summary>
bool Success,
/// <summary>Path to the exported bundle.</summary>
string? OutputPath,
/// <summary>SHA-256 of the bundle.</summary>
string? BundleSha256,
/// <summary>Size in bytes.</summary>
long SizeBytes,
/// <summary>Error message if export failed.</summary>
string? Error);
/// <summary>
/// Default implementation of bundle import evidence service.
/// </summary>
public sealed class BundleImportEvidenceService : IBundleImportEvidenceService
{
private readonly IPackRunEvidenceStore _store;
private readonly IPackRunTimelineEventEmitter? _timelineEmitter;
private readonly ILogger<BundleImportEvidenceService> _logger;
public BundleImportEvidenceService(
IPackRunEvidenceStore store,
ILogger<BundleImportEvidenceService> logger,
IPackRunTimelineEventEmitter? timelineEmitter = null)
{
_store = store ?? throw new ArgumentNullException(nameof(store));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_timelineEmitter = timelineEmitter;
}
/// <inheritdoc />
public async Task<BundleImportEvidenceResult> CaptureAsync(
BundleImportEvidence evidence,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(evidence);
try
{
var materials = new List<PackRunEvidenceMaterial>();
// Add input manifest
if (evidence.InputManifest is not null)
{
materials.Add(PackRunEvidenceMaterial.FromJson(
"input",
"manifest.json",
evidence.InputManifest,
new Dictionary<string, string>
{
["bundleId"] = evidence.InputManifest.BundleId,
["bundleVersion"] = evidence.InputManifest.BundleVersion
}));
}
// Add output files as materials
foreach (var output in evidence.OutputFiles)
{
materials.Add(new PackRunEvidenceMaterial(
Section: "output",
Path: output.RelativePath,
Sha256: output.Sha256,
SizeBytes: output.SizeBytes,
MediaType: output.MediaType,
Attributes: new Dictionary<string, string>
{
["stagedAt"] = output.StagedAt.ToString("O")
}));
}
// Add transcript
materials.Add(PackRunEvidenceMaterial.FromJson(
"transcript",
"import-log.json",
evidence.Transcript));
// Add validation result
if (evidence.ValidationResult is not null)
{
materials.Add(PackRunEvidenceMaterial.FromJson(
"validation",
"result.json",
evidence.ValidationResult));
}
// Add hash chain
materials.Add(PackRunEvidenceMaterial.FromJson(
"hashchain",
"chain.json",
evidence.HashChain));
// Create metadata
var metadata = new Dictionary<string, string>
{
["jobId"] = evidence.JobId,
["status"] = evidence.Status.ToString(),
["sourcePath"] = evidence.SourcePath,
["startedAt"] = evidence.StartedAt.ToString("O"),
["outputCount"] = evidence.OutputFiles.Count.ToString(),
["rootHash"] = evidence.HashChain.RootHash
};
if (evidence.CompletedAt.HasValue)
{
metadata["completedAt"] = evidence.CompletedAt.Value.ToString("O");
metadata["durationMs"] = ((evidence.CompletedAt.Value - evidence.StartedAt).TotalMilliseconds).ToString("F0");
}
if (!string.IsNullOrWhiteSpace(evidence.InitiatedBy))
{
metadata["initiatedBy"] = evidence.InitiatedBy;
}
// Create snapshot
var snapshot = PackRunEvidenceSnapshot.Create(
tenantId: evidence.TenantId,
runId: evidence.JobId,
planHash: evidence.HashChain.RootHash,
kind: PackRunEvidenceSnapshotKind.BundleImport,
materials: materials,
metadata: metadata);
// Store snapshot
await _store.StoreAsync(snapshot, cancellationToken);
var evidencePointer = PackRunEvidencePointer.Bundle(
snapshot.SnapshotId,
snapshot.RootHash);
// Emit timeline event
if (_timelineEmitter is not null)
{
await _timelineEmitter.EmitAsync(
PackRunTimelineEvent.Create(
tenantId: evidence.TenantId,
eventType: "bundle.import.evidence_captured",
source: "taskrunner-bundle-import",
occurredAt: DateTimeOffset.UtcNow,
runId: evidence.JobId,
planHash: evidence.HashChain.RootHash,
attributes: new Dictionary<string, string>
{
["snapshotId"] = snapshot.SnapshotId.ToString(),
["rootHash"] = snapshot.RootHash,
["status"] = evidence.Status.ToString(),
["outputCount"] = evidence.OutputFiles.Count.ToString()
},
evidencePointer: evidencePointer),
cancellationToken);
}
_logger.LogInformation(
"Captured bundle import evidence for job {JobId} with {OutputCount} outputs, root hash {RootHash}",
evidence.JobId,
evidence.OutputFiles.Count,
evidence.HashChain.RootHash);
return new BundleImportEvidenceResult(
Success: true,
Snapshot: snapshot,
EvidencePointer: evidencePointer,
Error: null);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to capture bundle import evidence for job {JobId}", evidence.JobId);
return new BundleImportEvidenceResult(
Success: false,
Snapshot: null,
EvidencePointer: null,
Error: ex.Message);
}
}
/// <inheritdoc />
public async Task<PortableEvidenceBundleResult> ExportToPortableBundleAsync(
string jobId,
string outputPath,
CancellationToken cancellationToken = default)
{
try
{
// Get all snapshots for this job
var snapshots = await _store.GetByRunIdAsync(jobId, cancellationToken);
if (snapshots.Count == 0)
{
return new PortableEvidenceBundleResult(
Success: false,
OutputPath: null,
BundleSha256: null,
SizeBytes: 0,
Error: $"No evidence found for job {jobId}");
}
// Create portable bundle structure
var bundleManifest = new PortableEvidenceBundleManifest
{
Version = "1.0.0",
CreatedAt = DateTimeOffset.UtcNow,
JobId = jobId,
SnapshotCount = snapshots.Count,
Snapshots = snapshots.Select(s => new PortableSnapshotReference
{
SnapshotId = s.SnapshotId,
Kind = s.Kind.ToString(),
RootHash = s.RootHash,
CreatedAt = s.CreatedAt,
MaterialCount = s.Materials.Count
}).ToList()
};
// Serialize bundle
var bundleJson = System.Text.Json.JsonSerializer.Serialize(new
{
manifest = bundleManifest,
snapshots = snapshots
}, new System.Text.Json.JsonSerializerOptions
{
PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase,
WriteIndented = true
});
// Write to file
await File.WriteAllTextAsync(outputPath, bundleJson, cancellationToken);
var fileInfo = new FileInfo(outputPath);
// Compute bundle hash
var bundleBytes = await File.ReadAllBytesAsync(outputPath, cancellationToken);
var hash = System.Security.Cryptography.SHA256.HashData(bundleBytes);
var bundleSha256 = $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
_logger.LogInformation(
"Exported portable evidence bundle for job {JobId} to {OutputPath}, size {SizeBytes} bytes",
jobId,
outputPath,
fileInfo.Length);
return new PortableEvidenceBundleResult(
Success: true,
OutputPath: outputPath,
BundleSha256: bundleSha256,
SizeBytes: fileInfo.Length,
Error: null);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to export portable evidence bundle for job {JobId}", jobId);
return new PortableEvidenceBundleResult(
Success: false,
OutputPath: null,
BundleSha256: null,
SizeBytes: 0,
Error: ex.Message);
}
}
/// <inheritdoc />
public async Task<BundleImportEvidence?> GetAsync(
string jobId,
CancellationToken cancellationToken = default)
{
var snapshots = await _store.GetByRunIdAsync(jobId, cancellationToken);
var importSnapshot = snapshots.FirstOrDefault(s => s.Kind == PackRunEvidenceSnapshotKind.BundleImport);
if (importSnapshot is null)
{
return null;
}
// Reconstruct evidence from snapshot
return ReconstructEvidence(importSnapshot);
}
private static BundleImportEvidence? ReconstructEvidence(PackRunEvidenceSnapshot snapshot)
{
// This would deserialize the stored materials back into the evidence structure
// For now, return a minimal reconstruction from metadata
var metadata = snapshot.Metadata ?? new Dictionary<string, string>();
return new BundleImportEvidence(
JobId: metadata.GetValueOrDefault("jobId", snapshot.RunId),
TenantId: snapshot.TenantId,
SourcePath: metadata.GetValueOrDefault("sourcePath", "unknown"),
StartedAt: DateTimeOffset.TryParse(metadata.GetValueOrDefault("startedAt"), out var started)
? started : snapshot.CreatedAt,
CompletedAt: DateTimeOffset.TryParse(metadata.GetValueOrDefault("completedAt"), out var completed)
? completed : null,
Status: Enum.TryParse<BundleImportStatus>(metadata.GetValueOrDefault("status"), out var status)
? status : BundleImportStatus.Completed,
ErrorMessage: null,
InitiatedBy: metadata.GetValueOrDefault("initiatedBy"),
InputManifest: null,
OutputFiles: [],
Transcript: [],
ValidationResult: null,
HashChain: new BundleImportHashChain(
InputsHash: "sha256:reconstructed",
OutputsHash: "sha256:reconstructed",
TranscriptHash: "sha256:reconstructed",
RootHash: metadata.GetValueOrDefault("rootHash", snapshot.RootHash),
Algorithm: "sha256"));
}
private sealed class PortableEvidenceBundleManifest
{
public required string Version { get; init; }
public required DateTimeOffset CreatedAt { get; init; }
public required string JobId { get; init; }
public required int SnapshotCount { get; init; }
public required IReadOnlyList<PortableSnapshotReference> Snapshots { get; init; }
}
private sealed class PortableSnapshotReference
{
public required Guid SnapshotId { get; init; }
public required string Kind { get; init; }
public required string RootHash { get; init; }
public required DateTimeOffset CreatedAt { get; init; }
public required int MaterialCount { get; init; }
}
}

View File

@@ -28,6 +28,14 @@ public interface IPackRunEvidenceStore
string runId,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets evidence snapshots by run ID only (across all tenants).
/// For bundle import evidence lookups.
/// </summary>
Task<IReadOnlyList<PackRunEvidenceSnapshot>> GetByRunIdAsync(
string runId,
CancellationToken cancellationToken = default);
/// <summary>
/// Lists evidence snapshots by kind for a run.
/// </summary>
@@ -109,6 +117,20 @@ public sealed class InMemoryPackRunEvidenceStore : IPackRunEvidenceStore
}
}
public Task<IReadOnlyList<PackRunEvidenceSnapshot>> GetByRunIdAsync(
string runId,
CancellationToken cancellationToken = default)
{
lock (_lock)
{
var results = _snapshots.Values
.Where(s => s.RunId == runId)
.OrderBy(s => s.CreatedAt)
.ToList();
return Task.FromResult<IReadOnlyList<PackRunEvidenceSnapshot>>(results);
}
}
public Task<IReadOnlyList<PackRunEvidenceSnapshot>> ListByKindAsync(
string tenantId,
string runId,

View File

@@ -151,7 +151,10 @@ public enum PackRunEvidenceSnapshotKind
ArtifactManifest,
/// <summary>Environment digest snapshot.</summary>
EnvironmentDigest
EnvironmentDigest,
/// <summary>Bundle import snapshot (TASKRUN-AIRGAP-58-001).</summary>
BundleImport
}
/// <summary>

View File

@@ -0,0 +1,345 @@
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.TaskRunner.Core.Evidence;
using StellaOps.TaskRunner.Core.Events;
namespace StellaOps.TaskRunner.Tests;
public sealed class BundleImportEvidenceTests
{
[Fact]
public void BundleImportHashChain_Compute_CreatesDeterministicHash()
{
var input = new BundleImportInputManifest(
FormatVersion: "1.0.0",
BundleId: "test-bundle",
BundleVersion: "2025.10.0",
CreatedAt: DateTimeOffset.Parse("2025-12-06T00:00:00Z"),
CreatedBy: "test@example.com",
TotalSizeBytes: 1024,
ItemCount: 5,
ManifestSha256: "sha256:abc123",
Signature: null,
SignatureValid: null);
var outputs = new List<BundleImportOutputFile>
{
new("file1.json", "sha256:aaa", 100, "application/json", DateTimeOffset.UtcNow, "item1"),
new("file2.json", "sha256:bbb", 200, "application/json", DateTimeOffset.UtcNow, "item2")
};
var transcript = new List<BundleImportTranscriptEntry>
{
new(DateTimeOffset.UtcNow, "info", "import.started", "Import started", null)
};
var chain1 = BundleImportHashChain.Compute(input, outputs, transcript);
var chain2 = BundleImportHashChain.Compute(input, outputs, transcript);
Assert.Equal(chain1.RootHash, chain2.RootHash);
Assert.Equal(chain1.InputsHash, chain2.InputsHash);
Assert.Equal(chain1.OutputsHash, chain2.OutputsHash);
Assert.StartsWith("sha256:", chain1.RootHash);
}
[Fact]
public void BundleImportHashChain_Compute_DifferentInputsProduceDifferentHashes()
{
var input1 = new BundleImportInputManifest(
FormatVersion: "1.0.0",
BundleId: "bundle-1",
BundleVersion: "2025.10.0",
CreatedAt: DateTimeOffset.UtcNow,
CreatedBy: null,
TotalSizeBytes: 1024,
ItemCount: 5,
ManifestSha256: "sha256:abc123",
Signature: null,
SignatureValid: null);
var input2 = new BundleImportInputManifest(
FormatVersion: "1.0.0",
BundleId: "bundle-2",
BundleVersion: "2025.10.0",
CreatedAt: DateTimeOffset.UtcNow,
CreatedBy: null,
TotalSizeBytes: 1024,
ItemCount: 5,
ManifestSha256: "sha256:def456",
Signature: null,
SignatureValid: null);
var outputs = new List<BundleImportOutputFile>();
var transcript = new List<BundleImportTranscriptEntry>();
var chain1 = BundleImportHashChain.Compute(input1, outputs, transcript);
var chain2 = BundleImportHashChain.Compute(input2, outputs, transcript);
Assert.NotEqual(chain1.RootHash, chain2.RootHash);
Assert.NotEqual(chain1.InputsHash, chain2.InputsHash);
}
[Fact]
public async Task BundleImportEvidenceService_CaptureAsync_StoresEvidence()
{
var store = new InMemoryPackRunEvidenceStore();
var service = new BundleImportEvidenceService(
store,
NullLogger<BundleImportEvidenceService>.Instance);
var evidence = CreateTestEvidence();
var result = await service.CaptureAsync(evidence, TestContext.Current.CancellationToken);
Assert.True(result.Success);
Assert.NotNull(result.Snapshot);
Assert.NotNull(result.EvidencePointer);
Assert.Equal(1, store.Count);
}
[Fact]
public async Task BundleImportEvidenceService_CaptureAsync_CreatesCorrectMaterials()
{
var store = new InMemoryPackRunEvidenceStore();
var service = new BundleImportEvidenceService(
store,
NullLogger<BundleImportEvidenceService>.Instance);
var evidence = CreateTestEvidence();
var result = await service.CaptureAsync(evidence, TestContext.Current.CancellationToken);
Assert.True(result.Success);
var snapshot = result.Snapshot!;
// Should have: input manifest, 2 outputs, transcript, validation, hashchain = 6 materials
Assert.Equal(6, snapshot.Materials.Count);
Assert.Contains(snapshot.Materials, m => m.Section == "input");
Assert.Contains(snapshot.Materials, m => m.Section == "output");
Assert.Contains(snapshot.Materials, m => m.Section == "transcript");
Assert.Contains(snapshot.Materials, m => m.Section == "validation");
Assert.Contains(snapshot.Materials, m => m.Section == "hashchain");
}
[Fact]
public async Task BundleImportEvidenceService_CaptureAsync_SetsCorrectMetadata()
{
var store = new InMemoryPackRunEvidenceStore();
var service = new BundleImportEvidenceService(
store,
NullLogger<BundleImportEvidenceService>.Instance);
var evidence = CreateTestEvidence();
var result = await service.CaptureAsync(evidence, TestContext.Current.CancellationToken);
Assert.True(result.Success);
var snapshot = result.Snapshot!;
Assert.Equal(evidence.JobId, snapshot.Metadata!["jobId"]);
Assert.Equal(evidence.Status.ToString(), snapshot.Metadata["status"]);
Assert.Equal(evidence.SourcePath, snapshot.Metadata["sourcePath"]);
Assert.Equal("2", snapshot.Metadata["outputCount"]);
}
[Fact]
public async Task BundleImportEvidenceService_CaptureAsync_EmitsTimelineEvent()
{
var store = new InMemoryPackRunEvidenceStore();
var timelineSink = new InMemoryPackRunTimelineEventSink();
var emitter = new PackRunTimelineEventEmitter(
timelineSink,
TimeProvider.System,
NullLogger<PackRunTimelineEventEmitter>.Instance);
var service = new BundleImportEvidenceService(
store,
NullLogger<BundleImportEvidenceService>.Instance,
emitter);
var evidence = CreateTestEvidence();
var result = await service.CaptureAsync(evidence, TestContext.Current.CancellationToken);
Assert.True(result.Success);
Assert.Equal(1, timelineSink.Count);
var evt = timelineSink.GetEvents()[0];
Assert.Equal("bundle.import.evidence_captured", evt.EventType);
}
[Fact]
public async Task BundleImportEvidenceService_GetAsync_ReturnsEvidence()
{
var store = new InMemoryPackRunEvidenceStore();
var service = new BundleImportEvidenceService(
store,
NullLogger<BundleImportEvidenceService>.Instance);
var evidence = CreateTestEvidence();
await service.CaptureAsync(evidence, TestContext.Current.CancellationToken);
var retrieved = await service.GetAsync(evidence.JobId, TestContext.Current.CancellationToken);
Assert.NotNull(retrieved);
Assert.Equal(evidence.JobId, retrieved.JobId);
Assert.Equal(evidence.TenantId, retrieved.TenantId);
}
[Fact]
public async Task BundleImportEvidenceService_GetAsync_ReturnsNullForMissingJob()
{
var store = new InMemoryPackRunEvidenceStore();
var service = new BundleImportEvidenceService(
store,
NullLogger<BundleImportEvidenceService>.Instance);
var retrieved = await service.GetAsync("non-existent-job", TestContext.Current.CancellationToken);
Assert.Null(retrieved);
}
[Fact]
public async Task BundleImportEvidenceService_ExportToPortableBundleAsync_CreatesFile()
{
var store = new InMemoryPackRunEvidenceStore();
var service = new BundleImportEvidenceService(
store,
NullLogger<BundleImportEvidenceService>.Instance);
var evidence = CreateTestEvidence();
await service.CaptureAsync(evidence, TestContext.Current.CancellationToken);
var outputPath = Path.Combine(Path.GetTempPath(), $"evidence-{Guid.NewGuid():N}.json");
try
{
var result = await service.ExportToPortableBundleAsync(
evidence.JobId,
outputPath,
TestContext.Current.CancellationToken);
Assert.True(result.Success);
Assert.Equal(outputPath, result.OutputPath);
Assert.True(File.Exists(outputPath));
Assert.True(result.SizeBytes > 0);
Assert.StartsWith("sha256:", result.BundleSha256);
}
finally
{
if (File.Exists(outputPath))
{
File.Delete(outputPath);
}
}
}
[Fact]
public async Task BundleImportEvidenceService_ExportToPortableBundleAsync_FailsForMissingJob()
{
var store = new InMemoryPackRunEvidenceStore();
var service = new BundleImportEvidenceService(
store,
NullLogger<BundleImportEvidenceService>.Instance);
var outputPath = Path.Combine(Path.GetTempPath(), $"evidence-{Guid.NewGuid():N}.json");
var result = await service.ExportToPortableBundleAsync(
"non-existent-job",
outputPath,
TestContext.Current.CancellationToken);
Assert.False(result.Success);
Assert.Contains("No evidence found", result.Error);
}
[Fact]
public void BundleImportEvidence_RecordProperties_AreAccessible()
{
var evidence = CreateTestEvidence();
Assert.Equal("test-job-123", evidence.JobId);
Assert.Equal("tenant-1", evidence.TenantId);
Assert.Equal("/path/to/bundle.tar.gz", evidence.SourcePath);
Assert.Equal(BundleImportStatus.Completed, evidence.Status);
Assert.NotNull(evidence.InputManifest);
Assert.Equal(2, evidence.OutputFiles.Count);
Assert.Equal(2, evidence.Transcript.Count);
Assert.NotNull(evidence.ValidationResult);
}
[Fact]
public void BundleImportValidationResult_RecordProperties_AreAccessible()
{
var result = new BundleImportValidationResult(
Valid: true,
ChecksumValid: true,
SignatureValid: true,
FormatValid: true,
Errors: null,
Warnings: ["Advisory data may be stale"]);
Assert.True(result.Valid);
Assert.True(result.ChecksumValid);
Assert.True(result.SignatureValid);
Assert.True(result.FormatValid);
Assert.Null(result.Errors);
Assert.Single(result.Warnings!);
}
private static BundleImportEvidence CreateTestEvidence()
{
var now = DateTimeOffset.UtcNow;
var input = new BundleImportInputManifest(
FormatVersion: "1.0.0",
BundleId: "test-bundle-001",
BundleVersion: "2025.10.0",
CreatedAt: now.AddHours(-1),
CreatedBy: "bundle-builder@example.com",
TotalSizeBytes: 10240,
ItemCount: 5,
ManifestSha256: "sha256:abcdef1234567890",
Signature: "base64sig...",
SignatureValid: true);
var outputs = new List<BundleImportOutputFile>
{
new("advisories/CVE-2025-0001.json", "sha256:output1hash", 512, "application/json", now, "item1"),
new("advisories/CVE-2025-0002.json", "sha256:output2hash", 1024, "application/json", now, "item2")
};
var transcript = new List<BundleImportTranscriptEntry>
{
new(now.AddMinutes(-5), "info", "import.started", "Bundle import started", new Dictionary<string, string>
{
["sourcePath"] = "/path/to/bundle.tar.gz"
}),
new(now, "info", "import.completed", "Bundle import completed successfully", new Dictionary<string, string>
{
["itemsImported"] = "5"
})
};
var validation = new BundleImportValidationResult(
Valid: true,
ChecksumValid: true,
SignatureValid: true,
FormatValid: true,
Errors: null,
Warnings: null);
var hashChain = BundleImportHashChain.Compute(input, outputs, transcript);
return new BundleImportEvidence(
JobId: "test-job-123",
TenantId: "tenant-1",
SourcePath: "/path/to/bundle.tar.gz",
StartedAt: now.AddMinutes(-5),
CompletedAt: now,
Status: BundleImportStatus.Completed,
ErrorMessage: null,
InitiatedBy: "admin@example.com",
InputManifest: input,
OutputFiles: outputs,
Transcript: transcript,
ValidationResult: validation,
HashChain: hashChain);
}
}

View File

@@ -0,0 +1,491 @@
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.TaskRunner.Core.Attestation;
using StellaOps.TaskRunner.Core.Events;
using StellaOps.TaskRunner.Core.Evidence;
namespace StellaOps.TaskRunner.Tests;
public sealed class PackRunAttestationTests
{
[Fact]
public async Task GenerateAsync_CreatesAttestationWithSubjects()
{
var store = new InMemoryPackRunAttestationStore();
var signer = new StubPackRunAttestationSigner();
var service = new PackRunAttestationService(
store,
NullLogger<PackRunAttestationService>.Instance,
signer);
var subjects = new List<PackRunAttestationSubject>
{
new("artifact/output.tar.gz", new Dictionary<string, string> { ["sha256"] = "abc123" }),
new("artifact/sbom.json", new Dictionary<string, string> { ["sha256"] = "def456" })
};
var request = new PackRunAttestationRequest(
RunId: "run-001",
TenantId: "tenant-1",
PlanHash: "sha256:plan123",
Subjects: subjects,
EvidenceSnapshotId: Guid.NewGuid(),
StartedAt: DateTimeOffset.UtcNow.AddMinutes(-5),
CompletedAt: DateTimeOffset.UtcNow,
BuilderId: null,
ExternalParameters: null,
ResolvedDependencies: null,
Metadata: null);
var result = await service.GenerateAsync(request, TestContext.Current.CancellationToken);
Assert.True(result.Success);
Assert.NotNull(result.Attestation);
Assert.Equal(PackRunAttestationStatus.Signed, result.Attestation.Status);
Assert.Equal(2, result.Attestation.Subjects.Count);
Assert.NotNull(result.Attestation.Envelope);
}
[Fact]
public async Task GenerateAsync_WithoutSigner_CreatesPendingAttestation()
{
var store = new InMemoryPackRunAttestationStore();
var service = new PackRunAttestationService(
store,
NullLogger<PackRunAttestationService>.Instance);
var subjects = new List<PackRunAttestationSubject>
{
new("artifact/output.tar.gz", new Dictionary<string, string> { ["sha256"] = "abc123" })
};
var request = new PackRunAttestationRequest(
RunId: "run-002",
TenantId: "tenant-1",
PlanHash: "sha256:plan123",
Subjects: subjects,
EvidenceSnapshotId: null,
StartedAt: DateTimeOffset.UtcNow,
CompletedAt: null,
BuilderId: null,
ExternalParameters: null,
ResolvedDependencies: null,
Metadata: null);
var result = await service.GenerateAsync(request, TestContext.Current.CancellationToken);
Assert.True(result.Success);
Assert.NotNull(result.Attestation);
Assert.Equal(PackRunAttestationStatus.Pending, result.Attestation.Status);
Assert.Null(result.Attestation.Envelope);
}
[Fact]
public async Task GenerateAsync_EmitsTimelineEvent()
{
var store = new InMemoryPackRunAttestationStore();
var signer = new StubPackRunAttestationSigner();
var timelineSink = new InMemoryPackRunTimelineEventSink();
var emitter = new PackRunTimelineEventEmitter(
timelineSink,
TimeProvider.System,
NullLogger<PackRunTimelineEventEmitter>.Instance);
var service = new PackRunAttestationService(
store,
NullLogger<PackRunAttestationService>.Instance,
signer,
emitter);
var request = new PackRunAttestationRequest(
RunId: "run-003",
TenantId: "tenant-1",
PlanHash: "sha256:plan123",
Subjects: [new("artifact/test.json", new Dictionary<string, string> { ["sha256"] = "abc" })],
EvidenceSnapshotId: null,
StartedAt: DateTimeOffset.UtcNow,
CompletedAt: DateTimeOffset.UtcNow,
BuilderId: null,
ExternalParameters: null,
ResolvedDependencies: null,
Metadata: null);
await service.GenerateAsync(request, TestContext.Current.CancellationToken);
Assert.Equal(1, timelineSink.Count);
var evt = timelineSink.GetEvents()[0];
Assert.Equal(PackRunAttestationEventTypes.AttestationCreated, evt.EventType);
}
[Fact]
public async Task VerifyAsync_ValidatesSubjectsMatch()
{
var store = new InMemoryPackRunAttestationStore();
var signer = new StubPackRunAttestationSigner();
var service = new PackRunAttestationService(
store,
NullLogger<PackRunAttestationService>.Instance,
signer);
var subjects = new List<PackRunAttestationSubject>
{
new("artifact/output.tar.gz", new Dictionary<string, string> { ["sha256"] = "abc123" })
};
var request = new PackRunAttestationRequest(
RunId: "run-004",
TenantId: "tenant-1",
PlanHash: "sha256:plan123",
Subjects: subjects,
EvidenceSnapshotId: null,
StartedAt: DateTimeOffset.UtcNow,
CompletedAt: DateTimeOffset.UtcNow,
BuilderId: null,
ExternalParameters: null,
ResolvedDependencies: null,
Metadata: null);
var genResult = await service.GenerateAsync(request, TestContext.Current.CancellationToken);
Assert.NotNull(genResult.Attestation);
var verifyResult = await service.VerifyAsync(
new PackRunAttestationVerificationRequest(
AttestationId: genResult.Attestation.AttestationId,
ExpectedSubjects: subjects,
VerifySignature: true,
VerifySubjects: true,
CheckRevocation: true),
TestContext.Current.CancellationToken);
Assert.True(verifyResult.Valid);
Assert.Equal(PackRunSignatureVerificationStatus.Valid, verifyResult.SignatureStatus);
Assert.Equal(PackRunSubjectVerificationStatus.Match, verifyResult.SubjectStatus);
Assert.Equal(PackRunRevocationStatus.NotRevoked, verifyResult.RevocationStatus);
}
[Fact]
public async Task VerifyAsync_DetectsMismatchedSubjects()
{
var store = new InMemoryPackRunAttestationStore();
var signer = new StubPackRunAttestationSigner();
var service = new PackRunAttestationService(
store,
NullLogger<PackRunAttestationService>.Instance,
signer);
var subjects = new List<PackRunAttestationSubject>
{
new("artifact/output.tar.gz", new Dictionary<string, string> { ["sha256"] = "abc123" })
};
var request = new PackRunAttestationRequest(
RunId: "run-005",
TenantId: "tenant-1",
PlanHash: "sha256:plan123",
Subjects: subjects,
EvidenceSnapshotId: null,
StartedAt: DateTimeOffset.UtcNow,
CompletedAt: DateTimeOffset.UtcNow,
BuilderId: null,
ExternalParameters: null,
ResolvedDependencies: null,
Metadata: null);
var genResult = await service.GenerateAsync(request, TestContext.Current.CancellationToken);
Assert.NotNull(genResult.Attestation);
// Verify with different expected subjects
var differentSubjects = new List<PackRunAttestationSubject>
{
new("artifact/different.tar.gz", new Dictionary<string, string> { ["sha256"] = "xyz789" })
};
var verifyResult = await service.VerifyAsync(
new PackRunAttestationVerificationRequest(
AttestationId: genResult.Attestation.AttestationId,
ExpectedSubjects: differentSubjects,
VerifySignature: false,
VerifySubjects: true,
CheckRevocation: false),
TestContext.Current.CancellationToken);
Assert.False(verifyResult.Valid);
Assert.Equal(PackRunSubjectVerificationStatus.Missing, verifyResult.SubjectStatus);
Assert.NotNull(verifyResult.Errors);
Assert.Contains(verifyResult.Errors, e => e.Contains("Missing subjects"));
}
[Fact]
public async Task VerifyAsync_DetectsRevokedAttestation()
{
var store = new InMemoryPackRunAttestationStore();
var signer = new StubPackRunAttestationSigner();
var service = new PackRunAttestationService(
store,
NullLogger<PackRunAttestationService>.Instance,
signer);
var subjects = new List<PackRunAttestationSubject>
{
new("artifact/output.tar.gz", new Dictionary<string, string> { ["sha256"] = "abc123" })
};
var request = new PackRunAttestationRequest(
RunId: "run-006",
TenantId: "tenant-1",
PlanHash: "sha256:plan123",
Subjects: subjects,
EvidenceSnapshotId: null,
StartedAt: DateTimeOffset.UtcNow,
CompletedAt: DateTimeOffset.UtcNow,
BuilderId: null,
ExternalParameters: null,
ResolvedDependencies: null,
Metadata: null);
var genResult = await service.GenerateAsync(request, TestContext.Current.CancellationToken);
Assert.NotNull(genResult.Attestation);
// Revoke the attestation
await store.UpdateStatusAsync(
genResult.Attestation.AttestationId,
PackRunAttestationStatus.Revoked,
"Compromised key",
TestContext.Current.CancellationToken);
var verifyResult = await service.VerifyAsync(
new PackRunAttestationVerificationRequest(
AttestationId: genResult.Attestation.AttestationId,
ExpectedSubjects: null,
VerifySignature: false,
VerifySubjects: false,
CheckRevocation: true),
TestContext.Current.CancellationToken);
Assert.False(verifyResult.Valid);
Assert.Equal(PackRunRevocationStatus.Revoked, verifyResult.RevocationStatus);
}
[Fact]
public async Task VerifyAsync_ReturnsErrorForNonExistentAttestation()
{
var store = new InMemoryPackRunAttestationStore();
var service = new PackRunAttestationService(
store,
NullLogger<PackRunAttestationService>.Instance);
var verifyResult = await service.VerifyAsync(
new PackRunAttestationVerificationRequest(
AttestationId: Guid.NewGuid(),
ExpectedSubjects: null,
VerifySignature: false,
VerifySubjects: false,
CheckRevocation: false),
TestContext.Current.CancellationToken);
Assert.False(verifyResult.Valid);
Assert.NotNull(verifyResult.Errors);
Assert.Contains(verifyResult.Errors, e => e.Contains("not found"));
}
[Fact]
public async Task ListByRunAsync_ReturnsAttestationsForRun()
{
var store = new InMemoryPackRunAttestationStore();
var signer = new StubPackRunAttestationSigner();
var service = new PackRunAttestationService(
store,
NullLogger<PackRunAttestationService>.Instance,
signer);
// Create two attestations for the same run
for (var i = 0; i < 2; i++)
{
var request = new PackRunAttestationRequest(
RunId: "run-007",
TenantId: "tenant-1",
PlanHash: "sha256:plan123",
Subjects: [new($"artifact/output{i}.tar.gz", new Dictionary<string, string> { ["sha256"] = $"hash{i}" })],
EvidenceSnapshotId: null,
StartedAt: DateTimeOffset.UtcNow,
CompletedAt: DateTimeOffset.UtcNow,
BuilderId: null,
ExternalParameters: null,
ResolvedDependencies: null,
Metadata: null);
await service.GenerateAsync(request, TestContext.Current.CancellationToken);
}
var attestations = await service.ListByRunAsync("tenant-1", "run-007", TestContext.Current.CancellationToken);
Assert.Equal(2, attestations.Count);
Assert.All(attestations, a => Assert.Equal("run-007", a.RunId));
}
[Fact]
public async Task GetEnvelopeAsync_ReturnsEnvelopeForSignedAttestation()
{
var store = new InMemoryPackRunAttestationStore();
var signer = new StubPackRunAttestationSigner();
var service = new PackRunAttestationService(
store,
NullLogger<PackRunAttestationService>.Instance,
signer);
var request = new PackRunAttestationRequest(
RunId: "run-008",
TenantId: "tenant-1",
PlanHash: "sha256:plan123",
Subjects: [new("artifact/output.tar.gz", new Dictionary<string, string> { ["sha256"] = "abc123" })],
EvidenceSnapshotId: null,
StartedAt: DateTimeOffset.UtcNow,
CompletedAt: DateTimeOffset.UtcNow,
BuilderId: null,
ExternalParameters: null,
ResolvedDependencies: null,
Metadata: null);
var genResult = await service.GenerateAsync(request, TestContext.Current.CancellationToken);
Assert.NotNull(genResult.Attestation);
var envelope = await service.GetEnvelopeAsync(genResult.Attestation.AttestationId, TestContext.Current.CancellationToken);
Assert.NotNull(envelope);
Assert.Equal(PackRunDsseEnvelope.InTotoPayloadType, envelope.PayloadType);
Assert.Single(envelope.Signatures);
}
[Fact]
public void PackRunAttestationSubject_FromArtifact_ParsesSha256Prefix()
{
var artifact = new PackRunArtifactReference(
Name: "output.tar.gz",
Sha256: "sha256:abcdef123456",
SizeBytes: 1024,
MediaType: "application/gzip");
var subject = PackRunAttestationSubject.FromArtifact(artifact);
Assert.Equal("output.tar.gz", subject.Name);
Assert.Equal("abcdef123456", subject.Digest["sha256"]);
}
[Fact]
public void PackRunAttestation_ComputeStatementDigest_IsDeterministic()
{
var subjects = new List<PackRunAttestationSubject>
{
new("artifact/output.tar.gz", new Dictionary<string, string> { ["sha256"] = "abc123" })
};
var attestation = new PackRunAttestation(
AttestationId: Guid.NewGuid(),
TenantId: "tenant-1",
RunId: "run-001",
PlanHash: "sha256:plan123",
CreatedAt: DateTimeOffset.Parse("2025-12-06T00:00:00Z"),
Subjects: subjects,
PredicateType: PredicateTypes.PackRunProvenance,
PredicateJson: "{\"test\":true}",
Envelope: null,
Status: PackRunAttestationStatus.Pending,
Error: null,
EvidenceSnapshotId: null,
Metadata: null);
var digest1 = attestation.ComputeStatementDigest();
var digest2 = attestation.ComputeStatementDigest();
Assert.Equal(digest1, digest2);
Assert.StartsWith("sha256:", digest1);
}
[Fact]
public void PackRunDsseEnvelope_ComputeDigest_IsDeterministic()
{
var envelope = new PackRunDsseEnvelope(
PayloadType: PackRunDsseEnvelope.InTotoPayloadType,
Payload: Convert.ToBase64String([1, 2, 3]),
Signatures: [new PackRunDsseSignature("key-001", "sig123")]);
var digest1 = envelope.ComputeDigest();
var digest2 = envelope.ComputeDigest();
Assert.Equal(digest1, digest2);
Assert.StartsWith("sha256:", digest1);
}
[Fact]
public async Task GenerateAsync_WithExternalParameters_IncludesInPredicate()
{
var store = new InMemoryPackRunAttestationStore();
var signer = new StubPackRunAttestationSigner();
var service = new PackRunAttestationService(
store,
NullLogger<PackRunAttestationService>.Instance,
signer);
var externalParams = new Dictionary<string, object>
{
["manifestUrl"] = "https://registry.example.com/pack/v1",
["version"] = "1.0.0"
};
var request = new PackRunAttestationRequest(
RunId: "run-009",
TenantId: "tenant-1",
PlanHash: "sha256:plan123",
Subjects: [new("artifact/output.tar.gz", new Dictionary<string, string> { ["sha256"] = "abc" })],
EvidenceSnapshotId: null,
StartedAt: DateTimeOffset.UtcNow,
CompletedAt: DateTimeOffset.UtcNow,
BuilderId: "https://stellaops.io/task-runner/custom",
ExternalParameters: externalParams,
ResolvedDependencies: null,
Metadata: null);
var result = await service.GenerateAsync(request, TestContext.Current.CancellationToken);
Assert.True(result.Success);
Assert.NotNull(result.Attestation);
Assert.Contains("manifestUrl", result.Attestation.PredicateJson);
}
[Fact]
public async Task GenerateAsync_WithResolvedDependencies_IncludesInPredicate()
{
var store = new InMemoryPackRunAttestationStore();
var signer = new StubPackRunAttestationSigner();
var service = new PackRunAttestationService(
store,
NullLogger<PackRunAttestationService>.Instance,
signer);
var dependencies = new List<PackRunDependency>
{
new("https://registry.example.com/tool/scanner:v1",
new Dictionary<string, string> { ["sha256"] = "scanner123" },
"scanner",
"application/vnd.oci.image.index.v1+json")
};
var request = new PackRunAttestationRequest(
RunId: "run-010",
TenantId: "tenant-1",
PlanHash: "sha256:plan123",
Subjects: [new("artifact/output.tar.gz", new Dictionary<string, string> { ["sha256"] = "abc" })],
EvidenceSnapshotId: null,
StartedAt: DateTimeOffset.UtcNow,
CompletedAt: DateTimeOffset.UtcNow,
BuilderId: null,
ExternalParameters: null,
ResolvedDependencies: dependencies,
Metadata: null);
var result = await service.GenerateAsync(request, TestContext.Current.CancellationToken);
Assert.True(result.Success);
Assert.NotNull(result.Attestation);
Assert.Contains("resolvedDependencies", result.Attestation.PredicateJson);
Assert.Contains("scanner", result.Attestation.PredicateJson);
}
}

View File

@@ -0,0 +1,390 @@
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.TaskRunner.Core.AirGap;
using StellaOps.TaskRunner.Core.TaskPacks;
namespace StellaOps.TaskRunner.Tests;
public sealed class SealedInstallEnforcerTests
{
private static TaskPackManifest CreateManifest(bool sealedInstall, SealedRequirements? requirements = null)
{
return new TaskPackManifest
{
ApiVersion = "taskrunner/v1",
Kind = "TaskPack",
Metadata = new TaskPackMetadata
{
Name = "test-pack",
Version = "1.0.0"
},
Spec = new TaskPackSpec
{
SealedInstall = sealedInstall,
SealedRequirements = requirements
}
};
}
[Fact]
public async Task EnforceAsync_WhenPackDoesNotRequireSealedInstall_ReturnsAllowed()
{
var statusProvider = new MockAirGapStatusProvider(SealedModeStatus.Unsealed());
var options = Options.Create(new SealedInstallEnforcementOptions { Enabled = true });
var enforcer = new SealedInstallEnforcer(
statusProvider,
options,
NullLogger<SealedInstallEnforcer>.Instance);
var manifest = CreateManifest(sealedInstall: false);
var result = await enforcer.EnforceAsync(manifest, cancellationToken: TestContext.Current.CancellationToken);
Assert.True(result.Allowed);
Assert.Equal("Pack does not require sealed install", result.Message);
}
[Fact]
public async Task EnforceAsync_WhenEnforcementDisabled_ReturnsAllowed()
{
var statusProvider = new MockAirGapStatusProvider(SealedModeStatus.Unsealed());
var options = Options.Create(new SealedInstallEnforcementOptions { Enabled = false });
var enforcer = new SealedInstallEnforcer(
statusProvider,
options,
NullLogger<SealedInstallEnforcer>.Instance);
var manifest = CreateManifest(sealedInstall: true);
var result = await enforcer.EnforceAsync(manifest, cancellationToken: TestContext.Current.CancellationToken);
Assert.True(result.Allowed);
Assert.Equal("Enforcement disabled", result.Message);
}
[Fact]
public async Task EnforceAsync_WhenSealedRequiredButEnvironmentNotSealed_ReturnsDenied()
{
var statusProvider = new MockAirGapStatusProvider(SealedModeStatus.Unsealed());
var options = Options.Create(new SealedInstallEnforcementOptions { Enabled = true });
var enforcer = new SealedInstallEnforcer(
statusProvider,
options,
NullLogger<SealedInstallEnforcer>.Instance);
var manifest = CreateManifest(sealedInstall: true);
var result = await enforcer.EnforceAsync(manifest, cancellationToken: TestContext.Current.CancellationToken);
Assert.False(result.Allowed);
Assert.Equal(SealedInstallErrorCodes.SealedInstallViolation, result.ErrorCode);
Assert.NotNull(result.Violation);
Assert.True(result.Violation.RequiredSealed);
Assert.False(result.Violation.ActualSealed);
}
[Fact]
public async Task EnforceAsync_WhenSealedRequiredAndEnvironmentSealed_ReturnsAllowed()
{
var status = new SealedModeStatus(
Sealed: true,
Mode: "sealed",
SealedAt: DateTimeOffset.UtcNow.AddDays(-1),
SealedBy: "admin@test.com",
BundleVersion: "2025.10.0",
BundleDigest: "sha256:abc123",
LastAdvisoryUpdate: DateTimeOffset.UtcNow.AddHours(-12),
AdvisoryStalenessHours: 12,
TimeAnchor: new TimeAnchorInfo(
DateTimeOffset.UtcNow.AddHours(-1),
"base64signature",
Valid: true,
ExpiresAt: DateTimeOffset.UtcNow.AddDays(30)),
EgressBlocked: true,
NetworkPolicy: "deny-all");
var statusProvider = new MockAirGapStatusProvider(status);
var options = Options.Create(new SealedInstallEnforcementOptions { Enabled = true });
var enforcer = new SealedInstallEnforcer(
statusProvider,
options,
NullLogger<SealedInstallEnforcer>.Instance);
var manifest = CreateManifest(sealedInstall: true);
var result = await enforcer.EnforceAsync(manifest, cancellationToken: TestContext.Current.CancellationToken);
Assert.True(result.Allowed);
Assert.Equal("Sealed install requirements satisfied", result.Message);
}
[Fact]
public async Task EnforceAsync_WhenBundleVersionBelowMinimum_ReturnsDenied()
{
var status = new SealedModeStatus(
Sealed: true,
Mode: "sealed",
SealedAt: DateTimeOffset.UtcNow,
SealedBy: null,
BundleVersion: "2024.5.0",
BundleDigest: null,
LastAdvisoryUpdate: DateTimeOffset.UtcNow,
AdvisoryStalenessHours: 0,
TimeAnchor: new TimeAnchorInfo(DateTimeOffset.UtcNow, null, true, DateTimeOffset.UtcNow.AddDays(30)),
EgressBlocked: true,
NetworkPolicy: null);
var statusProvider = new MockAirGapStatusProvider(status);
var options = Options.Create(new SealedInstallEnforcementOptions { Enabled = true });
var enforcer = new SealedInstallEnforcer(
statusProvider,
options,
NullLogger<SealedInstallEnforcer>.Instance);
var requirements = new SealedRequirements(
MinBundleVersion: "2025.10.0",
MaxAdvisoryStalenessHours: 168,
RequireTimeAnchor: true,
AllowedOfflineDurationHours: 720,
RequireSignatureVerification: true);
var manifest = CreateManifest(sealedInstall: true, requirements);
var result = await enforcer.EnforceAsync(manifest, cancellationToken: TestContext.Current.CancellationToken);
Assert.False(result.Allowed);
Assert.Equal(SealedInstallErrorCodes.SealedRequirementsViolation, result.ErrorCode);
Assert.NotNull(result.RequirementViolations);
Assert.Single(result.RequirementViolations);
Assert.Equal("min_bundle_version", result.RequirementViolations[0].Requirement);
}
[Fact]
public async Task EnforceAsync_WhenAdvisoryTooStale_ReturnsDenied()
{
var status = new SealedModeStatus(
Sealed: true,
Mode: "sealed",
SealedAt: DateTimeOffset.UtcNow,
SealedBy: null,
BundleVersion: "2025.10.0",
BundleDigest: null,
LastAdvisoryUpdate: DateTimeOffset.UtcNow.AddHours(-200),
AdvisoryStalenessHours: 200,
TimeAnchor: new TimeAnchorInfo(DateTimeOffset.UtcNow, null, true, DateTimeOffset.UtcNow.AddDays(30)),
EgressBlocked: true,
NetworkPolicy: null);
var statusProvider = new MockAirGapStatusProvider(status);
var options = Options.Create(new SealedInstallEnforcementOptions
{
Enabled = true,
DenyOnStaleness = true,
StalenessGracePeriodHours = 0
});
var enforcer = new SealedInstallEnforcer(
statusProvider,
options,
NullLogger<SealedInstallEnforcer>.Instance);
var requirements = new SealedRequirements(
MinBundleVersion: null,
MaxAdvisoryStalenessHours: 168,
RequireTimeAnchor: false,
AllowedOfflineDurationHours: 720,
RequireSignatureVerification: false);
var manifest = CreateManifest(sealedInstall: true, requirements);
var result = await enforcer.EnforceAsync(manifest, cancellationToken: TestContext.Current.CancellationToken);
Assert.False(result.Allowed);
Assert.Equal(SealedInstallErrorCodes.SealedRequirementsViolation, result.ErrorCode);
Assert.NotNull(result.RequirementViolations);
Assert.Single(result.RequirementViolations);
Assert.Equal("max_advisory_staleness_hours", result.RequirementViolations[0].Requirement);
}
[Fact]
public async Task EnforceAsync_WhenTimeAnchorMissing_ReturnsDenied()
{
var status = new SealedModeStatus(
Sealed: true,
Mode: "sealed",
SealedAt: DateTimeOffset.UtcNow,
SealedBy: null,
BundleVersion: "2025.10.0",
BundleDigest: null,
LastAdvisoryUpdate: DateTimeOffset.UtcNow,
AdvisoryStalenessHours: 0,
TimeAnchor: null, // No time anchor
EgressBlocked: true,
NetworkPolicy: null);
var statusProvider = new MockAirGapStatusProvider(status);
var options = Options.Create(new SealedInstallEnforcementOptions { Enabled = true });
var enforcer = new SealedInstallEnforcer(
statusProvider,
options,
NullLogger<SealedInstallEnforcer>.Instance);
var requirements = new SealedRequirements(
MinBundleVersion: null,
MaxAdvisoryStalenessHours: 168,
RequireTimeAnchor: true,
AllowedOfflineDurationHours: 720,
RequireSignatureVerification: false);
var manifest = CreateManifest(sealedInstall: true, requirements);
var result = await enforcer.EnforceAsync(manifest, cancellationToken: TestContext.Current.CancellationToken);
Assert.False(result.Allowed);
Assert.Equal(SealedInstallErrorCodes.SealedRequirementsViolation, result.ErrorCode);
Assert.NotNull(result.RequirementViolations);
Assert.Single(result.RequirementViolations);
Assert.Equal("require_time_anchor", result.RequirementViolations[0].Requirement);
}
[Fact]
public async Task EnforceAsync_WhenTimeAnchorInvalid_ReturnsDenied()
{
var status = new SealedModeStatus(
Sealed: true,
Mode: "sealed",
SealedAt: DateTimeOffset.UtcNow,
SealedBy: null,
BundleVersion: "2025.10.0",
BundleDigest: null,
LastAdvisoryUpdate: DateTimeOffset.UtcNow,
AdvisoryStalenessHours: 0,
TimeAnchor: new TimeAnchorInfo(DateTimeOffset.UtcNow, null, Valid: false, null),
EgressBlocked: true,
NetworkPolicy: null);
var statusProvider = new MockAirGapStatusProvider(status);
var options = Options.Create(new SealedInstallEnforcementOptions { Enabled = true });
var enforcer = new SealedInstallEnforcer(
statusProvider,
options,
NullLogger<SealedInstallEnforcer>.Instance);
var requirements = new SealedRequirements(
MinBundleVersion: null,
MaxAdvisoryStalenessHours: 168,
RequireTimeAnchor: true,
AllowedOfflineDurationHours: 720,
RequireSignatureVerification: false);
var manifest = CreateManifest(sealedInstall: true, requirements);
var result = await enforcer.EnforceAsync(manifest, cancellationToken: TestContext.Current.CancellationToken);
Assert.False(result.Allowed);
Assert.Equal(SealedInstallErrorCodes.SealedRequirementsViolation, result.ErrorCode);
Assert.NotNull(result.RequirementViolations);
Assert.Contains(result.RequirementViolations, v => v.Requirement == "require_time_anchor");
}
[Fact]
public async Task EnforceAsync_WhenStatusProviderFails_ReturnsDenied()
{
var statusProvider = new FailingAirGapStatusProvider();
var options = Options.Create(new SealedInstallEnforcementOptions { Enabled = true });
var enforcer = new SealedInstallEnforcer(
statusProvider,
options,
NullLogger<SealedInstallEnforcer>.Instance);
var manifest = CreateManifest(sealedInstall: true);
var result = await enforcer.EnforceAsync(manifest, cancellationToken: TestContext.Current.CancellationToken);
Assert.False(result.Allowed);
Assert.Equal(SealedInstallErrorCodes.SealedInstallViolation, result.ErrorCode);
Assert.Contains("Failed to verify", result.Message);
}
[Fact]
public void SealedModeStatus_Unsealed_ReturnsCorrectDefaults()
{
var status = SealedModeStatus.Unsealed();
Assert.False(status.Sealed);
Assert.Equal("unsealed", status.Mode);
Assert.Null(status.SealedAt);
Assert.Null(status.BundleVersion);
}
[Fact]
public void SealedModeStatus_Unavailable_ReturnsCorrectDefaults()
{
var status = SealedModeStatus.Unavailable();
Assert.False(status.Sealed);
Assert.Equal("unavailable", status.Mode);
}
[Fact]
public void SealedRequirements_Default_HasExpectedValues()
{
var defaults = SealedRequirements.Default;
Assert.Null(defaults.MinBundleVersion);
Assert.Equal(168, defaults.MaxAdvisoryStalenessHours);
Assert.True(defaults.RequireTimeAnchor);
Assert.Equal(720, defaults.AllowedOfflineDurationHours);
Assert.True(defaults.RequireSignatureVerification);
}
[Fact]
public void EnforcementResult_CreateAllowed_SetsProperties()
{
var result = SealedInstallEnforcementResult.CreateAllowed("Test message");
Assert.True(result.Allowed);
Assert.Null(result.ErrorCode);
Assert.Equal("Test message", result.Message);
Assert.Null(result.Violation);
Assert.Null(result.RequirementViolations);
}
[Fact]
public void EnforcementResult_CreateDenied_SetsProperties()
{
var violation = new SealedInstallViolation("pack-1", "1.0.0", true, false, "Seal the environment");
var result = SealedInstallEnforcementResult.CreateDenied(
SealedInstallErrorCodes.SealedInstallViolation,
"Denied message",
violation);
Assert.False(result.Allowed);
Assert.Equal(SealedInstallErrorCodes.SealedInstallViolation, result.ErrorCode);
Assert.Equal("Denied message", result.Message);
Assert.NotNull(result.Violation);
Assert.Equal("pack-1", result.Violation.PackId);
}
private sealed class MockAirGapStatusProvider : IAirGapStatusProvider
{
private readonly SealedModeStatus _status;
public MockAirGapStatusProvider(SealedModeStatus status)
{
_status = status;
}
public Task<SealedModeStatus> GetStatusAsync(string? tenantId = null, CancellationToken cancellationToken = default)
{
return Task.FromResult(_status);
}
}
private sealed class FailingAirGapStatusProvider : IAirGapStatusProvider
{
public Task<SealedModeStatus> GetStatusAsync(string? tenantId = null, CancellationToken cancellationToken = default)
{
throw new HttpRequestException("Connection refused");
}
}
}

View File

@@ -13,11 +13,15 @@ using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using StellaOps.AirGap.Policy;
using StellaOps.TaskRunner.Core.AirGap;
using StellaOps.TaskRunner.Core.Attestation;
using StellaOps.TaskRunner.Core.Configuration;
using StellaOps.TaskRunner.Core.Events;
using StellaOps.TaskRunner.Core.Execution;
using StellaOps.TaskRunner.Core.Execution.Simulation;
using StellaOps.TaskRunner.Core.Planning;
using StellaOps.TaskRunner.Core.TaskPacks;
using StellaOps.TaskRunner.Infrastructure.AirGap;
using StellaOps.TaskRunner.Infrastructure.Execution;
using StellaOps.TaskRunner.WebService;
using StellaOps.TaskRunner.WebService.Deprecation;
@@ -101,6 +105,28 @@ builder.Services.AddSingleton<IPackRunJobScheduler>(sp => sp.GetRequiredService<
builder.Services.AddSingleton<PackRunApprovalDecisionService>();
builder.Services.AddApiDeprecation(builder.Configuration);
builder.Services.AddSingleton<IDeprecationNotificationService, LoggingDeprecationNotificationService>();
// Sealed install enforcement (TASKRUN-AIRGAP-57-001)
builder.Services.Configure<SealedInstallEnforcementOptions>(
builder.Configuration.GetSection("TaskRunner:Enforcement:SealedInstall"));
builder.Services.Configure<AirGapStatusProviderOptions>(
builder.Configuration.GetSection("TaskRunner:AirGap"));
builder.Services.AddHttpClient<IAirGapStatusProvider, HttpAirGapStatusProvider>((sp, client) =>
{
var options = sp.GetRequiredService<IOptions<AirGapStatusProviderOptions>>().Value;
client.BaseAddress = new Uri(options.BaseUrl);
client.Timeout = TimeSpan.FromSeconds(10);
});
builder.Services.AddSingleton<ISealedInstallEnforcer, SealedInstallEnforcer>();
builder.Services.AddSingleton<IPackRunTimelineEventSink, InMemoryPackRunTimelineEventSink>();
builder.Services.AddSingleton<IPackRunTimelineEventEmitter, PackRunTimelineEventEmitter>();
builder.Services.AddSingleton<ISealedInstallAuditLogger, SealedInstallAuditLogger>();
// Pack run attestations (TASKRUN-OBS-54-001)
builder.Services.AddSingleton<IPackRunAttestationStore, InMemoryPackRunAttestationStore>();
builder.Services.AddSingleton<IPackRunAttestationSigner, StubPackRunAttestationSigner>();
builder.Services.AddSingleton<IPackRunAttestationService, PackRunAttestationService>();
builder.Services.AddOpenApi();
var app = builder.Build();
@@ -191,6 +217,19 @@ app.MapPost("/api/runs/{runId}/approvals/{approvalId}", HandleApplyApprovalDecis
app.MapPost("/v1/task-runner/runs/{runId}/cancel", HandleCancelRun).WithName("CancelRun");
app.MapPost("/api/runs/{runId}/cancel", HandleCancelRun).WithName("CancelRunApi");
// Attestation endpoints (TASKRUN-OBS-54-001)
app.MapGet("/v1/task-runner/runs/{runId}/attestations", HandleListAttestations).WithName("ListRunAttestations");
app.MapGet("/api/runs/{runId}/attestations", HandleListAttestations).WithName("ListRunAttestationsApi");
app.MapGet("/v1/task-runner/attestations/{attestationId}", HandleGetAttestation).WithName("GetAttestation");
app.MapGet("/api/attestations/{attestationId}", HandleGetAttestation).WithName("GetAttestationApi");
app.MapGet("/v1/task-runner/attestations/{attestationId}/envelope", HandleGetAttestationEnvelope).WithName("GetAttestationEnvelope");
app.MapGet("/api/attestations/{attestationId}/envelope", HandleGetAttestationEnvelope).WithName("GetAttestationEnvelopeApi");
app.MapPost("/v1/task-runner/attestations/{attestationId}/verify", HandleVerifyAttestation).WithName("VerifyAttestation");
app.MapPost("/api/attestations/{attestationId}/verify", HandleVerifyAttestation).WithName("VerifyAttestationApi");
app.MapGet("/.well-known/openapi", (HttpResponse response) =>
{
var metadata = OpenApiMetadataFactory.Create("/openapi");
@@ -212,6 +251,8 @@ async Task<IResult> HandleCreateRun(
IPackRunStateStore stateStore,
IPackRunLogStore logStore,
IPackRunJobScheduler scheduler,
ISealedInstallEnforcer sealedInstallEnforcer,
ISealedInstallAuditLogger auditLogger,
CancellationToken cancellationToken)
{
if (request is null || string.IsNullOrWhiteSpace(request.Manifest))
@@ -229,6 +270,49 @@ async Task<IResult> HandleCreateRun(
return Results.BadRequest(new { error = "Invalid manifest", detail = ex.Message });
}
// TASKRUN-AIRGAP-57-001: Sealed install enforcement
var enforcementResult = await sealedInstallEnforcer.EnforceAsync(
manifest,
request.TenantId,
cancellationToken).ConfigureAwait(false);
// Log the enforcement decision
await auditLogger.LogEnforcementAsync(
manifest,
enforcementResult,
request.TenantId,
request.RunId,
cancellationToken: cancellationToken).ConfigureAwait(false);
if (!enforcementResult.Allowed)
{
return Results.Json(new
{
error = new
{
code = enforcementResult.ErrorCode,
message = enforcementResult.Message,
details = new
{
pack_id = manifest.Metadata.Name,
pack_version = manifest.Metadata.Version,
sealed_install_required = manifest.Spec.SealedInstall,
environment_sealed = enforcementResult.Violation?.ActualSealed ?? false,
violations = enforcementResult.RequirementViolations?.Select(v => new
{
requirement = v.Requirement,
expected = v.Expected,
actual = v.Actual,
message = v.Message
}),
recommendation = enforcementResult.Violation?.Recommendation
}
},
status = "rejected",
rejected_at = DateTimeOffset.UtcNow.ToString("O")
}, statusCode: StatusCodes.Status403Forbidden);
}
var inputs = ConvertInputs(request.Inputs);
var planResult = planner.Plan(manifest, inputs);
if (!planResult.Success || planResult.Plan is null)
@@ -465,6 +549,138 @@ async Task<IResult> HandleCancelRun(
return Results.Accepted($"/v1/task-runner/runs/{runId}", new { status = "cancelled" });
}
// Attestation handlers (TASKRUN-OBS-54-001)
async Task<IResult> HandleListAttestations(
string runId,
[FromHeader(Name = "X-Tenant-ID")] string? tenantId,
IPackRunAttestationService attestationService,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(runId))
{
return Results.BadRequest(new { error = "runId is required." });
}
var effectiveTenantId = tenantId ?? "default";
var attestations = await attestationService.ListByRunAsync(effectiveTenantId, runId, cancellationToken)
.ConfigureAwait(false);
return Results.Ok(new
{
runId,
count = attestations.Count,
attestations = attestations.Select(a => new
{
attestationId = a.AttestationId,
status = a.Status.ToString().ToLowerInvariant(),
predicateType = a.PredicateType,
subjectCount = a.Subjects.Count,
createdAt = a.CreatedAt.ToString("O"),
hasEnvelope = a.Envelope is not null
})
});
}
async Task<IResult> HandleGetAttestation(
string attestationId,
IPackRunAttestationService attestationService,
CancellationToken cancellationToken)
{
if (!Guid.TryParse(attestationId, out var id))
{
return Results.BadRequest(new { error = "Invalid attestationId format." });
}
var attestation = await attestationService.GetAsync(id, cancellationToken).ConfigureAwait(false);
if (attestation is null)
{
return Results.NotFound();
}
return Results.Ok(new
{
attestationId = attestation.AttestationId,
tenantId = attestation.TenantId,
runId = attestation.RunId,
planHash = attestation.PlanHash,
status = attestation.Status.ToString().ToLowerInvariant(),
predicateType = attestation.PredicateType,
subjects = attestation.Subjects.Select(s => new
{
name = s.Name,
digest = s.Digest
}),
createdAt = attestation.CreatedAt.ToString("O"),
evidenceSnapshotId = attestation.EvidenceSnapshotId,
error = attestation.Error,
metadata = attestation.Metadata
});
}
async Task<IResult> HandleGetAttestationEnvelope(
string attestationId,
IPackRunAttestationService attestationService,
CancellationToken cancellationToken)
{
if (!Guid.TryParse(attestationId, out var id))
{
return Results.BadRequest(new { error = "Invalid attestationId format." });
}
var envelope = await attestationService.GetEnvelopeAsync(id, cancellationToken).ConfigureAwait(false);
if (envelope is null)
{
return Results.NotFound();
}
return Results.Ok(new
{
payloadType = envelope.PayloadType,
payload = envelope.Payload,
signatures = envelope.Signatures.Select(s => new
{
keyid = s.KeyId,
sig = s.Sig
})
});
}
async Task<IResult> HandleVerifyAttestation(
string attestationId,
[FromBody] VerifyAttestationRequest? request,
IPackRunAttestationService attestationService,
CancellationToken cancellationToken)
{
if (!Guid.TryParse(attestationId, out var id))
{
return Results.BadRequest(new { error = "Invalid attestationId format." });
}
var expectedSubjects = request?.ExpectedSubjects?.Select(s =>
new PackRunAttestationSubject(s.Name, s.Digest ?? new Dictionary<string, string>())).ToList();
var verifyRequest = new PackRunAttestationVerificationRequest(
AttestationId: id,
ExpectedSubjects: expectedSubjects,
VerifySignature: request?.VerifySignature ?? true,
VerifySubjects: request?.VerifySubjects ?? (expectedSubjects is not null),
CheckRevocation: request?.CheckRevocation ?? true);
var result = await attestationService.VerifyAsync(verifyRequest, cancellationToken).ConfigureAwait(false);
var statusCode = result.Valid ? 200 : 400;
return Results.Json(new
{
valid = result.Valid,
attestationId = result.AttestationId,
signatureStatus = result.SignatureStatus.ToString().ToLowerInvariant(),
subjectStatus = result.SubjectStatus.ToString().ToLowerInvariant(),
revocationStatus = result.RevocationStatus.ToString().ToLowerInvariant(),
errors = result.Errors,
verifiedAt = result.VerifiedAt.ToString("O")
}, statusCode: statusCode);
}
app.Run();
static IDictionary<string, JsonNode?>? ConvertInputs(JsonObject? node)
@@ -487,6 +703,15 @@ internal sealed record CreateRunRequest(string? RunId, string Manifest, JsonObje
internal sealed record SimulationRequest(string Manifest, JsonObject? Inputs);
// Attestation API request models (TASKRUN-OBS-54-001)
internal sealed record VerifyAttestationRequest(
IReadOnlyList<VerifyAttestationSubject>? ExpectedSubjects,
bool VerifySignature = true,
bool VerifySubjects = false,
bool CheckRevocation = true);
internal sealed record VerifyAttestationSubject(string Name, IReadOnlyDictionary<string, string>? Digest);
internal sealed record SimulationResponse(
string PlanHash,
FailurePolicyResponse FailurePolicy,