This commit is contained in:
StellaOps Bot
2025-12-07 22:49:53 +02:00
parent 11597679ed
commit 7c24ed96ee
204 changed files with 23313 additions and 1430 deletions

View File

@@ -77,6 +77,7 @@ internal static class CommandFactory
root.Add(BuildSdkCommand(services, verboseOption, cancellationToken));
root.Add(BuildMirrorCommand(services, verboseOption, cancellationToken));
root.Add(BuildAirgapCommand(services, verboseOption, cancellationToken));
root.Add(BuildDevPortalCommand(services, verboseOption, cancellationToken));
root.Add(SystemCommandBuilder.BuildSystemCommand(services, verboseOption, cancellationToken));
var pluginLogger = loggerFactory.CreateLogger<CliCommandModuleLoader>();
@@ -10632,5 +10633,53 @@ internal static class CommandFactory
return airgap;
}
private static Command BuildDevPortalCommand(IServiceProvider services, Option<bool> verboseOption, CancellationToken cancellationToken)
{
var devportal = new Command("devportal", "Manage DevPortal offline operations.");
// devportal verify (DVOFF-64-002)
var verify = new Command("verify", "Verify integrity of a DevPortal/evidence bundle before import.");
var bundleOption = new Option<string>("--bundle", new[] { "-b" })
{
Description = "Path to the bundle .tgz file.",
Required = true
};
var offlineOption = new Option<bool>("--offline")
{
Description = "Skip TSA verification and online checks."
};
var jsonOption = new Option<bool>("--json")
{
Description = "Output results in JSON format."
};
verify.Add(bundleOption);
verify.Add(offlineOption);
verify.Add(jsonOption);
verify.SetAction((parseResult, _) =>
{
var bundlePath = parseResult.GetValue(bundleOption)!;
var offline = parseResult.GetValue(offlineOption);
var json = parseResult.GetValue(jsonOption);
var verbose = parseResult.GetValue(verboseOption);
return CommandHandlers.HandleDevPortalVerifyAsync(
services,
bundlePath,
offline,
json,
verbose,
cancellationToken);
});
devportal.Add(verify);
return devportal;
}
}

View File

@@ -27,17 +27,17 @@ using StellaOps.Cli.Configuration;
using StellaOps.Cli.Output;
using StellaOps.Cli.Prompts;
using StellaOps.Cli.Services;
using StellaOps.Cli.Services.Models;
using StellaOps.Cli.Services.Models.AdvisoryAi;
using StellaOps.Cli.Services.Models.Bun;
using StellaOps.Cli.Services.Models.Ruby;
using StellaOps.Cli.Telemetry;
using StellaOps.Cryptography;
using StellaOps.Cryptography.DependencyInjection;
using StellaOps.Cryptography.Kms;
using StellaOps.Policy.Scoring;
using StellaOps.Policy.Scoring.Engine;
using StellaOps.Policy.Scoring.Policies;
using StellaOps.Cli.Services.Models;
using StellaOps.Cli.Services.Models.AdvisoryAi;
using StellaOps.Cli.Services.Models.Bun;
using StellaOps.Cli.Services.Models.Ruby;
using StellaOps.Cli.Telemetry;
using StellaOps.Cryptography;
using StellaOps.Cryptography.DependencyInjection;
using StellaOps.Cryptography.Kms;
using StellaOps.Policy.Scoring;
using StellaOps.Policy.Scoring.Engine;
using StellaOps.Policy.Scoring.Policies;
using StellaOps.Scanner.Analyzers.Lang;
using StellaOps.Scanner.Analyzers.Lang.Java;
using StellaOps.Scanner.Analyzers.Lang.Node;
@@ -70,17 +70,17 @@ internal static class CommandHandlers
/// <summary>
/// JSON serializer options for output (alias for JsonOptions).
/// </summary>
private static readonly JsonSerializerOptions JsonOutputOptions = JsonOptions;
private static readonly JsonSerializerOptions CompactJson = new(JsonSerializerDefaults.Web)
{
WriteIndented = true
};
private static readonly JsonSerializerOptions JsonOutputOptions = JsonOptions;
private static readonly JsonSerializerOptions CompactJson = new(JsonSerializerDefaults.Web)
{
WriteIndented = true
};
/// <summary>
/// Sets the verbosity level for logging.
/// </summary>
private static void SetVerbosity(IServiceProvider services, bool verbose)
private static void SetVerbosity(IServiceProvider services, bool verbose)
{
// Configure logging level based on verbose flag
var loggerFactory = services.GetService<ILoggerFactory>();
@@ -90,215 +90,215 @@ internal static class CommandHandlers
var logger = loggerFactory.CreateLogger("StellaOps.Cli.Commands.CommandHandlers");
logger.LogDebug("Verbose logging enabled");
}
}
public static async Task HandleCvssScoreAsync(
IServiceProvider services,
string vulnerabilityId,
string policyPath,
string vector,
bool json,
bool verbose,
CancellationToken cancellationToken)
{
await using var scope = services.CreateAsyncScope();
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("cvss-score");
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
try
{
var policyJson = await File.ReadAllTextAsync(policyPath, cancellationToken).ConfigureAwait(false);
var loader = new CvssPolicyLoader();
var policyResult = loader.Load(policyJson, cancellationToken);
if (!policyResult.IsValid || policyResult.Policy is null || string.IsNullOrWhiteSpace(policyResult.Hash))
{
var errors = string.Join("; ", policyResult.Errors.Select(e => $"{e.Path}: {e.Message}"));
throw new InvalidOperationException($"Policy invalid: {errors}");
}
var policy = policyResult.Policy with { Hash = policyResult.Hash };
var engine = scope.ServiceProvider.GetRequiredService<ICvssV4Engine>();
var parsed = engine.ParseVector(vector);
var client = scope.ServiceProvider.GetRequiredService<ICvssClient>();
var request = new CreateCvssReceipt(
vulnerabilityId,
policy,
parsed.BaseMetrics,
parsed.ThreatMetrics,
parsed.EnvironmentalMetrics,
parsed.SupplementalMetrics,
Array.Empty<CvssEvidenceItem>(),
SigningKey: null,
CreatedBy: "cli",
CreatedAt: DateTimeOffset.UtcNow);
var receipt = await client.CreateReceiptAsync(request, cancellationToken).ConfigureAwait(false)
?? throw new InvalidOperationException("CVSS receipt creation failed.");
if (json)
{
Console.WriteLine(JsonSerializer.Serialize(receipt, CompactJson));
}
else
{
Console.WriteLine($"✔ CVSS receipt {receipt.ReceiptId} created | Severity {receipt.Severity} | Effective {receipt.Scores.EffectiveScore:0.0}");
Console.WriteLine($"Vector: {receipt.VectorString}");
Console.WriteLine($"Policy: {receipt.PolicyRef.PolicyId} v{receipt.PolicyRef.Version} ({receipt.PolicyRef.Hash})");
}
Environment.ExitCode = 0;
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to create CVSS receipt");
Environment.ExitCode = 1;
if (json)
{
var problem = new { error = "cvss_score_failed", message = ex.Message };
Console.WriteLine(JsonSerializer.Serialize(problem, CompactJson));
}
}
}
public static async Task HandleCvssShowAsync(
IServiceProvider services,
string receiptId,
bool json,
bool verbose,
CancellationToken cancellationToken)
{
await using var scope = services.CreateAsyncScope();
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("cvss-show");
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
try
{
var client = scope.ServiceProvider.GetRequiredService<ICvssClient>();
var receipt = await client.GetReceiptAsync(receiptId, cancellationToken).ConfigureAwait(false);
if (receipt is null)
{
Environment.ExitCode = 5;
Console.WriteLine(json
? JsonSerializer.Serialize(new { error = "not_found", receiptId }, CompactJson)
: $"✖ Receipt {receiptId} not found");
return;
}
if (json)
{
Console.WriteLine(JsonSerializer.Serialize(receipt, CompactJson));
}
else
{
Console.WriteLine($"Receipt {receipt.ReceiptId} | Severity {receipt.Severity} | Effective {receipt.Scores.EffectiveScore:0.0}");
Console.WriteLine($"Created {receipt.CreatedAt:u} by {receipt.CreatedBy}");
Console.WriteLine($"Vector: {receipt.VectorString}");
}
Environment.ExitCode = 0;
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to fetch CVSS receipt {ReceiptId}", receiptId);
Environment.ExitCode = 1;
}
}
public static async Task HandleCvssHistoryAsync(
IServiceProvider services,
string receiptId,
bool json,
bool verbose,
CancellationToken cancellationToken)
{
await using var scope = services.CreateAsyncScope();
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("cvss-history");
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
try
{
var client = scope.ServiceProvider.GetRequiredService<ICvssClient>();
var history = await client.GetHistoryAsync(receiptId, cancellationToken).ConfigureAwait(false);
if (json)
{
Console.WriteLine(JsonSerializer.Serialize(history, CompactJson));
}
else
{
if (history.Count == 0)
{
Console.WriteLine("(no history)");
}
else
{
foreach (var entry in history.OrderBy(h => h.Timestamp))
{
Console.WriteLine($"{entry.Timestamp:u} | {entry.Actor} | {entry.ChangeType} {entry.Field} => {entry.NewValue ?? ""} ({entry.Reason})");
}
}
}
Environment.ExitCode = 0;
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to fetch CVSS receipt history {ReceiptId}", receiptId);
Environment.ExitCode = 1;
}
}
public static async Task HandleCvssExportAsync(
IServiceProvider services,
string receiptId,
string format,
string? output,
bool verbose,
CancellationToken cancellationToken)
{
await using var scope = services.CreateAsyncScope();
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("cvss-export");
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
try
{
var client = scope.ServiceProvider.GetRequiredService<ICvssClient>();
var receipt = await client.GetReceiptAsync(receiptId, cancellationToken).ConfigureAwait(false);
if (receipt is null)
{
Environment.ExitCode = 5;
Console.WriteLine($"✖ Receipt {receiptId} not found");
return;
}
if (!string.Equals(format, "json", StringComparison.OrdinalIgnoreCase))
{
Environment.ExitCode = 9;
Console.WriteLine("Only json export is supported at this time.");
return;
}
var targetPath = string.IsNullOrWhiteSpace(output)
? $"cvss-receipt-{receipt.ReceiptId}.json"
: output!;
var jsonPayload = JsonSerializer.Serialize(receipt, CompactJson);
await File.WriteAllTextAsync(targetPath, jsonPayload, cancellationToken).ConfigureAwait(false);
Console.WriteLine($"✔ Exported receipt to {targetPath}");
Environment.ExitCode = 0;
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to export CVSS receipt {ReceiptId}", receiptId);
Environment.ExitCode = 1;
}
}
}
public static async Task HandleCvssScoreAsync(
IServiceProvider services,
string vulnerabilityId,
string policyPath,
string vector,
bool json,
bool verbose,
CancellationToken cancellationToken)
{
await using var scope = services.CreateAsyncScope();
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("cvss-score");
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
try
{
var policyJson = await File.ReadAllTextAsync(policyPath, cancellationToken).ConfigureAwait(false);
var loader = new CvssPolicyLoader();
var policyResult = loader.Load(policyJson, cancellationToken);
if (!policyResult.IsValid || policyResult.Policy is null || string.IsNullOrWhiteSpace(policyResult.Hash))
{
var errors = string.Join("; ", policyResult.Errors.Select(e => $"{e.Path}: {e.Message}"));
throw new InvalidOperationException($"Policy invalid: {errors}");
}
var policy = policyResult.Policy with { Hash = policyResult.Hash };
var engine = scope.ServiceProvider.GetRequiredService<ICvssV4Engine>();
var parsed = engine.ParseVector(vector);
var client = scope.ServiceProvider.GetRequiredService<ICvssClient>();
var request = new CreateCvssReceipt(
vulnerabilityId,
policy,
parsed.BaseMetrics,
parsed.ThreatMetrics,
parsed.EnvironmentalMetrics,
parsed.SupplementalMetrics,
Array.Empty<CvssEvidenceItem>(),
SigningKey: null,
CreatedBy: "cli",
CreatedAt: DateTimeOffset.UtcNow);
var receipt = await client.CreateReceiptAsync(request, cancellationToken).ConfigureAwait(false)
?? throw new InvalidOperationException("CVSS receipt creation failed.");
if (json)
{
Console.WriteLine(JsonSerializer.Serialize(receipt, CompactJson));
}
else
{
Console.WriteLine($"✔ CVSS receipt {receipt.ReceiptId} created | Severity {receipt.Severity} | Effective {receipt.Scores.EffectiveScore:0.0}");
Console.WriteLine($"Vector: {receipt.VectorString}");
Console.WriteLine($"Policy: {receipt.PolicyRef.PolicyId} v{receipt.PolicyRef.Version} ({receipt.PolicyRef.Hash})");
}
Environment.ExitCode = 0;
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to create CVSS receipt");
Environment.ExitCode = 1;
if (json)
{
var problem = new { error = "cvss_score_failed", message = ex.Message };
Console.WriteLine(JsonSerializer.Serialize(problem, CompactJson));
}
}
}
public static async Task HandleCvssShowAsync(
IServiceProvider services,
string receiptId,
bool json,
bool verbose,
CancellationToken cancellationToken)
{
await using var scope = services.CreateAsyncScope();
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("cvss-show");
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
try
{
var client = scope.ServiceProvider.GetRequiredService<ICvssClient>();
var receipt = await client.GetReceiptAsync(receiptId, cancellationToken).ConfigureAwait(false);
if (receipt is null)
{
Environment.ExitCode = 5;
Console.WriteLine(json
? JsonSerializer.Serialize(new { error = "not_found", receiptId }, CompactJson)
: $"✖ Receipt {receiptId} not found");
return;
}
if (json)
{
Console.WriteLine(JsonSerializer.Serialize(receipt, CompactJson));
}
else
{
Console.WriteLine($"Receipt {receipt.ReceiptId} | Severity {receipt.Severity} | Effective {receipt.Scores.EffectiveScore:0.0}");
Console.WriteLine($"Created {receipt.CreatedAt:u} by {receipt.CreatedBy}");
Console.WriteLine($"Vector: {receipt.VectorString}");
}
Environment.ExitCode = 0;
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to fetch CVSS receipt {ReceiptId}", receiptId);
Environment.ExitCode = 1;
}
}
public static async Task HandleCvssHistoryAsync(
IServiceProvider services,
string receiptId,
bool json,
bool verbose,
CancellationToken cancellationToken)
{
await using var scope = services.CreateAsyncScope();
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("cvss-history");
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
try
{
var client = scope.ServiceProvider.GetRequiredService<ICvssClient>();
var history = await client.GetHistoryAsync(receiptId, cancellationToken).ConfigureAwait(false);
if (json)
{
Console.WriteLine(JsonSerializer.Serialize(history, CompactJson));
}
else
{
if (history.Count == 0)
{
Console.WriteLine("(no history)");
}
else
{
foreach (var entry in history.OrderBy(h => h.Timestamp))
{
Console.WriteLine($"{entry.Timestamp:u} | {entry.Actor} | {entry.ChangeType} {entry.Field} => {entry.NewValue ?? ""} ({entry.Reason})");
}
}
}
Environment.ExitCode = 0;
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to fetch CVSS receipt history {ReceiptId}", receiptId);
Environment.ExitCode = 1;
}
}
public static async Task HandleCvssExportAsync(
IServiceProvider services,
string receiptId,
string format,
string? output,
bool verbose,
CancellationToken cancellationToken)
{
await using var scope = services.CreateAsyncScope();
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("cvss-export");
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
try
{
var client = scope.ServiceProvider.GetRequiredService<ICvssClient>();
var receipt = await client.GetReceiptAsync(receiptId, cancellationToken).ConfigureAwait(false);
if (receipt is null)
{
Environment.ExitCode = 5;
Console.WriteLine($"✖ Receipt {receiptId} not found");
return;
}
if (!string.Equals(format, "json", StringComparison.OrdinalIgnoreCase))
{
Environment.ExitCode = 9;
Console.WriteLine("Only json export is supported at this time.");
return;
}
var targetPath = string.IsNullOrWhiteSpace(output)
? $"cvss-receipt-{receipt.ReceiptId}.json"
: output!;
var jsonPayload = JsonSerializer.Serialize(receipt, CompactJson);
await File.WriteAllTextAsync(targetPath, jsonPayload, cancellationToken).ConfigureAwait(false);
Console.WriteLine($"✔ Exported receipt to {targetPath}");
Environment.ExitCode = 0;
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to export CVSS receipt {ReceiptId}", receiptId);
Environment.ExitCode = 1;
}
}
private static async Task VerifyBundleAsync(string path, ILogger logger, CancellationToken cancellationToken)
{
@@ -29676,4 +29676,105 @@ stella policy test {policyName}.stella
}
#endregion
#region DevPortal Commands
/// <summary>
/// Handler for 'stella devportal verify' command (DVOFF-64-002).
/// Verifies integrity of a DevPortal/evidence bundle before import.
/// Exit codes: 0 success, 2 checksum mismatch, 3 signature failure, 4 TSA missing, 5 unexpected.
/// </summary>
public static async Task<int> HandleDevPortalVerifyAsync(
IServiceProvider services,
string bundlePath,
bool offline,
bool emitJson,
bool verbose,
CancellationToken cancellationToken)
{
await using var scope = services.CreateAsyncScope();
var loggerFactory = scope.ServiceProvider.GetRequiredService<ILoggerFactory>();
var logger = loggerFactory.CreateLogger<DevPortalBundleVerifier>();
var verifier = new DevPortalBundleVerifier(logger);
using var activity = CliActivitySource.Instance.StartActivity("cli.devportal.verify", System.Diagnostics.ActivityKind.Client);
activity?.SetTag("stellaops.cli.command", "devportal verify");
activity?.SetTag("stellaops.cli.devportal.offline", offline);
using var duration = CliMetrics.MeasureCommandDuration("devportal verify");
try
{
var resolvedPath = Path.GetFullPath(bundlePath);
if (verbose)
{
AnsiConsole.MarkupLine($"[grey]Verifying bundle: {Markup.Escape(resolvedPath)}[/]");
if (offline)
{
AnsiConsole.MarkupLine("[grey]Mode: offline (TSA verification skipped)[/]");
}
}
var result = await verifier.VerifyBundleAsync(resolvedPath, offline, cancellationToken)
.ConfigureAwait(false);
activity?.SetTag("stellaops.cli.devportal.status", result.Status);
activity?.SetTag("stellaops.cli.devportal.exit_code", (int)result.ExitCode);
if (emitJson)
{
Console.WriteLine(result.ToJson());
}
else
{
if (result.ExitCode == DevPortalVerifyExitCode.Success)
{
AnsiConsole.MarkupLine("[green]Bundle verification successful.[/]");
AnsiConsole.MarkupLine($" Bundle ID: {Markup.Escape(result.BundleId ?? "unknown")}");
AnsiConsole.MarkupLine($" Root Hash: {Markup.Escape(result.RootHash ?? "unknown")}");
AnsiConsole.MarkupLine($" Entries: {result.Entries}");
AnsiConsole.MarkupLine($" Created: {result.CreatedAt?.ToString("O") ?? "unknown"}");
AnsiConsole.MarkupLine($" Portable: {(result.Portable ? "yes" : "no")}");
}
else
{
AnsiConsole.MarkupLine($"[red]Bundle verification failed:[/] {Markup.Escape(result.ErrorMessage ?? "Unknown error")}");
if (!string.IsNullOrEmpty(result.ErrorDetail))
{
AnsiConsole.MarkupLine($" [grey]{Markup.Escape(result.ErrorDetail)}[/]");
}
}
}
return (int)result.ExitCode;
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
if (!emitJson)
{
AnsiConsole.MarkupLine("[yellow]Operation cancelled.[/]");
}
return 130;
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to verify bundle");
if (emitJson)
{
var errorResult = DevPortalBundleVerificationResult.Failed(
DevPortalVerifyExitCode.Unexpected,
ex.Message);
Console.WriteLine(errorResult.ToJson());
}
else
{
AnsiConsole.MarkupLine($"[red]Error:[/] {Markup.Escape(ex.Message)}");
}
return (int)DevPortalVerifyExitCode.Unexpected;
}
}
#endregion
}

View File

@@ -0,0 +1,533 @@
using System.Formats.Tar;
using System.IO.Compression;
using System.Security.Cryptography;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using StellaOps.Cli.Services.Models;
namespace StellaOps.Cli.Services;
/// <summary>
/// Verifier for attestation bundles exported from the Export Center.
/// Per EXPORT-ATTEST-75-001.
/// </summary>
internal sealed class AttestationBundleVerifier : IAttestationBundleVerifier
{
private const string DsseEnvelopeFileName = "attestation.dsse.json";
private const string StatementFileName = "statement.json";
private const string TransparencyFileName = "transparency.ndjson";
private const string MetadataFileName = "metadata.json";
private const string ChecksumsFileName = "checksums.txt";
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
{
PropertyNameCaseInsensitive = true,
WriteIndented = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
private readonly ILogger<AttestationBundleVerifier> _logger;
public AttestationBundleVerifier(ILogger<AttestationBundleVerifier> logger)
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<AttestationBundleVerifyResult> VerifyAsync(
AttestationBundleVerifyOptions options,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(options);
ArgumentException.ThrowIfNullOrWhiteSpace(options.FilePath);
_logger.LogDebug("Verifying attestation bundle at {FilePath}, offline={Offline}",
options.FilePath, options.Offline);
// Step 1: Check bundle exists
if (!File.Exists(options.FilePath))
{
return CreateFailedResult(
AttestationBundleExitCodes.FileNotFound,
"Bundle file not found",
options.FilePath);
}
// Step 2: Verify SHA-256 against .sha256 file if present
var sha256Path = options.FilePath + ".sha256";
if (File.Exists(sha256Path))
{
var checksumResult = await VerifyBundleChecksumAsync(options.FilePath, sha256Path, cancellationToken)
.ConfigureAwait(false);
if (!checksumResult.IsValid)
{
return CreateFailedResult(
AttestationBundleExitCodes.ChecksumMismatch,
"SHA-256 checksum mismatch",
options.FilePath,
$"Expected: {checksumResult.ExpectedHash}, Computed: {checksumResult.ActualHash}");
}
}
else
{
_logger.LogDebug("No co-located .sha256 file found for external checksum verification");
}
// Step 3: Extract and parse bundle contents
BundleContents contents;
try
{
contents = await ExtractBundleContentsAsync(options.FilePath, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex) when (ex is InvalidDataException or JsonException or IOException)
{
_logger.LogError(ex, "Failed to extract bundle contents");
return CreateFailedResult(
AttestationBundleExitCodes.FormatError,
"Failed to extract bundle contents",
options.FilePath,
ex.Message);
}
// Step 4: Verify internal checksums from checksums.txt
if (contents.ChecksumsText is not null)
{
var internalCheckResult = VerifyInternalChecksums(contents);
if (!internalCheckResult.Success)
{
return CreateFailedResult(
AttestationBundleExitCodes.ChecksumMismatch,
"Internal checksum verification failed",
options.FilePath,
internalCheckResult.ErrorMessage);
}
}
// Step 5: Verify DSSE signature
var signatureValid = VerifyDsseSignature(contents, options.Offline, out var signatureError);
if (!signatureValid && !string.IsNullOrEmpty(signatureError))
{
return CreateFailedResult(
AttestationBundleExitCodes.SignatureFailure,
"DSSE signature verification failed",
options.FilePath,
signatureError);
}
// Step 6: Check transparency entries (only if not offline and verifyTransparency is true)
if (!options.Offline && options.VerifyTransparency)
{
if (string.IsNullOrWhiteSpace(contents.TransparencyNdjson))
{
return CreateFailedResult(
AttestationBundleExitCodes.MissingTransparency,
"Transparency log entry missing",
options.FilePath,
"Bundle requires transparency.ndjson when not in offline mode");
}
}
// Step 7: Build success result
var metadata = contents.Metadata;
var subjects = ExtractSubjects(contents);
return new AttestationBundleVerifyResult(
Success: true,
Status: "verified",
ExportId: metadata?.ExportId,
AttestationId: metadata?.AttestationId,
RootHash: FormatRootHash(metadata?.RootHash),
Subjects: subjects,
PredicateType: ExtractPredicateType(contents),
StatementVersion: metadata?.StatementVersion,
BundlePath: options.FilePath,
ExitCode: AttestationBundleExitCodes.Success);
}
public async Task<AttestationBundleImportResult> ImportAsync(
AttestationBundleImportOptions options,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(options);
ArgumentException.ThrowIfNullOrWhiteSpace(options.FilePath);
_logger.LogDebug("Importing attestation bundle from {FilePath}", options.FilePath);
// First verify the bundle
var verifyOptions = new AttestationBundleVerifyOptions(
options.FilePath,
options.Offline,
options.VerifyTransparency,
options.TrustRootPath);
var verifyResult = await VerifyAsync(verifyOptions, cancellationToken).ConfigureAwait(false);
if (!verifyResult.Success)
{
return new AttestationBundleImportResult(
Success: false,
Status: "verification_failed",
AttestationId: verifyResult.AttestationId,
TenantId: null,
Namespace: options.Namespace,
RootHash: verifyResult.RootHash,
ErrorMessage: verifyResult.ErrorMessage,
ExitCode: verifyResult.ExitCode);
}
// Extract metadata for import
BundleContents contents;
try
{
contents = await ExtractBundleContentsAsync(options.FilePath, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
return new AttestationBundleImportResult(
Success: false,
Status: "extraction_failed",
AttestationId: null,
TenantId: null,
Namespace: options.Namespace,
RootHash: null,
ErrorMessage: ex.Message,
ExitCode: AttestationBundleExitCodes.ImportFailed);
}
var metadata = contents.Metadata;
var tenantId = options.Tenant ?? metadata?.TenantId;
// Import is a local-only operation for air-gap scenarios
// The actual import to backend would happen via separate API call
_logger.LogInformation("Attestation bundle imported: {AttestationId} for tenant {TenantId}",
metadata?.AttestationId, tenantId);
return new AttestationBundleImportResult(
Success: true,
Status: "imported",
AttestationId: metadata?.AttestationId,
TenantId: tenantId,
Namespace: options.Namespace,
RootHash: FormatRootHash(metadata?.RootHash),
ExitCode: AttestationBundleExitCodes.Success);
}
private async Task<(bool IsValid, string? ExpectedHash, string? ActualHash)> VerifyBundleChecksumAsync(
string bundlePath,
string sha256Path,
CancellationToken cancellationToken)
{
// Read expected hash from .sha256 file
var content = await File.ReadAllTextAsync(sha256Path, cancellationToken).ConfigureAwait(false);
var expectedHash = content.Split(' ', StringSplitOptions.RemoveEmptyEntries).FirstOrDefault()?.Trim()?.ToLowerInvariant();
if (string.IsNullOrEmpty(expectedHash))
{
return (false, null, null);
}
// Compute actual hash
await using var stream = File.OpenRead(bundlePath);
var hashBytes = await SHA256.HashDataAsync(stream, cancellationToken).ConfigureAwait(false);
var actualHash = Convert.ToHexString(hashBytes).ToLowerInvariant();
return (string.Equals(expectedHash, actualHash, StringComparison.OrdinalIgnoreCase), expectedHash, actualHash);
}
private async Task<BundleContents> ExtractBundleContentsAsync(
string bundlePath,
CancellationToken cancellationToken)
{
var contents = new BundleContents();
await using var fileStream = File.OpenRead(bundlePath);
await using var gzipStream = new GZipStream(fileStream, CompressionMode.Decompress);
using var tarReader = new TarReader(gzipStream);
TarEntry? entry;
while ((entry = await tarReader.GetNextEntryAsync(cancellationToken: cancellationToken).ConfigureAwait(false)) is not null)
{
if (entry.EntryType != TarEntryType.RegularFile || entry.DataStream is null)
{
continue;
}
using var memoryStream = new MemoryStream();
await entry.DataStream.CopyToAsync(memoryStream, cancellationToken).ConfigureAwait(false);
var data = memoryStream.ToArray();
var text = System.Text.Encoding.UTF8.GetString(data);
switch (entry.Name)
{
case DsseEnvelopeFileName:
contents.DsseEnvelopeJson = text;
contents.DsseEnvelopeBytes = data;
contents.DsseEnvelope = JsonSerializer.Deserialize<DsseEnvelope>(text, SerializerOptions);
break;
case StatementFileName:
contents.StatementJson = text;
contents.StatementBytes = data;
contents.Statement = JsonSerializer.Deserialize<InTotoStatement>(text, SerializerOptions);
break;
case TransparencyFileName:
contents.TransparencyNdjson = text;
contents.TransparencyBytes = data;
break;
case MetadataFileName:
contents.MetadataJson = text;
contents.MetadataBytes = data;
contents.Metadata = JsonSerializer.Deserialize<AttestationBundleMetadata>(text, SerializerOptions);
break;
case ChecksumsFileName:
contents.ChecksumsText = text;
break;
}
}
return contents;
}
private (bool Success, string? ErrorMessage) VerifyInternalChecksums(BundleContents contents)
{
if (string.IsNullOrWhiteSpace(contents.ChecksumsText))
{
return (true, null);
}
var lines = contents.ChecksumsText.Split('\n', StringSplitOptions.RemoveEmptyEntries);
foreach (var line in lines)
{
// Skip comments
if (line.TrimStart().StartsWith('#'))
{
continue;
}
// Parse "hash filename" format
var parts = line.Split(new[] { ' ' }, 2, StringSplitOptions.RemoveEmptyEntries);
if (parts.Length != 2)
{
continue;
}
var expectedHash = parts[0].Trim().ToLowerInvariant();
var fileName = parts[1].Trim();
byte[]? fileBytes = fileName switch
{
DsseEnvelopeFileName => contents.DsseEnvelopeBytes,
StatementFileName => contents.StatementBytes,
TransparencyFileName => contents.TransparencyBytes,
MetadataFileName => contents.MetadataBytes,
_ => null
};
if (fileBytes is null)
{
// File not found in bundle - could be optional
if (fileName == TransparencyFileName)
{
continue; // transparency.ndjson is optional
}
return (false, $"File '{fileName}' referenced in checksums but not found in bundle");
}
var actualHash = Convert.ToHexString(SHA256.HashData(fileBytes)).ToLowerInvariant();
if (!string.Equals(expectedHash, actualHash, StringComparison.OrdinalIgnoreCase))
{
return (false, $"Checksum mismatch for '{fileName}': expected {expectedHash}, got {actualHash}");
}
}
return (true, null);
}
private bool VerifyDsseSignature(BundleContents contents, bool offline, out string? error)
{
error = null;
if (contents.DsseEnvelope is null || string.IsNullOrEmpty(contents.DsseEnvelope.Payload))
{
error = "DSSE envelope not found or has no payload";
return false;
}
// Verify payload matches statement
if (contents.StatementJson is not null)
{
try
{
var payloadBytes = Convert.FromBase64String(contents.DsseEnvelope.Payload);
var payloadJson = System.Text.Encoding.UTF8.GetString(payloadBytes);
// Compare parsed JSON to handle whitespace differences
using var statementDoc = JsonDocument.Parse(contents.StatementJson);
using var payloadDoc = JsonDocument.Parse(payloadJson);
// Check _type field matches
var statementType = statementDoc.RootElement.TryGetProperty("_type", out var sType)
? sType.GetString()
: null;
var payloadType = payloadDoc.RootElement.TryGetProperty("_type", out var pType)
? pType.GetString()
: null;
if (!string.Equals(statementType, payloadType, StringComparison.Ordinal))
{
error = "DSSE payload does not match statement _type";
return false;
}
}
catch (FormatException ex)
{
error = $"Invalid DSSE payload encoding: {ex.Message}";
return false;
}
catch (JsonException ex)
{
error = $"Invalid DSSE payload JSON: {ex.Message}";
return false;
}
}
// In offline mode, we don't verify the actual cryptographic signature
// (would require access to signing keys/certificates)
if (offline)
{
_logger.LogDebug("Offline mode: skipping cryptographic signature verification");
return true;
}
// Check that signatures exist
if (contents.DsseEnvelope.Signatures is null || contents.DsseEnvelope.Signatures.Count == 0)
{
error = "DSSE envelope has no signatures";
return false;
}
// Online signature verification would require access to trust roots
// For now, we trust the signature if payload matches and signatures exist
return true;
}
private static IReadOnlyList<string>? ExtractSubjects(BundleContents contents)
{
if (contents.Statement?.Subject is null || contents.Statement.Subject.Count == 0)
{
// Fall back to metadata subjects
if (contents.Metadata?.SubjectDigests is not null)
{
return contents.Metadata.SubjectDigests
.Select(s => $"{s.Name}@{s.Algorithm}:{s.Digest}")
.ToList();
}
return null;
}
return contents.Statement.Subject
.Select(s =>
{
var digest = s.Digest?.FirstOrDefault();
return digest.HasValue
? $"{s.Name}@{digest.Value.Key}:{digest.Value.Value}"
: s.Name ?? "unknown";
})
.ToList();
}
private static string? ExtractPredicateType(BundleContents contents)
{
return contents.Statement?.PredicateType ?? contents.DsseEnvelope?.PayloadType;
}
private static string? FormatRootHash(string? rootHash)
{
if (string.IsNullOrWhiteSpace(rootHash))
{
return null;
}
return rootHash.StartsWith("sha256:", StringComparison.OrdinalIgnoreCase)
? rootHash
: $"sha256:{rootHash}";
}
private static AttestationBundleVerifyResult CreateFailedResult(
int exitCode,
string message,
string bundlePath,
string? detail = null)
=> new(
Success: false,
Status: "failed",
ExportId: null,
AttestationId: null,
RootHash: null,
Subjects: null,
PredicateType: null,
StatementVersion: null,
BundlePath: bundlePath,
ErrorMessage: detail ?? message,
ExitCode: exitCode);
private sealed class BundleContents
{
public string? DsseEnvelopeJson { get; set; }
public byte[]? DsseEnvelopeBytes { get; set; }
public DsseEnvelope? DsseEnvelope { get; set; }
public string? StatementJson { get; set; }
public byte[]? StatementBytes { get; set; }
public InTotoStatement? Statement { get; set; }
public string? TransparencyNdjson { get; set; }
public byte[]? TransparencyBytes { get; set; }
public string? MetadataJson { get; set; }
public byte[]? MetadataBytes { get; set; }
public AttestationBundleMetadata? Metadata { get; set; }
public string? ChecksumsText { get; set; }
}
private sealed class DsseEnvelope
{
public string? PayloadType { get; set; }
public string? Payload { get; set; }
public IReadOnlyList<DsseSignature>? Signatures { get; set; }
}
private sealed class DsseSignature
{
public string? KeyId { get; set; }
public string? Sig { get; set; }
}
private sealed class InTotoStatement
{
public string? Type { get; set; }
public string? PredicateType { get; set; }
public IReadOnlyList<InTotoSubject>? Subject { get; set; }
}
private sealed class InTotoSubject
{
public string? Name { get; set; }
public Dictionary<string, string>? Digest { get; set; }
}
private sealed record AttestationBundleMetadata(
string? Version,
string? ExportId,
string? AttestationId,
string? TenantId,
DateTimeOffset? CreatedAtUtc,
string? RootHash,
string? SourceUri,
string? StatementVersion,
IReadOnlyList<AttestationSubjectDigest>? SubjectDigests);
private sealed record AttestationSubjectDigest(
string? Name,
string? Digest,
string? Algorithm);
}

View File

@@ -0,0 +1,380 @@
using System.Formats.Tar;
using System.IO.Compression;
using System.Security.Cryptography;
using System.Text.Json;
using Microsoft.Extensions.Logging;
namespace StellaOps.Cli.Services;
/// <summary>
/// Verifier for EvidenceLocker sealed bundles used in DevPortal offline verification.
/// Per DVOFF-64-002.
/// </summary>
internal sealed class DevPortalBundleVerifier : IDevPortalBundleVerifier
{
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
{
PropertyNameCaseInsensitive = true,
WriteIndented = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
private readonly ILogger<DevPortalBundleVerifier> _logger;
public DevPortalBundleVerifier(ILogger<DevPortalBundleVerifier> logger)
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<DevPortalBundleVerificationResult> VerifyBundleAsync(
string bundlePath,
bool offline,
CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(bundlePath);
_logger.LogDebug("Verifying DevPortal bundle at {BundlePath}, offline={Offline}", bundlePath, offline);
// Step 1: Check bundle exists
if (!File.Exists(bundlePath))
{
return DevPortalBundleVerificationResult.Failed(
DevPortalVerifyExitCode.Unexpected,
"Bundle file not found",
bundlePath);
}
// Step 2: Validate SHA-256 against .sha256 file if present
var sha256Path = bundlePath + ".sha256";
if (File.Exists(sha256Path))
{
var checksumResult = await VerifyBundleChecksumAsync(bundlePath, sha256Path, cancellationToken)
.ConfigureAwait(false);
if (!checksumResult.IsValid)
{
return DevPortalBundleVerificationResult.Failed(
DevPortalVerifyExitCode.ChecksumMismatch,
"SHA-256 checksum mismatch",
$"Expected: {checksumResult.ExpectedHash}, Computed: {checksumResult.ActualHash}");
}
}
else
{
_logger.LogDebug("No .sha256 file found, skipping checksum verification");
}
// Step 3: Extract and parse bundle contents
BundleContents contents;
try
{
contents = await ExtractBundleContentsAsync(bundlePath, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex) when (ex is InvalidDataException or JsonException or IOException)
{
_logger.LogError(ex, "Failed to extract bundle contents");
return DevPortalBundleVerificationResult.Failed(
DevPortalVerifyExitCode.Unexpected,
"Failed to extract bundle contents",
ex.Message);
}
// Step 4: Verify DSSE signature
var signatureValid = VerifyDsseSignature(contents, offline, out var signatureError);
if (!signatureValid && !string.IsNullOrEmpty(signatureError))
{
return DevPortalBundleVerificationResult.Failed(
DevPortalVerifyExitCode.SignatureFailure,
"DSSE signature verification failed",
signatureError);
}
// Step 5: Verify TSA (only if not offline)
if (!offline && contents.Signature is not null)
{
if (string.IsNullOrEmpty(contents.Signature.TimestampAuthority) ||
string.IsNullOrEmpty(contents.Signature.TimestampToken))
{
return DevPortalBundleVerificationResult.Failed(
DevPortalVerifyExitCode.TsaMissing,
"RFC3161 timestamp missing",
"Bundle requires timestamping when not in offline mode");
}
}
// Step 6: Build success result
return new DevPortalBundleVerificationResult
{
Status = "verified",
BundleId = contents.Manifest?.BundleId ?? contents.BundleMetadata?.BundleId,
RootHash = contents.BundleMetadata?.RootHash is not null
? $"sha256:{contents.BundleMetadata.RootHash}"
: null,
Entries = contents.Manifest?.Entries?.Count ?? 0,
CreatedAt = contents.Manifest?.CreatedAt ?? contents.BundleMetadata?.CreatedAt,
Portable = contents.BundleMetadata?.PortableGeneratedAt is not null,
ExitCode = DevPortalVerifyExitCode.Success
};
}
private async Task<(bool IsValid, string? ExpectedHash, string? ActualHash)> VerifyBundleChecksumAsync(
string bundlePath,
string sha256Path,
CancellationToken cancellationToken)
{
// Read expected hash from .sha256 file
var content = await File.ReadAllTextAsync(sha256Path, cancellationToken).ConfigureAwait(false);
var expectedHash = content.Split(' ', StringSplitOptions.RemoveEmptyEntries).FirstOrDefault()?.Trim()?.ToLowerInvariant();
if (string.IsNullOrEmpty(expectedHash))
{
return (false, null, null);
}
// Compute actual hash
await using var stream = File.OpenRead(bundlePath);
var hashBytes = await SHA256.HashDataAsync(stream, cancellationToken).ConfigureAwait(false);
var actualHash = Convert.ToHexString(hashBytes).ToLowerInvariant();
return (string.Equals(expectedHash, actualHash, StringComparison.OrdinalIgnoreCase), expectedHash, actualHash);
}
private async Task<BundleContents> ExtractBundleContentsAsync(
string bundlePath,
CancellationToken cancellationToken)
{
var contents = new BundleContents();
await using var fileStream = File.OpenRead(bundlePath);
await using var gzipStream = new GZipStream(fileStream, CompressionMode.Decompress);
using var tarReader = new TarReader(gzipStream);
TarEntry? entry;
while ((entry = await tarReader.GetNextEntryAsync(cancellationToken: cancellationToken).ConfigureAwait(false)) is not null)
{
if (entry.EntryType != TarEntryType.RegularFile || entry.DataStream is null)
{
continue;
}
using var memoryStream = new MemoryStream();
await entry.DataStream.CopyToAsync(memoryStream, cancellationToken).ConfigureAwait(false);
var json = System.Text.Encoding.UTF8.GetString(memoryStream.ToArray());
switch (entry.Name)
{
case "manifest.json":
contents.ManifestJson = json;
contents.Manifest = JsonSerializer.Deserialize<BundleManifest>(json, SerializerOptions);
break;
case "signature.json":
contents.SignatureJson = json;
contents.Signature = JsonSerializer.Deserialize<BundleSignature>(json, SerializerOptions);
break;
case "bundle.json":
contents.BundleMetadataJson = json;
contents.BundleMetadata = JsonSerializer.Deserialize<BundleMetadataDocument>(json, SerializerOptions);
break;
case "checksums.txt":
contents.ChecksumsText = json;
break;
}
}
return contents;
}
private bool VerifyDsseSignature(BundleContents contents, bool offline, out string? error)
{
error = null;
if (contents.Signature is null || string.IsNullOrEmpty(contents.Signature.Payload))
{
error = "Signature not found in bundle";
return false;
}
// Verify payload matches manifest
if (contents.ManifestJson is not null)
{
try
{
var payloadBytes = Convert.FromBase64String(contents.Signature.Payload);
var payloadJson = System.Text.Encoding.UTF8.GetString(payloadBytes);
// Compare parsed JSON to handle whitespace differences
using var manifestDoc = JsonDocument.Parse(contents.ManifestJson);
using var payloadDoc = JsonDocument.Parse(payloadJson);
var manifestBundleId = manifestDoc.RootElement.TryGetProperty("bundleId", out var mId)
? mId.GetString()
: null;
var payloadBundleId = payloadDoc.RootElement.TryGetProperty("bundleId", out var pId)
? pId.GetString()
: null;
if (!string.Equals(manifestBundleId, payloadBundleId, StringComparison.OrdinalIgnoreCase))
{
error = "Signature payload does not match manifest bundleId";
return false;
}
}
catch (FormatException ex)
{
error = $"Invalid signature payload encoding: {ex.Message}";
return false;
}
catch (JsonException ex)
{
error = $"Invalid signature payload JSON: {ex.Message}";
return false;
}
}
// In offline mode, we don't verify the actual cryptographic signature
// (would require access to signing keys/certificates)
if (offline)
{
_logger.LogDebug("Offline mode: skipping cryptographic signature verification");
return true;
}
// Online signature verification would go here
// For now, we trust the signature if payload matches
return true;
}
private sealed class BundleContents
{
public string? ManifestJson { get; set; }
public BundleManifest? Manifest { get; set; }
public string? SignatureJson { get; set; }
public BundleSignature? Signature { get; set; }
public string? BundleMetadataJson { get; set; }
public BundleMetadataDocument? BundleMetadata { get; set; }
public string? ChecksumsText { get; set; }
}
private sealed class BundleManifest
{
public string? BundleId { get; set; }
public string? TenantId { get; set; }
public int Kind { get; set; }
public DateTimeOffset? CreatedAt { get; set; }
public Dictionary<string, string>? Metadata { get; set; }
public List<BundleManifestEntry>? Entries { get; set; }
}
private sealed class BundleManifestEntry
{
public string? Section { get; set; }
public string? CanonicalPath { get; set; }
public string? Sha256 { get; set; }
public long SizeBytes { get; set; }
public string? MediaType { get; set; }
}
private sealed class BundleSignature
{
public string? PayloadType { get; set; }
public string? Payload { get; set; }
public string? Signature { get; set; }
public string? KeyId { get; set; }
public string? Algorithm { get; set; }
public string? Provider { get; set; }
public DateTimeOffset? SignedAt { get; set; }
public DateTimeOffset? TimestampedAt { get; set; }
public string? TimestampAuthority { get; set; }
public string? TimestampToken { get; set; }
}
private sealed class BundleMetadataDocument
{
public string? BundleId { get; set; }
public string? TenantId { get; set; }
public int Kind { get; set; }
public int Status { get; set; }
public string? RootHash { get; set; }
public string? StorageKey { get; set; }
public DateTimeOffset? CreatedAt { get; set; }
public DateTimeOffset? SealedAt { get; set; }
public DateTimeOffset? PortableGeneratedAt { get; set; }
}
}
/// <summary>
/// Exit codes for DevPortal bundle verification per DVOFF-64-002.
/// </summary>
public enum DevPortalVerifyExitCode
{
/// <summary>Verification successful.</summary>
Success = 0,
/// <summary>SHA-256 checksum mismatch.</summary>
ChecksumMismatch = 2,
/// <summary>DSSE signature verification failed.</summary>
SignatureFailure = 3,
/// <summary>RFC3161 timestamp missing (when not offline).</summary>
TsaMissing = 4,
/// <summary>Unexpected error.</summary>
Unexpected = 5
}
/// <summary>
/// Result of DevPortal bundle verification.
/// </summary>
public sealed class DevPortalBundleVerificationResult
{
public string Status { get; set; } = "failed";
public string? BundleId { get; set; }
public string? RootHash { get; set; }
public int Entries { get; set; }
public DateTimeOffset? CreatedAt { get; set; }
public bool Portable { get; set; }
public DevPortalVerifyExitCode ExitCode { get; set; } = DevPortalVerifyExitCode.Unexpected;
public string? ErrorMessage { get; set; }
public string? ErrorDetail { get; set; }
public static DevPortalBundleVerificationResult Failed(
DevPortalVerifyExitCode exitCode,
string message,
string? detail = null)
=> new()
{
Status = "failed",
ExitCode = exitCode,
ErrorMessage = message,
ErrorDetail = detail
};
public string ToJson()
{
var options = new JsonSerializerOptions
{
WriteIndented = false,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull
};
// Build output with sorted keys
var output = new SortedDictionary<string, object?>(StringComparer.Ordinal);
if (BundleId is not null)
output["bundleId"] = BundleId;
if (CreatedAt.HasValue)
output["createdAt"] = CreatedAt.Value.ToString("O");
output["entries"] = Entries;
if (ErrorDetail is not null)
output["errorDetail"] = ErrorDetail;
if (ErrorMessage is not null)
output["errorMessage"] = ErrorMessage;
output["portable"] = Portable;
if (RootHash is not null)
output["rootHash"] = RootHash;
output["status"] = Status;
return JsonSerializer.Serialize(output, options);
}
}

View File

@@ -0,0 +1,29 @@
using StellaOps.Cli.Services.Models;
namespace StellaOps.Cli.Services;
/// <summary>
/// Interface for attestation bundle verification.
/// </summary>
public interface IAttestationBundleVerifier
{
/// <summary>
/// Verifies an attestation bundle exported from the Export Center.
/// </summary>
/// <param name="options">Verification options.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Verification result with status and exit code.</returns>
Task<AttestationBundleVerifyResult> VerifyAsync(
AttestationBundleVerifyOptions options,
CancellationToken cancellationToken);
/// <summary>
/// Imports an attestation bundle into the local system.
/// </summary>
/// <param name="options">Import options.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Import result with status and exit code.</returns>
Task<AttestationBundleImportResult> ImportAsync(
AttestationBundleImportOptions options,
CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,19 @@
namespace StellaOps.Cli.Services;
/// <summary>
/// Interface for DevPortal bundle verification.
/// </summary>
public interface IDevPortalBundleVerifier
{
/// <summary>
/// Verifies a DevPortal/EvidenceLocker sealed bundle.
/// </summary>
/// <param name="bundlePath">Path to the bundle .tgz file.</param>
/// <param name="offline">If true, skip TSA verification and online checks.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Verification result with status and exit code.</returns>
Task<DevPortalBundleVerificationResult> VerifyBundleAsync(
string bundlePath,
bool offline,
CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,126 @@
using System.Text.Json.Serialization;
namespace StellaOps.Cli.Services.Models;
/// <summary>
/// Options for attestation bundle verification.
/// </summary>
public sealed record AttestationBundleVerifyOptions(
string FilePath,
bool Offline = false,
bool VerifyTransparency = true,
string? TrustRootPath = null);
/// <summary>
/// Options for attestation bundle import.
/// </summary>
public sealed record AttestationBundleImportOptions(
string FilePath,
string? Tenant = null,
string? Namespace = null,
bool Offline = false,
bool VerifyTransparency = true,
string? TrustRootPath = null);
/// <summary>
/// Result of attestation bundle verification.
/// </summary>
public sealed record AttestationBundleVerifyResult(
bool Success,
string Status,
string? ExportId,
string? AttestationId,
string? RootHash,
IReadOnlyList<string>? Subjects,
string? PredicateType,
string? StatementVersion,
string BundlePath,
string? ErrorMessage = null,
int ExitCode = 0);
/// <summary>
/// Result of attestation bundle import.
/// </summary>
public sealed record AttestationBundleImportResult(
bool Success,
string Status,
string? AttestationId,
string? TenantId,
string? Namespace,
string? RootHash,
string? ErrorMessage = null,
int ExitCode = 0);
/// <summary>
/// JSON output for attestation bundle verify command.
/// </summary>
public sealed record AttestationBundleVerifyJson(
[property: JsonPropertyName("status")] string Status,
[property: JsonPropertyName("exportId")] string? ExportId,
[property: JsonPropertyName("attestationId")] string? AttestationId,
[property: JsonPropertyName("rootHash")] string? RootHash,
[property: JsonPropertyName("subjects")] IReadOnlyList<string>? Subjects,
[property: JsonPropertyName("predicateType")] string? PredicateType,
[property: JsonPropertyName("bundlePath")] string BundlePath);
/// <summary>
/// JSON output for attestation bundle import command.
/// </summary>
public sealed record AttestationBundleImportJson(
[property: JsonPropertyName("status")] string Status,
[property: JsonPropertyName("attestationId")] string? AttestationId,
[property: JsonPropertyName("tenantId")] string? TenantId,
[property: JsonPropertyName("namespace")] string? Namespace,
[property: JsonPropertyName("rootHash")] string? RootHash);
/// <summary>
/// Exit codes for attestation bundle commands.
/// </summary>
public static class AttestationBundleExitCodes
{
/// <summary>Success.</summary>
public const int Success = 0;
/// <summary>General failure.</summary>
public const int GeneralFailure = 1;
/// <summary>Checksum mismatch.</summary>
public const int ChecksumMismatch = 2;
/// <summary>DSSE signature verification failure.</summary>
public const int SignatureFailure = 3;
/// <summary>Missing required TSA/CT log entry.</summary>
public const int MissingTransparency = 4;
/// <summary>Archive or file format error.</summary>
public const int FormatError = 5;
/// <summary>File not found.</summary>
public const int FileNotFound = 6;
/// <summary>Import failed.</summary>
public const int ImportFailed = 7;
}
/// <summary>
/// Metadata parsed from an attestation bundle.
/// </summary>
internal sealed record AttestationBundleMetadata(
string? Version,
string? ExportId,
string? AttestationId,
string? TenantId,
DateTimeOffset? CreatedAtUtc,
string? RootHash,
string? SourceUri,
string? StatementVersion,
IReadOnlyList<AttestationBundleSubjectDigest>? SubjectDigests);
/// <summary>
/// Subject digest from attestation bundle metadata.
/// </summary>
internal sealed record AttestationBundleSubjectDigest(
string? Name,
string? Digest,
string? Algorithm);