new two advisories and sprints work on them

This commit is contained in:
master
2026-01-16 18:39:36 +02:00
parent 9daf619954
commit c3a6269d55
72 changed files with 15540 additions and 18 deletions

View File

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

View File

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