license switch agpl -> busl1, sprints work, new product advisories

This commit is contained in:
master
2026-01-20 15:32:20 +02:00
parent 4903395618
commit c32fff8f86
1835 changed files with 38630 additions and 4359 deletions

View File

@@ -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; }
}