new two advisories and sprints work on them
This commit is contained in:
@@ -39,6 +39,9 @@ internal static class BinaryCommandGroup
|
||||
// Sprint: SPRINT_20260112_006_CLI - BinaryIndex ops commands
|
||||
binary.Add(BinaryIndexOpsCommandGroup.BuildOpsCommand(services, verboseOption, cancellationToken));
|
||||
|
||||
// Sprint: SPRINT_20260117_003_BINDEX - Delta-sig predicate operations
|
||||
binary.Add(DeltaSigCommandGroup.BuildDeltaSigCommand(services, verboseOption, cancellationToken));
|
||||
|
||||
return binary;
|
||||
}
|
||||
|
||||
|
||||
669
src/Cli/StellaOps.Cli/Commands/Binary/DeltaSigCommandGroup.cs
Normal file
669
src/Cli/StellaOps.Cli/Commands/Binary/DeltaSigCommandGroup.cs
Normal file
@@ -0,0 +1,669 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// DeltaSigCommandGroup.cs
|
||||
// Sprint: SPRINT_20260117_003_BINDEX_delta_sig_predicate
|
||||
// Task: DSP-007 - Add CLI commands for delta-sig operations
|
||||
// Description: CLI commands for delta-sig diff, attest, verify, and gate operations
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.CommandLine;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.BinaryIndex.DeltaSig;
|
||||
using StellaOps.BinaryIndex.DeltaSig.Attestation;
|
||||
using StellaOps.BinaryIndex.DeltaSig.Policy;
|
||||
using StellaOps.Cli.Extensions;
|
||||
|
||||
namespace StellaOps.Cli.Commands.Binary;
|
||||
|
||||
/// <summary>
|
||||
/// CLI command group for delta-sig binary diff operations.
|
||||
/// </summary>
|
||||
internal static class DeltaSigCommandGroup
|
||||
{
|
||||
/// <summary>
|
||||
/// Builds the delta-sig command group.
|
||||
/// </summary>
|
||||
internal static Command BuildDeltaSigCommand(
|
||||
IServiceProvider services,
|
||||
Option<bool> verboseOption,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var deltaSig = new Command("delta-sig", "Binary delta signature operations for patch verification.");
|
||||
|
||||
deltaSig.Add(BuildDiffCommand(services, verboseOption, cancellationToken));
|
||||
deltaSig.Add(BuildAttestCommand(services, verboseOption, cancellationToken));
|
||||
deltaSig.Add(BuildVerifyCommand(services, verboseOption, cancellationToken));
|
||||
deltaSig.Add(BuildGateCommand(services, verboseOption, cancellationToken));
|
||||
|
||||
return deltaSig;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// stella binary delta-sig diff - Generate delta-sig predicate from two binaries.
|
||||
/// </summary>
|
||||
private static Command BuildDiffCommand(
|
||||
IServiceProvider services,
|
||||
Option<bool> verboseOption,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var oldFileArg = new Argument<string>("old-file")
|
||||
{
|
||||
Description = "Path to the original (vulnerable) binary."
|
||||
};
|
||||
|
||||
var newFileArg = new Argument<string>("new-file")
|
||||
{
|
||||
Description = "Path to the patched binary."
|
||||
};
|
||||
|
||||
var outputOption = new Option<string?>("--output", new[] { "-o" })
|
||||
{
|
||||
Description = "Output file path (default: stdout)."
|
||||
};
|
||||
|
||||
var archOption = new Option<string?>("--arch", new[] { "-a" })
|
||||
{
|
||||
Description = "Architecture hint (e.g., linux-amd64, linux-arm64)."
|
||||
};
|
||||
|
||||
var cveOption = new Option<string[]>("--cve")
|
||||
{
|
||||
Description = "CVE IDs associated with the patch."
|
||||
}.SetDefaultValue(Array.Empty<string>());
|
||||
|
||||
var packageOption = new Option<string?>("--package", new[] { "-p" })
|
||||
{
|
||||
Description = "Package name."
|
||||
};
|
||||
|
||||
var oldVersionOption = new Option<string?>("--old-version")
|
||||
{
|
||||
Description = "Version of the old binary."
|
||||
};
|
||||
|
||||
var newVersionOption = new Option<string?>("--new-version")
|
||||
{
|
||||
Description = "Version of the new binary."
|
||||
};
|
||||
|
||||
var lifterOption = new Option<string>("--lifter")
|
||||
{
|
||||
Description = "Preferred binary lifter (b2r2, ghidra)."
|
||||
}.SetDefaultValue("b2r2").FromAmong("b2r2", "ghidra");
|
||||
|
||||
var semanticOption = new Option<bool>("--semantic")
|
||||
{
|
||||
Description = "Compute semantic similarity using BSim."
|
||||
};
|
||||
|
||||
var formatOption = new Option<string>("--format", new[] { "-f" })
|
||||
{
|
||||
Description = "Output format: json (default), yaml."
|
||||
}.SetDefaultValue("json").FromAmong("json", "yaml");
|
||||
|
||||
var command = new Command("diff", "Generate a delta-sig predicate from two binaries.")
|
||||
{
|
||||
oldFileArg,
|
||||
newFileArg,
|
||||
outputOption,
|
||||
archOption,
|
||||
cveOption,
|
||||
packageOption,
|
||||
oldVersionOption,
|
||||
newVersionOption,
|
||||
lifterOption,
|
||||
semanticOption,
|
||||
formatOption,
|
||||
verboseOption
|
||||
};
|
||||
|
||||
command.SetAction(async parseResult =>
|
||||
{
|
||||
var oldFile = parseResult.GetValue(oldFileArg)!;
|
||||
var newFile = parseResult.GetValue(newFileArg)!;
|
||||
var output = parseResult.GetValue(outputOption);
|
||||
var arch = parseResult.GetValue(archOption);
|
||||
var cves = parseResult.GetValue(cveOption) ?? [];
|
||||
var package = parseResult.GetValue(packageOption);
|
||||
var oldVersion = parseResult.GetValue(oldVersionOption);
|
||||
var newVersion = parseResult.GetValue(newVersionOption);
|
||||
var lifter = parseResult.GetValue(lifterOption)!;
|
||||
var semantic = parseResult.GetValue(semanticOption);
|
||||
var format = parseResult.GetValue(formatOption)!;
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
|
||||
await HandleDiffAsync(
|
||||
services,
|
||||
oldFile,
|
||||
newFile,
|
||||
output,
|
||||
arch,
|
||||
cves.ToList(),
|
||||
package,
|
||||
oldVersion,
|
||||
newVersion,
|
||||
lifter,
|
||||
semantic,
|
||||
format,
|
||||
verbose,
|
||||
cancellationToken);
|
||||
});
|
||||
|
||||
return command;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// stella binary delta-sig attest - Sign and submit delta-sig to Rekor.
|
||||
/// </summary>
|
||||
private static Command BuildAttestCommand(
|
||||
IServiceProvider services,
|
||||
Option<bool> verboseOption,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var predicateFileArg = new Argument<string>("predicate-file")
|
||||
{
|
||||
Description = "Path to delta-sig predicate JSON file."
|
||||
};
|
||||
|
||||
var keyOption = new Option<string?>("--key", new[] { "-k" })
|
||||
{
|
||||
Description = "Signing key identifier (uses default if not specified)."
|
||||
};
|
||||
|
||||
var rekorOption = new Option<string?>("--rekor-url")
|
||||
{
|
||||
Description = "Rekor server URL (default: https://rekor.sigstore.dev)."
|
||||
};
|
||||
|
||||
var outputOption = new Option<string?>("--output", new[] { "-o" })
|
||||
{
|
||||
Description = "Output file for DSSE envelope."
|
||||
};
|
||||
|
||||
var dryRunOption = new Option<bool>("--dry-run")
|
||||
{
|
||||
Description = "Create envelope without submitting to Rekor."
|
||||
};
|
||||
|
||||
var command = new Command("attest", "Sign and submit a delta-sig predicate to Rekor.")
|
||||
{
|
||||
predicateFileArg,
|
||||
keyOption,
|
||||
rekorOption,
|
||||
outputOption,
|
||||
dryRunOption,
|
||||
verboseOption
|
||||
};
|
||||
|
||||
command.SetAction(async parseResult =>
|
||||
{
|
||||
var predicateFile = parseResult.GetValue(predicateFileArg)!;
|
||||
var key = parseResult.GetValue(keyOption);
|
||||
var rekorUrl = parseResult.GetValue(rekorOption);
|
||||
var output = parseResult.GetValue(outputOption);
|
||||
var dryRun = parseResult.GetValue(dryRunOption);
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
|
||||
await HandleAttestAsync(
|
||||
services,
|
||||
predicateFile,
|
||||
key,
|
||||
rekorUrl,
|
||||
output,
|
||||
dryRun,
|
||||
verbose,
|
||||
cancellationToken);
|
||||
});
|
||||
|
||||
return command;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// stella binary delta-sig verify - Verify a binary against a delta-sig predicate.
|
||||
/// </summary>
|
||||
private static Command BuildVerifyCommand(
|
||||
IServiceProvider services,
|
||||
Option<bool> verboseOption,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var predicateArg = new Argument<string>("predicate")
|
||||
{
|
||||
Description = "Path to delta-sig predicate or Rekor entry UUID."
|
||||
};
|
||||
|
||||
var binaryArg = new Argument<string>("binary")
|
||||
{
|
||||
Description = "Path to binary file to verify."
|
||||
};
|
||||
|
||||
var rekorOption = new Option<string?>("--rekor-url")
|
||||
{
|
||||
Description = "Rekor server URL for fetching remote predicates."
|
||||
};
|
||||
|
||||
var formatOption = new Option<string>("--format", new[] { "-f" })
|
||||
{
|
||||
Description = "Output format: text (default), json."
|
||||
}.SetDefaultValue("text").FromAmong("text", "json");
|
||||
|
||||
var command = new Command("verify", "Verify a binary against a delta-sig predicate.")
|
||||
{
|
||||
predicateArg,
|
||||
binaryArg,
|
||||
rekorOption,
|
||||
formatOption,
|
||||
verboseOption
|
||||
};
|
||||
|
||||
command.SetAction(async parseResult =>
|
||||
{
|
||||
var predicate = parseResult.GetValue(predicateArg)!;
|
||||
var binary = parseResult.GetValue(binaryArg)!;
|
||||
var rekorUrl = parseResult.GetValue(rekorOption);
|
||||
var format = parseResult.GetValue(formatOption)!;
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
|
||||
await HandleVerifyAsync(
|
||||
services,
|
||||
predicate,
|
||||
binary,
|
||||
rekorUrl,
|
||||
format,
|
||||
verbose,
|
||||
cancellationToken);
|
||||
});
|
||||
|
||||
return command;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// stella binary delta-sig gate - Evaluate delta-sig against policy constraints.
|
||||
/// </summary>
|
||||
private static Command BuildGateCommand(
|
||||
IServiceProvider services,
|
||||
Option<bool> verboseOption,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var predicateArg = new Argument<string>("predicate")
|
||||
{
|
||||
Description = "Path to delta-sig predicate JSON file."
|
||||
};
|
||||
|
||||
var maxModifiedOption = new Option<int?>("--max-modified")
|
||||
{
|
||||
Description = "Maximum modified functions allowed."
|
||||
};
|
||||
|
||||
var maxAddedOption = new Option<int?>("--max-added")
|
||||
{
|
||||
Description = "Maximum added functions allowed."
|
||||
};
|
||||
|
||||
var maxRemovedOption = new Option<int?>("--max-removed")
|
||||
{
|
||||
Description = "Maximum removed functions allowed."
|
||||
};
|
||||
|
||||
var maxBytesOption = new Option<long?>("--max-bytes")
|
||||
{
|
||||
Description = "Maximum bytes changed allowed."
|
||||
};
|
||||
|
||||
var minSimilarityOption = new Option<double?>("--min-similarity")
|
||||
{
|
||||
Description = "Minimum semantic similarity (0.0-1.0)."
|
||||
};
|
||||
|
||||
var formatOption = new Option<string>("--format", new[] { "-f" })
|
||||
{
|
||||
Description = "Output format: text (default), json."
|
||||
}.SetDefaultValue("text").FromAmong("text", "json");
|
||||
|
||||
var command = new Command("gate", "Evaluate a delta-sig against policy constraints.")
|
||||
{
|
||||
predicateArg,
|
||||
maxModifiedOption,
|
||||
maxAddedOption,
|
||||
maxRemovedOption,
|
||||
maxBytesOption,
|
||||
minSimilarityOption,
|
||||
formatOption,
|
||||
verboseOption
|
||||
};
|
||||
|
||||
command.SetAction(async parseResult =>
|
||||
{
|
||||
var predicate = parseResult.GetValue(predicateArg)!;
|
||||
var maxModified = parseResult.GetValue(maxModifiedOption);
|
||||
var maxAdded = parseResult.GetValue(maxAddedOption);
|
||||
var maxRemoved = parseResult.GetValue(maxRemovedOption);
|
||||
var maxBytes = parseResult.GetValue(maxBytesOption);
|
||||
var minSimilarity = parseResult.GetValue(minSimilarityOption);
|
||||
var format = parseResult.GetValue(formatOption)!;
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
|
||||
await HandleGateAsync(
|
||||
services,
|
||||
predicate,
|
||||
maxModified,
|
||||
maxAdded,
|
||||
maxRemoved,
|
||||
maxBytes,
|
||||
minSimilarity,
|
||||
format,
|
||||
verbose,
|
||||
cancellationToken);
|
||||
});
|
||||
|
||||
return command;
|
||||
}
|
||||
|
||||
// Handler implementations
|
||||
|
||||
private static async Task HandleDiffAsync(
|
||||
IServiceProvider services,
|
||||
string oldFile,
|
||||
string newFile,
|
||||
string? output,
|
||||
string? arch,
|
||||
IReadOnlyList<string> cves,
|
||||
string? package,
|
||||
string? oldVersion,
|
||||
string? newVersion,
|
||||
string lifter,
|
||||
bool semantic,
|
||||
string format,
|
||||
bool verbose,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var deltaSigService = services.GetRequiredService<IDeltaSigService>();
|
||||
var console = Console.Out;
|
||||
|
||||
if (verbose)
|
||||
{
|
||||
await console.WriteLineAsync($"Generating delta-sig: {oldFile} -> {newFile}");
|
||||
}
|
||||
|
||||
// Open binary streams
|
||||
await using var oldStream = File.OpenRead(oldFile);
|
||||
await using var newStream = File.OpenRead(newFile);
|
||||
|
||||
var oldFileInfo = new FileInfo(oldFile);
|
||||
var newFileInfo = new FileInfo(newFile);
|
||||
|
||||
// Compute digests
|
||||
using var sha256 = System.Security.Cryptography.SHA256.Create();
|
||||
var oldDigest = Convert.ToHexString(await sha256.ComputeHashAsync(oldStream, ct)).ToLowerInvariant();
|
||||
oldStream.Position = 0;
|
||||
var newDigest = Convert.ToHexString(await sha256.ComputeHashAsync(newStream, ct)).ToLowerInvariant();
|
||||
newStream.Position = 0;
|
||||
|
||||
var request = new DeltaSigRequest
|
||||
{
|
||||
OldBinary = new BinaryReference
|
||||
{
|
||||
Uri = $"file://{oldFile}",
|
||||
Digest = new Dictionary<string, string> { ["sha256"] = oldDigest },
|
||||
Content = oldStream,
|
||||
Filename = oldFileInfo.Name,
|
||||
Size = oldFileInfo.Length
|
||||
},
|
||||
NewBinary = new BinaryReference
|
||||
{
|
||||
Uri = $"file://{newFile}",
|
||||
Digest = new Dictionary<string, string> { ["sha256"] = newDigest },
|
||||
Content = newStream,
|
||||
Filename = newFileInfo.Name,
|
||||
Size = newFileInfo.Length
|
||||
},
|
||||
Architecture = arch ?? "unknown",
|
||||
CveIds = cves,
|
||||
PackageName = package,
|
||||
OldVersion = oldVersion,
|
||||
NewVersion = newVersion,
|
||||
PreferredLifter = lifter,
|
||||
ComputeSemanticSimilarity = semantic
|
||||
};
|
||||
|
||||
var predicate = await deltaSigService.GenerateAsync(request, ct);
|
||||
|
||||
// Serialize output
|
||||
var json = System.Text.Json.JsonSerializer.Serialize(predicate, new System.Text.Json.JsonSerializerOptions
|
||||
{
|
||||
WriteIndented = true,
|
||||
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull
|
||||
});
|
||||
|
||||
if (!string.IsNullOrEmpty(output))
|
||||
{
|
||||
await File.WriteAllTextAsync(output, json, ct);
|
||||
await console.WriteLineAsync($"Delta-sig written to: {output}");
|
||||
}
|
||||
else
|
||||
{
|
||||
await console.WriteLineAsync(json);
|
||||
}
|
||||
|
||||
if (verbose)
|
||||
{
|
||||
await console.WriteLineAsync($"Summary: {predicate.Summary.FunctionsModified} modified, " +
|
||||
$"{predicate.Summary.FunctionsAdded} added, " +
|
||||
$"{predicate.Summary.FunctionsRemoved} removed");
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task HandleAttestAsync(
|
||||
IServiceProvider services,
|
||||
string predicateFile,
|
||||
string? key,
|
||||
string? rekorUrl,
|
||||
string? output,
|
||||
bool dryRun,
|
||||
bool verbose,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var console = Console.Out;
|
||||
|
||||
// Read predicate
|
||||
var json = await File.ReadAllTextAsync(predicateFile, ct);
|
||||
var predicate = System.Text.Json.JsonSerializer.Deserialize<DeltaSigPredicate>(json);
|
||||
|
||||
if (predicate is null)
|
||||
{
|
||||
Console.Error.WriteLine("Failed to parse predicate file.");
|
||||
Environment.ExitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
if (verbose)
|
||||
{
|
||||
await console.WriteLineAsync($"Loaded predicate with {predicate.Delta.Count} function deltas");
|
||||
}
|
||||
|
||||
// Build envelope
|
||||
var builder = new DeltaSigEnvelopeBuilder();
|
||||
var (payloadType, payload, pae) = builder.PrepareForSigning(predicate);
|
||||
|
||||
if (dryRun)
|
||||
{
|
||||
await console.WriteLineAsync("Dry run - envelope prepared but not submitted.");
|
||||
await console.WriteLineAsync($"Payload type: {payloadType}");
|
||||
await console.WriteLineAsync($"Payload size: {payload.Length} bytes");
|
||||
return;
|
||||
}
|
||||
|
||||
// In real implementation, we would:
|
||||
// 1. Sign the PAE using the configured key
|
||||
// 2. Create the DSSE envelope
|
||||
// 3. Submit to Rekor
|
||||
// For now, output a placeholder
|
||||
|
||||
await console.WriteLineAsync("Attestation not yet implemented - requires signing key configuration.");
|
||||
Environment.ExitCode = 1;
|
||||
}
|
||||
|
||||
private static async Task HandleVerifyAsync(
|
||||
IServiceProvider services,
|
||||
string predicateArg,
|
||||
string binary,
|
||||
string? rekorUrl,
|
||||
string format,
|
||||
bool verbose,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var deltaSigService = services.GetRequiredService<IDeltaSigService>();
|
||||
var console = Console.Out;
|
||||
|
||||
// Load predicate
|
||||
DeltaSigPredicate predicate;
|
||||
if (File.Exists(predicateArg))
|
||||
{
|
||||
var json = await File.ReadAllTextAsync(predicateArg, ct);
|
||||
predicate = System.Text.Json.JsonSerializer.Deserialize<DeltaSigPredicate>(json)!;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Assume it's a Rekor entry ID - fetch from Rekor
|
||||
Console.Error.WriteLine("Fetching from Rekor not yet implemented.");
|
||||
Environment.ExitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
if (verbose)
|
||||
{
|
||||
await console.WriteLineAsync($"Verifying {binary} against predicate");
|
||||
}
|
||||
|
||||
await using var binaryStream = File.OpenRead(binary);
|
||||
var result = await deltaSigService.VerifyAsync(predicate, binaryStream, ct);
|
||||
|
||||
if (format == "json")
|
||||
{
|
||||
var json = System.Text.Json.JsonSerializer.Serialize(result, new System.Text.Json.JsonSerializerOptions
|
||||
{
|
||||
WriteIndented = true
|
||||
});
|
||||
await console.WriteLineAsync(json);
|
||||
}
|
||||
else
|
||||
{
|
||||
if (result.IsValid)
|
||||
{
|
||||
await console.WriteLineAsync("✓ Verification PASSED");
|
||||
}
|
||||
else
|
||||
{
|
||||
await console.WriteLineAsync($"✗ Verification FAILED: {result.FailureReason}");
|
||||
Environment.ExitCode = 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task HandleGateAsync(
|
||||
IServiceProvider services,
|
||||
string predicateFile,
|
||||
int? maxModified,
|
||||
int? maxAdded,
|
||||
int? maxRemoved,
|
||||
long? maxBytes,
|
||||
double? minSimilarity,
|
||||
string format,
|
||||
bool verbose,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var gate = services.GetService<IDeltaScopePolicyGate>();
|
||||
var console = Console.Out;
|
||||
|
||||
// Read predicate
|
||||
var json = await File.ReadAllTextAsync(predicateFile, ct);
|
||||
var predicate = System.Text.Json.JsonSerializer.Deserialize<DeltaSigPredicate>(json);
|
||||
|
||||
if (predicate is null)
|
||||
{
|
||||
Console.Error.WriteLine("Failed to parse predicate file.");
|
||||
Environment.ExitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
// Build options
|
||||
var options = new DeltaScopeGateOptions
|
||||
{
|
||||
MaxModifiedFunctions = maxModified ?? 10,
|
||||
MaxAddedFunctions = maxAdded ?? 5,
|
||||
MaxRemovedFunctions = maxRemoved ?? 2,
|
||||
MaxBytesChanged = maxBytes ?? 10_000,
|
||||
MinSemanticSimilarity = minSimilarity ?? 0.8
|
||||
};
|
||||
|
||||
if (gate is null)
|
||||
{
|
||||
// Use inline evaluation
|
||||
var violations = new List<string>();
|
||||
|
||||
if (predicate.Summary.FunctionsModified > options.MaxModifiedFunctions)
|
||||
{
|
||||
violations.Add($"Modified {predicate.Summary.FunctionsModified} functions; max {options.MaxModifiedFunctions}");
|
||||
}
|
||||
if (predicate.Summary.FunctionsAdded > options.MaxAddedFunctions)
|
||||
{
|
||||
violations.Add($"Added {predicate.Summary.FunctionsAdded} functions; max {options.MaxAddedFunctions}");
|
||||
}
|
||||
if (predicate.Summary.FunctionsRemoved > options.MaxRemovedFunctions)
|
||||
{
|
||||
violations.Add($"Removed {predicate.Summary.FunctionsRemoved} functions; max {options.MaxRemovedFunctions}");
|
||||
}
|
||||
if (predicate.Summary.TotalBytesChanged > options.MaxBytesChanged)
|
||||
{
|
||||
violations.Add($"Changed {predicate.Summary.TotalBytesChanged} bytes; max {options.MaxBytesChanged}");
|
||||
}
|
||||
if (predicate.Summary.MinSemanticSimilarity < options.MinSemanticSimilarity)
|
||||
{
|
||||
violations.Add($"Min similarity {predicate.Summary.MinSemanticSimilarity:P0}; required {options.MinSemanticSimilarity:P0}");
|
||||
}
|
||||
|
||||
if (format == "json")
|
||||
{
|
||||
var result = new { passed = violations.Count == 0, violations };
|
||||
var resultJson = System.Text.Json.JsonSerializer.Serialize(result, new System.Text.Json.JsonSerializerOptions { WriteIndented = true });
|
||||
await console.WriteLineAsync(resultJson);
|
||||
}
|
||||
else
|
||||
{
|
||||
if (violations.Count == 0)
|
||||
{
|
||||
await console.WriteLineAsync("✓ Gate PASSED");
|
||||
}
|
||||
else
|
||||
{
|
||||
await console.WriteLineAsync("✗ Gate FAILED");
|
||||
foreach (var v in violations)
|
||||
{
|
||||
await console.WriteLineAsync($" - {v}");
|
||||
}
|
||||
Environment.ExitCode = 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
var result = await gate.EvaluateAsync(predicate, options, ct);
|
||||
|
||||
if (format == "json")
|
||||
{
|
||||
var resultJson = System.Text.Json.JsonSerializer.Serialize(result, new System.Text.Json.JsonSerializerOptions { WriteIndented = true });
|
||||
await console.WriteLineAsync(resultJson);
|
||||
}
|
||||
else
|
||||
{
|
||||
if (result.Passed)
|
||||
{
|
||||
await console.WriteLineAsync("✓ Gate PASSED");
|
||||
}
|
||||
else
|
||||
{
|
||||
await console.WriteLineAsync($"✗ Gate FAILED: {result.Reason}");
|
||||
Environment.ExitCode = 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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