1219 lines
42 KiB
C#
1219 lines
42 KiB
C#
// -----------------------------------------------------------------------------
|
|
// ChainCommandGroup.cs
|
|
// Sprint: SPRINT_20260106_003_004_ATTESTOR_chain_linking
|
|
// Task: T026, T027, T028
|
|
// Description: CLI commands for attestation chain operations.
|
|
// -----------------------------------------------------------------------------
|
|
|
|
using System.CommandLine;
|
|
using System.Text.Json;
|
|
using System.Text.Json.Serialization;
|
|
|
|
namespace StellaOps.Cli.Commands.Chain;
|
|
|
|
/// <summary>
|
|
/// CLI commands for attestation chain operations.
|
|
/// </summary>
|
|
public static class ChainCommandGroup
|
|
{
|
|
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
|
|
{
|
|
WriteIndented = true,
|
|
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
|
};
|
|
|
|
/// <summary>
|
|
/// Build the chain command tree.
|
|
/// </summary>
|
|
public static Command BuildChainCommand(Option<bool> verboseOption, CancellationToken cancellationToken)
|
|
{
|
|
var chainCommand = new Command("chain", "Attestation chain traversal and verification");
|
|
|
|
chainCommand.Add(BuildShowCommand(verboseOption, cancellationToken));
|
|
chainCommand.Add(BuildVerifyCommand(verboseOption, cancellationToken));
|
|
chainCommand.Add(BuildGraphCommand(verboseOption, cancellationToken));
|
|
chainCommand.Add(BuildLayerCommand(verboseOption, cancellationToken));
|
|
|
|
return chainCommand;
|
|
}
|
|
|
|
/// <summary>
|
|
/// T026: Build the 'chain show' subcommand.
|
|
/// Shows attestation chain from a starting attestation.
|
|
/// </summary>
|
|
private static Command BuildShowCommand(Option<bool> verboseOption, CancellationToken cancellationToken)
|
|
{
|
|
var attestationIdArg = new Argument<string>("attestation-id")
|
|
{
|
|
Description = "Attestation ID (sha256:...) to show chain for"
|
|
};
|
|
|
|
var directionOption = new Option<ChainDirection>("--direction", "-d")
|
|
{
|
|
Description = "Chain traversal direction"
|
|
};
|
|
directionOption.SetDefaultValue(ChainDirection.Full);
|
|
|
|
var maxDepthOption = new Option<int>("--max-depth", "-m")
|
|
{
|
|
Description = "Maximum traversal depth"
|
|
};
|
|
maxDepthOption.SetDefaultValue(5);
|
|
|
|
var formatOption = new Option<OutputFormat>("--format", "-f")
|
|
{
|
|
Description = "Output format (json, table, summary)"
|
|
};
|
|
formatOption.SetDefaultValue(OutputFormat.Summary);
|
|
|
|
var serverOption = new Option<string?>("--server", "-s")
|
|
{
|
|
Description = "StellaOps server URL (uses STELLAOPS_BACKEND_URL if not specified)"
|
|
};
|
|
|
|
var showCommand = new Command("show", "Show attestation chain from a starting attestation")
|
|
{
|
|
attestationIdArg,
|
|
directionOption,
|
|
maxDepthOption,
|
|
formatOption,
|
|
serverOption,
|
|
verboseOption
|
|
};
|
|
|
|
showCommand.SetAction(async (parseResult, ct) =>
|
|
{
|
|
var attestationId = parseResult.GetValue(attestationIdArg) ?? string.Empty;
|
|
var direction = parseResult.GetValue(directionOption);
|
|
var maxDepth = parseResult.GetValue(maxDepthOption);
|
|
var format = parseResult.GetValue(formatOption);
|
|
var server = parseResult.GetValue(serverOption);
|
|
var verbose = parseResult.GetValue(verboseOption);
|
|
|
|
return await ExecuteShowAsync(
|
|
attestationId,
|
|
direction,
|
|
maxDepth,
|
|
format,
|
|
server,
|
|
verbose,
|
|
cancellationToken);
|
|
});
|
|
|
|
return showCommand;
|
|
}
|
|
|
|
/// <summary>
|
|
/// T027: Build the 'chain verify' subcommand.
|
|
/// Verifies the integrity of an attestation chain.
|
|
/// </summary>
|
|
private static Command BuildVerifyCommand(Option<bool> verboseOption, CancellationToken cancellationToken)
|
|
{
|
|
var attestationIdArg = new Argument<string>("attestation-id")
|
|
{
|
|
Description = "Attestation ID (sha256:...) to verify chain for"
|
|
};
|
|
|
|
var artifactOption = new Option<string?>("--artifact", "-a")
|
|
{
|
|
Description = "Artifact digest to verify chain for (alternative to attestation-id)"
|
|
};
|
|
|
|
var strictOption = new Option<bool>("--strict")
|
|
{
|
|
Description = "Require complete chain with no missing attestations"
|
|
};
|
|
|
|
var verifySignaturesOption = new Option<bool>("--verify-signatures")
|
|
{
|
|
Description = "Verify signatures on all attestations in chain"
|
|
};
|
|
|
|
var formatOption = new Option<OutputFormat>("--format", "-f")
|
|
{
|
|
Description = "Output format (json, table, summary)"
|
|
};
|
|
formatOption.SetDefaultValue(OutputFormat.Summary);
|
|
|
|
var serverOption = new Option<string?>("--server", "-s")
|
|
{
|
|
Description = "StellaOps server URL (uses STELLAOPS_BACKEND_URL if not specified)"
|
|
};
|
|
|
|
var verifyCommand = new Command("verify", "Verify the integrity of an attestation chain")
|
|
{
|
|
attestationIdArg,
|
|
artifactOption,
|
|
strictOption,
|
|
verifySignaturesOption,
|
|
formatOption,
|
|
serverOption,
|
|
verboseOption
|
|
};
|
|
|
|
verifyCommand.SetAction(async (parseResult, ct) =>
|
|
{
|
|
var attestationId = parseResult.GetValue(attestationIdArg) ?? string.Empty;
|
|
var artifact = parseResult.GetValue(artifactOption);
|
|
var strict = parseResult.GetValue(strictOption);
|
|
var verifySignatures = parseResult.GetValue(verifySignaturesOption);
|
|
var format = parseResult.GetValue(formatOption);
|
|
var server = parseResult.GetValue(serverOption);
|
|
var verbose = parseResult.GetValue(verboseOption);
|
|
|
|
return await ExecuteVerifyAsync(
|
|
attestationId,
|
|
artifact,
|
|
strict,
|
|
verifySignatures,
|
|
format,
|
|
server,
|
|
verbose,
|
|
cancellationToken);
|
|
});
|
|
|
|
return verifyCommand;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Build the 'chain graph' subcommand.
|
|
/// Generates a graph visualization of the attestation chain.
|
|
/// </summary>
|
|
private static Command BuildGraphCommand(Option<bool> verboseOption, CancellationToken cancellationToken)
|
|
{
|
|
var attestationIdArg = new Argument<string>("attestation-id")
|
|
{
|
|
Description = "Attestation ID (sha256:...) to generate graph for"
|
|
};
|
|
|
|
var graphFormatOption = new Option<GraphFormat>("--graph-format", "-g")
|
|
{
|
|
Description = "Graph output format (mermaid, dot, json)"
|
|
};
|
|
graphFormatOption.SetDefaultValue(GraphFormat.Mermaid);
|
|
|
|
var maxDepthOption = new Option<int>("--max-depth", "-m")
|
|
{
|
|
Description = "Maximum traversal depth"
|
|
};
|
|
maxDepthOption.SetDefaultValue(5);
|
|
|
|
var outputOption = new Option<string?>("--output", "-o")
|
|
{
|
|
Description = "Output graph to file (prints to stdout if not specified)"
|
|
};
|
|
|
|
var serverOption = new Option<string?>("--server", "-s")
|
|
{
|
|
Description = "StellaOps server URL (uses STELLAOPS_BACKEND_URL if not specified)"
|
|
};
|
|
|
|
var graphCommand = new Command("graph", "Generate a graph visualization of the attestation chain")
|
|
{
|
|
attestationIdArg,
|
|
graphFormatOption,
|
|
maxDepthOption,
|
|
outputOption,
|
|
serverOption,
|
|
verboseOption
|
|
};
|
|
|
|
graphCommand.SetAction(async (parseResult, ct) =>
|
|
{
|
|
var attestationId = parseResult.GetValue(attestationIdArg) ?? string.Empty;
|
|
var graphFormat = parseResult.GetValue(graphFormatOption);
|
|
var maxDepth = parseResult.GetValue(maxDepthOption);
|
|
var output = parseResult.GetValue(outputOption);
|
|
var server = parseResult.GetValue(serverOption);
|
|
var verbose = parseResult.GetValue(verboseOption);
|
|
|
|
return await ExecuteGraphAsync(
|
|
attestationId,
|
|
graphFormat,
|
|
maxDepth,
|
|
output,
|
|
server,
|
|
verbose,
|
|
cancellationToken);
|
|
});
|
|
|
|
return graphCommand;
|
|
}
|
|
|
|
/// <summary>
|
|
/// T028: Build the 'chain layer' subcommand.
|
|
/// Operations for per-layer attestations.
|
|
/// </summary>
|
|
private static Command BuildLayerCommand(Option<bool> verboseOption, CancellationToken cancellationToken)
|
|
{
|
|
var layerCommand = new Command("layer", "Per-layer attestation operations");
|
|
|
|
layerCommand.Add(BuildLayerListCommand(verboseOption, cancellationToken));
|
|
layerCommand.Add(BuildLayerShowCommand(verboseOption, cancellationToken));
|
|
layerCommand.Add(BuildLayerCreateCommand(verboseOption, cancellationToken));
|
|
|
|
return layerCommand;
|
|
}
|
|
|
|
private static Command BuildLayerListCommand(Option<bool> verboseOption, CancellationToken cancellationToken)
|
|
{
|
|
var imageOption = new Option<string>("--image", "-i")
|
|
{
|
|
Description = "OCI image reference",
|
|
Required = true
|
|
};
|
|
|
|
var formatOption = new Option<OutputFormat>("--format", "-f")
|
|
{
|
|
Description = "Output format (json, table, summary)"
|
|
};
|
|
formatOption.SetDefaultValue(OutputFormat.Table);
|
|
|
|
var serverOption = new Option<string?>("--server", "-s")
|
|
{
|
|
Description = "StellaOps server URL"
|
|
};
|
|
|
|
var listCommand = new Command("list", "List per-layer attestations for an image")
|
|
{
|
|
imageOption,
|
|
formatOption,
|
|
serverOption,
|
|
verboseOption
|
|
};
|
|
|
|
listCommand.SetAction(async (parseResult, ct) =>
|
|
{
|
|
var image = parseResult.GetValue(imageOption) ?? string.Empty;
|
|
var format = parseResult.GetValue(formatOption);
|
|
var server = parseResult.GetValue(serverOption);
|
|
var verbose = parseResult.GetValue(verboseOption);
|
|
|
|
return await ExecuteLayerListAsync(image, format, server, verbose, cancellationToken);
|
|
});
|
|
|
|
return listCommand;
|
|
}
|
|
|
|
private static Command BuildLayerShowCommand(Option<bool> verboseOption, CancellationToken cancellationToken)
|
|
{
|
|
var imageOption = new Option<string>("--image", "-i")
|
|
{
|
|
Description = "OCI image reference",
|
|
Required = true
|
|
};
|
|
|
|
var layerIndexOption = new Option<int>("--layer", "-l")
|
|
{
|
|
Description = "Layer index (0-based)",
|
|
Required = true
|
|
};
|
|
|
|
var formatOption = new Option<OutputFormat>("--format", "-f")
|
|
{
|
|
Description = "Output format (json, summary)"
|
|
};
|
|
formatOption.SetDefaultValue(OutputFormat.Summary);
|
|
|
|
var serverOption = new Option<string?>("--server", "-s")
|
|
{
|
|
Description = "StellaOps server URL"
|
|
};
|
|
|
|
var showCommand = new Command("show", "Show attestation for a specific image layer")
|
|
{
|
|
imageOption,
|
|
layerIndexOption,
|
|
formatOption,
|
|
serverOption,
|
|
verboseOption
|
|
};
|
|
|
|
showCommand.SetAction(async (parseResult, ct) =>
|
|
{
|
|
var image = parseResult.GetValue(imageOption) ?? string.Empty;
|
|
var layerIndex = parseResult.GetValue(layerIndexOption);
|
|
var format = parseResult.GetValue(formatOption);
|
|
var server = parseResult.GetValue(serverOption);
|
|
var verbose = parseResult.GetValue(verboseOption);
|
|
|
|
return await ExecuteLayerShowAsync(image, layerIndex, format, server, verbose, cancellationToken);
|
|
});
|
|
|
|
return showCommand;
|
|
}
|
|
|
|
private static Command BuildLayerCreateCommand(Option<bool> verboseOption, CancellationToken cancellationToken)
|
|
{
|
|
var imageOption = new Option<string>("--image", "-i")
|
|
{
|
|
Description = "OCI image reference",
|
|
Required = true
|
|
};
|
|
|
|
var outputOption = new Option<string?>("--output", "-o")
|
|
{
|
|
Description = "Output layer attestations to file"
|
|
};
|
|
|
|
var signOption = new Option<bool>("--sign")
|
|
{
|
|
Description = "Sign the layer attestations"
|
|
};
|
|
|
|
var serverOption = new Option<string?>("--server", "-s")
|
|
{
|
|
Description = "StellaOps server URL"
|
|
};
|
|
|
|
var createCommand = new Command("create", "Create per-layer attestations for an image")
|
|
{
|
|
imageOption,
|
|
outputOption,
|
|
signOption,
|
|
serverOption,
|
|
verboseOption
|
|
};
|
|
|
|
createCommand.SetAction(async (parseResult, ct) =>
|
|
{
|
|
var image = parseResult.GetValue(imageOption) ?? string.Empty;
|
|
var output = parseResult.GetValue(outputOption);
|
|
var sign = parseResult.GetValue(signOption);
|
|
var server = parseResult.GetValue(serverOption);
|
|
var verbose = parseResult.GetValue(verboseOption);
|
|
|
|
return await ExecuteLayerCreateAsync(image, output, sign, server, verbose, cancellationToken);
|
|
});
|
|
|
|
return createCommand;
|
|
}
|
|
|
|
#region Command Handlers
|
|
|
|
private static async Task<int> ExecuteShowAsync(
|
|
string attestationId,
|
|
ChainDirection direction,
|
|
int maxDepth,
|
|
OutputFormat format,
|
|
string? server,
|
|
bool verbose,
|
|
CancellationToken ct)
|
|
{
|
|
try
|
|
{
|
|
if (string.IsNullOrWhiteSpace(attestationId))
|
|
{
|
|
Console.Error.WriteLine("Error: attestation-id is required");
|
|
return 1;
|
|
}
|
|
|
|
if (verbose)
|
|
{
|
|
Console.WriteLine($"Showing attestation chain for {attestationId}");
|
|
Console.WriteLine($" Direction: {direction}");
|
|
Console.WriteLine($" Max depth: {maxDepth}");
|
|
}
|
|
|
|
var backendUrl = server ?? Environment.GetEnvironmentVariable("STELLAOPS_BACKEND_URL");
|
|
if (string.IsNullOrWhiteSpace(backendUrl))
|
|
{
|
|
Console.Error.WriteLine("Error: Server URL not specified. Use --server or set STELLAOPS_BACKEND_URL");
|
|
return 1;
|
|
}
|
|
|
|
// Call the chain API
|
|
using var httpClient = new HttpClient { BaseAddress = new Uri(backendUrl) };
|
|
var endpoint = direction switch
|
|
{
|
|
ChainDirection.Upstream => $"api/v1/chains/{Uri.EscapeDataString(attestationId)}/upstream?maxDepth={maxDepth}",
|
|
ChainDirection.Downstream => $"api/v1/chains/{Uri.EscapeDataString(attestationId)}/downstream?maxDepth={maxDepth}",
|
|
_ => $"api/v1/chains/{Uri.EscapeDataString(attestationId)}?maxDepth={maxDepth}"
|
|
};
|
|
|
|
var response = await httpClient.GetAsync(endpoint, ct);
|
|
if (!response.IsSuccessStatusCode)
|
|
{
|
|
var errorContent = await response.Content.ReadAsStringAsync(ct);
|
|
Console.Error.WriteLine($"Error: API returned {(int)response.StatusCode} - {errorContent}");
|
|
return 2;
|
|
}
|
|
|
|
var json = await response.Content.ReadAsStringAsync(ct);
|
|
var chainResponse = JsonSerializer.Deserialize<ChainShowResult>(json, JsonOptions);
|
|
|
|
if (chainResponse is null)
|
|
{
|
|
Console.Error.WriteLine("Error: Failed to parse chain response");
|
|
return 2;
|
|
}
|
|
|
|
OutputChainResult(chainResponse, format, direction);
|
|
return 0;
|
|
}
|
|
catch (HttpRequestException ex)
|
|
{
|
|
Console.Error.WriteLine($"Error: Failed to connect to server - {ex.Message}");
|
|
return 2;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Console.Error.WriteLine($"Error: {ex.Message}");
|
|
return 2;
|
|
}
|
|
}
|
|
|
|
private static async Task<int> ExecuteVerifyAsync(
|
|
string attestationId,
|
|
string? artifact,
|
|
bool strict,
|
|
bool verifySignatures,
|
|
OutputFormat format,
|
|
string? server,
|
|
bool verbose,
|
|
CancellationToken ct)
|
|
{
|
|
try
|
|
{
|
|
if (string.IsNullOrWhiteSpace(attestationId) && string.IsNullOrWhiteSpace(artifact))
|
|
{
|
|
Console.Error.WriteLine("Error: Either attestation-id or --artifact is required");
|
|
return 1;
|
|
}
|
|
|
|
if (verbose)
|
|
{
|
|
Console.WriteLine($"Verifying attestation chain");
|
|
if (!string.IsNullOrWhiteSpace(attestationId))
|
|
Console.WriteLine($" Attestation ID: {attestationId}");
|
|
if (!string.IsNullOrWhiteSpace(artifact))
|
|
Console.WriteLine($" Artifact: {artifact}");
|
|
Console.WriteLine($" Strict mode: {strict}");
|
|
Console.WriteLine($" Verify signatures: {verifySignatures}");
|
|
}
|
|
|
|
var backendUrl = server ?? Environment.GetEnvironmentVariable("STELLAOPS_BACKEND_URL");
|
|
if (string.IsNullOrWhiteSpace(backendUrl))
|
|
{
|
|
Console.Error.WriteLine("Error: Server URL not specified. Use --server or set STELLAOPS_BACKEND_URL");
|
|
return 1;
|
|
}
|
|
|
|
// Call the chain API
|
|
using var httpClient = new HttpClient { BaseAddress = new Uri(backendUrl) };
|
|
var targetId = attestationId;
|
|
if (string.IsNullOrWhiteSpace(targetId) && !string.IsNullOrWhiteSpace(artifact))
|
|
{
|
|
// Look up attestation by artifact
|
|
var lookupEndpoint = $"api/v1/chains/artifact/{Uri.EscapeDataString(artifact)}";
|
|
var lookupResponse = await httpClient.GetAsync(lookupEndpoint, ct);
|
|
if (!lookupResponse.IsSuccessStatusCode)
|
|
{
|
|
Console.Error.WriteLine($"Error: No attestations found for artifact {artifact}");
|
|
return 2;
|
|
}
|
|
var lookupJson = await lookupResponse.Content.ReadAsStringAsync(ct);
|
|
var lookupResult = JsonSerializer.Deserialize<ArtifactLookupResult>(lookupJson, JsonOptions);
|
|
targetId = lookupResult?.RootAttestationId ?? string.Empty;
|
|
}
|
|
|
|
var endpoint = $"api/v1/chains/{Uri.EscapeDataString(targetId)}?maxDepth=10";
|
|
var response = await httpClient.GetAsync(endpoint, ct);
|
|
if (!response.IsSuccessStatusCode)
|
|
{
|
|
Console.Error.WriteLine($"Error: API returned {(int)response.StatusCode}");
|
|
return 2;
|
|
}
|
|
|
|
var json = await response.Content.ReadAsStringAsync(ct);
|
|
var chainResponse = JsonSerializer.Deserialize<ChainShowResult>(json, JsonOptions);
|
|
|
|
if (chainResponse is null)
|
|
{
|
|
Console.Error.WriteLine("Error: Failed to parse chain response");
|
|
return 2;
|
|
}
|
|
|
|
// Verify chain integrity
|
|
var verifyResult = VerifyChainIntegrity(chainResponse, strict, verifySignatures);
|
|
OutputVerifyResult(verifyResult, format);
|
|
|
|
return verifyResult.Valid ? 0 : 1;
|
|
}
|
|
catch (HttpRequestException ex)
|
|
{
|
|
Console.Error.WriteLine($"Error: Failed to connect to server - {ex.Message}");
|
|
return 2;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Console.Error.WriteLine($"Error: {ex.Message}");
|
|
return 2;
|
|
}
|
|
}
|
|
|
|
private static async Task<int> ExecuteGraphAsync(
|
|
string attestationId,
|
|
GraphFormat graphFormat,
|
|
int maxDepth,
|
|
string? output,
|
|
string? server,
|
|
bool verbose,
|
|
CancellationToken ct)
|
|
{
|
|
try
|
|
{
|
|
if (string.IsNullOrWhiteSpace(attestationId))
|
|
{
|
|
Console.Error.WriteLine("Error: attestation-id is required");
|
|
return 1;
|
|
}
|
|
|
|
if (verbose)
|
|
{
|
|
Console.WriteLine($"Generating {graphFormat} graph for {attestationId}");
|
|
Console.WriteLine($" Max depth: {maxDepth}");
|
|
}
|
|
|
|
var backendUrl = server ?? Environment.GetEnvironmentVariable("STELLAOPS_BACKEND_URL");
|
|
if (string.IsNullOrWhiteSpace(backendUrl))
|
|
{
|
|
Console.Error.WriteLine("Error: Server URL not specified. Use --server or set STELLAOPS_BACKEND_URL");
|
|
return 1;
|
|
}
|
|
|
|
// Call the chain graph API
|
|
using var httpClient = new HttpClient { BaseAddress = new Uri(backendUrl) };
|
|
var formatParam = graphFormat.ToString().ToLowerInvariant();
|
|
var endpoint = $"api/v1/chains/{Uri.EscapeDataString(attestationId)}/graph?format={formatParam}&maxDepth={maxDepth}";
|
|
|
|
var response = await httpClient.GetAsync(endpoint, ct);
|
|
if (!response.IsSuccessStatusCode)
|
|
{
|
|
Console.Error.WriteLine($"Error: API returned {(int)response.StatusCode}");
|
|
return 2;
|
|
}
|
|
|
|
var json = await response.Content.ReadAsStringAsync(ct);
|
|
var graphResponse = JsonSerializer.Deserialize<GraphResult>(json, JsonOptions);
|
|
|
|
if (graphResponse is null)
|
|
{
|
|
Console.Error.WriteLine("Error: Failed to parse graph response");
|
|
return 2;
|
|
}
|
|
|
|
var graphContent = graphResponse.Graph;
|
|
if (!string.IsNullOrWhiteSpace(output))
|
|
{
|
|
await File.WriteAllTextAsync(output, graphContent, ct);
|
|
Console.WriteLine($"Graph written to {output}");
|
|
}
|
|
else
|
|
{
|
|
Console.WriteLine(graphContent);
|
|
}
|
|
|
|
return 0;
|
|
}
|
|
catch (HttpRequestException ex)
|
|
{
|
|
Console.Error.WriteLine($"Error: Failed to connect to server - {ex.Message}");
|
|
return 2;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Console.Error.WriteLine($"Error: {ex.Message}");
|
|
return 2;
|
|
}
|
|
}
|
|
|
|
private static async Task<int> ExecuteLayerListAsync(
|
|
string image,
|
|
OutputFormat format,
|
|
string? server,
|
|
bool verbose,
|
|
CancellationToken ct)
|
|
{
|
|
try
|
|
{
|
|
if (verbose)
|
|
{
|
|
Console.WriteLine($"Listing layer attestations for {image}");
|
|
}
|
|
|
|
var backendUrl = server ?? Environment.GetEnvironmentVariable("STELLAOPS_BACKEND_URL");
|
|
if (string.IsNullOrWhiteSpace(backendUrl))
|
|
{
|
|
Console.Error.WriteLine("Error: Server URL not specified. Use --server or set STELLAOPS_BACKEND_URL");
|
|
return 1;
|
|
}
|
|
|
|
// Call the layer attestation API
|
|
using var httpClient = new HttpClient { BaseAddress = new Uri(backendUrl) };
|
|
var endpoint = $"api/v1/attestor/layers/{Uri.EscapeDataString(image)}";
|
|
|
|
var response = await httpClient.GetAsync(endpoint, ct);
|
|
if (!response.IsSuccessStatusCode)
|
|
{
|
|
Console.Error.WriteLine($"Error: API returned {(int)response.StatusCode}");
|
|
return 2;
|
|
}
|
|
|
|
var json = await response.Content.ReadAsStringAsync(ct);
|
|
var layers = JsonSerializer.Deserialize<LayerListResult>(json, JsonOptions);
|
|
|
|
if (layers is null)
|
|
{
|
|
Console.Error.WriteLine("Error: Failed to parse layer response");
|
|
return 2;
|
|
}
|
|
|
|
OutputLayerList(layers, format);
|
|
return 0;
|
|
}
|
|
catch (HttpRequestException ex)
|
|
{
|
|
Console.Error.WriteLine($"Error: Failed to connect to server - {ex.Message}");
|
|
return 2;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Console.Error.WriteLine($"Error: {ex.Message}");
|
|
return 2;
|
|
}
|
|
}
|
|
|
|
private static async Task<int> ExecuteLayerShowAsync(
|
|
string image,
|
|
int layerIndex,
|
|
OutputFormat format,
|
|
string? server,
|
|
bool verbose,
|
|
CancellationToken ct)
|
|
{
|
|
try
|
|
{
|
|
if (verbose)
|
|
{
|
|
Console.WriteLine($"Showing layer {layerIndex} attestation for {image}");
|
|
}
|
|
|
|
var backendUrl = server ?? Environment.GetEnvironmentVariable("STELLAOPS_BACKEND_URL");
|
|
if (string.IsNullOrWhiteSpace(backendUrl))
|
|
{
|
|
Console.Error.WriteLine("Error: Server URL not specified. Use --server or set STELLAOPS_BACKEND_URL");
|
|
return 1;
|
|
}
|
|
|
|
using var httpClient = new HttpClient { BaseAddress = new Uri(backendUrl) };
|
|
var endpoint = $"api/v1/attestor/layers/{Uri.EscapeDataString(image)}/{layerIndex}";
|
|
|
|
var response = await httpClient.GetAsync(endpoint, ct);
|
|
if (!response.IsSuccessStatusCode)
|
|
{
|
|
Console.Error.WriteLine($"Error: API returned {(int)response.StatusCode}");
|
|
return 2;
|
|
}
|
|
|
|
var json = await response.Content.ReadAsStringAsync(ct);
|
|
|
|
if (format == OutputFormat.Json)
|
|
{
|
|
Console.WriteLine(json);
|
|
}
|
|
else
|
|
{
|
|
var layer = JsonSerializer.Deserialize<LayerAttestationInfo>(json, JsonOptions);
|
|
if (layer is not null)
|
|
{
|
|
Console.WriteLine($"Layer {layerIndex} Attestation");
|
|
Console.WriteLine(new string('=', 40));
|
|
Console.WriteLine($" Layer digest: {layer.LayerDigest}");
|
|
Console.WriteLine($" Attestation ID: {layer.AttestationId}");
|
|
Console.WriteLine($" Predicate type: {layer.PredicateType}");
|
|
Console.WriteLine($" Created: {layer.CreatedAt:yyyy-MM-dd HH:mm:ss} UTC");
|
|
}
|
|
}
|
|
|
|
return 0;
|
|
}
|
|
catch (HttpRequestException ex)
|
|
{
|
|
Console.Error.WriteLine($"Error: Failed to connect to server - {ex.Message}");
|
|
return 2;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Console.Error.WriteLine($"Error: {ex.Message}");
|
|
return 2;
|
|
}
|
|
}
|
|
|
|
private static async Task<int> ExecuteLayerCreateAsync(
|
|
string image,
|
|
string? output,
|
|
bool sign,
|
|
string? server,
|
|
bool verbose,
|
|
CancellationToken ct)
|
|
{
|
|
try
|
|
{
|
|
if (verbose)
|
|
{
|
|
Console.WriteLine($"Creating layer attestations for {image}");
|
|
Console.WriteLine($" Sign: {sign}");
|
|
}
|
|
|
|
var backendUrl = server ?? Environment.GetEnvironmentVariable("STELLAOPS_BACKEND_URL");
|
|
if (string.IsNullOrWhiteSpace(backendUrl))
|
|
{
|
|
Console.Error.WriteLine("Error: Server URL not specified. Use --server or set STELLAOPS_BACKEND_URL");
|
|
return 1;
|
|
}
|
|
|
|
using var httpClient = new HttpClient { BaseAddress = new Uri(backendUrl) };
|
|
var endpoint = $"api/v1/attestor/layers/{Uri.EscapeDataString(image)}/create?sign={sign}";
|
|
|
|
var response = await httpClient.PostAsync(endpoint, null, ct);
|
|
if (!response.IsSuccessStatusCode)
|
|
{
|
|
Console.Error.WriteLine($"Error: API returned {(int)response.StatusCode}");
|
|
return 2;
|
|
}
|
|
|
|
var json = await response.Content.ReadAsStringAsync(ct);
|
|
|
|
if (!string.IsNullOrWhiteSpace(output))
|
|
{
|
|
await File.WriteAllTextAsync(output, json, ct);
|
|
Console.WriteLine($"Layer attestations written to {output}");
|
|
}
|
|
else
|
|
{
|
|
Console.WriteLine(json);
|
|
}
|
|
|
|
return 0;
|
|
}
|
|
catch (HttpRequestException ex)
|
|
{
|
|
Console.Error.WriteLine($"Error: Failed to connect to server - {ex.Message}");
|
|
return 2;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Console.Error.WriteLine($"Error: {ex.Message}");
|
|
return 2;
|
|
}
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Output Helpers
|
|
|
|
private static void OutputChainResult(ChainShowResult chain, OutputFormat format, ChainDirection direction)
|
|
{
|
|
if (format == OutputFormat.Json)
|
|
{
|
|
Console.WriteLine(JsonSerializer.Serialize(chain, JsonOptions));
|
|
return;
|
|
}
|
|
|
|
Console.WriteLine();
|
|
Console.WriteLine($"Attestation Chain ({direction})");
|
|
Console.WriteLine(new string('=', 50));
|
|
Console.WriteLine($" Attestation ID: {chain.AttestationId}");
|
|
Console.WriteLine($" Direction: {chain.Direction}");
|
|
Console.WriteLine($" Total nodes: {chain.Summary?.TotalNodes ?? 0}");
|
|
Console.WriteLine($" Max depth: {chain.Summary?.MaxDepth ?? 0}");
|
|
Console.WriteLine($" Complete: {chain.Summary?.IsComplete ?? false}");
|
|
Console.WriteLine();
|
|
|
|
if (chain.Nodes?.Count > 0)
|
|
{
|
|
if (format == OutputFormat.Table)
|
|
{
|
|
Console.WriteLine("DEPTH ATTESTATION ID PREDICATE TYPE LABEL");
|
|
Console.WriteLine(new string('-', 100));
|
|
foreach (var node in chain.Nodes.OrderBy(n => n.Depth))
|
|
{
|
|
var shortId = node.AttestationId.Length > 40
|
|
? node.AttestationId[..40] + "..."
|
|
: node.AttestationId;
|
|
Console.WriteLine($"{node.Depth,5} {shortId,-42} {node.PredicateType,-25} {node.Label ?? "-"}");
|
|
}
|
|
}
|
|
else
|
|
{
|
|
Console.WriteLine("Nodes:");
|
|
foreach (var node in chain.Nodes.OrderBy(n => n.Depth))
|
|
{
|
|
var prefix = node.IsRoot ? "[ROOT]" : node.IsLeaf ? "[LEAF]" : " ";
|
|
Console.WriteLine($" {prefix} {node.AttestationId}");
|
|
Console.WriteLine($" Predicate: {node.PredicateType}");
|
|
Console.WriteLine($" Depth: {node.Depth}");
|
|
if (!string.IsNullOrEmpty(node.Signer))
|
|
Console.WriteLine($" Signer: {node.Signer}");
|
|
Console.WriteLine();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private static ChainVerifyResult VerifyChainIntegrity(ChainShowResult chain, bool strict, bool verifySignatures)
|
|
{
|
|
var checks = new List<VerifyCheck>();
|
|
var valid = true;
|
|
|
|
// Check chain completeness
|
|
var isComplete = chain.Summary?.IsComplete ?? false;
|
|
checks.Add(new VerifyCheck(
|
|
Check: "chain_complete",
|
|
Status: isComplete ? "pass" : (strict ? "fail" : "warn"),
|
|
Details: isComplete ? "Chain has no missing attestations" : "Chain has missing attestations"));
|
|
if (strict && !isComplete) valid = false;
|
|
|
|
// Check root exists
|
|
var hasRoot = chain.Nodes?.Any(n => n.IsRoot) ?? false;
|
|
checks.Add(new VerifyCheck(
|
|
Check: "root_exists",
|
|
Status: hasRoot ? "pass" : "fail",
|
|
Details: hasRoot ? "Chain has a root attestation" : "No root attestation found"));
|
|
if (!hasRoot) valid = false;
|
|
|
|
// Check for cycles (by verifying DAG structure)
|
|
var hasCycle = DetectCycle(chain);
|
|
checks.Add(new VerifyCheck(
|
|
Check: "no_cycles",
|
|
Status: hasCycle ? "fail" : "pass",
|
|
Details: hasCycle ? "Cycle detected in chain" : "Chain is a valid DAG"));
|
|
if (hasCycle) valid = false;
|
|
|
|
// Check link consistency
|
|
var linksValid = VerifyLinkConsistency(chain);
|
|
checks.Add(new VerifyCheck(
|
|
Check: "links_valid",
|
|
Status: linksValid ? "pass" : "fail",
|
|
Details: linksValid ? "All links reference existing nodes" : "Some links reference missing nodes"));
|
|
if (!linksValid) valid = false;
|
|
|
|
// Signature verification (placeholder - actual impl would verify DSSE signatures)
|
|
if (verifySignatures)
|
|
{
|
|
checks.Add(new VerifyCheck(
|
|
Check: "signatures",
|
|
Status: "skip",
|
|
Details: "Signature verification not yet implemented in CLI"));
|
|
}
|
|
|
|
return new ChainVerifyResult(
|
|
Valid: valid,
|
|
AttestationId: chain.AttestationId,
|
|
TotalNodes: chain.Summary?.TotalNodes ?? 0,
|
|
MaxDepth: chain.Summary?.MaxDepth ?? 0,
|
|
IsComplete: isComplete,
|
|
Checks: checks);
|
|
}
|
|
|
|
private static bool DetectCycle(ChainShowResult chain)
|
|
{
|
|
// Simple cycle detection via DFS
|
|
if (chain.Nodes is null || chain.Links is null) return false;
|
|
|
|
var nodeIds = chain.Nodes.Select(n => n.AttestationId).ToHashSet();
|
|
var visited = new HashSet<string>();
|
|
var inStack = new HashSet<string>();
|
|
|
|
bool DFS(string nodeId)
|
|
{
|
|
if (inStack.Contains(nodeId)) return true; // Cycle found
|
|
if (visited.Contains(nodeId)) return false;
|
|
|
|
visited.Add(nodeId);
|
|
inStack.Add(nodeId);
|
|
|
|
var outgoingLinks = chain.Links
|
|
.Where(l => l.SourceAttestationId == nodeId)
|
|
.Select(l => l.TargetAttestationId);
|
|
|
|
foreach (var target in outgoingLinks)
|
|
{
|
|
if (DFS(target)) return true;
|
|
}
|
|
|
|
inStack.Remove(nodeId);
|
|
return false;
|
|
}
|
|
|
|
foreach (var nodeId in nodeIds)
|
|
{
|
|
if (DFS(nodeId)) return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
private static bool VerifyLinkConsistency(ChainShowResult chain)
|
|
{
|
|
if (chain.Nodes is null || chain.Links is null) return true;
|
|
|
|
var nodeIds = chain.Nodes.Select(n => n.AttestationId).ToHashSet();
|
|
foreach (var link in chain.Links)
|
|
{
|
|
if (!nodeIds.Contains(link.SourceAttestationId) ||
|
|
!nodeIds.Contains(link.TargetAttestationId))
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
private static void OutputVerifyResult(ChainVerifyResult result, OutputFormat format)
|
|
{
|
|
if (format == OutputFormat.Json)
|
|
{
|
|
Console.WriteLine(JsonSerializer.Serialize(result, JsonOptions));
|
|
return;
|
|
}
|
|
|
|
Console.WriteLine();
|
|
Console.WriteLine("Chain Verification Result");
|
|
Console.WriteLine(new string('=', 40));
|
|
Console.WriteLine($" Status: {(result.Valid ? "PASS" : "FAIL")}");
|
|
Console.WriteLine($" Attestation ID: {result.AttestationId}");
|
|
Console.WriteLine($" Total nodes: {result.TotalNodes}");
|
|
Console.WriteLine($" Max depth: {result.MaxDepth}");
|
|
Console.WriteLine($" Complete: {result.IsComplete}");
|
|
Console.WriteLine();
|
|
Console.WriteLine("Verification Checks:");
|
|
Console.WriteLine(new string('-', 40));
|
|
|
|
foreach (var check in result.Checks)
|
|
{
|
|
var statusIcon = check.Status switch
|
|
{
|
|
"pass" => "[PASS]",
|
|
"fail" => "[FAIL]",
|
|
"warn" => "[WARN]",
|
|
"skip" => "[SKIP]",
|
|
_ => "[????]"
|
|
};
|
|
Console.WriteLine($" {statusIcon} {check.Check}: {check.Details}");
|
|
}
|
|
|
|
Console.WriteLine();
|
|
}
|
|
|
|
private static void OutputLayerList(LayerListResult layers, OutputFormat format)
|
|
{
|
|
if (format == OutputFormat.Json)
|
|
{
|
|
Console.WriteLine(JsonSerializer.Serialize(layers, JsonOptions));
|
|
return;
|
|
}
|
|
|
|
Console.WriteLine();
|
|
Console.WriteLine($"Layer Attestations for {layers.Image}");
|
|
Console.WriteLine(new string('=', 60));
|
|
Console.WriteLine($" Total layers: {layers.TotalLayers}");
|
|
Console.WriteLine($" Attested layers: {layers.AttestedLayers}");
|
|
Console.WriteLine();
|
|
|
|
if (layers.Layers?.Count > 0)
|
|
{
|
|
if (format == OutputFormat.Table)
|
|
{
|
|
Console.WriteLine("INDEX LAYER DIGEST ATTESTATION ID STATUS");
|
|
Console.WriteLine(new string('-', 110));
|
|
foreach (var layer in layers.Layers.OrderBy(l => l.Index))
|
|
{
|
|
var shortDigest = layer.LayerDigest.Length > 45
|
|
? layer.LayerDigest[..45] + "..."
|
|
: layer.LayerDigest;
|
|
var shortAttId = string.IsNullOrEmpty(layer.AttestationId)
|
|
? "-"
|
|
: (layer.AttestationId.Length > 30 ? layer.AttestationId[..30] + "..." : layer.AttestationId);
|
|
var status = string.IsNullOrEmpty(layer.AttestationId) ? "missing" : "attested";
|
|
Console.WriteLine($"{layer.Index,5} {shortDigest,-48} {shortAttId,-32} {status}");
|
|
}
|
|
}
|
|
else
|
|
{
|
|
Console.WriteLine("Layers:");
|
|
foreach (var layer in layers.Layers.OrderBy(l => l.Index))
|
|
{
|
|
var status = string.IsNullOrEmpty(layer.AttestationId) ? "(not attested)" : "(attested)";
|
|
Console.WriteLine($" Layer {layer.Index}: {layer.LayerDigest} {status}");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region DTOs
|
|
|
|
public enum ChainDirection
|
|
{
|
|
Upstream,
|
|
Downstream,
|
|
Full
|
|
}
|
|
|
|
public enum OutputFormat
|
|
{
|
|
Json,
|
|
Table,
|
|
Summary
|
|
}
|
|
|
|
public enum GraphFormat
|
|
{
|
|
Mermaid,
|
|
Dot,
|
|
Json
|
|
}
|
|
|
|
private sealed record ChainShowResult
|
|
{
|
|
[JsonPropertyName("attestationId")]
|
|
public string AttestationId { get; init; } = string.Empty;
|
|
|
|
[JsonPropertyName("direction")]
|
|
public string Direction { get; init; } = string.Empty;
|
|
|
|
[JsonPropertyName("nodes")]
|
|
public IReadOnlyList<ChainNodeInfo>? Nodes { get; init; }
|
|
|
|
[JsonPropertyName("links")]
|
|
public IReadOnlyList<ChainLinkInfo>? Links { get; init; }
|
|
|
|
[JsonPropertyName("summary")]
|
|
public ChainSummaryInfo? Summary { get; init; }
|
|
}
|
|
|
|
private sealed record ChainNodeInfo
|
|
{
|
|
[JsonPropertyName("attestationId")]
|
|
public string AttestationId { get; init; } = string.Empty;
|
|
|
|
[JsonPropertyName("predicateType")]
|
|
public string PredicateType { get; init; } = string.Empty;
|
|
|
|
[JsonPropertyName("depth")]
|
|
public int Depth { get; init; }
|
|
|
|
[JsonPropertyName("isRoot")]
|
|
public bool IsRoot { get; init; }
|
|
|
|
[JsonPropertyName("isLeaf")]
|
|
public bool IsLeaf { get; init; }
|
|
|
|
[JsonPropertyName("signer")]
|
|
public string? Signer { get; init; }
|
|
|
|
[JsonPropertyName("label")]
|
|
public string? Label { get; init; }
|
|
}
|
|
|
|
private sealed record ChainLinkInfo
|
|
{
|
|
[JsonPropertyName("sourceAttestationId")]
|
|
public string SourceAttestationId { get; init; } = string.Empty;
|
|
|
|
[JsonPropertyName("targetAttestationId")]
|
|
public string TargetAttestationId { get; init; } = string.Empty;
|
|
}
|
|
|
|
private sealed record ChainSummaryInfo
|
|
{
|
|
[JsonPropertyName("totalNodes")]
|
|
public int TotalNodes { get; init; }
|
|
|
|
[JsonPropertyName("maxDepth")]
|
|
public int MaxDepth { get; init; }
|
|
|
|
[JsonPropertyName("isComplete")]
|
|
public bool IsComplete { get; init; }
|
|
}
|
|
|
|
private sealed record ChainVerifyResult(
|
|
bool Valid,
|
|
string AttestationId,
|
|
int TotalNodes,
|
|
int MaxDepth,
|
|
bool IsComplete,
|
|
IReadOnlyList<VerifyCheck> Checks);
|
|
|
|
private sealed record VerifyCheck(
|
|
string Check,
|
|
string Status,
|
|
string? Details = null);
|
|
|
|
private sealed record GraphResult
|
|
{
|
|
[JsonPropertyName("graph")]
|
|
public string Graph { get; init; } = string.Empty;
|
|
|
|
[JsonPropertyName("format")]
|
|
public string Format { get; init; } = string.Empty;
|
|
}
|
|
|
|
private sealed record ArtifactLookupResult
|
|
{
|
|
[JsonPropertyName("rootAttestationId")]
|
|
public string? RootAttestationId { get; init; }
|
|
}
|
|
|
|
private sealed record LayerListResult
|
|
{
|
|
[JsonPropertyName("image")]
|
|
public string Image { get; init; } = string.Empty;
|
|
|
|
[JsonPropertyName("totalLayers")]
|
|
public int TotalLayers { get; init; }
|
|
|
|
[JsonPropertyName("attestedLayers")]
|
|
public int AttestedLayers { get; init; }
|
|
|
|
[JsonPropertyName("layers")]
|
|
public IReadOnlyList<LayerInfo>? Layers { get; init; }
|
|
}
|
|
|
|
private sealed record LayerInfo
|
|
{
|
|
[JsonPropertyName("index")]
|
|
public int Index { get; init; }
|
|
|
|
[JsonPropertyName("layerDigest")]
|
|
public string LayerDigest { get; init; } = string.Empty;
|
|
|
|
[JsonPropertyName("attestationId")]
|
|
public string? AttestationId { get; init; }
|
|
}
|
|
|
|
private sealed record LayerAttestationInfo
|
|
{
|
|
[JsonPropertyName("layerIndex")]
|
|
public int LayerIndex { get; init; }
|
|
|
|
[JsonPropertyName("layerDigest")]
|
|
public string LayerDigest { get; init; } = string.Empty;
|
|
|
|
[JsonPropertyName("attestationId")]
|
|
public string AttestationId { get; init; } = string.Empty;
|
|
|
|
[JsonPropertyName("predicateType")]
|
|
public string PredicateType { get; init; } = string.Empty;
|
|
|
|
[JsonPropertyName("createdAt")]
|
|
public DateTimeOffset CreatedAt { get; init; }
|
|
}
|
|
|
|
#endregion
|
|
}
|