Add integration tests for Proof Chain and Reachability workflows

- Implement ProofChainTestFixture for PostgreSQL-backed integration tests.
- Create StellaOps.Integration.ProofChain project with necessary dependencies.
- Add ReachabilityIntegrationTests to validate call graph extraction and reachability analysis.
- Introduce ReachabilityTestFixture for managing corpus and fixture paths.
- Establish StellaOps.Integration.Reachability project with required references.
- Develop UnknownsWorkflowTests to cover the unknowns lifecycle: detection, ranking, escalation, and resolution.
- Create StellaOps.Integration.Unknowns project with dependencies for unknowns workflow.
This commit is contained in:
StellaOps Bot
2025-12-20 22:19:26 +02:00
parent 3c6e14fca5
commit efe9bd8cfe
86 changed files with 9616 additions and 323 deletions

View File

@@ -1,6 +1,17 @@
// -----------------------------------------------------------------------------
// ProofCommandGroup.cs
// Sprint: SPRINT_3500_0004_0001_cli_verbs
// Task: T4 - Complete Proof Verify
// Description: CLI commands for proof chain verification
// -----------------------------------------------------------------------------
using System.CommandLine;
using System.CommandLine.Invocation;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using StellaOps.Cli.Services;
using StellaOps.Cli.Services.Models;
namespace StellaOps.Cli.Commands.Proof;
@@ -8,248 +19,390 @@ namespace StellaOps.Cli.Commands.Proof;
/// Command group for proof chain operations.
/// Implements advisory §15 CLI commands.
/// </summary>
public class ProofCommandGroup
public static class ProofCommandGroup
{
private readonly ILogger<ProofCommandGroup> _logger;
public ProofCommandGroup(ILogger<ProofCommandGroup> logger)
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
_logger = logger;
}
WriteIndented = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
/// <summary>
/// Build the proof command tree.
/// </summary>
public Command BuildCommand()
public static Command BuildProofCommand(
IServiceProvider services,
Option<bool> verboseOption,
CancellationToken cancellationToken)
{
var proofCommand = new Command("proof", "Proof chain operations");
var proofCommand = new Command("proof", "Proof chain verification and operations");
proofCommand.AddCommand(BuildVerifyCommand());
proofCommand.AddCommand(BuildSpineCommand());
proofCommand.Add(BuildVerifyCommand(services, verboseOption, cancellationToken));
proofCommand.Add(BuildSpineCommand(services, verboseOption, cancellationToken));
return proofCommand;
}
private Command BuildVerifyCommand()
private static Command BuildVerifyCommand(
IServiceProvider services,
Option<bool> verboseOption,
CancellationToken cancellationToken)
{
var artifactArg = new Argument<string>(
name: "artifact",
description: "Artifact digest (sha256:...) or PURL");
var sbomOption = new Option<FileInfo?>(
aliases: ["-s", "--sbom"],
description: "Path to SBOM file");
var vexOption = new Option<FileInfo?>(
aliases: ["--vex"],
description: "Path to VEX file");
var anchorOption = new Option<Guid?>(
aliases: ["-a", "--anchor"],
description: "Trust anchor ID");
var offlineOption = new Option<bool>(
name: "--offline",
description: "Offline mode (skip Rekor verification)");
var outputOption = new Option<string>(
name: "--output",
getDefaultValue: () => "text",
description: "Output format: text, json");
var verboseOption = new Option<int>(
aliases: ["-v", "--verbose"],
getDefaultValue: () => 0,
description: "Verbose output level (use -vv for very verbose)");
var verifyCommand = new Command("verify", "Verify an artifact's proof chain")
var bundleOption = new Option<string>("--bundle", "-b")
{
artifactArg,
sbomOption,
vexOption,
anchorOption,
offlineOption,
outputOption,
verboseOption
Description = "Path to attestation bundle file (.tar.gz)",
Required = true
};
verifyCommand.SetHandler(async (context) =>
var offlineOption = new Option<bool>("--offline")
{
var artifact = context.ParseResult.GetValueForArgument(artifactArg);
var sbomFile = context.ParseResult.GetValueForOption(sbomOption);
var vexFile = context.ParseResult.GetValueForOption(vexOption);
var anchorId = context.ParseResult.GetValueForOption(anchorOption);
var offline = context.ParseResult.GetValueForOption(offlineOption);
var output = context.ParseResult.GetValueForOption(outputOption) ?? "text";
var verbose = context.ParseResult.GetValueForOption(verboseOption);
Description = "Offline mode (skip Rekor verification)"
};
context.ExitCode = await VerifyAsync(
artifact,
sbomFile,
vexFile,
anchorId,
var outputOption = new Option<string>("--output", "-o")
{
Description = "Output format: text, json"
};
var verifyCommand = new Command("verify", "Verify an attestation bundle's proof chain");
verifyCommand.Add(bundleOption);
verifyCommand.Add(offlineOption);
verifyCommand.Add(outputOption);
verifyCommand.Add(verboseOption);
verifyCommand.SetAction(async (parseResult, ct) =>
{
var bundlePath = parseResult.GetValue(bundleOption) ?? string.Empty;
var offline = parseResult.GetValue(offlineOption);
var output = parseResult.GetValue(outputOption) ?? "text";
var verbose = parseResult.GetValue(verboseOption);
return await HandleVerifyAsync(
services,
bundlePath,
offline,
output,
verbose,
context.GetCancellationToken());
cancellationToken);
});
return verifyCommand;
}
private Command BuildSpineCommand()
private static Command BuildSpineCommand(
IServiceProvider services,
Option<bool> verboseOption,
CancellationToken cancellationToken)
{
var spineCommand = new Command("spine", "Proof spine operations");
// stellaops proof spine create
var createCommand = new Command("create", "Create a proof spine for an artifact");
var artifactArg = new Argument<string>("artifact", "Artifact digest or PURL");
createCommand.AddArgument(artifactArg);
createCommand.SetHandler(async (context) =>
// proof spine show
var bundleIdArg = new Argument<string>("bundle-id")
{
var artifact = context.ParseResult.GetValueForArgument(artifactArg);
context.ExitCode = await CreateSpineAsync(artifact, context.GetCancellationToken());
});
Description = "Proof bundle ID"
};
// stellaops proof spine show
var showCommand = new Command("show", "Show proof spine details");
var bundleArg = new Argument<string>("bundleId", "Proof bundle ID");
showCommand.AddArgument(bundleArg);
showCommand.SetHandler(async (context) =>
showCommand.Add(bundleIdArg);
showCommand.Add(verboseOption);
showCommand.SetAction(async (parseResult, ct) =>
{
var bundleId = context.ParseResult.GetValueForArgument(bundleArg);
context.ExitCode = await ShowSpineAsync(bundleId, context.GetCancellationToken());
var bundleId = parseResult.GetValue(bundleIdArg) ?? string.Empty;
var verbose = parseResult.GetValue(verboseOption);
return await HandleSpineShowAsync(
services,
bundleId,
verbose,
cancellationToken);
});
spineCommand.AddCommand(createCommand);
spineCommand.AddCommand(showCommand);
spineCommand.Add(showCommand);
return spineCommand;
}
private async Task<int> VerifyAsync(
string artifact,
FileInfo? sbomFile,
FileInfo? vexFile,
Guid? anchorId,
private static async Task<int> HandleVerifyAsync(
IServiceProvider services,
string bundlePath,
bool offline,
string output,
int verbose,
bool verbose,
CancellationToken ct)
{
var loggerFactory = services.GetService<ILoggerFactory>();
var logger = loggerFactory?.CreateLogger(typeof(ProofCommandGroup));
try
{
if (verbose > 0)
if (verbose)
{
_logger.LogDebug("Starting proof verification for {Artifact}", artifact);
logger?.LogDebug("Verifying attestation bundle: {BundlePath}", bundlePath);
}
// Validate artifact format
if (!IsValidArtifactId(artifact))
// Check file exists
if (!File.Exists(bundlePath))
{
_logger.LogError("Invalid artifact format: {Artifact}", artifact);
return ProofExitCodes.SystemError;
var errorMsg = $"Bundle file not found: {bundlePath}";
logger?.LogError(errorMsg);
if (output == "json")
{
PrintJsonResult(new ProofVerifyResult(
Valid: false,
Status: "error",
BundlePath: bundlePath,
ErrorMessage: errorMsg));
}
else
{
Console.WriteLine($"Error: {errorMsg}");
}
return AttestationBundleExitCodes.FileNotFound;
}
if (verbose > 0)
// Get the attestation bundle verifier
var verifier = services.GetService<IAttestationBundleVerifier>();
if (verifier is null)
{
_logger.LogDebug("Artifact format valid: {Artifact}", artifact);
logger?.LogWarning("IAttestationBundleVerifier not available, using built-in verifier");
verifier = new AttestationBundleVerifier(
services.GetService<ILogger<AttestationBundleVerifier>>()
?? Microsoft.Extensions.Logging.Abstractions.NullLogger<AttestationBundleVerifier>.Instance);
}
// TODO: Implement actual verification using IVerificationPipeline
// 1. Load SBOM if provided
// 2. Load VEX if provided
// 3. Find or use specified trust anchor
// 4. Run verification pipeline
// 5. Check Rekor inclusion (unless offline)
// 6. Generate receipt
// Configure verification options
var options = new AttestationBundleVerifyOptions(
FilePath: bundlePath,
Offline: offline,
VerifyTransparency: !offline);
if (verbose > 0)
if (verbose)
{
_logger.LogDebug("Verification pipeline not yet implemented");
logger?.LogDebug("Verification options: offline={Offline}, verifyTransparency={VerifyTransparency}",
options.Offline, options.VerifyTransparency);
}
// Run verification
var result = await verifier.VerifyAsync(options, ct);
if (verbose)
{
logger?.LogDebug("Verification result: success={Success}, status={Status}",
result.Success, result.Status);
}
// Output result
if (output == "json")
{
Console.WriteLine("{");
Console.WriteLine($" \"artifact\": \"{artifact}\",");
Console.WriteLine(" \"status\": \"pass\",");
Console.WriteLine(" \"message\": \"Verification successful (stub)\"");
Console.WriteLine("}");
PrintJsonResult(new ProofVerifyResult(
Valid: result.Success,
Status: result.Status,
BundlePath: bundlePath,
RootHash: result.RootHash,
AttestationId: result.AttestationId,
ExportId: result.ExportId,
Subjects: result.Subjects,
PredicateType: result.PredicateType,
Checks: BuildVerificationChecks(result),
ErrorMessage: result.ErrorMessage));
}
else
{
Console.WriteLine("StellaOps Scan Summary");
Console.WriteLine("══════════════════════");
Console.WriteLine($"Artifact: {artifact}");
Console.WriteLine("Status: PASS (stub - verification not yet implemented)");
PrintTextResult(result, offline);
}
return ProofExitCodes.Success;
return result.ExitCode;
}
catch (Exception ex)
{
_logger.LogError(ex, "Verification failed for {Artifact}", artifact);
logger?.LogError(ex, "Verification failed for {BundlePath}", bundlePath);
if (output == "json")
{
PrintJsonResult(new ProofVerifyResult(
Valid: false,
Status: "error",
BundlePath: bundlePath,
ErrorMessage: ex.Message));
}
else
{
Console.WriteLine($"Error: {ex.Message}");
}
return ProofExitCodes.SystemError;
}
}
private async Task<int> CreateSpineAsync(string artifact, CancellationToken ct)
private static async Task<int> HandleSpineShowAsync(
IServiceProvider services,
string bundleId,
bool verbose,
CancellationToken ct)
{
var loggerFactory = services.GetService<ILoggerFactory>();
var logger = loggerFactory?.CreateLogger(typeof(ProofCommandGroup));
try
{
_logger.LogInformation("Creating proof spine for {Artifact}", artifact);
if (verbose)
{
logger?.LogDebug("Showing proof spine {BundleId}", bundleId);
}
// TODO: Implement spine creation using IProofSpineAssembler
Console.WriteLine($"Creating proof spine for: {artifact}");
Console.WriteLine("Spine creation not yet implemented");
return ProofExitCodes.Success;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to create spine for {Artifact}", artifact);
return ProofExitCodes.SystemError;
}
}
private async Task<int> ShowSpineAsync(string bundleId, CancellationToken ct)
{
try
{
_logger.LogInformation("Showing proof spine {BundleId}", bundleId);
// TODO: Implement spine retrieval
// TODO: Implement spine retrieval from backend
Console.WriteLine($"Proof spine: {bundleId}");
Console.WriteLine("Spine display not yet implemented");
Console.WriteLine("Use 'stella proof verify --bundle <path>' for local bundle verification.");
return ProofExitCodes.Success;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to show spine {BundleId}", bundleId);
logger?.LogError(ex, "Failed to show spine {BundleId}", bundleId);
Console.WriteLine($"Error: {ex.Message}");
return ProofExitCodes.SystemError;
}
}
private static bool IsValidArtifactId(string artifact)
private static IReadOnlyList<ProofVerifyCheck>? BuildVerificationChecks(AttestationBundleVerifyResult result)
{
if (string.IsNullOrWhiteSpace(artifact))
return false;
var checks = new List<ProofVerifyCheck>();
// sha256:<64-hex>
if (artifact.StartsWith("sha256:", StringComparison.OrdinalIgnoreCase))
// File integrity check
checks.Add(new ProofVerifyCheck(
Check: "file_integrity",
Status: result.ExitCode != AttestationBundleExitCodes.ChecksumMismatch ? "pass" : "fail",
Details: result.ExitCode == AttestationBundleExitCodes.ChecksumMismatch
? result.ErrorMessage
: "Bundle checksums verified"));
// DSSE signature check
checks.Add(new ProofVerifyCheck(
Check: "dsse_signature",
Status: result.ExitCode != AttestationBundleExitCodes.SignatureFailure ? "pass" : "fail",
Details: result.ExitCode == AttestationBundleExitCodes.SignatureFailure
? result.ErrorMessage
: "DSSE envelope signature valid"));
// Transparency check (if not offline)
if (result.ExitCode == AttestationBundleExitCodes.MissingTransparency)
{
var hash = artifact[7..];
return hash.Length == 64 && hash.All(c => "0123456789abcdef".Contains(char.ToLowerInvariant(c)));
checks.Add(new ProofVerifyCheck(
Check: "transparency_log",
Status: "fail",
Details: result.ErrorMessage));
}
else if (result.Success)
{
checks.Add(new ProofVerifyCheck(
Check: "transparency_log",
Status: "pass",
Details: "Transparency entry verified or skipped (offline)"));
}
// pkg:type/...
if (artifact.StartsWith("pkg:", StringComparison.OrdinalIgnoreCase))
{
return artifact.Length > 5; // Minimal PURL validation
}
return false;
return checks;
}
private static void PrintTextResult(AttestationBundleVerifyResult result, bool offline)
{
Console.WriteLine();
Console.WriteLine("Proof Verification Result");
Console.WriteLine(new string('=', 40));
var statusDisplay = result.Success ? "PASS" : "FAIL";
Console.WriteLine($"Status: {statusDisplay}");
Console.WriteLine($"Bundle: {result.BundlePath}");
if (!string.IsNullOrEmpty(result.RootHash))
{
Console.WriteLine($"Root Hash: {result.RootHash}");
}
if (!string.IsNullOrEmpty(result.AttestationId))
{
Console.WriteLine($"Attestation ID: {result.AttestationId}");
}
if (!string.IsNullOrEmpty(result.ExportId))
{
Console.WriteLine($"Export ID: {result.ExportId}");
}
if (!string.IsNullOrEmpty(result.PredicateType))
{
Console.WriteLine($"Predicate: {result.PredicateType}");
}
if (result.Subjects is { Count: > 0 })
{
Console.WriteLine($"Subjects: {result.Subjects.Count}");
foreach (var subject in result.Subjects.Take(5))
{
Console.WriteLine($" - {subject}");
}
if (result.Subjects.Count > 5)
{
Console.WriteLine($" ... and {result.Subjects.Count - 5} more");
}
}
Console.WriteLine();
Console.WriteLine("Verification Checks:");
Console.WriteLine(new string('-', 40));
if (result.Success)
{
Console.WriteLine($" [PASS] File integrity");
Console.WriteLine($" [PASS] DSSE envelope format");
Console.WriteLine($" [PASS] Signature validation");
if (offline)
{
Console.WriteLine($" [SKIP] Transparency log (offline mode)");
}
else
{
Console.WriteLine($" [PASS] Transparency log");
}
}
else
{
Console.WriteLine($" [FAIL] {result.ErrorMessage}");
}
Console.WriteLine();
}
private static void PrintJsonResult(ProofVerifyResult result)
{
Console.WriteLine(JsonSerializer.Serialize(result, JsonOptions));
}
#region DTOs
/// <summary>
/// Result of proof verification.
/// </summary>
private sealed record ProofVerifyResult(
bool Valid,
string Status,
string? BundlePath = null,
string? RootHash = null,
string? AttestationId = null,
string? ExportId = null,
IReadOnlyList<string>? Subjects = null,
string? PredicateType = null,
IReadOnlyList<ProofVerifyCheck>? Checks = null,
string? ErrorMessage = null);
/// <summary>
/// Individual verification check result.
/// </summary>
private sealed record ProofVerifyCheck(
string Check,
string Status,
string? Details = null);
#endregion
}

View File

@@ -49,6 +49,11 @@ public static class ProofExitCodes
/// </summary>
public const int OfflineModeError = 7;
/// <summary>
/// Input error - invalid arguments or missing required parameters.
/// </summary>
public const int InputError = 8;
/// <summary>
/// Get a human-readable description for an exit code.
/// </summary>
@@ -62,6 +67,7 @@ public static class ProofExitCodes
RekorVerificationFailed => "Rekor verification failed",
KeyRevoked => "Signing key revoked",
OfflineModeError => "Offline mode error",
InputError => "Invalid input or missing required parameters",
_ => $"Unknown exit code: {exitCode}"
};
}