license switch agpl -> busl1, sprints work, new product advisories
This commit is contained in:
@@ -8,10 +8,16 @@
|
||||
using System.CommandLine;
|
||||
using System.IO.Compression;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Attestor.Core.Predicates;
|
||||
using StellaOps.Attestor.Core.Signing;
|
||||
using StellaOps.Attestor.Envelope;
|
||||
using StellaOps.Attestor.Serialization;
|
||||
using StellaOps.Cryptography;
|
||||
|
||||
namespace StellaOps.Cli.Commands;
|
||||
|
||||
@@ -29,7 +35,7 @@ public static class BundleVerifyCommand
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Builds the 'verify --bundle' enhanced command.
|
||||
/// Builds the 'bundle verify' enhanced command.
|
||||
/// </summary>
|
||||
public static Command BuildVerifyBundleEnhancedCommand(
|
||||
IServiceProvider services,
|
||||
@@ -65,10 +71,20 @@ public static class BundleVerifyCommand
|
||||
|
||||
var strictOption = new Option<bool>("--strict")
|
||||
{
|
||||
Description = "Fail on any warning (missing optional artifacts)"
|
||||
Description = "Fail on any warning (missing optional artifacts)"
|
||||
};
|
||||
|
||||
var command = new Command("bundle-verify", "Verify offline evidence bundle with full cryptographic verification")
|
||||
var signerOption = new Option<string?>("--signer")
|
||||
{
|
||||
Description = "Path to signing key (PEM) for DSSE verification report"
|
||||
};
|
||||
|
||||
var signerCertOption = new Option<string?>("--signer-cert")
|
||||
{
|
||||
Description = "Path to signer certificate PEM (optional; embedded in report metadata)"
|
||||
};
|
||||
|
||||
var command = new Command("verify", "Verify offline evidence bundle with full cryptographic verification")
|
||||
{
|
||||
bundleOption,
|
||||
trustRootOption,
|
||||
@@ -76,6 +92,8 @@ public static class BundleVerifyCommand
|
||||
offlineOption,
|
||||
outputOption,
|
||||
strictOption,
|
||||
signerOption,
|
||||
signerCertOption,
|
||||
verboseOption
|
||||
};
|
||||
|
||||
@@ -87,6 +105,8 @@ public static class BundleVerifyCommand
|
||||
var offline = parseResult.GetValue(offlineOption);
|
||||
var output = parseResult.GetValue(outputOption) ?? "table";
|
||||
var strict = parseResult.GetValue(strictOption);
|
||||
var signer = parseResult.GetValue(signerOption);
|
||||
var signerCert = parseResult.GetValue(signerCertOption);
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
|
||||
return await HandleVerifyBundleAsync(
|
||||
@@ -97,6 +117,8 @@ public static class BundleVerifyCommand
|
||||
offline,
|
||||
output,
|
||||
strict,
|
||||
signer,
|
||||
signerCert,
|
||||
verbose,
|
||||
cancellationToken);
|
||||
});
|
||||
@@ -112,6 +134,8 @@ public static class BundleVerifyCommand
|
||||
bool offline,
|
||||
string outputFormat,
|
||||
bool strict,
|
||||
string? signerKeyPath,
|
||||
string? signerCertPath,
|
||||
bool verbose,
|
||||
CancellationToken ct)
|
||||
{
|
||||
@@ -125,6 +149,9 @@ public static class BundleVerifyCommand
|
||||
Offline = offline
|
||||
};
|
||||
|
||||
string? bundleDir = null;
|
||||
BundleManifestDto? manifest = null;
|
||||
|
||||
try
|
||||
{
|
||||
if (outputFormat != "json")
|
||||
@@ -136,18 +163,29 @@ public static class BundleVerifyCommand
|
||||
}
|
||||
|
||||
// Step 1: Extract/read bundle
|
||||
var bundleDir = await ExtractBundleAsync(bundlePath, ct);
|
||||
bundleDir = await ExtractBundleAsync(bundlePath, ct);
|
||||
|
||||
// Step 2: Parse manifest
|
||||
var manifestPath = Path.Combine(bundleDir, "manifest.json");
|
||||
if (!File.Exists(manifestPath))
|
||||
{
|
||||
result.Checks.Add(new VerificationCheck("manifest", false, "manifest.json not found"));
|
||||
return OutputResult(result, outputFormat, strict);
|
||||
return await FinalizeResultAsync(
|
||||
result,
|
||||
manifest,
|
||||
bundleDir,
|
||||
trustRoot,
|
||||
rekorCheckpoint,
|
||||
offline,
|
||||
outputFormat,
|
||||
strict,
|
||||
signerKeyPath,
|
||||
signerCertPath,
|
||||
ct);
|
||||
}
|
||||
|
||||
var manifestJson = await File.ReadAllTextAsync(manifestPath, ct);
|
||||
var manifest = JsonSerializer.Deserialize<BundleManifestDto>(manifestJson, JsonOptions);
|
||||
var manifestJson = await File.ReadAllTextAsync(manifestPath, ct);
|
||||
manifest = JsonSerializer.Deserialize<BundleManifestDto>(manifestJson, JsonOptions);
|
||||
result.Checks.Add(new VerificationCheck("manifest", true, "manifest.json parsed successfully"));
|
||||
result.SchemaVersion = manifest?.SchemaVersion;
|
||||
result.Image = manifest?.Bundle?.Image;
|
||||
@@ -185,11 +223,18 @@ public static class BundleVerifyCommand
|
||||
Console.WriteLine($"Step 5: Payload Types {(payloadsPassed ? "✓" : "⚠")}");
|
||||
}
|
||||
|
||||
result.CompletedAt = DateTimeOffset.UtcNow;
|
||||
result.OverallStatus = result.Checks.All(c => c.Passed) ? "PASSED" :
|
||||
result.Checks.Any(c => !c.Passed && c.Severity == "error") ? "FAILED" : "PASSED_WITH_WARNINGS";
|
||||
|
||||
return OutputResult(result, outputFormat, strict);
|
||||
return await FinalizeResultAsync(
|
||||
result,
|
||||
manifest,
|
||||
bundleDir,
|
||||
trustRoot,
|
||||
rekorCheckpoint,
|
||||
offline,
|
||||
outputFormat,
|
||||
strict,
|
||||
signerKeyPath,
|
||||
signerCertPath,
|
||||
ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -446,6 +491,377 @@ public static class BundleVerifyCommand
|
||||
return true;
|
||||
}
|
||||
|
||||
private static async Task<int> FinalizeResultAsync(
|
||||
VerificationResult result,
|
||||
BundleManifestDto? manifest,
|
||||
string bundleDir,
|
||||
string? trustRoot,
|
||||
string? rekorCheckpoint,
|
||||
bool offline,
|
||||
string outputFormat,
|
||||
bool strict,
|
||||
string? signerKeyPath,
|
||||
string? signerCertPath,
|
||||
CancellationToken ct)
|
||||
{
|
||||
result.CompletedAt ??= DateTimeOffset.UtcNow;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(signerKeyPath))
|
||||
{
|
||||
var outcome = await TryWriteSignedReportAsync(
|
||||
result,
|
||||
manifest,
|
||||
bundleDir,
|
||||
trustRoot,
|
||||
rekorCheckpoint,
|
||||
offline,
|
||||
signerKeyPath,
|
||||
signerCertPath,
|
||||
ct);
|
||||
|
||||
if (outcome.Success)
|
||||
{
|
||||
result.SignedReportPath = outcome.ReportPath;
|
||||
result.SignerKeyId = outcome.KeyId;
|
||||
result.SignerAlgorithm = outcome.Algorithm;
|
||||
result.SignedAt = outcome.SignedAt;
|
||||
result.Checks.Add(new VerificationCheck(
|
||||
"report:signature",
|
||||
true,
|
||||
$"Signed report written to {outcome.ReportPath}"));
|
||||
}
|
||||
else
|
||||
{
|
||||
result.Checks.Add(new VerificationCheck(
|
||||
"report:signature",
|
||||
false,
|
||||
outcome.Error ?? "Signed report generation failed")
|
||||
{
|
||||
Severity = "error"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
result.OverallStatus = ComputeOverallStatus(result.Checks);
|
||||
return OutputResult(result, outputFormat, strict);
|
||||
}
|
||||
|
||||
private static async Task<SignedReportOutcome> TryWriteSignedReportAsync(
|
||||
VerificationResult result,
|
||||
BundleManifestDto? manifest,
|
||||
string bundleDir,
|
||||
string? trustRoot,
|
||||
string? rekorCheckpoint,
|
||||
bool offline,
|
||||
string signerKeyPath,
|
||||
string? signerCertPath,
|
||||
CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var signingKey = LoadSigningKey(signerKeyPath);
|
||||
var signerCert = await LoadSignerCertificateAsync(signerCertPath, signerKeyPath, ct);
|
||||
var report = BuildVerificationReport(result, manifest, trustRoot, rekorCheckpoint, offline);
|
||||
var signer = new DsseVerificationReportSigner(new EnvelopeSignatureService());
|
||||
var signedAt = result.CompletedAt ?? DateTimeOffset.UtcNow;
|
||||
var signResult = await signer.SignAsync(new VerificationReportSigningRequest(
|
||||
report,
|
||||
signingKey,
|
||||
signerCert,
|
||||
signedAt), ct);
|
||||
|
||||
var outputDir = Path.Combine(bundleDir, "out");
|
||||
Directory.CreateDirectory(outputDir);
|
||||
var reportPath = Path.Combine(outputDir, "verification.report.json");
|
||||
await File.WriteAllTextAsync(reportPath, signResult.EnvelopeJson, ct);
|
||||
|
||||
return new SignedReportOutcome(
|
||||
true,
|
||||
reportPath,
|
||||
signingKey.KeyId,
|
||||
signingKey.AlgorithmId,
|
||||
signResult.Report.Verifier?.SignedAt,
|
||||
null);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new SignedReportOutcome(false, null, null, null, null, ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private static VerificationReportPredicate BuildVerificationReport(
|
||||
VerificationResult result,
|
||||
BundleManifestDto? manifest,
|
||||
string? trustRoot,
|
||||
string? rekorCheckpoint,
|
||||
bool offline)
|
||||
{
|
||||
var steps = result.Checks
|
||||
.Select((check, index) => new VerificationStep
|
||||
{
|
||||
Step = index + 1,
|
||||
Name = check.Name,
|
||||
Status = MapStepStatus(check),
|
||||
DurationMs = 0,
|
||||
Details = check.Message,
|
||||
Issues = BuildIssues(check)
|
||||
})
|
||||
.ToArray();
|
||||
|
||||
var summary = ComputeOverallStatus(result.Checks);
|
||||
var overallStatus = MapOverallStatus(summary);
|
||||
var overall = new OverallVerificationResult
|
||||
{
|
||||
Status = overallStatus,
|
||||
Summary = summary,
|
||||
TotalDurationMs = (long?)((result.CompletedAt - result.StartedAt)?.TotalMilliseconds) ?? 0,
|
||||
PassedSteps = steps.Count(step => step.Status == VerificationStepStatus.Passed),
|
||||
FailedSteps = steps.Count(step => step.Status == VerificationStepStatus.Failed),
|
||||
WarningSteps = steps.Count(step => step.Status == VerificationStepStatus.Warning),
|
||||
SkippedSteps = steps.Count(step => step.Status == VerificationStepStatus.Skipped)
|
||||
};
|
||||
|
||||
TrustChainInfo? trustChain = null;
|
||||
if (!string.IsNullOrWhiteSpace(trustRoot) || !string.IsNullOrWhiteSpace(rekorCheckpoint))
|
||||
{
|
||||
var rekorVerified = result.Checks.Any(check =>
|
||||
string.Equals(check.Name, "rekor:inclusion", StringComparison.OrdinalIgnoreCase) && check.Passed);
|
||||
trustChain = new TrustChainInfo
|
||||
{
|
||||
RootOfTrust = trustRoot,
|
||||
RekorVerified = rekorVerified,
|
||||
RekorLogIndex = null,
|
||||
TsaVerified = false,
|
||||
Timestamp = null,
|
||||
SignerIdentity = result.SignerKeyId
|
||||
};
|
||||
}
|
||||
|
||||
return new VerificationReportPredicate
|
||||
{
|
||||
ReportId = ComputeReportId(result, manifest),
|
||||
GeneratedAt = result.CompletedAt ?? DateTimeOffset.UtcNow,
|
||||
Generator = new GeneratorInfo
|
||||
{
|
||||
Tool = "stella bundle verify",
|
||||
Version = GetCliVersion()
|
||||
},
|
||||
Subject = new VerificationSubject
|
||||
{
|
||||
BundleId = manifest?.CanonicalManifestHash,
|
||||
BundleDigest = manifest?.Subject?.Sha256,
|
||||
ArtifactDigest = manifest?.Bundle?.Digest,
|
||||
ArtifactName = manifest?.Bundle?.Image
|
||||
},
|
||||
VerificationSteps = steps,
|
||||
OverallResult = overall,
|
||||
TrustChain = trustChain,
|
||||
ReplayMode = offline ? "offline" : "online"
|
||||
};
|
||||
}
|
||||
|
||||
private static VerificationStepStatus MapStepStatus(VerificationCheck check)
|
||||
{
|
||||
if (!check.Passed)
|
||||
{
|
||||
return VerificationStepStatus.Failed;
|
||||
}
|
||||
|
||||
return check.Severity switch
|
||||
{
|
||||
"warning" => VerificationStepStatus.Warning,
|
||||
"info" => VerificationStepStatus.Passed,
|
||||
_ => VerificationStepStatus.Passed
|
||||
};
|
||||
}
|
||||
|
||||
private static IReadOnlyList<VerificationIssue>? BuildIssues(VerificationCheck check)
|
||||
{
|
||||
if (check.Passed && !string.Equals(check.Severity, "warning", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new[]
|
||||
{
|
||||
new VerificationIssue
|
||||
{
|
||||
Severity = MapIssueSeverity(check),
|
||||
Code = check.Name,
|
||||
Message = check.Message
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static IssueSeverity MapIssueSeverity(VerificationCheck check)
|
||||
{
|
||||
if (!check.Passed)
|
||||
{
|
||||
return IssueSeverity.Error;
|
||||
}
|
||||
|
||||
return string.Equals(check.Severity, "warning", StringComparison.OrdinalIgnoreCase)
|
||||
? IssueSeverity.Warning
|
||||
: IssueSeverity.Info;
|
||||
}
|
||||
|
||||
private static VerificationStepStatus MapOverallStatus(string? status)
|
||||
{
|
||||
return status switch
|
||||
{
|
||||
"PASSED" => VerificationStepStatus.Passed,
|
||||
"FAILED" => VerificationStepStatus.Failed,
|
||||
"PASSED_WITH_WARNINGS" => VerificationStepStatus.Warning,
|
||||
_ => VerificationStepStatus.Skipped
|
||||
};
|
||||
}
|
||||
|
||||
private static string ComputeOverallStatus(IReadOnlyList<VerificationCheck> checks)
|
||||
{
|
||||
if (checks.Count == 0)
|
||||
{
|
||||
return "UNKNOWN";
|
||||
}
|
||||
|
||||
if (checks.All(check => check.Passed))
|
||||
{
|
||||
return "PASSED";
|
||||
}
|
||||
|
||||
return checks.Any(check => !check.Passed && check.Severity == "error")
|
||||
? "FAILED"
|
||||
: "PASSED_WITH_WARNINGS";
|
||||
}
|
||||
|
||||
private static string ComputeReportId(VerificationResult result, BundleManifestDto? manifest)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(manifest?.CanonicalManifestHash))
|
||||
{
|
||||
return manifest.CanonicalManifestHash!;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(manifest?.Subject?.Sha256))
|
||||
{
|
||||
return manifest.Subject.Sha256!;
|
||||
}
|
||||
|
||||
return ComputeSha256Hex(result.BundlePath);
|
||||
}
|
||||
|
||||
private static string ComputeSha256Hex(string value)
|
||||
{
|
||||
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(value ?? string.Empty));
|
||||
return $"sha256:{Convert.ToHexString(bytes).ToLowerInvariant()}";
|
||||
}
|
||||
|
||||
private static EnvelopeKey LoadSigningKey(string path)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
{
|
||||
throw new InvalidOperationException("Signing key path is required for report signing.");
|
||||
}
|
||||
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
throw new FileNotFoundException($"Signing key file not found: {path}");
|
||||
}
|
||||
|
||||
var pem = File.ReadAllText(path);
|
||||
using var ecdsa = ECDsa.Create();
|
||||
try
|
||||
{
|
||||
ecdsa.ImportFromPem(pem);
|
||||
}
|
||||
catch (CryptographicException ex)
|
||||
{
|
||||
throw new InvalidOperationException("Failed to load ECDSA private key from PEM.", ex);
|
||||
}
|
||||
|
||||
var parameters = ecdsa.ExportParameters(true);
|
||||
var algorithm = ResolveEcdsaAlgorithm(ecdsa.KeySize);
|
||||
return EnvelopeKey.CreateEcdsaSigner(algorithm, parameters);
|
||||
}
|
||||
|
||||
private static string ResolveEcdsaAlgorithm(int keySize)
|
||||
{
|
||||
return keySize switch
|
||||
{
|
||||
256 => SignatureAlgorithms.Es256,
|
||||
384 => SignatureAlgorithms.Es384,
|
||||
521 => SignatureAlgorithms.Es512,
|
||||
_ => throw new InvalidOperationException($"Unsupported ECDSA key size: {keySize}.")
|
||||
};
|
||||
}
|
||||
|
||||
private static async Task<string?> LoadSignerCertificateAsync(
|
||||
string? signerCertPath,
|
||||
string signerKeyPath,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(signerCertPath))
|
||||
{
|
||||
if (!File.Exists(signerCertPath))
|
||||
{
|
||||
throw new FileNotFoundException($"Signer certificate file not found: {signerCertPath}");
|
||||
}
|
||||
|
||||
var certPem = await File.ReadAllTextAsync(signerCertPath, ct);
|
||||
return NormalizePem(certPem);
|
||||
}
|
||||
|
||||
var keyPem = await File.ReadAllTextAsync(signerKeyPath, ct);
|
||||
return ExtractCertificatePem(keyPem);
|
||||
}
|
||||
|
||||
private static string? ExtractCertificatePem(string pem)
|
||||
{
|
||||
const string beginMarker = "-----BEGIN CERTIFICATE-----";
|
||||
const string endMarker = "-----END CERTIFICATE-----";
|
||||
|
||||
var builder = new StringBuilder();
|
||||
var startIndex = 0;
|
||||
while (true)
|
||||
{
|
||||
var begin = pem.IndexOf(beginMarker, startIndex, StringComparison.Ordinal);
|
||||
if (begin < 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
var end = pem.IndexOf(endMarker, begin, StringComparison.Ordinal);
|
||||
if (end < 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
var block = pem.Substring(begin, end - begin + endMarker.Length).Trim();
|
||||
if (builder.Length > 0)
|
||||
{
|
||||
builder.Append('\n');
|
||||
}
|
||||
|
||||
builder.Append(block);
|
||||
startIndex = end + endMarker.Length;
|
||||
}
|
||||
|
||||
return builder.Length == 0 ? null : NormalizePem(builder.ToString());
|
||||
}
|
||||
|
||||
private static string? NormalizePem(string? pem)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(pem))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return pem.Replace("\r\n", "\n").Trim();
|
||||
}
|
||||
|
||||
private static string GetCliVersion()
|
||||
{
|
||||
return typeof(BundleVerifyCommand).Assembly.GetName().Version?.ToString() ?? "unknown";
|
||||
}
|
||||
|
||||
private static int OutputResult(VerificationResult result, string format, bool strict)
|
||||
{
|
||||
if (format == "json")
|
||||
@@ -472,6 +888,18 @@ public static class BundleVerifyCommand
|
||||
|
||||
Console.WriteLine();
|
||||
Console.WriteLine($"Duration: {(result.CompletedAt - result.StartedAt)?.TotalMilliseconds:F0}ms");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(result.SignedReportPath))
|
||||
{
|
||||
Console.WriteLine($"Signed report: {result.SignedReportPath}");
|
||||
if (!string.IsNullOrWhiteSpace(result.SignerKeyId))
|
||||
{
|
||||
var algo = string.IsNullOrWhiteSpace(result.SignerAlgorithm)
|
||||
? string.Empty
|
||||
: $" ({result.SignerAlgorithm})";
|
||||
Console.WriteLine($"Signer key: {result.SignerKeyId}{algo}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Exit code
|
||||
@@ -509,6 +937,18 @@ public static class BundleVerifyCommand
|
||||
[JsonPropertyName("image")]
|
||||
public string? Image { get; set; }
|
||||
|
||||
[JsonPropertyName("signedReportPath")]
|
||||
public string? SignedReportPath { get; set; }
|
||||
|
||||
[JsonPropertyName("signerKeyId")]
|
||||
public string? SignerKeyId { get; set; }
|
||||
|
||||
[JsonPropertyName("signerAlgorithm")]
|
||||
public string? SignerAlgorithm { get; set; }
|
||||
|
||||
[JsonPropertyName("signedAt")]
|
||||
public DateTimeOffset? SignedAt { get; set; }
|
||||
|
||||
[JsonPropertyName("checks")]
|
||||
public List<VerificationCheck> Checks { get; set; } = [];
|
||||
}
|
||||
@@ -538,11 +978,25 @@ public static class BundleVerifyCommand
|
||||
public string Severity { get; set; } = "info";
|
||||
}
|
||||
|
||||
private sealed record SignedReportOutcome(
|
||||
bool Success,
|
||||
string? ReportPath,
|
||||
string? KeyId,
|
||||
string? Algorithm,
|
||||
DateTimeOffset? SignedAt,
|
||||
string? Error);
|
||||
|
||||
private sealed class BundleManifestDto
|
||||
{
|
||||
[JsonPropertyName("canonicalManifestHash")]
|
||||
public string? CanonicalManifestHash { get; set; }
|
||||
|
||||
[JsonPropertyName("schemaVersion")]
|
||||
public string? SchemaVersion { get; set; }
|
||||
|
||||
[JsonPropertyName("subject")]
|
||||
public BundleSubjectDto? Subject { get; set; }
|
||||
|
||||
[JsonPropertyName("bundle")]
|
||||
public BundleInfoDto? Bundle { get; set; }
|
||||
|
||||
@@ -550,11 +1004,23 @@ public static class BundleVerifyCommand
|
||||
public VerifySectionDto? Verify { get; set; }
|
||||
}
|
||||
|
||||
private sealed class BundleSubjectDto
|
||||
{
|
||||
[JsonPropertyName("sha256")]
|
||||
public string? Sha256 { get; set; }
|
||||
|
||||
[JsonPropertyName("sha512")]
|
||||
public string? Sha512 { get; set; }
|
||||
}
|
||||
|
||||
private sealed class BundleInfoDto
|
||||
{
|
||||
[JsonPropertyName("image")]
|
||||
public string? Image { get; set; }
|
||||
|
||||
[JsonPropertyName("digest")]
|
||||
public string? Digest { get; set; }
|
||||
|
||||
[JsonPropertyName("artifacts")]
|
||||
public List<ArtifactDto>? Artifacts { get; set; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user