Refactor code structure for improved readability and maintainability; optimize performance in key functions.
This commit is contained in:
200
src/Cli/StellaOps.Cli/Services/DsseSignatureVerifier.cs
Normal file
200
src/Cli/StellaOps.Cli/Services/DsseSignatureVerifier.cs
Normal file
@@ -0,0 +1,200 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using StellaOps.Cli.Services.Models;
|
||||
|
||||
namespace StellaOps.Cli.Services;
|
||||
|
||||
internal sealed class DsseSignatureVerifier : IDsseSignatureVerifier
|
||||
{
|
||||
public DsseSignatureVerificationResult Verify(
|
||||
string payloadType,
|
||||
string payloadBase64,
|
||||
IReadOnlyList<DsseSignatureInput> signatures,
|
||||
TrustPolicyContext policy)
|
||||
{
|
||||
if (signatures.Count == 0)
|
||||
{
|
||||
return new DsseSignatureVerificationResult
|
||||
{
|
||||
IsValid = false,
|
||||
Error = "dsse-signatures-missing"
|
||||
};
|
||||
}
|
||||
|
||||
if (policy.Keys.Count == 0)
|
||||
{
|
||||
return new DsseSignatureVerificationResult
|
||||
{
|
||||
IsValid = false,
|
||||
Error = "trust-policy-keys-missing"
|
||||
};
|
||||
}
|
||||
|
||||
byte[] payloadBytes;
|
||||
try
|
||||
{
|
||||
payloadBytes = Convert.FromBase64String(payloadBase64);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return new DsseSignatureVerificationResult
|
||||
{
|
||||
IsValid = false,
|
||||
Error = "dsse-payload-invalid"
|
||||
};
|
||||
}
|
||||
|
||||
var pae = BuildPae(payloadType, payloadBytes);
|
||||
string? lastError = null;
|
||||
|
||||
foreach (var signature in signatures)
|
||||
{
|
||||
var key = FindKey(signature.KeyId, policy.Keys);
|
||||
if (key is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!TryDecodeSignature(signature.SignatureBase64, out var signatureBytes))
|
||||
{
|
||||
lastError = "dsse-signature-invalid";
|
||||
continue;
|
||||
}
|
||||
|
||||
if (TryVerifySignature(key, pae, signatureBytes, out var error))
|
||||
{
|
||||
return new DsseSignatureVerificationResult
|
||||
{
|
||||
IsValid = true,
|
||||
KeyId = signature.KeyId
|
||||
};
|
||||
}
|
||||
|
||||
lastError = error;
|
||||
}
|
||||
|
||||
return new DsseSignatureVerificationResult
|
||||
{
|
||||
IsValid = false,
|
||||
Error = lastError ?? "dsse-signature-untrusted"
|
||||
};
|
||||
}
|
||||
|
||||
private static TrustPolicyKeyMaterial? FindKey(string keyId, IReadOnlyList<TrustPolicyKeyMaterial> keys)
|
||||
{
|
||||
foreach (var key in keys)
|
||||
{
|
||||
if (string.Equals(key.KeyId, keyId, StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(key.Fingerprint, keyId, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return key;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool TryDecodeSignature(string signatureBase64, out byte[] signature)
|
||||
{
|
||||
try
|
||||
{
|
||||
signature = Convert.FromBase64String(signatureBase64);
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
signature = Array.Empty<byte>();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryVerifySignature(
|
||||
TrustPolicyKeyMaterial key,
|
||||
byte[] pae,
|
||||
byte[] signature,
|
||||
out string error)
|
||||
{
|
||||
error = "dsse-signature-invalid";
|
||||
var algorithm = key.Algorithm.ToLowerInvariant();
|
||||
|
||||
if (algorithm.Contains("ed25519", StringComparison.Ordinal))
|
||||
{
|
||||
error = "dsse-algorithm-unsupported";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (algorithm.Contains("es", StringComparison.Ordinal) || algorithm.Contains("ecdsa", StringComparison.Ordinal))
|
||||
{
|
||||
return TryVerifyEcdsa(key.PublicKey, pae, signature, out error);
|
||||
}
|
||||
|
||||
if (algorithm.Contains("rsa", StringComparison.Ordinal) || algorithm.Contains("pss", StringComparison.Ordinal))
|
||||
{
|
||||
return TryVerifyRsa(key.PublicKey, pae, signature, out error);
|
||||
}
|
||||
|
||||
if (TryVerifyRsa(key.PublicKey, pae, signature, out error))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return TryVerifyEcdsa(key.PublicKey, pae, signature, out error);
|
||||
}
|
||||
|
||||
private static bool TryVerifyRsa(byte[] publicKey, byte[] pae, byte[] signature, out string error)
|
||||
{
|
||||
error = "dsse-signature-invalid";
|
||||
try
|
||||
{
|
||||
using var rsa = RSA.Create();
|
||||
rsa.ImportSubjectPublicKeyInfo(publicKey, out _);
|
||||
return rsa.VerifyData(pae, signature, HashAlgorithmName.SHA256, RSASignaturePadding.Pss);
|
||||
}
|
||||
catch
|
||||
{
|
||||
error = "dsse-signature-verification-failed";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryVerifyEcdsa(byte[] publicKey, byte[] pae, byte[] signature, out string error)
|
||||
{
|
||||
error = "dsse-signature-invalid";
|
||||
try
|
||||
{
|
||||
using var ecdsa = ECDsa.Create();
|
||||
ecdsa.ImportSubjectPublicKeyInfo(publicKey, out _);
|
||||
return ecdsa.VerifyData(pae, signature, HashAlgorithmName.SHA256);
|
||||
}
|
||||
catch
|
||||
{
|
||||
error = "dsse-signature-verification-failed";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] BuildPae(string payloadType, byte[] payload)
|
||||
{
|
||||
var header = Encoding.UTF8.GetBytes("DSSEv1");
|
||||
var pt = Encoding.UTF8.GetBytes(payloadType ?? string.Empty);
|
||||
var lenPt = Encoding.UTF8.GetBytes(pt.Length.ToString());
|
||||
var lenPayload = Encoding.UTF8.GetBytes(payload.Length.ToString());
|
||||
var space = new[] { (byte)' ' };
|
||||
|
||||
return Concat(header, space, lenPt, space, pt, space, lenPayload, space, payload);
|
||||
}
|
||||
|
||||
private static byte[] Concat(params byte[][] parts)
|
||||
{
|
||||
var length = parts.Sum(part => part.Length);
|
||||
var buffer = new byte[length];
|
||||
var offset = 0;
|
||||
foreach (var part in parts)
|
||||
{
|
||||
Buffer.BlockCopy(part, 0, buffer, offset, part.Length);
|
||||
offset += part.Length;
|
||||
}
|
||||
|
||||
return buffer;
|
||||
}
|
||||
}
|
||||
21
src/Cli/StellaOps.Cli/Services/IDsseSignatureVerifier.cs
Normal file
21
src/Cli/StellaOps.Cli/Services/IDsseSignatureVerifier.cs
Normal file
@@ -0,0 +1,21 @@
|
||||
using StellaOps.Cli.Services.Models;
|
||||
|
||||
namespace StellaOps.Cli.Services;
|
||||
|
||||
internal interface IDsseSignatureVerifier
|
||||
{
|
||||
DsseSignatureVerificationResult Verify(string payloadType, string payloadBase64, IReadOnlyList<DsseSignatureInput> signatures, TrustPolicyContext policy);
|
||||
}
|
||||
|
||||
internal sealed record DsseSignatureVerificationResult
|
||||
{
|
||||
public required bool IsValid { get; init; }
|
||||
public string? KeyId { get; init; }
|
||||
public string? Error { get; init; }
|
||||
}
|
||||
|
||||
internal sealed record DsseSignatureInput
|
||||
{
|
||||
public required string KeyId { get; init; }
|
||||
public required string SignatureBase64 { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
using StellaOps.Cli.Services.Models;
|
||||
|
||||
namespace StellaOps.Cli.Services;
|
||||
|
||||
public interface IImageAttestationVerifier
|
||||
{
|
||||
Task<ImageVerificationResult> VerifyAsync(ImageVerificationRequest request, CancellationToken cancellationToken = default);
|
||||
}
|
||||
23
src/Cli/StellaOps.Cli/Services/IOciRegistryClient.cs
Normal file
23
src/Cli/StellaOps.Cli/Services/IOciRegistryClient.cs
Normal file
@@ -0,0 +1,23 @@
|
||||
using StellaOps.Cli.Services.Models;
|
||||
|
||||
namespace StellaOps.Cli.Services;
|
||||
|
||||
public interface IOciRegistryClient
|
||||
{
|
||||
Task<string> ResolveDigestAsync(OciImageReference reference, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<OciReferrersResponse> ListReferrersAsync(
|
||||
OciImageReference reference,
|
||||
string digest,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<OciManifest> GetManifestAsync(
|
||||
OciImageReference reference,
|
||||
string digest,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<byte[]> GetBlobAsync(
|
||||
OciImageReference reference,
|
||||
string digest,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.IO;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Cli.Services.Models;
|
||||
@@ -44,6 +44,13 @@ internal interface ISbomClient
|
||||
SbomExportRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Uploads an SBOM for BYOS ingestion.
|
||||
/// </summary>
|
||||
Task<SbomUploadResponse?> UploadAsync(
|
||||
SbomUploadRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the parity matrix showing CLI command coverage.
|
||||
/// </summary>
|
||||
|
||||
8
src/Cli/StellaOps.Cli/Services/ITrustPolicyLoader.cs
Normal file
8
src/Cli/StellaOps.Cli/Services/ITrustPolicyLoader.cs
Normal file
@@ -0,0 +1,8 @@
|
||||
using StellaOps.Cli.Services.Models;
|
||||
|
||||
namespace StellaOps.Cli.Services;
|
||||
|
||||
public interface ITrustPolicyLoader
|
||||
{
|
||||
Task<TrustPolicyContext> LoadAsync(string path, CancellationToken cancellationToken = default);
|
||||
}
|
||||
453
src/Cli/StellaOps.Cli/Services/ImageAttestationVerifier.cs
Normal file
453
src/Cli/StellaOps.Cli/Services/ImageAttestationVerifier.cs
Normal file
@@ -0,0 +1,453 @@
|
||||
using System.IO.Compression;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Cli.Services.Models;
|
||||
|
||||
namespace StellaOps.Cli.Services;
|
||||
|
||||
public sealed class ImageAttestationVerifier : IImageAttestationVerifier
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
private readonly IOciRegistryClient _registryClient;
|
||||
private readonly ITrustPolicyLoader _trustPolicyLoader;
|
||||
private readonly IDsseSignatureVerifier _dsseVerifier;
|
||||
private readonly ILogger<ImageAttestationVerifier> _logger;
|
||||
|
||||
public ImageAttestationVerifier(
|
||||
IOciRegistryClient registryClient,
|
||||
ITrustPolicyLoader trustPolicyLoader,
|
||||
IDsseSignatureVerifier dsseVerifier,
|
||||
ILogger<ImageAttestationVerifier> logger)
|
||||
{
|
||||
_registryClient = registryClient ?? throw new ArgumentNullException(nameof(registryClient));
|
||||
_trustPolicyLoader = trustPolicyLoader ?? throw new ArgumentNullException(nameof(trustPolicyLoader));
|
||||
_dsseVerifier = dsseVerifier ?? throw new ArgumentNullException(nameof(dsseVerifier));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<ImageVerificationResult> VerifyAsync(
|
||||
ImageVerificationRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.Reference))
|
||||
{
|
||||
throw new ArgumentException("Image reference is required.", nameof(request));
|
||||
}
|
||||
|
||||
var reference = OciImageReferenceParser.Parse(request.Reference);
|
||||
var digest = await _registryClient.ResolveDigestAsync(reference, cancellationToken).ConfigureAwait(false);
|
||||
var policy = request.TrustPolicyPath is not null
|
||||
? await _trustPolicyLoader.LoadAsync(request.TrustPolicyPath, cancellationToken).ConfigureAwait(false)
|
||||
: CreateDefaultTrustPolicy();
|
||||
|
||||
var result = new ImageVerificationResult
|
||||
{
|
||||
ImageReference = request.Reference,
|
||||
ImageDigest = digest,
|
||||
Registry = reference.Registry,
|
||||
Repository = reference.Repository,
|
||||
VerifiedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
OciReferrersResponse referrers;
|
||||
try
|
||||
{
|
||||
referrers = await _registryClient.ListReferrersAsync(reference, digest, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to list OCI referrers for {Reference}", request.Reference);
|
||||
result.Errors.Add($"Failed to list referrers: {ex.Message}");
|
||||
result.IsValid = false;
|
||||
return result;
|
||||
}
|
||||
|
||||
var orderedReferrers = (referrers.Referrers ?? new List<OciReferrerDescriptor>())
|
||||
.OrderBy(r => r.Digest, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
var referrersByType = orderedReferrers
|
||||
.GroupBy(ResolveAttestationType)
|
||||
.ToDictionary(group => group.Key, group => group.ToList(), StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var requiredType in request.RequiredTypes)
|
||||
{
|
||||
var verification = await VerifyAttestationTypeAsync(
|
||||
reference,
|
||||
requiredType,
|
||||
referrersByType,
|
||||
policy,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
result.Attestations.Add(verification);
|
||||
}
|
||||
|
||||
result.MissingTypes = result.Attestations
|
||||
.Where(attestation => attestation.Status == AttestationStatus.Missing)
|
||||
.Select(attestation => attestation.Type)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.OrderBy(type => type, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
var hasInvalid = result.Attestations.Any(attestation => attestation.Status is AttestationStatus.Invalid or AttestationStatus.Expired or AttestationStatus.UntrustedSigner);
|
||||
if (request.Strict)
|
||||
{
|
||||
result.IsValid = !hasInvalid && result.Attestations.All(attestation => attestation.Status == AttestationStatus.Verified);
|
||||
}
|
||||
else
|
||||
{
|
||||
result.IsValid = !hasInvalid;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static TrustPolicyContext CreateDefaultTrustPolicy()
|
||||
{
|
||||
return new TrustPolicyContext
|
||||
{
|
||||
Policy = new TrustPolicy(),
|
||||
Keys = Array.Empty<TrustPolicyKeyMaterial>(),
|
||||
RequireRekor = false,
|
||||
MaxAge = null
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<AttestationVerification> VerifyAttestationTypeAsync(
|
||||
OciImageReference reference,
|
||||
string type,
|
||||
Dictionary<string, List<OciReferrerDescriptor>> referrersByType,
|
||||
TrustPolicyContext policy,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!referrersByType.TryGetValue(type, out var referrers) || referrers.Count == 0)
|
||||
{
|
||||
return new AttestationVerification
|
||||
{
|
||||
Type = type,
|
||||
IsValid = false,
|
||||
Status = AttestationStatus.Missing,
|
||||
Message = $"No {type} attestation found"
|
||||
};
|
||||
}
|
||||
|
||||
var candidate = referrers
|
||||
.OrderByDescending(GetCreatedAt)
|
||||
.ThenBy(r => r.Digest, StringComparer.Ordinal)
|
||||
.First();
|
||||
|
||||
try
|
||||
{
|
||||
var manifest = await _registryClient.GetManifestAsync(reference, candidate.Digest, cancellationToken).ConfigureAwait(false);
|
||||
var layer = SelectDsseLayer(manifest);
|
||||
if (layer is null)
|
||||
{
|
||||
return new AttestationVerification
|
||||
{
|
||||
Type = type,
|
||||
IsValid = false,
|
||||
Status = AttestationStatus.Invalid,
|
||||
Digest = candidate.Digest,
|
||||
Message = "DSSE layer not found"
|
||||
};
|
||||
}
|
||||
|
||||
var blob = await _registryClient.GetBlobAsync(reference, layer.Digest, cancellationToken).ConfigureAwait(false);
|
||||
var payload = await DecodeLayerAsync(layer, blob, cancellationToken).ConfigureAwait(false);
|
||||
var envelope = ParseEnvelope(payload);
|
||||
var signatures = envelope.Signatures
|
||||
.Where(signature => !string.IsNullOrWhiteSpace(signature.KeyId) && !string.IsNullOrWhiteSpace(signature.Signature))
|
||||
.Select(signature => new DsseSignatureInput
|
||||
{
|
||||
KeyId = signature.KeyId!,
|
||||
SignatureBase64 = signature.Signature!
|
||||
})
|
||||
.ToList();
|
||||
|
||||
if (signatures.Count == 0)
|
||||
{
|
||||
return new AttestationVerification
|
||||
{
|
||||
Type = type,
|
||||
IsValid = false,
|
||||
Status = AttestationStatus.Invalid,
|
||||
Digest = candidate.Digest,
|
||||
Message = "DSSE signatures missing"
|
||||
};
|
||||
}
|
||||
|
||||
var verification = _dsseVerifier.Verify(envelope.PayloadType, envelope.Payload, signatures, policy);
|
||||
if (!verification.IsValid)
|
||||
{
|
||||
return new AttestationVerification
|
||||
{
|
||||
Type = type,
|
||||
IsValid = false,
|
||||
Status = MapFailureToStatus(verification.Error),
|
||||
Digest = candidate.Digest,
|
||||
SignerIdentity = verification.KeyId,
|
||||
Message = verification.Error ?? "Signature verification failed",
|
||||
VerifiedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
}
|
||||
|
||||
var signerKeyId = verification.KeyId ?? signatures[0].KeyId;
|
||||
if (!IsSignerAllowed(policy, type, signerKeyId))
|
||||
{
|
||||
return new AttestationVerification
|
||||
{
|
||||
Type = type,
|
||||
IsValid = false,
|
||||
Status = AttestationStatus.UntrustedSigner,
|
||||
Digest = candidate.Digest,
|
||||
SignerIdentity = signerKeyId,
|
||||
Message = "Signer not allowed by trust policy",
|
||||
VerifiedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
}
|
||||
|
||||
if (policy.RequireRekor && !HasRekorReceipt(candidate))
|
||||
{
|
||||
return new AttestationVerification
|
||||
{
|
||||
Type = type,
|
||||
IsValid = false,
|
||||
Status = AttestationStatus.Invalid,
|
||||
Digest = candidate.Digest,
|
||||
SignerIdentity = signerKeyId,
|
||||
Message = "Rekor receipt missing",
|
||||
VerifiedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
}
|
||||
|
||||
if (policy.MaxAge.HasValue)
|
||||
{
|
||||
var created = GetCreatedAt(candidate);
|
||||
if (created.HasValue && DateTimeOffset.UtcNow - created.Value > policy.MaxAge.Value)
|
||||
{
|
||||
return new AttestationVerification
|
||||
{
|
||||
Type = type,
|
||||
IsValid = false,
|
||||
Status = AttestationStatus.Expired,
|
||||
Digest = candidate.Digest,
|
||||
SignerIdentity = signerKeyId,
|
||||
Message = "Attestation exceeded max age",
|
||||
VerifiedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return new AttestationVerification
|
||||
{
|
||||
Type = type,
|
||||
IsValid = true,
|
||||
Status = AttestationStatus.Verified,
|
||||
Digest = candidate.Digest,
|
||||
SignerIdentity = signerKeyId,
|
||||
Message = "Signature valid",
|
||||
VerifiedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to verify attestation {Type}", type);
|
||||
return new AttestationVerification
|
||||
{
|
||||
Type = type,
|
||||
IsValid = false,
|
||||
Status = AttestationStatus.Invalid,
|
||||
Digest = candidate.Digest,
|
||||
Message = ex.Message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private static AttestationStatus MapFailureToStatus(string? error) => error switch
|
||||
{
|
||||
"trust-policy-keys-missing" => AttestationStatus.UntrustedSigner,
|
||||
"dsse-signature-untrusted" => AttestationStatus.UntrustedSigner,
|
||||
"dsse-signature-untrusted-or-invalid" => AttestationStatus.UntrustedSigner,
|
||||
_ => AttestationStatus.Invalid
|
||||
};
|
||||
|
||||
private static bool IsSignerAllowed(TrustPolicyContext policy, string type, string signerKeyId)
|
||||
{
|
||||
if (!policy.Policy.Attestations.TryGetValue(type, out var attestation) ||
|
||||
attestation.Signers.Count == 0)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return attestation.Signers.Any(signer => MatchPattern(signer.Identity, signerKeyId));
|
||||
}
|
||||
|
||||
private static bool MatchPattern(string? pattern, string value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(pattern))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (pattern == "*")
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!pattern.Contains('*', StringComparison.Ordinal))
|
||||
{
|
||||
return string.Equals(pattern, value, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
var parts = pattern.Split('*');
|
||||
var index = 0;
|
||||
foreach (var part in parts)
|
||||
{
|
||||
if (string.IsNullOrEmpty(part))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var next = value.IndexOf(part, index, StringComparison.OrdinalIgnoreCase);
|
||||
if (next < 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
index = next + part.Length;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static DateTimeOffset? GetCreatedAt(OciReferrerDescriptor referrer)
|
||||
{
|
||||
if (referrer.Annotations is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (referrer.Annotations.TryGetValue("created", out var created) ||
|
||||
referrer.Annotations.TryGetValue("org.opencontainers.image.created", out created))
|
||||
{
|
||||
if (DateTimeOffset.TryParse(created, out var parsed))
|
||||
{
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool HasRekorReceipt(OciReferrerDescriptor referrer)
|
||||
{
|
||||
if (referrer.Annotations is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return referrer.Annotations.Keys.Any(key =>
|
||||
key.Contains("rekor", StringComparison.OrdinalIgnoreCase) ||
|
||||
key.Contains("transparency", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
private static string ResolveAttestationType(OciReferrerDescriptor referrer)
|
||||
{
|
||||
var candidate = referrer.ArtifactType ?? referrer.MediaType ?? string.Empty;
|
||||
if (referrer.Annotations is not null)
|
||||
{
|
||||
if (referrer.Annotations.TryGetValue("predicateType", out var predicateType) ||
|
||||
referrer.Annotations.TryGetValue("predicate-type", out predicateType))
|
||||
{
|
||||
candidate = $"{candidate} {predicateType}";
|
||||
}
|
||||
}
|
||||
|
||||
if (candidate.Contains("spdx", StringComparison.OrdinalIgnoreCase) ||
|
||||
candidate.Contains("cyclonedx", StringComparison.OrdinalIgnoreCase) ||
|
||||
candidate.Contains("sbom", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return "sbom";
|
||||
}
|
||||
|
||||
if (candidate.Contains("openvex", StringComparison.OrdinalIgnoreCase) ||
|
||||
candidate.Contains("csaf", StringComparison.OrdinalIgnoreCase) ||
|
||||
candidate.Contains("vex", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return "vex";
|
||||
}
|
||||
|
||||
if (candidate.Contains("decision", StringComparison.OrdinalIgnoreCase) ||
|
||||
candidate.Contains("verdict", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return "decision";
|
||||
}
|
||||
|
||||
if (candidate.Contains("approval", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return "approval";
|
||||
}
|
||||
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
private static OciDescriptor? SelectDsseLayer(OciManifest manifest)
|
||||
{
|
||||
if (manifest.Layers.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var dsse = manifest.Layers.FirstOrDefault(layer =>
|
||||
layer.MediaType is not null &&
|
||||
(layer.MediaType.Contains("dsse", StringComparison.OrdinalIgnoreCase) ||
|
||||
layer.MediaType.Contains("in-toto", StringComparison.OrdinalIgnoreCase) ||
|
||||
layer.MediaType.Contains("intoto", StringComparison.OrdinalIgnoreCase)));
|
||||
|
||||
return dsse ?? manifest.Layers[0];
|
||||
}
|
||||
|
||||
private static async Task<byte[]> DecodeLayerAsync(OciDescriptor layer, byte[] content, CancellationToken ct)
|
||||
{
|
||||
if (layer.MediaType is null || !layer.MediaType.Contains("gzip", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return content;
|
||||
}
|
||||
|
||||
await using var input = new MemoryStream(content);
|
||||
await using var gzip = new GZipStream(input, CompressionMode.Decompress);
|
||||
await using var output = new MemoryStream();
|
||||
await gzip.CopyToAsync(output, ct).ConfigureAwait(false);
|
||||
return output.ToArray();
|
||||
}
|
||||
|
||||
private static DsseEnvelopeWire ParseEnvelope(byte[] payload)
|
||||
{
|
||||
var json = Encoding.UTF8.GetString(payload);
|
||||
var envelope = JsonSerializer.Deserialize<DsseEnvelopeWire>(json, JsonOptions);
|
||||
if (envelope is null || string.IsNullOrWhiteSpace(envelope.PayloadType) || string.IsNullOrWhiteSpace(envelope.Payload))
|
||||
{
|
||||
throw new InvalidDataException("Invalid DSSE envelope.");
|
||||
}
|
||||
|
||||
envelope.Signatures ??= new List<DsseSignatureWire>();
|
||||
return envelope;
|
||||
}
|
||||
|
||||
private sealed record DsseEnvelopeWire
|
||||
{
|
||||
public string PayloadType { get; init; } = string.Empty;
|
||||
public string Payload { get; init; } = string.Empty;
|
||||
public List<DsseSignatureWire> Signatures { get; set; } = new();
|
||||
}
|
||||
|
||||
private sealed record DsseSignatureWire
|
||||
{
|
||||
public string? KeyId { get; init; }
|
||||
public string? Signature { get; init; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.Cli.Services.Models;
|
||||
|
||||
public sealed record ImageVerificationRequest
|
||||
{
|
||||
public required string Reference { get; init; }
|
||||
public required IReadOnlyList<string> RequiredTypes { get; init; }
|
||||
public string? TrustPolicyPath { get; init; }
|
||||
public bool Strict { get; init; }
|
||||
}
|
||||
|
||||
public sealed record ImageVerificationResult
|
||||
{
|
||||
public required string ImageReference { get; init; }
|
||||
public required string ImageDigest { get; init; }
|
||||
public string? Registry { get; init; }
|
||||
public string? Repository { get; init; }
|
||||
public required DateTimeOffset VerifiedAt { get; init; }
|
||||
public bool IsValid { get; set; }
|
||||
public List<AttestationVerification> Attestations { get; } = new();
|
||||
public List<string> MissingTypes { get; set; } = new();
|
||||
public List<string> Errors { get; } = new();
|
||||
}
|
||||
|
||||
public sealed record AttestationVerification
|
||||
{
|
||||
public required string Type { get; init; }
|
||||
public required bool IsValid { get; init; }
|
||||
public required AttestationStatus Status { get; init; }
|
||||
public string? Digest { get; init; }
|
||||
public string? SignerIdentity { get; init; }
|
||||
public string? Message { get; init; }
|
||||
public DateTimeOffset? VerifiedAt { get; init; }
|
||||
}
|
||||
|
||||
public enum AttestationStatus
|
||||
{
|
||||
Verified,
|
||||
Invalid,
|
||||
Missing,
|
||||
Expired,
|
||||
UntrustedSigner
|
||||
}
|
||||
70
src/Cli/StellaOps.Cli/Services/Models/OciModels.cs
Normal file
70
src/Cli/StellaOps.Cli/Services/Models/OciModels.cs
Normal file
@@ -0,0 +1,70 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Cli.Services.Models;
|
||||
|
||||
public sealed record OciImageReference
|
||||
{
|
||||
public required string Registry { get; init; }
|
||||
public required string Repository { get; init; }
|
||||
public string? Tag { get; init; }
|
||||
public string? Digest { get; init; }
|
||||
public required string Original { get; init; }
|
||||
}
|
||||
|
||||
public sealed record OciReferrersResponse
|
||||
{
|
||||
[JsonPropertyName("referrers")]
|
||||
public List<OciReferrerDescriptor> Referrers { get; init; } = new();
|
||||
}
|
||||
|
||||
public sealed record OciReferrerDescriptor
|
||||
{
|
||||
[JsonPropertyName("mediaType")]
|
||||
public string? MediaType { get; init; }
|
||||
|
||||
[JsonPropertyName("artifactType")]
|
||||
public string? ArtifactType { get; init; }
|
||||
|
||||
[JsonPropertyName("digest")]
|
||||
public string Digest { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("size")]
|
||||
public long Size { get; init; }
|
||||
|
||||
[JsonPropertyName("annotations")]
|
||||
public Dictionary<string, string>? Annotations { get; init; }
|
||||
}
|
||||
|
||||
public sealed record OciManifest
|
||||
{
|
||||
[JsonPropertyName("mediaType")]
|
||||
public string? MediaType { get; init; }
|
||||
|
||||
[JsonPropertyName("artifactType")]
|
||||
public string? ArtifactType { get; init; }
|
||||
|
||||
[JsonPropertyName("config")]
|
||||
public OciDescriptor? Config { get; init; }
|
||||
|
||||
[JsonPropertyName("layers")]
|
||||
public List<OciDescriptor> Layers { get; init; } = new();
|
||||
|
||||
[JsonPropertyName("annotations")]
|
||||
public Dictionary<string, string>? Annotations { get; init; }
|
||||
}
|
||||
|
||||
public sealed record OciDescriptor
|
||||
{
|
||||
[JsonPropertyName("mediaType")]
|
||||
public string? MediaType { get; init; }
|
||||
|
||||
[JsonPropertyName("digest")]
|
||||
public string Digest { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("size")]
|
||||
public long Size { get; init; }
|
||||
|
||||
[JsonPropertyName("annotations")]
|
||||
public Dictionary<string, string>? Annotations { get; init; }
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Cli.Services.Models;
|
||||
@@ -66,6 +68,102 @@ internal sealed class SbomListResponse
|
||||
public string? NextCursor { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SBOM upload request payload.
|
||||
/// </summary>
|
||||
internal sealed class SbomUploadRequest
|
||||
{
|
||||
[JsonPropertyName("artifactRef")]
|
||||
public string ArtifactRef { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("sbom")]
|
||||
public JsonElement? Sbom { get; init; }
|
||||
|
||||
[JsonPropertyName("sbomBase64")]
|
||||
public string? SbomBase64 { get; init; }
|
||||
|
||||
[JsonPropertyName("format")]
|
||||
public string? Format { get; init; }
|
||||
|
||||
[JsonPropertyName("source")]
|
||||
public SbomUploadSource? Source { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SBOM upload source metadata.
|
||||
/// </summary>
|
||||
internal sealed class SbomUploadSource
|
||||
{
|
||||
[JsonPropertyName("tool")]
|
||||
public string? Tool { get; init; }
|
||||
|
||||
[JsonPropertyName("version")]
|
||||
public string? Version { get; init; }
|
||||
|
||||
[JsonPropertyName("ciContext")]
|
||||
public SbomUploadCiContext? CiContext { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// CI context metadata for SBOM uploads.
|
||||
/// </summary>
|
||||
internal sealed class SbomUploadCiContext
|
||||
{
|
||||
[JsonPropertyName("buildId")]
|
||||
public string? BuildId { get; init; }
|
||||
|
||||
[JsonPropertyName("repository")]
|
||||
public string? Repository { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SBOM upload response payload.
|
||||
/// </summary>
|
||||
internal sealed class SbomUploadResponse
|
||||
{
|
||||
[JsonPropertyName("sbomId")]
|
||||
public string SbomId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("artifactRef")]
|
||||
public string ArtifactRef { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("digest")]
|
||||
public string Digest { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("format")]
|
||||
public string Format { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("formatVersion")]
|
||||
public string FormatVersion { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("validationResult")]
|
||||
public SbomUploadValidationSummary ValidationResult { get; init; } = new();
|
||||
|
||||
[JsonPropertyName("analysisJobId")]
|
||||
public string AnalysisJobId { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SBOM upload validation summary.
|
||||
/// </summary>
|
||||
internal sealed class SbomUploadValidationSummary
|
||||
{
|
||||
[JsonPropertyName("valid")]
|
||||
public bool Valid { get; init; }
|
||||
|
||||
[JsonPropertyName("qualityScore")]
|
||||
public double QualityScore { get; init; }
|
||||
|
||||
[JsonPropertyName("warnings")]
|
||||
public IReadOnlyList<string> Warnings { get; init; } = [];
|
||||
|
||||
[JsonPropertyName("errors")]
|
||||
public IReadOnlyList<string> Errors { get; init; } = [];
|
||||
|
||||
[JsonPropertyName("componentCount")]
|
||||
public int ComponentCount { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Summary view of an SBOM.
|
||||
/// </summary>
|
||||
@@ -552,6 +650,111 @@ internal sealed class SbomExportResult
|
||||
public IReadOnlyList<string>? Errors { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SBOM upload request payload.
|
||||
/// </summary>
|
||||
internal sealed class SbomUploadRequest
|
||||
{
|
||||
[JsonPropertyName("artifactRef")]
|
||||
public string ArtifactRef { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("artifactDigest")]
|
||||
public string? ArtifactDigest { get; init; }
|
||||
|
||||
[JsonPropertyName("sbom")]
|
||||
public JsonElement? Sbom { get; init; }
|
||||
|
||||
[JsonPropertyName("sbomBase64")]
|
||||
public string? SbomBase64 { get; init; }
|
||||
|
||||
[JsonPropertyName("format")]
|
||||
public string? Format { get; init; }
|
||||
|
||||
[JsonPropertyName("source")]
|
||||
public SbomUploadSource? Source { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SBOM upload provenance metadata.
|
||||
/// </summary>
|
||||
internal sealed class SbomUploadSource
|
||||
{
|
||||
[JsonPropertyName("tool")]
|
||||
public string? Tool { get; init; }
|
||||
|
||||
[JsonPropertyName("version")]
|
||||
public string? Version { get; init; }
|
||||
|
||||
[JsonPropertyName("ciContext")]
|
||||
public SbomUploadCiContext? CiContext { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// CI context for SBOM upload provenance.
|
||||
/// </summary>
|
||||
internal sealed class SbomUploadCiContext
|
||||
{
|
||||
[JsonPropertyName("buildId")]
|
||||
public string? BuildId { get; init; }
|
||||
|
||||
[JsonPropertyName("repository")]
|
||||
public string? Repository { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SBOM upload response payload.
|
||||
/// </summary>
|
||||
internal sealed class SbomUploadResponse
|
||||
{
|
||||
[JsonPropertyName("sbomId")]
|
||||
public string SbomId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("artifactRef")]
|
||||
public string ArtifactRef { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("artifactDigest")]
|
||||
public string? ArtifactDigest { get; init; }
|
||||
|
||||
[JsonPropertyName("digest")]
|
||||
public string Digest { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("format")]
|
||||
public string Format { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("formatVersion")]
|
||||
public string FormatVersion { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("validationResult")]
|
||||
public SbomUploadValidationSummary? ValidationResult { get; init; }
|
||||
|
||||
[JsonPropertyName("analysisJobId")]
|
||||
public string AnalysisJobId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("uploadedAtUtc")]
|
||||
public DateTimeOffset UploadedAtUtc { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SBOM upload validation summary.
|
||||
/// </summary>
|
||||
internal sealed class SbomUploadValidationSummary
|
||||
{
|
||||
[JsonPropertyName("valid")]
|
||||
public bool Valid { get; init; }
|
||||
|
||||
[JsonPropertyName("qualityScore")]
|
||||
public double QualityScore { get; init; }
|
||||
|
||||
[JsonPropertyName("warnings")]
|
||||
public IReadOnlyList<string> Warnings { get; init; } = [];
|
||||
|
||||
[JsonPropertyName("errors")]
|
||||
public IReadOnlyList<string> Errors { get; init; } = [];
|
||||
|
||||
[JsonPropertyName("componentCount")]
|
||||
public int ComponentCount { get; init; }
|
||||
}
|
||||
|
||||
// CLI-PARITY-41-001: Parity matrix models
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.Cli.Services.Models;
|
||||
|
||||
public sealed record TrustPolicyContext
|
||||
{
|
||||
public TrustPolicy Policy { get; init; } = new();
|
||||
public IReadOnlyList<TrustPolicyKeyMaterial> Keys { get; init; } = Array.Empty<TrustPolicyKeyMaterial>();
|
||||
public bool RequireRekor { get; init; }
|
||||
public TimeSpan? MaxAge { get; init; }
|
||||
}
|
||||
|
||||
public sealed record TrustPolicyKeyMaterial
|
||||
{
|
||||
public required string KeyId { get; init; }
|
||||
public required string Fingerprint { get; init; }
|
||||
public required string Algorithm { get; init; }
|
||||
public required byte[] PublicKey { get; init; }
|
||||
}
|
||||
45
src/Cli/StellaOps.Cli/Services/Models/TrustPolicyModels.cs
Normal file
45
src/Cli/StellaOps.Cli/Services/Models/TrustPolicyModels.cs
Normal file
@@ -0,0 +1,45 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.Cli.Services.Models;
|
||||
|
||||
public sealed class TrustPolicy
|
||||
{
|
||||
public string Version { get; set; } = "1";
|
||||
|
||||
public Dictionary<string, TrustPolicyAttestation> Attestations { get; set; } = new();
|
||||
|
||||
public TrustPolicyDefaults Defaults { get; set; } = new();
|
||||
|
||||
public List<TrustPolicyKey> Keys { get; set; } = new();
|
||||
}
|
||||
|
||||
public sealed class TrustPolicyAttestation
|
||||
{
|
||||
public bool Required { get; set; }
|
||||
|
||||
public List<TrustPolicySigner> Signers { get; set; } = new();
|
||||
}
|
||||
|
||||
public sealed class TrustPolicySigner
|
||||
{
|
||||
public string? Identity { get; set; }
|
||||
|
||||
public string? Issuer { get; set; }
|
||||
}
|
||||
|
||||
public sealed class TrustPolicyDefaults
|
||||
{
|
||||
public bool RequireRekor { get; set; }
|
||||
|
||||
public string? MaxAge { get; set; }
|
||||
}
|
||||
|
||||
public sealed class TrustPolicyKey
|
||||
{
|
||||
public string? Id { get; set; }
|
||||
|
||||
public string? Path { get; set; }
|
||||
|
||||
public string? Algorithm { get; set; }
|
||||
}
|
||||
141
src/Cli/StellaOps.Cli/Services/OciImageReferenceParser.cs
Normal file
141
src/Cli/StellaOps.Cli/Services/OciImageReferenceParser.cs
Normal file
@@ -0,0 +1,141 @@
|
||||
using StellaOps.Cli.Services.Models;
|
||||
|
||||
namespace StellaOps.Cli.Services;
|
||||
|
||||
internal static class OciImageReferenceParser
|
||||
{
|
||||
public static OciImageReference Parse(string reference)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(reference))
|
||||
{
|
||||
throw new ArgumentException("Image reference is required.", nameof(reference));
|
||||
}
|
||||
|
||||
reference = reference.Trim();
|
||||
if (reference.StartsWith("http://", StringComparison.OrdinalIgnoreCase) ||
|
||||
reference.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return ParseUri(reference);
|
||||
}
|
||||
|
||||
var registry = string.Empty;
|
||||
var remainder = reference;
|
||||
var parts = reference.Split('/', StringSplitOptions.RemoveEmptyEntries);
|
||||
|
||||
if (parts.Length > 1 && LooksLikeRegistry(parts[0]))
|
||||
{
|
||||
registry = parts[0];
|
||||
remainder = string.Join('/', parts.Skip(1));
|
||||
}
|
||||
else
|
||||
{
|
||||
registry = "docker.io";
|
||||
}
|
||||
|
||||
var repository = remainder;
|
||||
string? tag = null;
|
||||
string? digest = null;
|
||||
|
||||
var atIndex = remainder.LastIndexOf('@');
|
||||
if (atIndex >= 0)
|
||||
{
|
||||
repository = remainder[..atIndex];
|
||||
digest = remainder[(atIndex + 1)..];
|
||||
}
|
||||
else
|
||||
{
|
||||
var lastColon = remainder.LastIndexOf(':');
|
||||
var lastSlash = remainder.LastIndexOf('/');
|
||||
if (lastColon > lastSlash)
|
||||
{
|
||||
repository = remainder[..lastColon];
|
||||
tag = remainder[(lastColon + 1)..];
|
||||
}
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(repository))
|
||||
{
|
||||
throw new ArgumentException("Image repository is required.", nameof(reference));
|
||||
}
|
||||
|
||||
if (string.Equals(registry, "docker.io", StringComparison.OrdinalIgnoreCase) &&
|
||||
!repository.Contains('/', StringComparison.Ordinal))
|
||||
{
|
||||
repository = $"library/{repository}";
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(tag) && string.IsNullOrWhiteSpace(digest))
|
||||
{
|
||||
tag = "latest";
|
||||
}
|
||||
|
||||
return new OciImageReference
|
||||
{
|
||||
Registry = registry,
|
||||
Repository = repository,
|
||||
Tag = tag,
|
||||
Digest = digest,
|
||||
Original = reference
|
||||
};
|
||||
}
|
||||
|
||||
private static OciImageReference ParseUri(string reference)
|
||||
{
|
||||
if (!Uri.TryCreate(reference, UriKind.Absolute, out var uri))
|
||||
{
|
||||
throw new ArgumentException("Invalid image reference URI.", nameof(reference));
|
||||
}
|
||||
|
||||
var registry = uri.Authority;
|
||||
var remainder = uri.AbsolutePath.Trim('/');
|
||||
|
||||
string? tag = null;
|
||||
string? digest = null;
|
||||
|
||||
var atIndex = remainder.LastIndexOf('@');
|
||||
if (atIndex >= 0)
|
||||
{
|
||||
digest = remainder[(atIndex + 1)..];
|
||||
remainder = remainder[..atIndex];
|
||||
}
|
||||
else
|
||||
{
|
||||
var lastColon = remainder.LastIndexOf(':');
|
||||
if (lastColon > remainder.LastIndexOf('/'))
|
||||
{
|
||||
tag = remainder[(lastColon + 1)..];
|
||||
remainder = remainder[..lastColon];
|
||||
}
|
||||
}
|
||||
|
||||
if (string.Equals(registry, "docker.io", StringComparison.OrdinalIgnoreCase) &&
|
||||
!remainder.Contains('/', StringComparison.Ordinal))
|
||||
{
|
||||
remainder = $"library/{remainder}";
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(tag) && string.IsNullOrWhiteSpace(digest))
|
||||
{
|
||||
tag = "latest";
|
||||
}
|
||||
|
||||
return new OciImageReference
|
||||
{
|
||||
Registry = registry,
|
||||
Repository = remainder,
|
||||
Tag = tag,
|
||||
Digest = digest,
|
||||
Original = reference
|
||||
};
|
||||
}
|
||||
|
||||
private static bool LooksLikeRegistry(string value)
|
||||
{
|
||||
if (string.Equals(value, "localhost", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return value.Contains('.', StringComparison.Ordinal) || value.Contains(':', StringComparison.Ordinal);
|
||||
}
|
||||
}
|
||||
320
src/Cli/StellaOps.Cli/Services/OciRegistryClient.cs
Normal file
320
src/Cli/StellaOps.Cli/Services/OciRegistryClient.cs
Normal file
@@ -0,0 +1,320 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text.Json;
|
||||
using StellaOps.Cli.Services.Models;
|
||||
|
||||
namespace StellaOps.Cli.Services;
|
||||
|
||||
public sealed class OciRegistryClient : IOciRegistryClient
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
private static readonly string[] ManifestAccept =
|
||||
{
|
||||
"application/vnd.oci.artifact.manifest.v1+json",
|
||||
"application/vnd.oci.image.manifest.v1+json",
|
||||
"application/vnd.docker.distribution.manifest.v2+json",
|
||||
"application/vnd.oci.image.index.v1+json",
|
||||
"application/vnd.docker.distribution.manifest.list.v2+json"
|
||||
};
|
||||
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly ILogger<OciRegistryClient> _logger;
|
||||
private readonly Dictionary<string, string> _tokenCache = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public OciRegistryClient(HttpClient httpClient, ILogger<OciRegistryClient> logger)
|
||||
{
|
||||
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<string> ResolveDigestAsync(OciImageReference reference, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(reference.Digest))
|
||||
{
|
||||
return reference.Digest!;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(reference.Tag))
|
||||
{
|
||||
throw new InvalidOperationException("Image reference does not include a tag or digest.");
|
||||
}
|
||||
|
||||
var path = $"/v2/{reference.Repository}/manifests/{reference.Tag}";
|
||||
using var request = new HttpRequestMessage(HttpMethod.Head, BuildUri(reference, path));
|
||||
AddAcceptHeaders(request, ManifestAccept);
|
||||
|
||||
using var response = await SendWithAuthAsync(reference, request, cancellationToken).ConfigureAwait(false);
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
if (response.Headers.TryGetValues("Docker-Content-Digest", out var digestHeaders))
|
||||
{
|
||||
var digest = digestHeaders.FirstOrDefault();
|
||||
if (!string.IsNullOrWhiteSpace(digest))
|
||||
{
|
||||
return digest;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
using var getRequest = new HttpRequestMessage(HttpMethod.Get, BuildUri(reference, path));
|
||||
AddAcceptHeaders(getRequest, ManifestAccept);
|
||||
using var getResponse = await SendWithAuthAsync(reference, getRequest, cancellationToken).ConfigureAwait(false);
|
||||
if (!getResponse.IsSuccessStatusCode)
|
||||
{
|
||||
throw new InvalidOperationException($"Failed to resolve digest: {getResponse.StatusCode}");
|
||||
}
|
||||
|
||||
if (getResponse.Headers.TryGetValues("Docker-Content-Digest", out var getDigestHeaders))
|
||||
{
|
||||
var digest = getDigestHeaders.FirstOrDefault();
|
||||
if (!string.IsNullOrWhiteSpace(digest))
|
||||
{
|
||||
return digest;
|
||||
}
|
||||
}
|
||||
|
||||
throw new InvalidOperationException("Registry response did not include Docker-Content-Digest.");
|
||||
}
|
||||
|
||||
public async Task<OciReferrersResponse> ListReferrersAsync(
|
||||
OciImageReference reference,
|
||||
string digest,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var path = $"/v2/{reference.Repository}/referrers/{digest}";
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, BuildUri(reference, path));
|
||||
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/vnd.oci.image.index.v1+json"));
|
||||
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
|
||||
|
||||
using var response = await SendWithAuthAsync(reference, request, cancellationToken).ConfigureAwait(false);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
throw new InvalidOperationException($"Failed to list referrers: {response.StatusCode}");
|
||||
}
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
return JsonSerializer.Deserialize<OciReferrersResponse>(json, JsonOptions)
|
||||
?? new OciReferrersResponse();
|
||||
}
|
||||
|
||||
public async Task<OciManifest> GetManifestAsync(
|
||||
OciImageReference reference,
|
||||
string digest,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var path = $"/v2/{reference.Repository}/manifests/{digest}";
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, BuildUri(reference, path));
|
||||
AddAcceptHeaders(request, ManifestAccept);
|
||||
|
||||
using var response = await SendWithAuthAsync(reference, request, cancellationToken).ConfigureAwait(false);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
throw new InvalidOperationException($"Failed to fetch manifest: {response.StatusCode}");
|
||||
}
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
return JsonSerializer.Deserialize<OciManifest>(json, JsonOptions)
|
||||
?? new OciManifest();
|
||||
}
|
||||
|
||||
public async Task<byte[]> GetBlobAsync(
|
||||
OciImageReference reference,
|
||||
string digest,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var path = $"/v2/{reference.Repository}/blobs/{digest}";
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, BuildUri(reference, path));
|
||||
|
||||
using var response = await SendWithAuthAsync(reference, request, cancellationToken).ConfigureAwait(false);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
throw new InvalidOperationException($"Failed to fetch blob: {response.StatusCode}");
|
||||
}
|
||||
|
||||
return await response.Content.ReadAsByteArrayAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task<HttpResponseMessage> SendWithAuthAsync(
|
||||
OciImageReference reference,
|
||||
HttpRequestMessage request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
if (response.StatusCode != HttpStatusCode.Unauthorized)
|
||||
{
|
||||
return response;
|
||||
}
|
||||
|
||||
var challenge = response.Headers.WwwAuthenticate.FirstOrDefault(header =>
|
||||
header.Scheme.Equals("Bearer", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (challenge is null)
|
||||
{
|
||||
return response;
|
||||
}
|
||||
|
||||
var token = await GetTokenAsync(reference, challenge, cancellationToken).ConfigureAwait(false);
|
||||
if (string.IsNullOrWhiteSpace(token))
|
||||
{
|
||||
return response;
|
||||
}
|
||||
|
||||
response.Dispose();
|
||||
var retry = CloneRequest(request);
|
||||
retry.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||
return await _httpClient.SendAsync(retry, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task<string?> GetTokenAsync(
|
||||
OciImageReference reference,
|
||||
AuthenticationHeaderValue challenge,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var parameters = ParseChallengeParameters(challenge.Parameter);
|
||||
if (!parameters.TryGetValue("realm", out var realm))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var service = parameters.GetValueOrDefault("service");
|
||||
var scope = parameters.GetValueOrDefault("scope") ?? $"repository:{reference.Repository}:pull";
|
||||
var cacheKey = $"{realm}|{service}|{scope}";
|
||||
|
||||
if (_tokenCache.TryGetValue(cacheKey, out var cached))
|
||||
{
|
||||
return cached;
|
||||
}
|
||||
|
||||
var tokenUri = BuildTokenUri(realm, service, scope);
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, tokenUri);
|
||||
var authHeader = BuildBasicAuthHeader();
|
||||
if (authHeader is not null)
|
||||
{
|
||||
request.Headers.Authorization = authHeader;
|
||||
}
|
||||
|
||||
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogWarning("OCI token request failed: {StatusCode}", response.StatusCode);
|
||||
return null;
|
||||
}
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
using var document = JsonDocument.Parse(json);
|
||||
if (!document.RootElement.TryGetProperty("token", out var tokenElement) &&
|
||||
!document.RootElement.TryGetProperty("access_token", out tokenElement))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var token = tokenElement.GetString();
|
||||
if (!string.IsNullOrWhiteSpace(token))
|
||||
{
|
||||
_tokenCache[cacheKey] = token;
|
||||
}
|
||||
|
||||
return token;
|
||||
}
|
||||
|
||||
private static AuthenticationHeaderValue? BuildBasicAuthHeader()
|
||||
{
|
||||
var username = Environment.GetEnvironmentVariable("STELLAOPS_REGISTRY_USERNAME");
|
||||
var password = Environment.GetEnvironmentVariable("STELLAOPS_REGISTRY_PASSWORD");
|
||||
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var token = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes($"{username}:{password}"));
|
||||
return new AuthenticationHeaderValue("Basic", token);
|
||||
}
|
||||
|
||||
private static Dictionary<string, string> ParseChallengeParameters(string? parameter)
|
||||
{
|
||||
var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
if (string.IsNullOrWhiteSpace(parameter))
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
var parts = parameter.Split(',', StringSplitOptions.RemoveEmptyEntries);
|
||||
foreach (var part in parts)
|
||||
{
|
||||
var tokens = part.Split('=', 2, StringSplitOptions.RemoveEmptyEntries);
|
||||
if (tokens.Length != 2)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var key = tokens[0].Trim();
|
||||
var value = tokens[1].Trim().Trim('"');
|
||||
if (!string.IsNullOrWhiteSpace(key))
|
||||
{
|
||||
result[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static Uri BuildTokenUri(string realm, string? service, string? scope)
|
||||
{
|
||||
var builder = new UriBuilder(realm);
|
||||
var query = new List<string>();
|
||||
if (!string.IsNullOrWhiteSpace(service))
|
||||
{
|
||||
query.Add($"service={Uri.EscapeDataString(service)}");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(scope))
|
||||
{
|
||||
query.Add($"scope={Uri.EscapeDataString(scope)}");
|
||||
}
|
||||
|
||||
builder.Query = string.Join("&", query);
|
||||
return builder.Uri;
|
||||
}
|
||||
|
||||
private Uri BuildUri(OciImageReference reference, string path)
|
||||
{
|
||||
var scheme = reference.Original.StartsWith("http://", StringComparison.OrdinalIgnoreCase)
|
||||
? "http"
|
||||
: "https";
|
||||
|
||||
var builder = new UriBuilder(scheme, reference.Registry)
|
||||
{
|
||||
Path = path
|
||||
};
|
||||
|
||||
return builder.Uri;
|
||||
}
|
||||
|
||||
private static void AddAcceptHeaders(HttpRequestMessage request, IEnumerable<string> accepts)
|
||||
{
|
||||
foreach (var accept in accepts)
|
||||
{
|
||||
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue(accept));
|
||||
}
|
||||
}
|
||||
|
||||
private static HttpRequestMessage CloneRequest(HttpRequestMessage request)
|
||||
{
|
||||
var clone = new HttpRequestMessage(request.Method, request.RequestUri);
|
||||
foreach (var header in request.Headers)
|
||||
{
|
||||
clone.Headers.TryAddWithoutValidation(header.Key, header.Value);
|
||||
}
|
||||
|
||||
if (request.Content is not null)
|
||||
{
|
||||
clone.Content = request.Content;
|
||||
}
|
||||
|
||||
return clone;
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,8 @@ using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
@@ -333,6 +335,105 @@ internal sealed class SbomClient : ISbomClient
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<SbomUploadResponse?> UploadAsync(
|
||||
SbomUploadRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
try
|
||||
{
|
||||
EnsureConfigured();
|
||||
|
||||
var uri = "/api/v1/sbom/upload";
|
||||
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, uri);
|
||||
await AuthorizeRequestAsync(httpRequest, "sbom.write", cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var payload = JsonSerializer.Serialize(request, SerializerOptions);
|
||||
httpRequest.Content = new StringContent(payload, Encoding.UTF8, "application/json");
|
||||
|
||||
using var response = await httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
logger.LogError(
|
||||
"Failed to upload SBOM (status {StatusCode}). Response: {Payload}",
|
||||
(int)response.StatusCode,
|
||||
string.IsNullOrWhiteSpace(body) ? "<empty>" : body);
|
||||
return null;
|
||||
}
|
||||
|
||||
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||
return await JsonSerializer
|
||||
.DeserializeAsync<SbomUploadResponse>(stream, SerializerOptions, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
logger.LogError(ex, "HTTP error while uploading SBOM");
|
||||
return null;
|
||||
}
|
||||
catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
logger.LogError(ex, "Request timed out while uploading SBOM");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<SbomUploadResponse?> UploadAsync(
|
||||
SbomUploadRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
try
|
||||
{
|
||||
EnsureConfigured();
|
||||
|
||||
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, "/api/v1/sbom/upload")
|
||||
{
|
||||
Content = JsonContent.Create(request, options: SerializerOptions)
|
||||
};
|
||||
|
||||
await AuthorizeRequestAsync(httpRequest, "sbom.write", cancellationToken).ConfigureAwait(false);
|
||||
|
||||
using var response = await httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
logger.LogError(
|
||||
"Failed to upload SBOM (status {StatusCode}). Response: {Payload}",
|
||||
(int)response.StatusCode,
|
||||
string.IsNullOrWhiteSpace(payload) ? "<empty>" : payload);
|
||||
|
||||
var validation = TryParseValidation(payload, request);
|
||||
if (validation is not null)
|
||||
{
|
||||
return validation;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||
return await JsonSerializer
|
||||
.DeserializeAsync<SbomUploadResponse>(stream, SerializerOptions, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
logger.LogError(ex, "HTTP error while uploading SBOM");
|
||||
return null;
|
||||
}
|
||||
catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
logger.LogError(ex, "Request timed out while uploading SBOM");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<ParityMatrixResponse> GetParityMatrixAsync(
|
||||
string? tenant,
|
||||
CancellationToken cancellationToken)
|
||||
@@ -481,4 +582,67 @@ internal sealed class SbomClient : ISbomClient
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static SbomUploadResponse? TryParseValidation(string payload, SbomUploadRequest request)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(payload))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var document = JsonDocument.Parse(payload);
|
||||
if (!document.RootElement.TryGetProperty("extensions", out var extensions) || extensions.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var errors = ReadStringList(extensions, "errors");
|
||||
var warnings = ReadStringList(extensions, "warnings");
|
||||
|
||||
if (errors.Count == 0 && warnings.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new SbomUploadResponse
|
||||
{
|
||||
ArtifactRef = request.ArtifactRef,
|
||||
ValidationResult = new SbomUploadValidationSummary
|
||||
{
|
||||
Valid = false,
|
||||
Errors = errors,
|
||||
Warnings = warnings
|
||||
}
|
||||
};
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> ReadStringList(JsonElement parent, string name)
|
||||
{
|
||||
if (!parent.TryGetProperty(name, out var element) || element.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
return Array.Empty<string>();
|
||||
}
|
||||
|
||||
var list = new List<string>();
|
||||
foreach (var entry in element.EnumerateArray())
|
||||
{
|
||||
if (entry.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
var value = entry.GetString();
|
||||
if (!string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
list.Add(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return list;
|
||||
}
|
||||
}
|
||||
|
||||
218
src/Cli/StellaOps.Cli/Services/TrustPolicyLoader.cs
Normal file
218
src/Cli/StellaOps.Cli/Services/TrustPolicyLoader.cs
Normal file
@@ -0,0 +1,218 @@
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Cli.Services.Models;
|
||||
|
||||
namespace StellaOps.Cli.Services;
|
||||
|
||||
public sealed class TrustPolicyLoader : ITrustPolicyLoader
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
private readonly ILogger<TrustPolicyLoader> _logger;
|
||||
|
||||
public TrustPolicyLoader(ILogger<TrustPolicyLoader> logger)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<TrustPolicyContext> LoadAsync(string path, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
{
|
||||
throw new ArgumentException("Trust policy path must be provided.", nameof(path));
|
||||
}
|
||||
|
||||
var fullPath = Path.GetFullPath(path);
|
||||
if (!File.Exists(fullPath))
|
||||
{
|
||||
throw new FileNotFoundException("Trust policy file not found.", fullPath);
|
||||
}
|
||||
|
||||
var policy = await LoadPolicyDocumentAsync(fullPath, cancellationToken).ConfigureAwait(false);
|
||||
var normalized = NormalizePolicy(policy);
|
||||
var keyMaterials = await LoadKeysAsync(fullPath, normalized.Keys, cancellationToken).ConfigureAwait(false);
|
||||
var maxAge = ParseDuration(normalized.Defaults?.MaxAge);
|
||||
|
||||
return new TrustPolicyContext
|
||||
{
|
||||
Policy = normalized,
|
||||
Keys = keyMaterials,
|
||||
RequireRekor = normalized.Defaults?.RequireRekor ?? false,
|
||||
MaxAge = maxAge
|
||||
};
|
||||
}
|
||||
|
||||
private static async Task<TrustPolicy> LoadPolicyDocumentAsync(string path, CancellationToken cancellationToken)
|
||||
{
|
||||
var extension = Path.GetExtension(path).ToLowerInvariant();
|
||||
if (extension is ".yaml" or ".yml")
|
||||
{
|
||||
var builder = new ConfigurationBuilder()
|
||||
.AddYamlFile(path, optional: false, reloadOnChange: false);
|
||||
var config = builder.Build();
|
||||
var policy = new TrustPolicy();
|
||||
config.Bind(policy);
|
||||
return policy;
|
||||
}
|
||||
|
||||
var json = await File.ReadAllTextAsync(path, cancellationToken).ConfigureAwait(false);
|
||||
return JsonSerializer.Deserialize<TrustPolicy>(json, JsonOptions) ?? new TrustPolicy();
|
||||
}
|
||||
|
||||
private TrustPolicy NormalizePolicy(TrustPolicy policy)
|
||||
{
|
||||
policy.Attestations ??= new Dictionary<string, TrustPolicyAttestation>();
|
||||
policy.Keys ??= new List<TrustPolicyKey>();
|
||||
policy.Defaults ??= new TrustPolicyDefaults();
|
||||
|
||||
var normalizedAttestations = new Dictionary<string, TrustPolicyAttestation>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var (key, value) in policy.Attestations)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(key))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
value ??= new TrustPolicyAttestation();
|
||||
value.Signers ??= new List<TrustPolicySigner>();
|
||||
normalizedAttestations[key.Trim()] = value;
|
||||
}
|
||||
|
||||
policy.Attestations = normalizedAttestations;
|
||||
return policy;
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<TrustPolicyKeyMaterial>> LoadKeysAsync(
|
||||
string policyPath,
|
||||
IReadOnlyList<TrustPolicyKey> keys,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (keys.Count == 0)
|
||||
{
|
||||
return Array.Empty<TrustPolicyKeyMaterial>();
|
||||
}
|
||||
|
||||
var keyMaterials = new List<TrustPolicyKeyMaterial>(keys.Count);
|
||||
var baseDir = Path.GetDirectoryName(policyPath) ?? Environment.CurrentDirectory;
|
||||
|
||||
foreach (var key in keys)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(key.Path))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var resolvedPath = Path.IsPathRooted(key.Path)
|
||||
? key.Path
|
||||
: Path.Combine(baseDir, key.Path);
|
||||
var fullPath = Path.GetFullPath(resolvedPath);
|
||||
if (!File.Exists(fullPath))
|
||||
{
|
||||
throw new FileNotFoundException($"Trust policy key file not found: {fullPath}", fullPath);
|
||||
}
|
||||
|
||||
var publicKey = await LoadPublicKeyDerBytesAsync(fullPath, cancellationToken).ConfigureAwait(false);
|
||||
var fingerprint = ComputeFingerprint(publicKey);
|
||||
var keyId = string.IsNullOrWhiteSpace(key.Id) ? fingerprint : key.Id.Trim();
|
||||
var algorithm = NormalizeAlgorithm(key.Algorithm);
|
||||
|
||||
keyMaterials.Add(new TrustPolicyKeyMaterial
|
||||
{
|
||||
KeyId = keyId,
|
||||
Fingerprint = fingerprint,
|
||||
Algorithm = algorithm,
|
||||
PublicKey = publicKey
|
||||
});
|
||||
}
|
||||
|
||||
if (keyMaterials.Count == 0)
|
||||
{
|
||||
_logger.LogWarning("Trust policy did not load any keys.");
|
||||
}
|
||||
|
||||
return keyMaterials;
|
||||
}
|
||||
|
||||
private static string NormalizeAlgorithm(string? algorithm)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(algorithm))
|
||||
{
|
||||
return "rsa-pss-sha256";
|
||||
}
|
||||
|
||||
return algorithm.Trim().ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static string ComputeFingerprint(byte[] publicKey)
|
||||
{
|
||||
var hash = SHA256.HashData(publicKey);
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static async Task<byte[]> LoadPublicKeyDerBytesAsync(string path, CancellationToken ct)
|
||||
{
|
||||
var bytes = await File.ReadAllBytesAsync(path, ct).ConfigureAwait(false);
|
||||
var text = Encoding.UTF8.GetString(bytes);
|
||||
|
||||
const string Begin = "-----BEGIN PUBLIC KEY-----";
|
||||
const string End = "-----END PUBLIC KEY-----";
|
||||
|
||||
var begin = text.IndexOf(Begin, StringComparison.Ordinal);
|
||||
var end = text.IndexOf(End, StringComparison.Ordinal);
|
||||
if (begin >= 0 && end > begin)
|
||||
{
|
||||
var base64 = text
|
||||
.Substring(begin + Begin.Length, end - (begin + Begin.Length))
|
||||
.Replace("\r", string.Empty, StringComparison.Ordinal)
|
||||
.Replace("\n", string.Empty, StringComparison.Ordinal)
|
||||
.Trim();
|
||||
return Convert.FromBase64String(base64);
|
||||
}
|
||||
|
||||
var trimmed = text.Trim();
|
||||
try
|
||||
{
|
||||
return Convert.FromBase64String(trimmed);
|
||||
}
|
||||
catch
|
||||
{
|
||||
throw new InvalidDataException("Unsupported public key format (expected PEM or raw base64 SPKI).");
|
||||
}
|
||||
}
|
||||
|
||||
private static TimeSpan? ParseDuration(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
value = value.Trim();
|
||||
if (TimeSpan.TryParse(value, CultureInfo.InvariantCulture, out var parsed))
|
||||
{
|
||||
return parsed;
|
||||
}
|
||||
|
||||
var suffix = value[^1];
|
||||
if (!double.TryParse(value[..^1], NumberStyles.Float, CultureInfo.InvariantCulture, out var amount))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return suffix switch
|
||||
{
|
||||
's' or 'S' => TimeSpan.FromSeconds(amount),
|
||||
'm' or 'M' => TimeSpan.FromMinutes(amount),
|
||||
'h' or 'H' => TimeSpan.FromHours(amount),
|
||||
'd' or 'D' => TimeSpan.FromDays(amount),
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user