new two advisories and sprints work on them
This commit is contained in:
@@ -52,6 +52,9 @@ public sealed class VexCliCommandModule : ICliCommandModule
|
||||
vex.Add(BuildListCommand());
|
||||
vex.Add(BuildNotReachableCommand(services, options, verboseOption));
|
||||
|
||||
// Sprint: SPRINT_20260117_002_EXCITITOR - VEX observation and Rekor attestation commands
|
||||
vex.Add(VexRekorCommandGroup.BuildObservationCommand(services, options, verboseOption));
|
||||
|
||||
return vex;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,570 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// VexRekorCommandGroup.cs
|
||||
// Sprint: SPRINT_20260117_002_EXCITITOR_vex_rekor_linkage
|
||||
// Task: VRL-009 - CLI commands for VEX-Rekor verification
|
||||
// Description: CLI commands for VEX observation attestation and Rekor verification
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.CommandLine;
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Cli.Configuration;
|
||||
|
||||
namespace StellaOps.Cli.Plugins.Vex;
|
||||
|
||||
/// <summary>
|
||||
/// CLI command group for VEX-Rekor attestation and verification.
|
||||
/// </summary>
|
||||
public static class VexRekorCommandGroup
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
WriteIndented = true,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Builds the 'stella vex observation' command group.
|
||||
/// </summary>
|
||||
public static Command BuildObservationCommand(
|
||||
IServiceProvider services,
|
||||
StellaOpsCliOptions options,
|
||||
Option<bool> verboseOption)
|
||||
{
|
||||
var observation = new Command("observation", "VEX observation management and Rekor attestation.");
|
||||
|
||||
observation.Add(BuildShowCommand(services, options, verboseOption));
|
||||
observation.Add(BuildAttestCommand(services, options, verboseOption));
|
||||
observation.Add(BuildVerifyRekorCommand(services, options, verboseOption));
|
||||
observation.Add(BuildListPendingCommand(services, options, verboseOption));
|
||||
|
||||
return observation;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// stella vex observation show - Display observation details including Rekor linkage.
|
||||
/// </summary>
|
||||
private static Command BuildShowCommand(
|
||||
IServiceProvider services,
|
||||
StellaOpsCliOptions options,
|
||||
Option<bool> verboseOption)
|
||||
{
|
||||
var idArg = new Argument<string>("observation-id")
|
||||
{
|
||||
Description = "The observation ID to display."
|
||||
};
|
||||
|
||||
var showRekorOption = new Option<bool>("--show-rekor")
|
||||
{
|
||||
Description = "Include Rekor linkage details in output."
|
||||
};
|
||||
|
||||
var formatOption = new Option<string>("--format", new[] { "-f" })
|
||||
{
|
||||
Description = "Output format: text (default), json, yaml."
|
||||
}.SetDefaultValue("text").FromAmong("text", "json", "yaml");
|
||||
|
||||
var command = new Command("show", "Display observation details including Rekor linkage.")
|
||||
{
|
||||
idArg,
|
||||
showRekorOption,
|
||||
formatOption,
|
||||
verboseOption
|
||||
};
|
||||
|
||||
command.SetAction(async parseResult =>
|
||||
{
|
||||
var id = parseResult.GetValue(idArg)!;
|
||||
var showRekor = parseResult.GetValue(showRekorOption);
|
||||
var format = parseResult.GetValue(formatOption)!;
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
|
||||
await HandleShowAsync(services, options, id, showRekor, format, verbose);
|
||||
});
|
||||
|
||||
return command;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// stella vex observation attest - Attest a VEX observation to Rekor.
|
||||
/// </summary>
|
||||
private static Command BuildAttestCommand(
|
||||
IServiceProvider services,
|
||||
StellaOpsCliOptions options,
|
||||
Option<bool> verboseOption)
|
||||
{
|
||||
var idArg = new Argument<string>("observation-id")
|
||||
{
|
||||
Description = "The observation ID to attest."
|
||||
};
|
||||
|
||||
var rekorUrlOption = new Option<string?>("--rekor-url")
|
||||
{
|
||||
Description = "Rekor server URL (default: https://rekor.sigstore.dev)."
|
||||
};
|
||||
|
||||
var keyOption = new Option<string?>("--key", new[] { "-k" })
|
||||
{
|
||||
Description = "Signing key identifier."
|
||||
};
|
||||
|
||||
var dryRunOption = new Option<bool>("--dry-run")
|
||||
{
|
||||
Description = "Create DSSE envelope without submitting to Rekor."
|
||||
};
|
||||
|
||||
var outputOption = new Option<string?>("--output", new[] { "-o" })
|
||||
{
|
||||
Description = "Output file for DSSE envelope."
|
||||
};
|
||||
|
||||
var command = new Command("attest", "Attest a VEX observation to Rekor transparency log.")
|
||||
{
|
||||
idArg,
|
||||
rekorUrlOption,
|
||||
keyOption,
|
||||
dryRunOption,
|
||||
outputOption,
|
||||
verboseOption
|
||||
};
|
||||
|
||||
command.SetAction(async parseResult =>
|
||||
{
|
||||
var id = parseResult.GetValue(idArg)!;
|
||||
var rekorUrl = parseResult.GetValue(rekorUrlOption);
|
||||
var key = parseResult.GetValue(keyOption);
|
||||
var dryRun = parseResult.GetValue(dryRunOption);
|
||||
var output = parseResult.GetValue(outputOption);
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
|
||||
await HandleAttestAsync(services, options, id, rekorUrl, key, dryRun, output, verbose);
|
||||
});
|
||||
|
||||
return command;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// stella vex observation verify-rekor - Verify an observation's Rekor linkage.
|
||||
/// </summary>
|
||||
private static Command BuildVerifyRekorCommand(
|
||||
IServiceProvider services,
|
||||
StellaOpsCliOptions options,
|
||||
Option<bool> verboseOption)
|
||||
{
|
||||
var idArg = new Argument<string>("observation-id")
|
||||
{
|
||||
Description = "The observation ID to verify."
|
||||
};
|
||||
|
||||
var offlineOption = new Option<bool>("--offline")
|
||||
{
|
||||
Description = "Verify using stored inclusion proof (offline mode)."
|
||||
};
|
||||
|
||||
var rekorUrlOption = new Option<string?>("--rekor-url")
|
||||
{
|
||||
Description = "Rekor server URL for online verification."
|
||||
};
|
||||
|
||||
var formatOption = new Option<string>("--format", new[] { "-f" })
|
||||
{
|
||||
Description = "Output format: text (default), json."
|
||||
}.SetDefaultValue("text").FromAmong("text", "json");
|
||||
|
||||
var command = new Command("verify-rekor", "Verify an observation's Rekor transparency log linkage.")
|
||||
{
|
||||
idArg,
|
||||
offlineOption,
|
||||
rekorUrlOption,
|
||||
formatOption,
|
||||
verboseOption
|
||||
};
|
||||
|
||||
command.SetAction(async parseResult =>
|
||||
{
|
||||
var id = parseResult.GetValue(idArg)!;
|
||||
var offline = parseResult.GetValue(offlineOption);
|
||||
var rekorUrl = parseResult.GetValue(rekorUrlOption);
|
||||
var format = parseResult.GetValue(formatOption)!;
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
|
||||
await HandleVerifyRekorAsync(services, options, id, offline, rekorUrl, format, verbose);
|
||||
});
|
||||
|
||||
return command;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// stella vex observation list-pending - List observations pending attestation.
|
||||
/// </summary>
|
||||
private static Command BuildListPendingCommand(
|
||||
IServiceProvider services,
|
||||
StellaOpsCliOptions options,
|
||||
Option<bool> verboseOption)
|
||||
{
|
||||
var limitOption = new Option<int>("--limit", new[] { "-n" })
|
||||
{
|
||||
Description = "Maximum number of results to return."
|
||||
}.SetDefaultValue(50);
|
||||
|
||||
var formatOption = new Option<string>("--format", new[] { "-f" })
|
||||
{
|
||||
Description = "Output format: text (default), json."
|
||||
}.SetDefaultValue("text").FromAmong("text", "json");
|
||||
|
||||
var command = new Command("list-pending", "List VEX observations pending Rekor attestation.")
|
||||
{
|
||||
limitOption,
|
||||
formatOption,
|
||||
verboseOption
|
||||
};
|
||||
|
||||
command.SetAction(async parseResult =>
|
||||
{
|
||||
var limit = parseResult.GetValue(limitOption);
|
||||
var format = parseResult.GetValue(formatOption)!;
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
|
||||
await HandleListPendingAsync(services, options, limit, format, verbose);
|
||||
});
|
||||
|
||||
return command;
|
||||
}
|
||||
|
||||
// Handler implementations
|
||||
|
||||
private static async Task HandleShowAsync(
|
||||
IServiceProvider services,
|
||||
StellaOpsCliOptions options,
|
||||
string observationId,
|
||||
bool showRekor,
|
||||
string format,
|
||||
bool verbose)
|
||||
{
|
||||
var console = Console.Out;
|
||||
|
||||
// Get HTTP client and make API call
|
||||
var httpClientFactory = services.GetRequiredService<IHttpClientFactory>();
|
||||
var httpClient = httpClientFactory.CreateClient("StellaOpsApi");
|
||||
|
||||
var baseUrl = options.ApiBaseUrl?.TrimEnd('/') ?? "http://localhost:5000";
|
||||
var url = $"{baseUrl}/api/v1/vex/observations/{observationId}";
|
||||
|
||||
if (showRekor)
|
||||
{
|
||||
url += "?includeRekor=true";
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var response = await httpClient.GetAsync(url);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
Console.Error.WriteLine($"Error: {response.StatusCode}");
|
||||
var error = await response.Content.ReadAsStringAsync();
|
||||
Console.Error.WriteLine(error);
|
||||
Environment.ExitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
var content = await response.Content.ReadAsStringAsync();
|
||||
|
||||
if (format == "json")
|
||||
{
|
||||
// Re-format with indentation
|
||||
using var doc = JsonDocument.Parse(content);
|
||||
var formatted = JsonSerializer.Serialize(doc.RootElement, JsonOptions);
|
||||
await console.WriteLineAsync(formatted);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Parse and display as text
|
||||
using var doc = JsonDocument.Parse(content);
|
||||
var root = doc.RootElement;
|
||||
|
||||
await console.WriteLineAsync($"Observation: {observationId}");
|
||||
await console.WriteLineAsync(new string('-', 60));
|
||||
|
||||
if (root.TryGetProperty("vulnerabilityId", out var vulnId))
|
||||
{
|
||||
await console.WriteLineAsync($"Vulnerability: {vulnId}");
|
||||
}
|
||||
if (root.TryGetProperty("status", out var status))
|
||||
{
|
||||
await console.WriteLineAsync($"Status: {status}");
|
||||
}
|
||||
if (root.TryGetProperty("productKey", out var product))
|
||||
{
|
||||
await console.WriteLineAsync($"Product: {product}");
|
||||
}
|
||||
if (root.TryGetProperty("createdAt", out var created))
|
||||
{
|
||||
await console.WriteLineAsync($"Created: {created}");
|
||||
}
|
||||
|
||||
if (showRekor && root.TryGetProperty("rekorLinkage", out var rekor))
|
||||
{
|
||||
await console.WriteLineAsync();
|
||||
await console.WriteLineAsync("Rekor Linkage:");
|
||||
|
||||
if (rekor.TryGetProperty("entryUuid", out var uuid))
|
||||
{
|
||||
await console.WriteLineAsync($" Entry UUID: {uuid}");
|
||||
}
|
||||
if (rekor.TryGetProperty("logIndex", out var index))
|
||||
{
|
||||
await console.WriteLineAsync($" Log Index: {index}");
|
||||
}
|
||||
if (rekor.TryGetProperty("integratedTime", out var intTime))
|
||||
{
|
||||
await console.WriteLineAsync($" Integrated: {intTime}");
|
||||
}
|
||||
if (rekor.TryGetProperty("verified", out var verified))
|
||||
{
|
||||
var verifiedStr = verified.GetBoolean() ? "✓ Yes" : "✗ No";
|
||||
await console.WriteLineAsync($" Verified: {verifiedStr}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
Console.Error.WriteLine($"Error connecting to API: {ex.Message}");
|
||||
Environment.ExitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task HandleAttestAsync(
|
||||
IServiceProvider services,
|
||||
StellaOpsCliOptions options,
|
||||
string observationId,
|
||||
string? rekorUrl,
|
||||
string? key,
|
||||
bool dryRun,
|
||||
string? output,
|
||||
bool verbose)
|
||||
{
|
||||
var console = Console.Out;
|
||||
|
||||
var httpClientFactory = services.GetRequiredService<IHttpClientFactory>();
|
||||
var httpClient = httpClientFactory.CreateClient("StellaOpsApi");
|
||||
|
||||
var baseUrl = options.ApiBaseUrl?.TrimEnd('/') ?? "http://localhost:5000";
|
||||
|
||||
if (dryRun)
|
||||
{
|
||||
await console.WriteLineAsync($"[DRY RUN] Would attest observation {observationId} to Rekor");
|
||||
if (!string.IsNullOrEmpty(rekorUrl))
|
||||
{
|
||||
await console.WriteLineAsync($" Rekor URL: {rekorUrl}");
|
||||
}
|
||||
if (!string.IsNullOrEmpty(key))
|
||||
{
|
||||
await console.WriteLineAsync($" Signing key: {key}");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var requestBody = new
|
||||
{
|
||||
rekorUrl,
|
||||
signingKeyId = key,
|
||||
storeInclusionProof = true
|
||||
};
|
||||
|
||||
var content = new StringContent(
|
||||
JsonSerializer.Serialize(requestBody),
|
||||
System.Text.Encoding.UTF8,
|
||||
"application/json");
|
||||
|
||||
var url = $"{baseUrl}/attestations/rekor/observations/{observationId}";
|
||||
var response = await httpClient.PostAsync(url, content);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
Console.Error.WriteLine($"Attestation failed: {response.StatusCode}");
|
||||
var error = await response.Content.ReadAsStringAsync();
|
||||
Console.Error.WriteLine(error);
|
||||
Environment.ExitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
var result = await response.Content.ReadAsStringAsync();
|
||||
using var doc = JsonDocument.Parse(result);
|
||||
|
||||
var entryId = doc.RootElement.TryGetProperty("rekorEntryId", out var eid) ? eid.GetString() : "unknown";
|
||||
var logIndex = doc.RootElement.TryGetProperty("logIndex", out var li) ? li.GetInt64().ToString(CultureInfo.InvariantCulture) : "unknown";
|
||||
|
||||
await console.WriteLineAsync("✓ Observation attested to Rekor");
|
||||
await console.WriteLineAsync($" Entry ID: {entryId}");
|
||||
await console.WriteLineAsync($" Log Index: {logIndex}");
|
||||
|
||||
if (!string.IsNullOrEmpty(output))
|
||||
{
|
||||
await File.WriteAllTextAsync(output, result);
|
||||
await console.WriteLineAsync($" Response saved to: {output}");
|
||||
}
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
Console.Error.WriteLine($"Error: {ex.Message}");
|
||||
Environment.ExitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task HandleVerifyRekorAsync(
|
||||
IServiceProvider services,
|
||||
StellaOpsCliOptions options,
|
||||
string observationId,
|
||||
bool offline,
|
||||
string? rekorUrl,
|
||||
string format,
|
||||
bool verbose)
|
||||
{
|
||||
var console = Console.Out;
|
||||
|
||||
var httpClientFactory = services.GetRequiredService<IHttpClientFactory>();
|
||||
var httpClient = httpClientFactory.CreateClient("StellaOpsApi");
|
||||
|
||||
var baseUrl = options.ApiBaseUrl?.TrimEnd('/') ?? "http://localhost:5000";
|
||||
var url = $"{baseUrl}/attestations/rekor/observations/{observationId}/verify";
|
||||
|
||||
if (offline)
|
||||
{
|
||||
url += "?mode=offline";
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var response = await httpClient.GetAsync(url);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
Console.Error.WriteLine($"Verification failed: {response.StatusCode}");
|
||||
var error = await response.Content.ReadAsStringAsync();
|
||||
Console.Error.WriteLine(error);
|
||||
Environment.ExitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
var result = await response.Content.ReadAsStringAsync();
|
||||
|
||||
if (format == "json")
|
||||
{
|
||||
using var doc = JsonDocument.Parse(result);
|
||||
var formatted = JsonSerializer.Serialize(doc.RootElement, JsonOptions);
|
||||
await console.WriteLineAsync(formatted);
|
||||
}
|
||||
else
|
||||
{
|
||||
using var doc = JsonDocument.Parse(result);
|
||||
var root = doc.RootElement;
|
||||
|
||||
var isVerified = root.TryGetProperty("isVerified", out var v) && v.GetBoolean();
|
||||
|
||||
if (isVerified)
|
||||
{
|
||||
await console.WriteLineAsync("✓ Rekor verification PASSED");
|
||||
|
||||
if (root.TryGetProperty("rekorEntryId", out var entryId))
|
||||
{
|
||||
await console.WriteLineAsync($" Entry ID: {entryId}");
|
||||
}
|
||||
if (root.TryGetProperty("logIndex", out var logIndex))
|
||||
{
|
||||
await console.WriteLineAsync($" Log Index: {logIndex}");
|
||||
}
|
||||
if (root.TryGetProperty("verifiedAt", out var verifiedAt))
|
||||
{
|
||||
await console.WriteLineAsync($" Verified: {verifiedAt}");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
await console.WriteLineAsync("✗ Rekor verification FAILED");
|
||||
|
||||
if (root.TryGetProperty("failureReason", out var reason))
|
||||
{
|
||||
await console.WriteLineAsync($" Reason: {reason}");
|
||||
}
|
||||
|
||||
Environment.ExitCode = 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
Console.Error.WriteLine($"Error: {ex.Message}");
|
||||
Environment.ExitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task HandleListPendingAsync(
|
||||
IServiceProvider services,
|
||||
StellaOpsCliOptions options,
|
||||
int limit,
|
||||
string format,
|
||||
bool verbose)
|
||||
{
|
||||
var console = Console.Out;
|
||||
|
||||
var httpClientFactory = services.GetRequiredService<IHttpClientFactory>();
|
||||
var httpClient = httpClientFactory.CreateClient("StellaOpsApi");
|
||||
|
||||
var baseUrl = options.ApiBaseUrl?.TrimEnd('/') ?? "http://localhost:5000";
|
||||
var url = $"{baseUrl}/attestations/rekor/pending?limit={limit}";
|
||||
|
||||
try
|
||||
{
|
||||
var response = await httpClient.GetAsync(url);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
Console.Error.WriteLine($"Error: {response.StatusCode}");
|
||||
Environment.ExitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
var result = await response.Content.ReadAsStringAsync();
|
||||
|
||||
if (format == "json")
|
||||
{
|
||||
using var doc = JsonDocument.Parse(result);
|
||||
var formatted = JsonSerializer.Serialize(doc.RootElement, JsonOptions);
|
||||
await console.WriteLineAsync(formatted);
|
||||
}
|
||||
else
|
||||
{
|
||||
using var doc = JsonDocument.Parse(result);
|
||||
var root = doc.RootElement;
|
||||
|
||||
var count = root.TryGetProperty("count", out var c) ? c.GetInt32() : 0;
|
||||
|
||||
await console.WriteLineAsync($"Pending Attestations: {count}");
|
||||
await console.WriteLineAsync(new string('-', 40));
|
||||
|
||||
if (root.TryGetProperty("observationIds", out var ids) && ids.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var id in ids.EnumerateArray())
|
||||
{
|
||||
await console.WriteLineAsync($" {id}");
|
||||
}
|
||||
}
|
||||
|
||||
if (count == 0)
|
||||
{
|
||||
await console.WriteLineAsync(" (none)");
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
Console.Error.WriteLine($"Error: {ex.Message}");
|
||||
Environment.ExitCode = 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user