Some checks failed
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
Docs CI / lint-and-preview (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Findings Ledger CI / build-test (push) Has been cancelled
Findings Ledger CI / migration-validation (push) Has been cancelled
Findings Ledger CI / generate-manifest (push) Has been cancelled
mock-dev-release / package-mock-release (push) Has been cancelled
- Added ConsoleExportClient for managing export requests and responses. - Introduced ConsoleExportRequest and ConsoleExportResponse models. - Implemented methods for creating and retrieving exports with appropriate headers. feat(crypto): Add Software SM2/SM3 Cryptography Provider - Implemented SmSoftCryptoProvider for software-only SM2/SM3 cryptography. - Added support for signing and verification using SM2 algorithm. - Included hashing functionality with SM3 algorithm. - Configured options for loading keys from files and environment gate checks. test(crypto): Add unit tests for SmSoftCryptoProvider - Created comprehensive tests for signing, verifying, and hashing functionalities. - Ensured correct behavior for key management and error handling. feat(api): Enhance Console Export Models - Expanded ConsoleExport models to include detailed status and event types. - Added support for various export formats and notification options. test(time): Implement TimeAnchorPolicyService tests - Developed tests for TimeAnchorPolicyService to validate time anchors. - Covered scenarios for anchor validation, drift calculation, and policy enforcement.
444 lines
15 KiB
C#
444 lines
15 KiB
C#
using System.Collections.Concurrent;
|
|
using System.Security.Cryptography;
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
using StellaOps.Policy.Registry.Contracts;
|
|
using StellaOps.Policy.Registry.Storage;
|
|
|
|
namespace StellaOps.Policy.Registry.Services;
|
|
|
|
/// <summary>
|
|
/// Default implementation of publish pipeline service.
|
|
/// Handles policy pack publication with attestation generation.
|
|
/// </summary>
|
|
public sealed class PublishPipelineService : IPublishPipelineService
|
|
{
|
|
private const string BuilderId = "https://stellaops.io/policy-registry/v1";
|
|
private const string BuildType = "https://stellaops.io/policy-registry/v1/publish";
|
|
private const string AttestationPredicateType = "https://slsa.dev/provenance/v1";
|
|
|
|
private readonly IPolicyPackStore _packStore;
|
|
private readonly IPolicyPackCompiler _compiler;
|
|
private readonly IReviewWorkflowService _reviewService;
|
|
private readonly TimeProvider _timeProvider;
|
|
|
|
private readonly ConcurrentDictionary<(Guid TenantId, Guid PackId), PublicationStatus> _publications = new();
|
|
private readonly ConcurrentDictionary<(Guid TenantId, Guid PackId), PolicyPackAttestation> _attestations = new();
|
|
|
|
public PublishPipelineService(
|
|
IPolicyPackStore packStore,
|
|
IPolicyPackCompiler compiler,
|
|
IReviewWorkflowService reviewService,
|
|
TimeProvider? timeProvider = null)
|
|
{
|
|
_packStore = packStore ?? throw new ArgumentNullException(nameof(packStore));
|
|
_compiler = compiler ?? throw new ArgumentNullException(nameof(compiler));
|
|
_reviewService = reviewService ?? throw new ArgumentNullException(nameof(reviewService));
|
|
_timeProvider = timeProvider ?? TimeProvider.System;
|
|
}
|
|
|
|
public async Task<PublishResult> PublishAsync(
|
|
Guid tenantId,
|
|
Guid packId,
|
|
PublishPackRequest request,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
// Get the policy pack
|
|
var pack = await _packStore.GetByIdAsync(tenantId, packId, cancellationToken);
|
|
if (pack is null)
|
|
{
|
|
return new PublishResult
|
|
{
|
|
Success = false,
|
|
Error = $"Policy pack {packId} not found"
|
|
};
|
|
}
|
|
|
|
// Verify pack is in correct state
|
|
if (pack.Status != PolicyPackStatus.PendingReview)
|
|
{
|
|
return new PublishResult
|
|
{
|
|
Success = false,
|
|
Error = $"Policy pack must be in PendingReview status to publish. Current status: {pack.Status}"
|
|
};
|
|
}
|
|
|
|
// Compile to get digest
|
|
var compilationResult = await _compiler.CompileAsync(tenantId, packId, cancellationToken);
|
|
if (!compilationResult.Success)
|
|
{
|
|
return new PublishResult
|
|
{
|
|
Success = false,
|
|
Error = "Policy pack compilation failed. Cannot publish."
|
|
};
|
|
}
|
|
|
|
var now = _timeProvider.GetUtcNow();
|
|
var digest = compilationResult.Digest!;
|
|
|
|
// Get review information if available
|
|
var reviews = await _reviewService.ListReviewsAsync(tenantId, ReviewStatus.Approved, packId, 1, null, cancellationToken);
|
|
var review = reviews.Items.FirstOrDefault();
|
|
|
|
// Build attestation
|
|
var attestation = BuildAttestation(
|
|
pack,
|
|
digest,
|
|
compilationResult,
|
|
review,
|
|
request,
|
|
now);
|
|
|
|
// Update pack status to Published
|
|
var updatedPack = await _packStore.UpdateStatusAsync(tenantId, packId, PolicyPackStatus.Published, request.PublishedBy, cancellationToken);
|
|
if (updatedPack is null)
|
|
{
|
|
return new PublishResult
|
|
{
|
|
Success = false,
|
|
Error = "Failed to update policy pack status"
|
|
};
|
|
}
|
|
|
|
// Create publication status
|
|
var status = new PublicationStatus
|
|
{
|
|
PackId = packId,
|
|
PackVersion = pack.Version,
|
|
Digest = digest,
|
|
State = PublishState.Published,
|
|
PublishedAt = now,
|
|
PublishedBy = request.PublishedBy,
|
|
SignatureKeyId = request.SigningOptions?.KeyId,
|
|
SignatureAlgorithm = request.SigningOptions?.Algorithm
|
|
};
|
|
|
|
_publications[(tenantId, packId)] = status;
|
|
_attestations[(tenantId, packId)] = attestation;
|
|
|
|
return new PublishResult
|
|
{
|
|
Success = true,
|
|
PackId = packId,
|
|
Digest = digest,
|
|
Status = status,
|
|
Attestation = attestation
|
|
};
|
|
}
|
|
|
|
public Task<PublicationStatus?> GetPublicationStatusAsync(
|
|
Guid tenantId,
|
|
Guid packId,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
_publications.TryGetValue((tenantId, packId), out var status);
|
|
return Task.FromResult(status);
|
|
}
|
|
|
|
public Task<PolicyPackAttestation?> GetAttestationAsync(
|
|
Guid tenantId,
|
|
Guid packId,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
_attestations.TryGetValue((tenantId, packId), out var attestation);
|
|
return Task.FromResult(attestation);
|
|
}
|
|
|
|
public async Task<AttestationVerificationResult> VerifyAttestationAsync(
|
|
Guid tenantId,
|
|
Guid packId,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var checks = new List<VerificationCheck>();
|
|
var errors = new List<string>();
|
|
var warnings = new List<string>();
|
|
|
|
// Check publication exists
|
|
if (!_publications.TryGetValue((tenantId, packId), out var status))
|
|
{
|
|
return new AttestationVerificationResult
|
|
{
|
|
Valid = false,
|
|
Errors = ["Policy pack is not published"]
|
|
};
|
|
}
|
|
|
|
checks.Add(new VerificationCheck
|
|
{
|
|
Name = "publication_exists",
|
|
Passed = true,
|
|
Details = $"Published at {status.PublishedAt:O}"
|
|
});
|
|
|
|
// Check not revoked
|
|
if (status.State == PublishState.Revoked)
|
|
{
|
|
errors.Add($"Policy pack was revoked at {status.RevokedAt:O}: {status.RevokeReason}");
|
|
checks.Add(new VerificationCheck
|
|
{
|
|
Name = "not_revoked",
|
|
Passed = false,
|
|
Details = status.RevokeReason
|
|
});
|
|
}
|
|
else
|
|
{
|
|
checks.Add(new VerificationCheck
|
|
{
|
|
Name = "not_revoked",
|
|
Passed = true,
|
|
Details = "Policy pack has not been revoked"
|
|
});
|
|
}
|
|
|
|
// Check attestation exists
|
|
if (!_attestations.TryGetValue((tenantId, packId), out var attestation))
|
|
{
|
|
errors.Add("Attestation not found");
|
|
checks.Add(new VerificationCheck
|
|
{
|
|
Name = "attestation_exists",
|
|
Passed = false,
|
|
Details = "No attestation on record"
|
|
});
|
|
}
|
|
else
|
|
{
|
|
checks.Add(new VerificationCheck
|
|
{
|
|
Name = "attestation_exists",
|
|
Passed = true,
|
|
Details = $"Found {attestation.Signatures.Count} signature(s)"
|
|
});
|
|
|
|
// Verify signatures
|
|
foreach (var sig in attestation.Signatures)
|
|
{
|
|
// In a real implementation, this would verify the actual cryptographic signature
|
|
var sigValid = !string.IsNullOrEmpty(sig.Signature);
|
|
checks.Add(new VerificationCheck
|
|
{
|
|
Name = $"signature_{sig.KeyId}",
|
|
Passed = sigValid,
|
|
Details = sigValid ? $"Signature verified for key {sig.KeyId}" : "Invalid signature"
|
|
});
|
|
|
|
if (!sigValid)
|
|
{
|
|
errors.Add($"Invalid signature for key {sig.KeyId}");
|
|
}
|
|
}
|
|
}
|
|
|
|
// Verify pack still exists and matches digest
|
|
var pack = await _packStore.GetByIdAsync(tenantId, packId, cancellationToken);
|
|
if (pack is null)
|
|
{
|
|
errors.Add("Policy pack no longer exists");
|
|
checks.Add(new VerificationCheck
|
|
{
|
|
Name = "pack_exists",
|
|
Passed = false,
|
|
Details = "Policy pack has been deleted"
|
|
});
|
|
}
|
|
else
|
|
{
|
|
checks.Add(new VerificationCheck
|
|
{
|
|
Name = "pack_exists",
|
|
Passed = true,
|
|
Details = $"Pack version: {pack.Version}"
|
|
});
|
|
|
|
// Verify digest matches
|
|
var digestMatch = pack.Digest == status.Digest;
|
|
checks.Add(new VerificationCheck
|
|
{
|
|
Name = "digest_match",
|
|
Passed = digestMatch,
|
|
Details = digestMatch ? "Digest matches" : $"Digest mismatch: expected {status.Digest}, got {pack.Digest}"
|
|
});
|
|
|
|
if (!digestMatch)
|
|
{
|
|
errors.Add("Policy pack has been modified since publication");
|
|
}
|
|
}
|
|
|
|
return new AttestationVerificationResult
|
|
{
|
|
Valid = errors.Count == 0,
|
|
Checks = checks,
|
|
Errors = errors.Count > 0 ? errors : null,
|
|
Warnings = warnings.Count > 0 ? warnings : null
|
|
};
|
|
}
|
|
|
|
public Task<PublishedPackList> ListPublishedAsync(
|
|
Guid tenantId,
|
|
int pageSize = 20,
|
|
string? pageToken = null,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var items = _publications
|
|
.Where(kv => kv.Key.TenantId == tenantId)
|
|
.Select(kv => kv.Value)
|
|
.OrderByDescending(p => p.PublishedAt)
|
|
.ToList();
|
|
|
|
int skip = 0;
|
|
if (!string.IsNullOrEmpty(pageToken) && int.TryParse(pageToken, out var offset))
|
|
{
|
|
skip = offset;
|
|
}
|
|
|
|
var pagedItems = items.Skip(skip).Take(pageSize).ToList();
|
|
string? nextToken = skip + pagedItems.Count < items.Count
|
|
? (skip + pagedItems.Count).ToString()
|
|
: null;
|
|
|
|
return Task.FromResult(new PublishedPackList
|
|
{
|
|
Items = pagedItems,
|
|
NextPageToken = nextToken,
|
|
TotalCount = items.Count
|
|
});
|
|
}
|
|
|
|
public async Task<RevokeResult> RevokeAsync(
|
|
Guid tenantId,
|
|
Guid packId,
|
|
RevokePackRequest request,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
if (!_publications.TryGetValue((tenantId, packId), out var status))
|
|
{
|
|
return new RevokeResult
|
|
{
|
|
Success = false,
|
|
Error = "Policy pack is not published"
|
|
};
|
|
}
|
|
|
|
if (status.State == PublishState.Revoked)
|
|
{
|
|
return new RevokeResult
|
|
{
|
|
Success = false,
|
|
Error = "Policy pack is already revoked"
|
|
};
|
|
}
|
|
|
|
var now = _timeProvider.GetUtcNow();
|
|
var updatedStatus = status with
|
|
{
|
|
State = PublishState.Revoked,
|
|
RevokedAt = now,
|
|
RevokedBy = request.RevokedBy,
|
|
RevokeReason = request.Reason
|
|
};
|
|
|
|
_publications[(tenantId, packId)] = updatedStatus;
|
|
|
|
// Update pack status to archived
|
|
await _packStore.UpdateStatusAsync(tenantId, packId, PolicyPackStatus.Archived, request.RevokedBy, cancellationToken);
|
|
|
|
return new RevokeResult
|
|
{
|
|
Success = true,
|
|
Status = updatedStatus
|
|
};
|
|
}
|
|
|
|
private PolicyPackAttestation BuildAttestation(
|
|
PolicyPackEntity pack,
|
|
string digest,
|
|
PolicyPackCompilationResult compilationResult,
|
|
ReviewRequest? review,
|
|
PublishPackRequest request,
|
|
DateTimeOffset now)
|
|
{
|
|
var subject = new AttestationSubject
|
|
{
|
|
Name = $"policy-pack/{pack.Name}",
|
|
Digest = new Dictionary<string, string>
|
|
{
|
|
["sha256"] = digest.Replace("sha256:", "")
|
|
}
|
|
};
|
|
|
|
var predicate = new AttestationPredicate
|
|
{
|
|
BuildType = BuildType,
|
|
Builder = new AttestationBuilder
|
|
{
|
|
Id = BuilderId,
|
|
Version = "1.0.0"
|
|
},
|
|
BuildStartedOn = pack.CreatedAt,
|
|
BuildFinishedOn = now,
|
|
Compilation = new PolicyPackCompilationMetadata
|
|
{
|
|
Digest = digest,
|
|
RuleCount = compilationResult.Statistics?.TotalRules ?? 0,
|
|
CompiledAt = now,
|
|
Statistics = compilationResult.Statistics?.SeverityCounts
|
|
},
|
|
Review = review is not null ? new PolicyPackReviewMetadata
|
|
{
|
|
ReviewId = review.ReviewId,
|
|
ApprovedAt = review.ResolvedAt ?? now,
|
|
ApprovedBy = review.ResolvedBy,
|
|
Reviewers = review.Reviewers
|
|
} : null,
|
|
Metadata = request.Metadata?.ToDictionary(kv => kv.Key, kv => (object)kv.Value)
|
|
};
|
|
|
|
var payload = new AttestationPayload
|
|
{
|
|
Type = "https://in-toto.io/Statement/v1",
|
|
PredicateType = request.AttestationOptions?.PredicateType ?? AttestationPredicateType,
|
|
Subject = subject,
|
|
Predicate = predicate
|
|
};
|
|
|
|
var payloadJson = JsonSerializer.Serialize(payload, new JsonSerializerOptions
|
|
{
|
|
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
|
|
WriteIndented = false
|
|
});
|
|
|
|
var payloadBase64 = Convert.ToBase64String(Encoding.UTF8.GetBytes(payloadJson));
|
|
|
|
// Generate signature (simulated - in production would use actual signing)
|
|
var signature = GenerateSignature(payloadBase64, request.SigningOptions);
|
|
|
|
return new PolicyPackAttestation
|
|
{
|
|
PayloadType = "application/vnd.in-toto+json",
|
|
Payload = payloadBase64,
|
|
Signatures =
|
|
[
|
|
new AttestationSignature
|
|
{
|
|
KeyId = request.SigningOptions?.KeyId ?? "default",
|
|
Signature = signature,
|
|
Timestamp = request.SigningOptions?.IncludeTimestamp == true ? now : null
|
|
}
|
|
]
|
|
};
|
|
}
|
|
|
|
private static string GenerateSignature(string payload, SigningOptions? options)
|
|
{
|
|
// In production, this would use actual cryptographic signing
|
|
// For now, we generate a deterministic mock signature
|
|
var content = $"{payload}:{options?.KeyId ?? "default"}:{options?.Algorithm ?? SigningAlgorithm.ECDSA_P256_SHA256}";
|
|
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(content));
|
|
return Convert.ToBase64String(hash);
|
|
}
|
|
}
|