tests fixes and sprints work

This commit is contained in:
master
2026-01-22 19:08:46 +02:00
parent c32fff8f86
commit 726d70dc7f
881 changed files with 134434 additions and 6228 deletions

View File

@@ -7,7 +7,6 @@
using System.CommandLine;
using System.CommandLine.Binding;
using System.CommandLine.Builder;
using System.CommandLine.Parsing;
using System.Text.Json;
using Microsoft.Extensions.DependencyInjection;
@@ -22,6 +21,7 @@ public sealed class CliApplication
{
private readonly IServiceProvider _services;
private readonly ILogger<CliApplication> _logger;
private Option<bool>? _verboseOption;
public CliApplication(IServiceProvider services, ILogger<CliApplication> logger)
{
@@ -35,39 +35,52 @@ public sealed class CliApplication
public async Task<int> RunAsync(string[] args)
{
var rootCommand = BuildRootCommand();
var parserConfig = new ParserConfiguration();
var parseResult = rootCommand.Parse(args, parserConfig);
var invocationConfig = new InvocationConfiguration
{
EnableDefaultExceptionHandler = false,
Output = Console.Out,
Error = Console.Error
};
var parser = new CommandLineBuilder(rootCommand)
.UseDefaults()
.UseExceptionHandler(HandleException)
.Build();
return await parser.InvokeAsync(args);
try
{
return await parseResult.InvokeAsync(invocationConfig, CancellationToken.None);
}
catch (Exception ex)
{
HandleException(ex, parseResult);
return 1;
}
}
private RootCommand BuildRootCommand()
{
var rootCommand = new RootCommand("Stella Ops - Release Control Plane CLI")
{
Name = "stella"
};
;
// Global options
var configOption = new Option<string?>(
aliases: ["--config", "-c"],
description: "Path to config file");
var configOption = new Option<string?>("--config", "-c")
{
Description = "Path to config file"
};
var formatOption = new Option<OutputFormat>(
aliases: ["--format", "-f"],
getDefaultValue: () => OutputFormat.Table,
description: "Output format (table, json, yaml)");
var formatOption = new Option<OutputFormat>("--format", "-f")
{
Description = "Output format (table, json, yaml)"
};
formatOption.SetDefaultValue(OutputFormat.Table);
var verboseOption = new Option<bool>(
aliases: ["--verbose", "-v"],
description: "Enable verbose output");
var verboseOption = new Option<bool>("--verbose", "-v")
{
Description = "Enable verbose output"
};
rootCommand.AddGlobalOption(configOption);
rootCommand.AddGlobalOption(formatOption);
rootCommand.AddGlobalOption(verboseOption);
_verboseOption = verboseOption;
// Add command groups
rootCommand.AddCommand(BuildAuthCommand());
@@ -90,17 +103,26 @@ public sealed class CliApplication
// Login command
var loginCommand = new Command("login", "Authenticate with Stella server");
var serverArg = new Argument<string>("server", "Server URL");
var interactiveOption = new Option<bool>("--interactive", "Use interactive login");
var tokenOption = new Option<string?>("--token", "API token for authentication");
var serverArg = new Argument<string>("server")
{
Description = "Server URL"
};
var interactiveOption = new Option<bool>("--interactive")
{
Description = "Use interactive login"
};
var tokenOption = new Option<string?>("--token")
{
Description = "API token for authentication"
};
loginCommand.AddArgument(serverArg);
loginCommand.AddOption(interactiveOption);
loginCommand.AddOption(tokenOption);
loginCommand.SetHandler(async (server, interactive, token) =>
loginCommand.SetHandler<string, bool, string?>(async (server, interactive, token) =>
{
var handler = _services.GetRequiredService<AuthCommandHandler>();
var handler = _services.GetRequiredService<AuthCommandHandler>();
await handler.LoginAsync(server, interactive, token);
}, serverArg, interactiveOption, tokenOption);
@@ -146,12 +168,15 @@ public sealed class CliApplication
// Init command
var initCommand = new Command("init", "Initialize configuration file");
var pathOption = new Option<string?>("--path", "Path to create config");
var pathOption = new Option<string?>("--path")
{
Description = "Path to create config"
};
initCommand.AddOption(pathOption);
initCommand.SetHandler(async (path) =>
initCommand.SetHandler<string?>(async (path) =>
{
var handler = _services.GetRequiredService<ConfigCommandHandler>();
var handler = _services.GetRequiredService<ConfigCommandHandler>();
await handler.InitAsync(path);
}, pathOption);
@@ -165,25 +190,34 @@ public sealed class CliApplication
// Set command
var setCommand = new Command("set", "Set a configuration value");
var keyArg = new Argument<string>("key", "Configuration key");
var valueArg = new Argument<string>("value", "Configuration value");
var keyArg = new Argument<string>("key")
{
Description = "Configuration key"
};
var valueArg = new Argument<string>("value")
{
Description = "Configuration value"
};
setCommand.AddArgument(keyArg);
setCommand.AddArgument(valueArg);
setCommand.SetHandler(async (key, value) =>
setCommand.SetHandler<string, string>(async (key, value) =>
{
var handler = _services.GetRequiredService<ConfigCommandHandler>();
var handler = _services.GetRequiredService<ConfigCommandHandler>();
await handler.SetAsync(key, value);
}, keyArg, valueArg);
// Get command
var getCommand = new Command("get", "Get a configuration value");
var getKeyArg = new Argument<string>("key", "Configuration key");
var getKeyArg = new Argument<string>("key")
{
Description = "Configuration key"
};
getCommand.AddArgument(getKeyArg);
getCommand.SetHandler(async (key) =>
getCommand.SetHandler<string>(async (key) =>
{
var handler = _services.GetRequiredService<ConfigCommandHandler>();
var handler = _services.GetRequiredService<ConfigCommandHandler>();
await handler.GetAsync(key);
}, getKeyArg);
@@ -214,17 +248,29 @@ public sealed class CliApplication
// Create command
var createCommand = new Command("create", "Create a new release");
var serviceArg = new Argument<string>("service", "Service name");
var versionArg = new Argument<string>("version", "Version");
var notesOption = new Option<string?>("--notes", "Release notes");
var draftOption = new Option<bool>("--draft", "Create as draft");
var serviceArg = new Argument<string>("service")
{
Description = "Service name"
};
var versionArg = new Argument<string>("version")
{
Description = "Version"
};
var notesOption = new Option<string?>("--notes")
{
Description = "Release notes"
};
var draftOption = new Option<bool>("--draft")
{
Description = "Create as draft"
};
createCommand.AddArgument(serviceArg);
createCommand.AddArgument(versionArg);
createCommand.AddOption(notesOption);
createCommand.AddOption(draftOption);
createCommand.SetHandler(async (service, version, notes, draft) =>
createCommand.SetHandler<string, string, string?, bool>(async (service, version, notes, draft) =>
{
var handler = _services.GetRequiredService<ReleaseCommandHandler>();
await handler.CreateAsync(service, version, notes, draft);
@@ -232,15 +278,25 @@ public sealed class CliApplication
// List command
var listCommand = new Command("list", "List releases");
var serviceOption = new Option<string?>("--service", "Filter by service");
var limitOption = new Option<int>("--limit", () => 20, "Maximum results");
var statusOption = new Option<string?>("--status", "Filter by status");
var serviceOption = new Option<string?>("--service")
{
Description = "Filter by service"
};
var limitOption = new Option<int>("--limit")
{
Description = "Maximum results"
};
limitOption.SetDefaultValue(20);
var statusOption = new Option<string?>("--status")
{
Description = "Filter by status"
};
listCommand.AddOption(serviceOption);
listCommand.AddOption(limitOption);
listCommand.AddOption(statusOption);
listCommand.SetHandler(async (service, limit, status) =>
listCommand.SetHandler<string?, int, string?>(async (service, limit, status) =>
{
var handler = _services.GetRequiredService<ReleaseCommandHandler>();
await handler.ListAsync(service, limit, status);
@@ -248,10 +304,13 @@ public sealed class CliApplication
// Get command
var getCommand = new Command("get", "Get release details");
var releaseIdArg = new Argument<string>("release-id", "Release ID");
var releaseIdArg = new Argument<string>("release-id")
{
Description = "Release ID"
};
getCommand.AddArgument(releaseIdArg);
getCommand.SetHandler(async (releaseId) =>
getCommand.SetHandler<string>(async (releaseId) =>
{
var handler = _services.GetRequiredService<ReleaseCommandHandler>();
await handler.GetAsync(releaseId);
@@ -259,13 +318,19 @@ public sealed class CliApplication
// Diff command
var diffCommand = new Command("diff", "Compare two releases");
var fromArg = new Argument<string>("from", "Source release");
var toArg = new Argument<string>("to", "Target release");
var fromArg = new Argument<string>("from")
{
Description = "Source release"
};
var toArg = new Argument<string>("to")
{
Description = "Target release"
};
diffCommand.AddArgument(fromArg);
diffCommand.AddArgument(toArg);
diffCommand.SetHandler(async (from, to) =>
diffCommand.SetHandler<string, string>(async (from, to) =>
{
var handler = _services.GetRequiredService<ReleaseCommandHandler>();
await handler.DiffAsync(from, to);
@@ -273,10 +338,13 @@ public sealed class CliApplication
// History command
var historyCommand = new Command("history", "Show release history");
var historyServiceArg = new Argument<string>("service", "Service name");
var historyServiceArg = new Argument<string>("service")
{
Description = "Service name"
};
historyCommand.AddArgument(historyServiceArg);
historyCommand.SetHandler(async (service) =>
historyCommand.SetHandler<string>(async (service) =>
{
var handler = _services.GetRequiredService<ReleaseCommandHandler>();
await handler.HistoryAsync(service);
@@ -301,43 +369,64 @@ public sealed class CliApplication
// Start promotion
var startCommand = new Command("start", "Start a promotion");
var releaseArg = new Argument<string>("release", "Release to promote");
var targetArg = new Argument<string>("target", "Target environment");
var autoApproveOption = new Option<bool>("--auto-approve", "Skip approval");
var releaseArg = new Argument<string>("release")
{
Description = "Release to promote"
};
var targetArg = new Argument<string>("target")
{
Description = "Target environment"
};
var autoApproveOption = new Option<bool>("--auto-approve")
{
Description = "Skip approval"
};
startCommand.AddArgument(releaseArg);
startCommand.AddArgument(targetArg);
startCommand.AddOption(autoApproveOption);
startCommand.SetHandler(async (release, target, autoApprove) =>
startCommand.SetHandler<string, string, bool>(async (release, target, autoApprove) =>
{
var handler = _services.GetRequiredService<PromoteCommandHandler>();
var handler = _services.GetRequiredService<PromoteCommandHandler>();
await handler.StartAsync(release, target, autoApprove);
}, releaseArg, targetArg, autoApproveOption);
// Status command
var statusCommand = new Command("status", "Get promotion status");
var promotionIdArg = new Argument<string>("promotion-id", "Promotion ID");
var watchOption = new Option<bool>("--watch", "Watch for updates");
var promotionIdArg = new Argument<string>("promotion-id")
{
Description = "Promotion ID"
};
var watchOption = new Option<bool>("--watch")
{
Description = "Watch for updates"
};
statusCommand.AddArgument(promotionIdArg);
statusCommand.AddOption(watchOption);
statusCommand.SetHandler(async (promotionId, watch) =>
statusCommand.SetHandler<string, bool>(async (promotionId, watch) =>
{
var handler = _services.GetRequiredService<PromoteCommandHandler>();
var handler = _services.GetRequiredService<PromoteCommandHandler>();
await handler.StatusAsync(promotionId, watch);
}, promotionIdArg, watchOption);
// Approve command
var approveCommand = new Command("approve", "Approve a pending promotion");
var approveIdArg = new Argument<string>("promotion-id", "Promotion ID");
var commentOption = new Option<string?>("--comment", "Approval comment");
var approveIdArg = new Argument<string>("promotion-id")
{
Description = "Promotion ID"
};
var commentOption = new Option<string?>("--comment")
{
Description = "Approval comment"
};
approveCommand.AddArgument(approveIdArg);
approveCommand.AddOption(commentOption);
approveCommand.SetHandler(async (promotionId, comment) =>
approveCommand.SetHandler<string, string?>(async (promotionId, comment) =>
{
var handler = _services.GetRequiredService<PromoteCommandHandler>();
await handler.ApproveAsync(promotionId, comment);
@@ -345,13 +434,20 @@ public sealed class CliApplication
// Reject command
var rejectCommand = new Command("reject", "Reject a pending promotion");
var rejectIdArg = new Argument<string>("promotion-id", "Promotion ID");
var reasonOption = new Option<string>("--reason", "Rejection reason") { IsRequired = true };
var rejectIdArg = new Argument<string>("promotion-id")
{
Description = "Promotion ID"
};
var reasonOption = new Option<string>("--reason")
{
Description = "Rejection reason",
Required = true
};
rejectCommand.AddArgument(rejectIdArg);
rejectCommand.AddOption(reasonOption);
rejectCommand.SetHandler(async (promotionId, reason) =>
rejectCommand.SetHandler<string, string>(async (promotionId, reason) =>
{
var handler = _services.GetRequiredService<PromoteCommandHandler>();
await handler.RejectAsync(promotionId, reason);
@@ -359,13 +455,19 @@ public sealed class CliApplication
// List command
var listCommand = new Command("list", "List promotions");
var envOption = new Option<string?>("--env", "Filter by environment");
var pendingOption = new Option<bool>("--pending", "Show only pending");
var envOption = new Option<string?>("--env")
{
Description = "Filter by environment"
};
var pendingOption = new Option<bool>("--pending")
{
Description = "Show only pending"
};
listCommand.AddOption(envOption);
listCommand.AddOption(pendingOption);
listCommand.SetHandler(async (env, pending) =>
listCommand.SetHandler<string?, bool>(async (env, pending) =>
{
var handler = _services.GetRequiredService<PromoteCommandHandler>();
await handler.ListAsync(env, pending);
@@ -390,17 +492,30 @@ public sealed class CliApplication
// Start deployment
var startCommand = new Command("start", "Start a deployment");
var releaseArg = new Argument<string>("release", "Release to deploy");
var targetArg = new Argument<string>("target", "Target environment");
var strategyOption = new Option<string>("--strategy", () => "rolling", "Deployment strategy");
var dryRunOption = new Option<bool>("--dry-run", "Simulate deployment");
var releaseArg = new Argument<string>("release")
{
Description = "Release to deploy"
};
var targetArg = new Argument<string>("target")
{
Description = "Target environment"
};
var strategyOption = new Option<string>("--strategy")
{
Description = "Deployment strategy"
};
strategyOption.SetDefaultValue("rolling");
var dryRunOption = new Option<bool>("--dry-run")
{
Description = "Simulate deployment"
};
startCommand.AddArgument(releaseArg);
startCommand.AddArgument(targetArg);
startCommand.AddOption(strategyOption);
startCommand.AddOption(dryRunOption);
startCommand.SetHandler(async (release, target, strategy, dryRun) =>
startCommand.SetHandler<string, string, string, bool>(async (release, target, strategy, dryRun) =>
{
var handler = _services.GetRequiredService<DeployCommandHandler>();
await handler.StartAsync(release, target, strategy, dryRun);
@@ -408,57 +523,85 @@ public sealed class CliApplication
// Status command
var statusCommand = new Command("status", "Get deployment status");
var deploymentIdArg = new Argument<string>("deployment-id", "Deployment ID");
var watchOption = new Option<bool>("--watch", "Watch for updates");
var deploymentIdArg = new Argument<string>("deployment-id")
{
Description = "Deployment ID"
};
var watchOption = new Option<bool>("--watch")
{
Description = "Watch for updates"
};
statusCommand.AddArgument(deploymentIdArg);
statusCommand.AddOption(watchOption);
statusCommand.SetHandler(async (deploymentId, watch) =>
statusCommand.SetHandler<string, bool>(async (deploymentId, watch) =>
{
var handler = _services.GetRequiredService<DeployCommandHandler>();
var handler = _services.GetRequiredService<DeployCommandHandler>();
await handler.StatusAsync(deploymentId, watch);
}, deploymentIdArg, watchOption);
// Logs command
var logsCommand = new Command("logs", "View deployment logs");
var logsIdArg = new Argument<string>("deployment-id", "Deployment ID");
var followOption = new Option<bool>("--follow", "Follow log output");
var tailOption = new Option<int>("--tail", () => 100, "Lines to show");
var logsIdArg = new Argument<string>("deployment-id")
{
Description = "Deployment ID"
};
var followOption = new Option<bool>("--follow")
{
Description = "Follow log output"
};
var tailOption = new Option<int>("--tail")
{
Description = "Lines to show"
};
tailOption.SetDefaultValue(100);
logsCommand.AddArgument(logsIdArg);
logsCommand.AddOption(followOption);
logsCommand.AddOption(tailOption);
logsCommand.SetHandler(async (deploymentId, follow, tail) =>
logsCommand.SetHandler<string, bool, int>(async (deploymentId, follow, tail) =>
{
var handler = _services.GetRequiredService<DeployCommandHandler>();
var handler = _services.GetRequiredService<DeployCommandHandler>();
await handler.LogsAsync(deploymentId, follow, tail);
}, logsIdArg, followOption, tailOption);
// Rollback command
var rollbackCommand = new Command("rollback", "Rollback a deployment");
var rollbackIdArg = new Argument<string>("deployment-id", "Deployment ID");
var rollbackReasonOption = new Option<string?>("--reason", "Rollback reason");
var rollbackIdArg = new Argument<string>("deployment-id")
{
Description = "Deployment ID"
};
var rollbackReasonOption = new Option<string?>("--reason")
{
Description = "Rollback reason"
};
rollbackCommand.AddArgument(rollbackIdArg);
rollbackCommand.AddOption(rollbackReasonOption);
rollbackCommand.SetHandler(async (deploymentId, reason) =>
rollbackCommand.SetHandler<string, string?>(async (deploymentId, reason) =>
{
var handler = _services.GetRequiredService<DeployCommandHandler>();
var handler = _services.GetRequiredService<DeployCommandHandler>();
await handler.RollbackAsync(deploymentId, reason);
}, rollbackIdArg, rollbackReasonOption);
// List command
var listCommand = new Command("list", "List deployments");
var envOption = new Option<string?>("--env", "Filter by environment");
var activeOption = new Option<bool>("--active", "Show only active");
var envOption = new Option<string?>("--env")
{
Description = "Filter by environment"
};
var activeOption = new Option<bool>("--active")
{
Description = "Show only active"
};
listCommand.AddOption(envOption);
listCommand.AddOption(activeOption);
listCommand.SetHandler(async (env, active) =>
listCommand.SetHandler<string?, bool>(async (env, active) =>
{
var handler = _services.GetRequiredService<DeployCommandHandler>();
await handler.ListAsync(env, active);
@@ -483,15 +626,25 @@ public sealed class CliApplication
// Run scan
var runCommand = new Command("run", "Run a security scan");
var imageArg = new Argument<string>("image", "Image to scan");
var outputOption = new Option<string?>("--output", "Output file");
var failOnOption = new Option<string>("--fail-on", () => "high", "Fail on severity");
var imageArg = new Argument<string>("image")
{
Description = "Image to scan"
};
var outputOption = new Option<string?>("--output")
{
Description = "Output file"
};
var failOnOption = new Option<string>("--fail-on")
{
Description = "Fail on severity"
};
failOnOption.SetDefaultValue("high");
runCommand.AddArgument(imageArg);
runCommand.AddOption(outputOption);
runCommand.AddOption(failOnOption);
runCommand.SetHandler(async (image, output, failOn) =>
runCommand.SetHandler<string, string?, string>(async (image, output, failOn) =>
{
var handler = _services.GetRequiredService<ScanCommandHandler>();
await handler.RunAsync(image, output, failOn);
@@ -499,11 +652,14 @@ public sealed class CliApplication
// Results command
var resultsCommand = new Command("results", "Get scan results");
var scanIdArg = new Argument<string>("scan-id", "Scan ID");
var scanIdArg = new Argument<string>("scan-id")
{
Description = "Scan ID"
};
resultsCommand.AddArgument(scanIdArg);
resultsCommand.SetHandler(async (scanId) =>
resultsCommand.SetHandler<string>(async (scanId) =>
{
var handler = _services.GetRequiredService<ScanCommandHandler>();
await handler.ResultsAsync(scanId);
@@ -525,11 +681,14 @@ public sealed class CliApplication
// Check command
var checkCommand = new Command("check", "Check policy compliance");
var releaseArg = new Argument<string>("release", "Release to check");
var releaseArg = new Argument<string>("release")
{
Description = "Release to check"
};
checkCommand.AddArgument(releaseArg);
checkCommand.SetHandler(async (release) =>
checkCommand.SetHandler<string>(async (release) =>
{
var handler = _services.GetRequiredService<PolicyCommandHandler>();
await handler.CheckAsync(release);
@@ -569,18 +728,17 @@ public sealed class CliApplication
#endregion
private void HandleException(Exception exception, InvocationContext context)
private void HandleException(Exception exception, ParseResult parseResult)
{
Console.ForegroundColor = ConsoleColor.Red;
Console.Error.WriteLine($"Error: {exception.Message}");
Console.ResetColor();
if (context.ParseResult.HasOption(new Option<bool>("--verbose")))
if (_verboseOption is not null && parseResult.GetValue(_verboseOption))
{
Console.Error.WriteLine(exception.StackTrace);
}
context.ExitCode = 1;
Environment.ExitCode = 1;
}
}

View File

@@ -17,28 +17,33 @@ public static class BootstrapCommands
{
var command = new Command("bootstrap", "Bootstrap a new agent with zero-touch deployment");
var nameOption = new Option<string>(
["--name", "-n"],
"Agent name")
{ IsRequired = true };
var nameOption = new Option<string>("--name", "-n")
{
Description = "Agent name",
Required = true
};
var envOption = new Option<string>(
["--env", "-e"],
() => "production",
"Target environment");
var envOption = new Option<string>("--env", "-e")
{
Description = "Target environment"
};
envOption.SetDefaultValue("production");
var platformOption = new Option<string>(
["--platform", "-p"],
"Target platform (linux, windows, docker). Auto-detected if not specified.");
var platformOption = new Option<string>("--platform", "-p")
{
Description = "Target platform (linux, windows, docker). Auto-detected if not specified."
};
var outputOption = new Option<string>(
["--output", "-o"],
"Output file for install script");
var outputOption = new Option<string>("--output", "-o")
{
Description = "Output file for install script"
};
var capabilitiesOption = new Option<string[]>(
["--capabilities", "-c"],
() => ["docker", "scripts"],
"Agent capabilities");
var capabilitiesOption = new Option<string[]>("--capabilities", "-c")
{
Description = "Agent capabilities"
};
capabilitiesOption.SetDefaultValue(["docker", "scripts"]);
command.AddOption(nameOption);
command.AddOption(envOption);
@@ -61,19 +66,22 @@ public static class BootstrapCommands
{
var command = new Command("install-script", "Generate an install script from a bootstrap token");
var tokenOption = new Option<string>(
["--token", "-t"],
"Bootstrap token")
{ IsRequired = true };
var tokenOption = new Option<string>("--token", "-t")
{
Description = "Bootstrap token",
Required = true
};
var platformOption = new Option<string>(
["--platform", "-p"],
() => DetectPlatform(),
"Target platform (linux, windows, docker)");
var platformOption = new Option<string>("--platform", "-p")
{
Description = "Target platform (linux, windows, docker)"
};
platformOption.SetDefaultValue(DetectPlatform());
var outputOption = new Option<string>(
["--output", "-o"],
"Output file path");
var outputOption = new Option<string>("--output", "-o")
{
Description = "Output file path"
};
command.AddOption(tokenOption);
command.AddOption(platformOption);
@@ -225,3 +233,5 @@ public static class BootstrapCommands
- /var/run/docker.sock:/var/run/docker.sock
""";
}

View File

@@ -16,14 +16,15 @@ public static class CertificateCommands
{
var command = new Command("renew-cert", "Renew agent mTLS certificate");
var forceOption = new Option<bool>(
["--force", "-f"],
() => false,
"Force renewal even if certificate is not near expiry");
var forceOption = new Option<bool>("--force", "-f")
{
Description = "Force renewal even if certificate is not near expiry"
};
forceOption.SetDefaultValue(false);
command.AddOption(forceOption);
command.SetHandler(async (force) =>
command.SetHandler<bool>(async (force) =>
{
await HandleRenewCertAsync(force);
}, forceOption);

View File

@@ -17,20 +17,22 @@ public static class ConfigCommands
{
var command = new Command("config", "Show agent configuration");
var diffOption = new Option<bool>(
["--diff", "-d"],
() => false,
"Show drift between current and desired configuration");
var diffOption = new Option<bool>("--diff", "-d")
{
Description = "Show drift between current and desired configuration"
};
diffOption.SetDefaultValue(false);
var formatOption = new Option<string>(
["--format"],
() => "yaml",
"Output format (yaml, json)");
var formatOption = new Option<string>("--format")
{
Description = "Output format (yaml, json)"
};
formatOption.SetDefaultValue("yaml");
command.AddOption(diffOption);
command.AddOption(formatOption);
command.SetHandler(async (diff, format) =>
command.SetHandler<bool, string>(async (diff, format) =>
{
await HandleConfigAsync(diff, format);
}, diffOption, formatOption);
@@ -45,20 +47,22 @@ public static class ConfigCommands
{
var command = new Command("apply", "Apply agent configuration");
var fileOption = new Option<string>(
["--file", "-f"],
"Configuration file path")
{ IsRequired = true };
var fileOption = new Option<string>("--file", "-f")
{
Description = "Configuration file path",
Required = true
};
var dryRunOption = new Option<bool>(
["--dry-run"],
() => false,
"Validate without applying");
var dryRunOption = new Option<bool>("--dry-run")
{
Description = "Validate without applying"
};
dryRunOption.SetDefaultValue(false);
command.AddOption(fileOption);
command.AddOption(dryRunOption);
command.SetHandler(async (file, dryRun) =>
command.SetHandler<string, bool>(async (file, dryRun) =>
{
await HandleApplyAsync(file, dryRun);
}, fileOption, dryRunOption);

View File

@@ -17,30 +17,34 @@ public static class DoctorCommands
{
var command = new Command("doctor", "Run agent health diagnostics");
var agentIdOption = new Option<string?>(
["--agent-id", "-a"],
"Run diagnostics on a remote agent (omit for local)");
var agentIdOption = new Option<string?>("--agent-id", "-a")
{
Description = "Run diagnostics on a remote agent (omit for local)"
};
var categoryOption = new Option<string?>(
["--category", "-c"],
"Filter by category (security, network, runtime, resources, configuration)");
var categoryOption = new Option<string?>("--category", "-c")
{
Description = "Filter by category (security, network, runtime, resources, configuration)"
};
var fixOption = new Option<bool>(
["--fix", "-f"],
() => false,
"Apply automated fixes for detected issues");
var fixOption = new Option<bool>("--fix", "-f")
{
Description = "Apply automated fixes for detected issues"
};
fixOption.SetDefaultValue(false);
var formatOption = new Option<string>(
["--format"],
() => "table",
"Output format (table, json, yaml)");
var formatOption = new Option<string>("--format")
{
Description = "Output format (table, json, yaml)"
};
formatOption.SetDefaultValue("table");
command.AddOption(agentIdOption);
command.AddOption(categoryOption);
command.AddOption(fixOption);
command.AddOption(formatOption);
command.SetHandler(async (agentId, category, fix, format) =>
command.SetHandler<string?, string?, bool, string>(async (agentId, category, fix, format) =>
{
await HandleDoctorAsync(agentId, category, fix, format);
}, agentIdOption, categoryOption, fixOption, formatOption);

View File

@@ -16,25 +16,28 @@ public static class UpdateCommands
{
var command = new Command("update", "Check and apply agent updates");
var versionOption = new Option<string?>(
["--version", "-v"],
"Update to a specific version");
var versionOption = new Option<string?>("--version", "-v")
{
Description = "Update to a specific version"
};
var checkOption = new Option<bool>(
["--check", "-c"],
() => false,
"Check for updates without applying");
var checkOption = new Option<bool>("--check", "-c")
{
Description = "Check for updates without applying"
};
checkOption.SetDefaultValue(false);
var forceOption = new Option<bool>(
["--force", "-f"],
() => false,
"Force update even outside maintenance window");
var forceOption = new Option<bool>("--force", "-f")
{
Description = "Force update even outside maintenance window"
};
forceOption.SetDefaultValue(false);
command.AddOption(versionOption);
command.AddOption(checkOption);
command.AddOption(forceOption);
command.SetHandler(async (version, check, force) =>
command.SetHandler<string?, bool, bool>(async (version, check, force) =>
{
await HandleUpdateAsync(version, check, force);
}, versionOption, checkOption, forceOption);

File diff suppressed because it is too large Load Diff

View File

@@ -702,7 +702,7 @@ public static class AttestCommandGroup
}
else
{
checks.Add(new OfflineVerificationCheck("Rekor inclusion proof", true, "Skipped (not present)", optional: true));
checks.Add(new OfflineVerificationCheck("Rekor inclusion proof", true, "Skipped (not present)", Optional: true));
}
// Check 4: Validate content hash matches
@@ -714,7 +714,7 @@ public static class AttestCommandGroup
}
else
{
checks.Add(new OfflineVerificationCheck("Content hash", true, "Skipped (no metadata.json)", optional: true));
checks.Add(new OfflineVerificationCheck("Content hash", true, "Skipped (no metadata.json)", Optional: true));
}
// Determine overall status

View File

@@ -459,9 +459,9 @@ internal static class AuditCommandGroup
decision = "BLOCKED",
gates = new[]
{
new { name = "SbomPresent", result = "PASS" },
new { name = "VulnScan", result = "PASS" },
new { name = "VexTrust", result = "FAIL", reason = "Trust score below threshold" }
new { name = "SbomPresent", result = "PASS", reason = (string?)null },
new { name = "VulnScan", result = "PASS", reason = (string?)null },
new { name = "VexTrust", result = "FAIL", reason = (string?)"Trust score below threshold" }
}
};
@@ -547,9 +547,9 @@ internal static class AuditCommandGroup
overallResult = "FAIL",
gateResults = new[]
{
new { gate = "SbomPresent", result = "PASS", durationMs = 15 },
new { gate = "VulnScan", result = "PASS", durationMs = 250 },
new { gate = "VexTrust", result = "FAIL", durationMs = 45, reason = "Trust score 0.45 < 0.70" }
new { gate = "SbomPresent", result = "PASS", durationMs = 15, reason = (string?)null },
new { gate = "VulnScan", result = "PASS", durationMs = 250, reason = (string?)null },
new { gate = "VexTrust", result = "FAIL", durationMs = 45, reason = (string?)"Trust score 0.45 < 0.70" }
}
};
await File.WriteAllTextAsync(

View File

@@ -50,7 +50,7 @@ internal static class BenchCommandBuilder
{
var corpusOption = new Option<string>("--corpus", "Path to corpus.json index file")
{
IsRequired = true
Required = true
};
var outputOption = new Option<string?>("--output", "Output path for results JSON");
var categoryOption = new Option<string[]?>("--category", "Filter to specific categories");
@@ -157,11 +157,11 @@ internal static class BenchCommandBuilder
{
var resultsOption = new Option<string>("--results", "Path to benchmark results JSON")
{
IsRequired = true
Required = true
};
var baselineOption = new Option<string>("--baseline", "Path to baseline JSON")
{
IsRequired = true
Required = true
};
var strictOption = new Option<bool>("--strict", () => false, "Fail on any metric degradation");
var outputOption = new Option<string?>("--output", "Output path for regression report");
@@ -249,11 +249,11 @@ internal static class BenchCommandBuilder
// baseline update
var resultsOption = new Option<string>("--results", "Path to benchmark results JSON")
{
IsRequired = true
Required = true
};
var outputOption = new Option<string>("--output", "Output path for new baseline")
{
IsRequired = true
Required = true
};
var noteOption = new Option<string?>("--note", "Note explaining the baseline update");
@@ -305,7 +305,7 @@ internal static class BenchCommandBuilder
// baseline show
var baselinePathOption = new Option<string>("--path", "Path to baseline JSON")
{
IsRequired = true
Required = true
};
var show = new Command("show", "Display baseline metrics");
@@ -359,7 +359,7 @@ internal static class BenchCommandBuilder
{
var resultsOption = new Option<string>("--results", "Path to benchmark results JSON")
{
IsRequired = true
Required = true
};
var formatOption = new Option<string>("--format", () => "markdown", "Output format: markdown, html");
var outputOption = new Option<string?>("--output", "Output path for report");
@@ -473,3 +473,4 @@ internal static class BenchCommandBuilder
return sb.ToString();
}
}

View File

@@ -552,7 +552,7 @@ internal static class DeltaSigCommandGroup
}
else
{
await console.WriteLineAsync($"✗ Verification FAILED: {result.FailureReason}");
await console.WriteLineAsync($"✗ Verification FAILED: {result.Message ?? "Unknown failure"}");
Environment.ExitCode = 1;
}
}

View File

@@ -39,7 +39,7 @@ public static class BundleExportCommand
var imageOption = new Option<string>("--image", "-i")
{
Description = "Image reference (registry/repo@sha256:...)",
IsRequired = true
Required = true
};
var outputOption = new Option<string>("--output", "-o")

View File

@@ -45,7 +45,7 @@ public static class BundleVerifyCommand
var bundleOption = new Option<string>("--bundle", "-b")
{
Description = "Path to bundle (tar.gz or directory)",
IsRequired = true
Required = true
};
var trustRootOption = new Option<string?>("--trust-root")
@@ -267,10 +267,9 @@ public static class BundleVerifyCommand
using var reader = new StreamReader(gz);
// Simple extraction (matches our simple tar format)
while (!reader.EndOfStream)
string? line;
while ((line = await reader.ReadLineAsync(ct)) != null)
{
var line = await reader.ReadLineAsync(ct);
if (line == null) break;
if (line.StartsWith("FILE:"))
{
@@ -288,7 +287,7 @@ public static class BundleVerifyCommand
}
var buffer = new char[size];
await reader.ReadBlockAsync(buffer, 0, size, ct);
await reader.ReadBlockAsync(buffer, 0, size);
await File.WriteAllTextAsync(fullPath, new string(buffer), ct);
}
}
@@ -472,9 +471,10 @@ public static class BundleVerifyCommand
// Check that required payload types are present
var present = manifest?.Bundle?.Artifacts?
.Where(a => !string.IsNullOrEmpty(a.MediaType))
.Select(a => a.MediaType)
.ToHashSet() ?? [];
.Where(mediaType => !string.IsNullOrWhiteSpace(mediaType))
.Select(mediaType => mediaType!)
.ToHashSet(StringComparer.OrdinalIgnoreCase) ?? [];
var missing = expected.Where(e => !present.Any(p =>
p.Contains(e.Split(';')[0], StringComparison.OrdinalIgnoreCase))).ToList();

View File

@@ -61,7 +61,7 @@ public static class CheckpointCommands
var outputOption = new Option<string>("--output", "-o")
{
Description = "Output path for checkpoint bundle",
IsRequired = true
Required = true
};
var includeTilesOption = new Option<bool>("--include-tiles")
@@ -109,7 +109,7 @@ public static class CheckpointCommands
var inputOption = new Option<string>("--input", "-i")
{
Description = "Path to checkpoint bundle",
IsRequired = true
Required = true
};
var verifySignatureOption = new Option<bool>("--verify-signature")

View File

@@ -82,7 +82,6 @@ internal static class CommandFactory
root.Add(BuildExportCommand(services, verboseOption, cancellationToken));
root.Add(BuildAttestCommand(services, verboseOption, cancellationToken));
root.Add(BundleCommandGroup.BuildBundleCommand(services, verboseOption, cancellationToken));
root.Add(TimestampCommandGroup.BuildTimestampCommand(verboseOption, cancellationToken));
root.Add(BuildRiskProfileCommand(verboseOption, cancellationToken));
root.Add(BuildAdvisoryCommand(services, verboseOption, cancellationToken));
root.Add(BuildForensicCommand(services, verboseOption, cancellationToken));
@@ -93,11 +92,12 @@ internal static class CommandFactory
root.Add(BuildExceptionsCommand(services, verboseOption, cancellationToken));
root.Add(BuildOrchCommand(services, verboseOption, cancellationToken));
root.Add(BuildSbomCommand(services, verboseOption, cancellationToken));
root.Add(LicenseCommandGroup.BuildLicenseCommand(verboseOption, cancellationToken)); // Sprint: SPRINT_20260119_024 - License detection
root.Add(AnalyticsCommandGroup.BuildAnalyticsCommand(services, verboseOption, cancellationToken));
root.Add(BuildNotifyCommand(services, verboseOption, cancellationToken));
root.Add(BuildSbomerCommand(services, verboseOption, cancellationToken));
root.Add(BuildCvssCommand(services, verboseOption, cancellationToken));
root.Add(BuildRiskCommand(services, verboseOption, cancellationToken));
root.Add(BuildReachabilityCommand(services, verboseOption, cancellationToken));
root.Add(BuildGraphCommand(services, verboseOption, cancellationToken));
root.Add(DeltaSigCommandGroup.BuildDeltaSigCommand(services, verboseOption, cancellationToken)); // Sprint: SPRINT_20260102_001_BE - Delta signatures
root.Add(Binary.BinaryCommandGroup.BuildBinaryCommand(services, verboseOption, cancellationToken)); // Sprint: SPRINT_3850_0001_0001
@@ -105,6 +105,10 @@ internal static class CommandFactory
root.Add(BuildSdkCommand(services, verboseOption, cancellationToken));
root.Add(BuildMirrorCommand(services, verboseOption, cancellationToken));
root.Add(BuildAirgapCommand(services, verboseOption, cancellationToken));
root.Add(TrustProfileCommandGroup.BuildTrustProfileCommand(
services,
verboseOption,
cancellationToken));
root.Add(OfflineCommandGroup.BuildOfflineCommand(services, verboseOption, cancellationToken));
root.Add(VerifyCommandGroup.BuildVerifyCommand(services, verboseOption, cancellationToken));
root.Add(BuildDevPortalCommand(services, verboseOption, cancellationToken));
@@ -276,7 +280,7 @@ internal static class CommandFactory
var countOption = new Option<int>("--count", "-c")
{
Description = "Number of scanner workers",
IsRequired = true
Required = true
};
var poolOption = new Option<string?>("--pool")
{
@@ -490,6 +494,50 @@ internal static class CommandFactory
{
Description = "Override scanner worker count for this run"
};
var serviceAnalysisOption = new Option<bool>("--service-analysis")
{
Description = "Enable service endpoint security analysis."
};
var cryptoAnalysisOption = new Option<bool>("--crypto-analysis")
{
Description = "Enable CBOM cryptographic analysis."
};
var cryptoPolicyOption = new Option<string?>("--crypto-policy")
{
Description = "Path to crypto policy file (YAML/JSON)."
};
var fipsModeOption = new Option<bool>("--fips-mode")
{
Description = "Force FIPS compliance checks for crypto analysis."
};
var pqcAnalysisOption = new Option<bool>("--pqc-analysis")
{
Description = "Enable post-quantum crypto analysis."
};
var aiGovernancePolicyOption = new Option<string?>("--ai-governance-policy")
{
Description = "Path to AI governance policy file (YAML/JSON)."
};
var aiRiskAssessmentOption = new Option<bool>("--ai-risk-assessment")
{
Description = "Require AI risk assessments during AI/ML analysis."
};
var skipAiAnalysisOption = new Option<bool>("--skip-ai-analysis")
{
Description = "Skip AI/ML supply chain analysis."
};
var verifyProvenanceOption = new Option<bool>("--verify-provenance")
{
Description = "Enable build provenance verification."
};
var slsaPolicyOption = new Option<string?>("--slsa-policy")
{
Description = "Path to build provenance (SLSA) policy file (YAML/JSON)."
};
var verifyReproducibilityOption = new Option<bool>("--verify-reproducibility")
{
Description = "Trigger reproducibility verification (rebuild) when possible."
};
var argsArgument = new Argument<string[]>("scanner-args")
{
@@ -500,21 +548,44 @@ internal static class CommandFactory
run.Add(entryOption);
run.Add(targetOption);
run.Add(workersOption);
run.Add(serviceAnalysisOption);
run.Add(cryptoAnalysisOption);
run.Add(cryptoPolicyOption);
run.Add(fipsModeOption);
run.Add(pqcAnalysisOption);
run.Add(aiGovernancePolicyOption);
run.Add(aiRiskAssessmentOption);
run.Add(skipAiAnalysisOption);
run.Add(verifyProvenanceOption);
run.Add(slsaPolicyOption);
run.Add(verifyReproducibilityOption);
run.Add(argsArgument);
run.SetAction((parseResult, _) =>
run.SetAction(async (parseResult, _) =>
{
var runner = parseResult.GetValue(runnerOption) ?? options.DefaultRunner;
var entry = parseResult.GetValue(entryOption) ?? string.Empty;
var target = parseResult.GetValue(targetOption) ?? string.Empty;
var forwardedArgs = parseResult.GetValue(argsArgument) ?? Array.Empty<string>();
var workers = parseResult.GetValue(workersOption);
var serviceAnalysis = parseResult.GetValue(serviceAnalysisOption);
var cryptoAnalysis = parseResult.GetValue(cryptoAnalysisOption);
var cryptoPolicy = parseResult.GetValue(cryptoPolicyOption);
var fipsMode = parseResult.GetValue(fipsModeOption);
var pqcAnalysis = parseResult.GetValue(pqcAnalysisOption);
var aiGovernancePolicy = parseResult.GetValue(aiGovernancePolicyOption);
var aiRiskAssessment = parseResult.GetValue(aiRiskAssessmentOption);
var skipAiAnalysis = parseResult.GetValue(skipAiAnalysisOption);
var verifyProvenance = parseResult.GetValue(verifyProvenanceOption);
var slsaPolicy = parseResult.GetValue(slsaPolicyOption);
var verifyReproducibility = parseResult.GetValue(verifyReproducibilityOption);
var verbose = parseResult.GetValue(verboseOption);
if (workers.HasValue && workers.Value <= 0)
{
Console.Error.WriteLine("--workers must be greater than zero.");
return 1;
Environment.ExitCode = 1;
return;
}
var effectiveArgs = new List<string>(forwardedArgs);
@@ -533,7 +604,78 @@ internal static class CommandFactory
}
}
return CommandHandlers.HandleScannerRunAsync(services, runner, entry, target, effectiveArgs, verbose, cancellationToken);
if (serviceAnalysis)
{
effectiveArgs.Add("--Scanner:Worker:ServiceSecurity:Enabled");
effectiveArgs.Add("true");
}
if (cryptoAnalysis || !string.IsNullOrWhiteSpace(cryptoPolicy) || fipsMode || pqcAnalysis)
{
effectiveArgs.Add("--Scanner:Worker:CryptoAnalysis:Enabled");
effectiveArgs.Add("true");
}
if (!string.IsNullOrWhiteSpace(cryptoPolicy))
{
effectiveArgs.Add("--Scanner:Worker:CryptoAnalysis:PolicyPath");
effectiveArgs.Add(cryptoPolicy);
}
if (fipsMode)
{
effectiveArgs.Add("--Scanner:Worker:CryptoAnalysis:RequireFips");
effectiveArgs.Add("true");
}
if (pqcAnalysis)
{
effectiveArgs.Add("--Scanner:Worker:CryptoAnalysis:EnablePostQuantumAnalysis");
effectiveArgs.Add("true");
}
if (skipAiAnalysis)
{
effectiveArgs.Add("--Scanner:Worker:AiMlSecurity:Enabled");
effectiveArgs.Add("false");
}
else if (!string.IsNullOrWhiteSpace(aiGovernancePolicy) || aiRiskAssessment)
{
effectiveArgs.Add("--Scanner:Worker:AiMlSecurity:Enabled");
effectiveArgs.Add("true");
}
if (!string.IsNullOrWhiteSpace(aiGovernancePolicy))
{
effectiveArgs.Add("--Scanner:Worker:AiMlSecurity:PolicyPath");
effectiveArgs.Add(aiGovernancePolicy);
}
if (aiRiskAssessment)
{
effectiveArgs.Add("--Scanner:Worker:AiMlSecurity:RequireRiskAssessment");
effectiveArgs.Add("true");
}
if (verifyProvenance || !string.IsNullOrWhiteSpace(slsaPolicy) || verifyReproducibility)
{
effectiveArgs.Add("--Scanner:Worker:BuildProvenance:Enabled");
effectiveArgs.Add("true");
}
if (!string.IsNullOrWhiteSpace(slsaPolicy))
{
effectiveArgs.Add("--Scanner:Worker:BuildProvenance:PolicyPath");
effectiveArgs.Add(slsaPolicy);
}
if (verifyReproducibility)
{
effectiveArgs.Add("--Scanner:Worker:BuildProvenance:VerifyReproducibility");
effectiveArgs.Add("true");
}
await CommandHandlers.HandleScannerRunAsync(services, runner, entry, target, effectiveArgs, verbose, cancellationToken);
});
var upload = new Command("upload", "Upload completed scan results to the backend.");
@@ -1470,12 +1612,12 @@ internal static class CommandFactory
var typeOption = new Option<string>("--type")
{
Description = "Key type (rsa, ecdsa, eddsa)",
IsRequired = true
Required = true
};
var nameOption = new Option<string>("--name")
{
Description = "Key name",
IsRequired = true
Required = true
};
var create = new Command("create", "Create a new issuer key")
@@ -3288,81 +3430,6 @@ internal static class CommandFactory
policy.Add(publish);
// CLI-POLICY-27-004: promote command
var promote = new Command("promote", "Promote a policy to a target environment.");
var promotePolicyIdArg = new Argument<string>("policy-id")
{
Description = "Policy identifier."
};
var promoteVersionOption = new Option<int>("--version")
{
Description = "Version to promote.",
Required = true
};
var promoteEnvOption = new Option<string>("--env")
{
Description = "Target environment (e.g. staging, production).",
Required = true
};
var promoteCanaryOption = new Option<bool>("--canary")
{
Description = "Enable canary deployment."
};
var promoteCanaryPercentOption = new Option<int?>("--canary-percent")
{
Description = "Canary traffic percentage (1-99)."
};
var promoteNoteOption = new Option<string?>("--note")
{
Description = "Promotion note."
};
var promoteTenantOption = new Option<string?>("--tenant")
{
Description = "Tenant context."
};
var promoteJsonOption = new Option<bool>("--json")
{
Description = "Output as JSON."
};
promote.Add(promotePolicyIdArg);
promote.Add(promoteVersionOption);
promote.Add(promoteEnvOption);
promote.Add(promoteCanaryOption);
promote.Add(promoteCanaryPercentOption);
promote.Add(promoteNoteOption);
promote.Add(promoteTenantOption);
promote.Add(promoteJsonOption);
promote.Add(verboseOption);
promote.SetAction((parseResult, _) =>
{
var policyId = parseResult.GetValue(promotePolicyIdArg) ?? string.Empty;
var version = parseResult.GetValue(promoteVersionOption);
var env = parseResult.GetValue(promoteEnvOption) ?? string.Empty;
var canary = parseResult.GetValue(promoteCanaryOption);
var canaryPercent = parseResult.GetValue(promoteCanaryPercentOption);
var note = parseResult.GetValue(promoteNoteOption);
var tenant = parseResult.GetValue(promoteTenantOption);
var json = parseResult.GetValue(promoteJsonOption);
var verbose = parseResult.GetValue(verboseOption);
return CommandHandlers.HandlePolicyPromoteAsync(
services,
policyId,
version,
env,
canary,
canaryPercent,
note,
tenant,
json,
verbose,
cancellationToken);
});
policy.Add(promote);
// CLI-POLICY-27-004: rollback command
var rollback = new Command("rollback", "Rollback a policy to a previous version.");
var rollbackPolicyIdArg = new Argument<string>("policy-id")
@@ -3692,9 +3759,11 @@ flowchart TB
DateTimeOffset? from = null;
DateTimeOffset? to = null;
DateTimeOffset fromParsed = default;
DateTimeOffset toParsed = default;
if (!string.IsNullOrEmpty(fromText) &&
!DateTimeOffset.TryParse(fromText, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var fromParsed))
!DateTimeOffset.TryParse(fromText, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out fromParsed))
{
Console.Error.WriteLine("Invalid --from value. Use ISO-8601 UTC timestamps.");
return 1;
@@ -3705,7 +3774,7 @@ flowchart TB
}
if (!string.IsNullOrEmpty(toText) &&
!DateTimeOffset.TryParse(toText, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var toParsed))
!DateTimeOffset.TryParse(toText, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out toParsed))
{
Console.Error.WriteLine("Invalid --to value. Use ISO-8601 UTC timestamps.");
return 1;

View File

@@ -9,14 +9,14 @@ using StellaOps.Cli.Services;
namespace StellaOps.Cli.Commands;
public static partial class CommandHandlers
internal static partial class CommandHandlers
{
public static class Config
internal static class Config
{
/// <summary>
/// Lists all available configuration paths.
/// </summary>
public static Task<int> ListAsync(string? category)
internal static Task<int> ListAsync(string? category)
{
var catalog = ConfigCatalog.GetAll();
@@ -75,7 +75,7 @@ public static partial class CommandHandlers
/// <summary>
/// Shows configuration for a specific path.
/// </summary>
public static async Task<int> ShowAsync(
internal static async Task<int> ShowAsync(
IBackendOperationsClient client,
string path,
string format,

View File

@@ -1088,7 +1088,7 @@ internal static partial class CommandHandlers
}
}
public static async Task HandleAdviseRunAsync(
internal static async Task HandleAdviseRunAsync(
IServiceProvider services,
AdvisoryAiTaskType taskType,
string advisoryKey,
@@ -1244,7 +1244,7 @@ internal static partial class CommandHandlers
}
}
public static async Task HandleAdviseBatchAsync(
internal static async Task HandleAdviseBatchAsync(
IServiceProvider services,
AdvisoryAiTaskType taskType,
IReadOnlyList<string> advisoryKeys,

View File

@@ -11,7 +11,7 @@ namespace StellaOps.Cli.Commands;
/// <summary>
/// CLI commands for inspecting StellaOps configuration.
/// </summary>
public static class ConfigCommandGroup
internal static class ConfigCommandGroup
{
public static Command Create(IBackendOperationsClient client)
{
@@ -19,26 +19,32 @@ public static class ConfigCommandGroup
// stella config list
var listCommand = new Command("list", "List all available configuration paths");
var categoryOption = new Option<string?>(
["--category", "-c"],
"Filter by category (e.g., policy, scanner, notifier)");
var categoryOption = new Option<string?>("--category", "-c")
{
Description = "Filter by category (e.g., policy, scanner, notifier)"
};
listCommand.AddOption(categoryOption);
listCommand.SetHandler(
async (string? category) => await CommandHandlers.Config.ListAsync(category),
categoryOption);
// stella config <path> show
var pathArgument = new Argument<string>("path", "Configuration path (e.g., policy.determinization, scanner.epss)");
var pathArgument = new Argument<string>("path")
{
Description = "Configuration path (e.g., policy.determinization, scanner.epss)"
};
var showCommand = new Command("show", "Show configuration for a specific path");
showCommand.AddArgument(pathArgument);
var formatOption = new Option<string>(
["--format", "-f"],
() => "table",
"Output format: table, json, yaml");
var showSecretsOption = new Option<bool>(
"--show-secrets",
() => false,
"Show secret values (default: redacted)");
var formatOption = new Option<string>("--format", "-f")
{
Description = "Output format: table, json, yaml"
};
formatOption.SetDefaultValue("table");
var showSecretsOption = new Option<bool>("--show-secrets")
{
Description = "Show secret values (default: redacted)"
};
showSecretsOption.SetDefaultValue(false);
showCommand.AddOption(formatOption);
showCommand.AddOption(showSecretsOption);
showCommand.SetHandler(

View File

@@ -483,20 +483,32 @@ internal static class DeltaSigCommandHandlers
// Load signatures
var signatures = await LoadSignaturesAsync(sigpackPath, cveFilter, ct);
if (semantic)
{
var semanticSignatures = signatures
.Where(s => s.Symbols.Any(sym => !string.IsNullOrWhiteSpace(sym.SemanticHashHex)))
.ToList();
if (semanticSignatures.Count > 0)
{
signatures = semanticSignatures;
}
}
if (verbose)
{
AnsiConsole.MarkupLine($"[dim]Loaded {signatures.Count} signatures[/]");
if (semantic)
{
var withSemantic = signatures.Count(s => s.SemanticFingerprint != null);
var withSemantic = signatures.Count(s =>
s.Symbols.Any(sym => !string.IsNullOrWhiteSpace(sym.SemanticHashHex)));
AnsiConsole.MarkupLine($"[dim]Signatures with semantic fingerprints: {withSemantic}[/]");
}
}
// Match with semantic preference
var matchOptions = new MatchOptions(PreferSemantic: semantic);
// Match signatures
using var binaryStream = new MemoryStream(binaryBytes);
var results = await matcher.MatchAsync(binaryStream, signatures, cveFilter, matchOptions, ct);
var results = await matcher.MatchAsync(binaryStream, signatures, cveFilter, ct);
// Output results
var matchedResults = results.Where(r => r.Matched).ToList();

View File

@@ -1820,26 +1820,31 @@ public static class EvidenceCommandGroup
Option<bool> verboseOption,
CancellationToken cancellationToken)
{
var dryRunOption = new Option<bool>(
aliases: ["--dry-run", "-n"],
description: "Perform a dry run without making changes, showing impact assessment");
var dryRunOption = new Option<bool>("--dry-run", "-n")
{
Description = "Perform a dry run without making changes, showing impact assessment"
};
var sinceOption = new Option<DateTimeOffset?>(
aliases: ["--since", "-s"],
description: "Only reindex evidence created after this date (ISO 8601 format)");
var sinceOption = new Option<DateTimeOffset?>("--since", "-s")
{
Description = "Only reindex evidence created after this date (ISO 8601 format)"
};
var batchSizeOption = new Option<int>(
aliases: ["--batch-size", "-b"],
getDefaultValue: () => 100,
description: "Number of evidence records to process per batch");
var batchSizeOption = new Option<int>("--batch-size", "-b")
{
Description = "Number of evidence records to process per batch"
};
batchSizeOption.SetDefaultValue(100);
var outputOption = new Option<string?>(
aliases: ["--output", "-o"],
description: "Output file for dry-run report (JSON format)");
var outputOption = new Option<string?>("--output", "-o")
{
Description = "Output file for dry-run report (JSON format)"
};
var serverOption = new Option<string?>(
aliases: ["--server"],
description: "Evidence Locker server URL (default: from config)");
var serverOption = new Option<string?>("--server")
{
Description = "Evidence Locker server URL (default: from config)"
};
var cmd = new Command("reindex", "Re-index evidence bundles after schema or algorithm changes")
{
@@ -1851,7 +1856,7 @@ public static class EvidenceCommandGroup
verboseOption
};
cmd.SetHandler(async (dryRun, since, batchSize, output, server, verbose) =>
cmd.SetHandler<bool, DateTimeOffset?, int, string?, string?, bool>(async (dryRun, since, batchSize, output, server, verbose) =>
{
var logger = services.GetRequiredService<ILoggerFactory>().CreateLogger("EvidenceReindex");
@@ -1864,7 +1869,13 @@ public static class EvidenceCommandGroup
AnsiConsole.WriteLine();
}
var serverUrl = server ?? options.EvidenceLockerUrl ?? "http://localhost:5080";
var serverUrl = !string.IsNullOrWhiteSpace(server)
? server
: !string.IsNullOrWhiteSpace(options.BackendUrl)
? options.BackendUrl
: Environment.GetEnvironmentVariable("STELLAOPS_EVIDENCE_URL")
?? Environment.GetEnvironmentVariable("STELLAOPS_BACKEND_URL")
?? "http://localhost:5080";
// Show configuration
var configTable = new Table()
@@ -1982,26 +1993,33 @@ public static class EvidenceCommandGroup
Option<bool> verboseOption,
CancellationToken cancellationToken)
{
var oldRootOption = new Option<string>(
aliases: ["--old-root"],
description: "Previous Merkle root hash (sha256:...)") { IsRequired = true };
var oldRootOption = new Option<string>("--old-root")
{
Description = "Previous Merkle root hash (sha256:...)",
Required = true
};
var newRootOption = new Option<string>(
aliases: ["--new-root"],
description: "New Merkle root hash after reindex (sha256:...)") { IsRequired = true };
var newRootOption = new Option<string>("--new-root")
{
Description = "New Merkle root hash after reindex (sha256:...)",
Required = true
};
var outputOption = new Option<string?>(
aliases: ["--output", "-o"],
description: "Output file for verification report");
var outputOption = new Option<string?>("--output", "-o")
{
Description = "Output file for verification report"
};
var formatOption = new Option<string>(
aliases: ["--format", "-f"],
getDefaultValue: () => "json",
description: "Report format: json, html, or text");
var formatOption = new Option<string>("--format", "-f")
{
Description = "Report format: json, html, or text"
};
formatOption.SetDefaultValue("json");
var serverOption = new Option<string?>(
aliases: ["--server"],
description: "Evidence Locker server URL (default: from config)");
var serverOption = new Option<string?>("--server")
{
Description = "Evidence Locker server URL (default: from config)"
};
var cmd = new Command("verify-continuity", "Verify chain-of-custody after evidence reindex or upgrade")
{
@@ -2013,14 +2031,20 @@ public static class EvidenceCommandGroup
verboseOption
};
cmd.SetHandler(async (oldRoot, newRoot, output, format, server, verbose) =>
cmd.SetHandler<string, string, string?, string, string?, bool>(async (oldRoot, newRoot, output, format, server, verbose) =>
{
var logger = services.GetRequiredService<ILoggerFactory>().CreateLogger("EvidenceContinuity");
AnsiConsole.MarkupLine("[bold blue]Evidence Continuity Verification[/]");
AnsiConsole.WriteLine();
var serverUrl = server ?? options.EvidenceLockerUrl ?? "http://localhost:5080";
var serverUrl = !string.IsNullOrWhiteSpace(server)
? server
: !string.IsNullOrWhiteSpace(options.BackendUrl)
? options.BackendUrl
: Environment.GetEnvironmentVariable("STELLAOPS_EVIDENCE_URL")
?? Environment.GetEnvironmentVariable("STELLAOPS_BACKEND_URL")
?? "http://localhost:5080";
AnsiConsole.MarkupLine($"Old Root: [cyan]{oldRoot}[/]");
AnsiConsole.MarkupLine($"New Root: [cyan]{newRoot}[/]");
@@ -2136,25 +2160,31 @@ public static class EvidenceCommandGroup
Option<bool> verboseOption,
CancellationToken cancellationToken)
{
var fromVersionOption = new Option<string>(
aliases: ["--from-version"],
description: "Source schema version") { IsRequired = true };
var fromVersionOption = new Option<string>("--from-version")
{
Description = "Source schema version",
Required = true
};
var toVersionOption = new Option<string?>(
aliases: ["--to-version"],
description: "Target schema version (default: latest)");
var toVersionOption = new Option<string?>("--to-version")
{
Description = "Target schema version (default: latest)"
};
var dryRunOption = new Option<bool>(
aliases: ["--dry-run", "-n"],
description: "Show migration plan without executing");
var dryRunOption = new Option<bool>("--dry-run", "-n")
{
Description = "Show migration plan without executing"
};
var rollbackOption = new Option<bool>(
aliases: ["--rollback"],
description: "Roll back a previously failed migration");
var rollbackOption = new Option<bool>("--rollback")
{
Description = "Roll back a previously failed migration"
};
var serverOption = new Option<string?>(
aliases: ["--server"],
description: "Evidence Locker server URL (default: from config)");
var serverOption = new Option<string?>("--server")
{
Description = "Evidence Locker server URL (default: from config)"
};
var cmd = new Command("migrate", "Migrate evidence schema between versions")
{
@@ -2166,14 +2196,20 @@ public static class EvidenceCommandGroup
verboseOption
};
cmd.SetHandler(async (fromVersion, toVersion, dryRun, rollback, server, verbose) =>
cmd.SetHandler<string, string?, bool, bool, string?, bool>(async (fromVersion, toVersion, dryRun, rollback, server, verbose) =>
{
var logger = services.GetRequiredService<ILoggerFactory>().CreateLogger("EvidenceMigrate");
AnsiConsole.MarkupLine("[bold blue]Evidence Schema Migration[/]");
AnsiConsole.WriteLine();
var serverUrl = server ?? options.EvidenceLockerUrl ?? "http://localhost:5080";
var serverUrl = !string.IsNullOrWhiteSpace(server)
? server
: !string.IsNullOrWhiteSpace(options.BackendUrl)
? options.BackendUrl
: Environment.GetEnvironmentVariable("STELLAOPS_EVIDENCE_URL")
?? Environment.GetEnvironmentVariable("STELLAOPS_BACKEND_URL")
?? "http://localhost:5080";
if (rollback)
{

File diff suppressed because it is too large Load Diff

View File

@@ -39,16 +39,28 @@ public static class IrCommandGroup
{
var command = new Command("lift", "Lift a binary to intermediate representation");
var inOption = new Option<FileInfo>("--in", "Input binary file path") { IsRequired = true };
inOption.AddAlias("-i");
var inOption = new Option<FileInfo>("--in", new[] { "-i" })
{
Description = "Input binary file path",
Required = true
};
var outOption = new Option<DirectoryInfo>("--out", "Output directory for IR cache") { IsRequired = true };
outOption.AddAlias("-o");
var outOption = new Option<DirectoryInfo>("--out", new[] { "-o" })
{
Description = "Output directory for IR cache",
Required = true
};
var archOption = new Option<string?>("--arch", "Architecture override (x86-64, arm64, arm32, auto)");
var archOption = new Option<string?>("--arch")
{
Description = "Architecture override (x86-64, arm64, arm32, auto)"
};
archOption.SetDefaultValue("auto");
var formatOption = new Option<string>("--format", "Output format (json, binary)");
var formatOption = new Option<string>("--format")
{
Description = "Output format (json, binary)"
};
formatOption.SetDefaultValue("json");
command.AddOption(inOption);
@@ -56,7 +68,7 @@ public static class IrCommandGroup
command.AddOption(archOption);
command.AddOption(formatOption);
command.SetHandler(HandleLiftAsync, inOption, outOption, archOption, formatOption);
command.SetHandler<FileInfo, DirectoryInfo, string?, string>(HandleLiftAsync, inOption, outOption, archOption, formatOption);
return command;
}
@@ -68,20 +80,29 @@ public static class IrCommandGroup
{
var command = new Command("canon", "Canonicalize IR with SSA transformation and CFG ordering");
var inOption = new Option<DirectoryInfo>("--in", "Input IR cache directory") { IsRequired = true };
inOption.AddAlias("-i");
var inOption = new Option<DirectoryInfo>("--in", new[] { "-i" })
{
Description = "Input IR cache directory",
Required = true
};
var outOption = new Option<DirectoryInfo>("--out", "Output directory for canonicalized IR") { IsRequired = true };
outOption.AddAlias("-o");
var outOption = new Option<DirectoryInfo>("--out", new[] { "-o" })
{
Description = "Output directory for canonicalized IR",
Required = true
};
var recipeOption = new Option<string?>("--recipe", "Normalization recipe version");
var recipeOption = new Option<string?>("--recipe")
{
Description = "Normalization recipe version"
};
recipeOption.SetDefaultValue("v1");
command.AddOption(inOption);
command.AddOption(outOption);
command.AddOption(recipeOption);
command.SetHandler(HandleCanonAsync, inOption, outOption, recipeOption);
command.SetHandler<DirectoryInfo, DirectoryInfo, string?>(HandleCanonAsync, inOption, outOption, recipeOption);
return command;
}
@@ -93,16 +114,28 @@ public static class IrCommandGroup
{
var command = new Command("fp", "Generate semantic fingerprints using Weisfeiler-Lehman hashing");
var inOption = new Option<DirectoryInfo>("--in", "Input canonicalized IR directory") { IsRequired = true };
inOption.AddAlias("-i");
var inOption = new Option<DirectoryInfo>("--in", new[] { "-i" })
{
Description = "Input canonicalized IR directory",
Required = true
};
var outOption = new Option<FileInfo>("--out", "Output fingerprint file path") { IsRequired = true };
outOption.AddAlias("-o");
var outOption = new Option<FileInfo>("--out", new[] { "-o" })
{
Description = "Output fingerprint file path",
Required = true
};
var iterationsOption = new Option<int>("--iterations", "Number of WL iterations");
var iterationsOption = new Option<int>("--iterations")
{
Description = "Number of WL iterations"
};
iterationsOption.SetDefaultValue(3);
var formatOption = new Option<string>("--format", "Output format (json, hex, binary)");
var formatOption = new Option<string>("--format")
{
Description = "Output format (json, hex, binary)"
};
formatOption.SetDefaultValue("json");
command.AddOption(inOption);
@@ -110,7 +143,7 @@ public static class IrCommandGroup
command.AddOption(iterationsOption);
command.AddOption(formatOption);
command.SetHandler(HandleFpAsync, inOption, outOption, iterationsOption, formatOption);
command.SetHandler<DirectoryInfo, FileInfo, int, string>(HandleFpAsync, inOption, outOption, iterationsOption, formatOption);
return command;
}
@@ -122,18 +155,33 @@ public static class IrCommandGroup
{
var command = new Command("pipeline", "Run full IR pipeline: lift → canon → fp");
var inOption = new Option<FileInfo>("--in", "Input binary file path") { IsRequired = true };
inOption.AddAlias("-i");
var inOption = new Option<FileInfo>("--in", new[] { "-i" })
{
Description = "Input binary file path",
Required = true
};
var outOption = new Option<FileInfo>("--out", "Output fingerprint file path") { IsRequired = true };
outOption.AddAlias("-o");
var outOption = new Option<FileInfo>("--out", new[] { "-o" })
{
Description = "Output fingerprint file path",
Required = true
};
var cacheOption = new Option<DirectoryInfo?>("--cache", "Cache directory for intermediate artifacts");
var cacheOption = new Option<DirectoryInfo?>("--cache")
{
Description = "Cache directory for intermediate artifacts"
};
var archOption = new Option<string?>("--arch", "Architecture override");
var archOption = new Option<string?>("--arch")
{
Description = "Architecture override"
};
archOption.SetDefaultValue("auto");
var cleanupOption = new Option<bool>("--cleanup", "Remove intermediate cache after completion");
var cleanupOption = new Option<bool>("--cleanup")
{
Description = "Remove intermediate cache after completion"
};
cleanupOption.SetDefaultValue(false);
command.AddOption(inOption);
@@ -142,7 +190,7 @@ public static class IrCommandGroup
command.AddOption(archOption);
command.AddOption(cleanupOption);
command.SetHandler(HandlePipelineAsync, inOption, outOption, cacheOption, archOption, cleanupOption);
command.SetHandler<FileInfo, FileInfo, DirectoryInfo?, string?, bool>(HandlePipelineAsync, inOption, outOption, cacheOption, archOption, cleanupOption);
return command;
}

View File

@@ -0,0 +1,798 @@
// -----------------------------------------------------------------------------
// LicenseCommandGroup.cs
// Sprint: SPRINT_20260119_024_Scanner_license_detection_enhancements
// Task: TASK-024-012 - Create license detection CLI commands
// Description: CLI commands for license detection, categorization, and validation
// -----------------------------------------------------------------------------
using System.CommandLine;
using System.Text.Json;
using System.Text.Json.Serialization;
using Spectre.Console;
using StellaOps.Scanner.Analyzers.Lang.Core.Licensing;
namespace StellaOps.Cli.Commands;
/// <summary>
/// Command group for license detection and management operations.
/// Implements `stella license detect`, `stella license categorize`,
/// `stella license validate`, and `stella license extract`.
/// </summary>
public static class LicenseCommandGroup
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
WriteIndented = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) }
};
/// <summary>
/// Build the 'license' command group.
/// </summary>
public static Command BuildLicenseCommand(Option<bool> verboseOption, CancellationToken cancellationToken)
{
var license = new Command("license", "License detection and compliance commands");
license.Add(BuildDetectCommand(verboseOption, cancellationToken));
license.Add(BuildCategorizeCommand(verboseOption));
license.Add(BuildValidateCommand(verboseOption));
license.Add(BuildExtractCommand(verboseOption, cancellationToken));
license.Add(BuildSummaryCommand(verboseOption, cancellationToken));
return license;
}
#region Detect Command
/// <summary>
/// Build the 'license detect' command for detecting licenses in a directory.
/// </summary>
private static Command BuildDetectCommand(Option<bool> verboseOption, CancellationToken cancellationToken)
{
var pathArg = new Argument<string>("path")
{
Description = "Path to directory or file to scan for licenses"
};
var formatOption = new Option<OutputFormat>("--format", "-f")
{
Description = "Output format: table, json, or spdx"
};
formatOption.SetDefaultValue(OutputFormat.Table);
var recursiveOption = new Option<bool>("--recursive", "-r")
{
Description = "Recursively scan subdirectories"
};
recursiveOption.SetDefaultValue(true);
var detect = new Command("detect", "Detect licenses in a directory or file")
{
pathArg,
formatOption,
recursiveOption,
verboseOption
};
detect.SetAction(async (parseResult, ct) =>
{
var path = parseResult.GetValue(pathArg) ?? ".";
var format = parseResult.GetValue(formatOption);
var recursive = parseResult.GetValue(recursiveOption);
var verbose = parseResult.GetValue(verboseOption);
return await ExecuteDetectAsync(path, format, recursive, verbose, cancellationToken);
});
return detect;
}
private static async Task<int> ExecuteDetectAsync(
string path,
OutputFormat format,
bool recursive,
bool verbose,
CancellationToken ct)
{
try
{
path = Path.GetFullPath(path);
if (!Directory.Exists(path) && !File.Exists(path))
{
Console.Error.WriteLine($"Error: Path not found: {path}");
return 1;
}
if (verbose)
{
Console.WriteLine($"Scanning for licenses in: {path}");
}
var extractor = new LicenseTextExtractor();
var categorizationService = new LicenseCategorizationService();
var results = new List<LicenseDetectionResult>();
if (File.Exists(path))
{
// Single file
var result = await extractor.ExtractAsync(path, ct);
if (result is not null && !string.IsNullOrWhiteSpace(result.DetectedLicenseId))
{
var detection = new LicenseDetectionResult
{
SpdxId = result.DetectedLicenseId,
Confidence = result.Confidence,
Method = LicenseDetectionMethod.LicenseFile,
SourceFile = Path.GetFileName(path),
LicenseText = result.FullText,
LicenseTextHash = result.TextHash,
CopyrightNotice = result.CopyrightNotices.Length > 0
? result.CopyrightNotices[0].FullText
: null
};
results.Add(categorizationService.Enrich(detection));
}
}
else
{
// Directory
var searchOption = recursive ? SearchOption.AllDirectories : SearchOption.TopDirectoryOnly;
var licenseFiles = await extractor.ExtractFromDirectoryAsync(path, ct);
foreach (var result in licenseFiles)
{
if (!string.IsNullOrWhiteSpace(result.DetectedLicenseId))
{
var detection = new LicenseDetectionResult
{
SpdxId = result.DetectedLicenseId,
Confidence = result.Confidence,
Method = LicenseDetectionMethod.LicenseFile,
SourceFile = result.SourceFile,
LicenseTextHash = result.TextHash,
CopyrightNotice = result.CopyrightNotices.Length > 0
? result.CopyrightNotices[0].FullText
: null
};
results.Add(categorizationService.Enrich(detection));
}
}
}
if (results.Count == 0)
{
Console.WriteLine("No licenses detected.");
return 0;
}
OutputResults(results, format);
return 0;
}
catch (Exception ex)
{
Console.Error.WriteLine($"Error: {ex.Message}");
return 1;
}
}
#endregion
#region Categorize Command
/// <summary>
/// Build the 'license categorize' command for showing license category and obligations.
/// </summary>
private static Command BuildCategorizeCommand(Option<bool> verboseOption)
{
var spdxIdArg = new Argument<string>("spdx-id")
{
Description = "SPDX license identifier (e.g., MIT, Apache-2.0, GPL-3.0-only)"
};
var formatOption = new Option<OutputFormat>("--format", "-f")
{
Description = "Output format: table or json"
};
formatOption.SetDefaultValue(OutputFormat.Table);
var categorize = new Command("categorize", "Show category and obligations for a license")
{
spdxIdArg,
formatOption,
verboseOption
};
categorize.SetAction((parseResult, ct) =>
{
var spdxId = parseResult.GetValue(spdxIdArg) ?? string.Empty;
var format = parseResult.GetValue(formatOption);
var verbose = parseResult.GetValue(verboseOption);
return Task.FromResult(ExecuteCategorize(spdxId, format, verbose));
});
return categorize;
}
private static int ExecuteCategorize(string spdxId, OutputFormat format, bool verbose)
{
if (string.IsNullOrWhiteSpace(spdxId))
{
Console.Error.WriteLine("Error: SPDX license identifier is required.");
return 1;
}
var service = new LicenseCategorizationService();
var category = service.Categorize(spdxId);
var obligations = service.GetObligations(spdxId);
var isOsiApproved = service.IsOsiApproved(spdxId);
var isFsfFree = service.IsFsfFree(spdxId);
var isDeprecated = service.IsDeprecated(spdxId);
if (format == OutputFormat.Json)
{
var output = new
{
SpdxId = spdxId,
Category = category.ToString(),
Obligations = obligations.Select(o => o.ToString()).ToArray(),
IsOsiApproved = isOsiApproved,
IsFsfFree = isFsfFree,
IsDeprecated = isDeprecated
};
Console.WriteLine(JsonSerializer.Serialize(output, JsonOptions));
}
else
{
var table = new Table();
table.AddColumn("Property");
table.AddColumn("Value");
table.AddRow("SPDX ID", spdxId);
table.AddRow("Category", GetCategoryDisplay(category));
table.AddRow("Obligations", obligations.Count > 0
? string.Join(", ", obligations.Select(GetObligationDisplay))
: "[dim]None[/]");
table.AddRow("OSI Approved", (isOsiApproved ?? false) ? "[green]Yes[/]" : "[dim]No[/]");
table.AddRow("FSF Free", (isFsfFree ?? false) ? "[green]Yes[/]" : "[dim]No[/]");
table.AddRow("Deprecated", isDeprecated ? "[yellow]Yes[/]" : "[dim]No[/]");
AnsiConsole.Write(table);
}
return 0;
}
#endregion
#region Validate Command
/// <summary>
/// Build the 'license validate' command for validating SPDX expressions.
/// </summary>
private static Command BuildValidateCommand(Option<bool> verboseOption)
{
var expressionArg = new Argument<string>("expression")
{
Description = "SPDX license expression to validate (e.g., 'MIT OR Apache-2.0')"
};
var formatOption = new Option<OutputFormat>("--format", "-f")
{
Description = "Output format: table or json"
};
formatOption.SetDefaultValue(OutputFormat.Table);
var validate = new Command("validate", "Validate an SPDX license expression")
{
expressionArg,
formatOption,
verboseOption
};
validate.SetAction((parseResult, ct) =>
{
var expression = parseResult.GetValue(expressionArg) ?? string.Empty;
var format = parseResult.GetValue(formatOption);
var verbose = parseResult.GetValue(verboseOption);
return Task.FromResult(ExecuteValidate(expression, format, verbose));
});
return validate;
}
private static int ExecuteValidate(string expression, OutputFormat format, bool verbose)
{
if (string.IsNullOrWhiteSpace(expression))
{
Console.Error.WriteLine("Error: SPDX expression is required.");
return 1;
}
var service = new LicenseCategorizationService();
var isValid = true;
var components = new List<string>();
var errors = new List<string>();
// Parse the expression
var tokens = expression
.Replace("(", " ")
.Replace(")", " ")
.Split([' '], StringSplitOptions.RemoveEmptyEntries);
foreach (var token in tokens)
{
var upper = token.ToUpperInvariant();
if (upper is "OR" or "AND" or "WITH")
{
continue;
}
components.Add(token);
// Check if it's a known license
var category = service.Categorize(token);
if (category == LicenseCategory.Unknown && !token.StartsWith("LicenseRef-", StringComparison.Ordinal))
{
errors.Add($"Unknown license identifier: {token}");
}
if (service.IsDeprecated(token))
{
errors.Add($"Deprecated license identifier: {token}");
}
}
isValid = errors.Count == 0;
if (format == OutputFormat.Json)
{
var output = new
{
Expression = expression,
IsValid = isValid,
Components = components.ToArray(),
Errors = errors.ToArray()
};
Console.WriteLine(JsonSerializer.Serialize(output, JsonOptions));
}
else
{
var panel = new Panel(new Markup(isValid
? $"[green]Valid[/] SPDX expression: [bold]{Markup.Escape(expression)}[/]"
: $"[red]Invalid[/] SPDX expression: [bold]{Markup.Escape(expression)}[/]"));
panel.Header = new PanelHeader("Validation Result");
AnsiConsole.Write(panel);
if (components.Count > 0)
{
Console.WriteLine();
Console.WriteLine("Components:");
foreach (var component in components)
{
var cat = service.Categorize(component);
Console.WriteLine($" - {component}: {GetCategoryDisplay(cat)}");
}
}
if (errors.Count > 0)
{
Console.WriteLine();
AnsiConsole.MarkupLine("[yellow]Warnings/Errors:[/]");
foreach (var error in errors)
{
AnsiConsole.MarkupLine($" [yellow]![/] {Markup.Escape(error)}");
}
}
}
return isValid ? 0 : 1;
}
#endregion
#region Extract Command
/// <summary>
/// Build the 'license extract' command for extracting license text and copyright.
/// </summary>
private static Command BuildExtractCommand(Option<bool> verboseOption, CancellationToken cancellationToken)
{
var fileArg = new Argument<string>("file")
{
Description = "Path to license file to extract"
};
var formatOption = new Option<OutputFormat>("--format", "-f")
{
Description = "Output format: table or json"
};
formatOption.SetDefaultValue(OutputFormat.Table);
var extract = new Command("extract", "Extract license text and copyright from a file")
{
fileArg,
formatOption,
verboseOption
};
extract.SetAction(async (parseResult, ct) =>
{
var file = parseResult.GetValue(fileArg) ?? string.Empty;
var format = parseResult.GetValue(formatOption);
var verbose = parseResult.GetValue(verboseOption);
return await ExecuteExtractAsync(file, format, verbose, cancellationToken);
});
return extract;
}
private static async Task<int> ExecuteExtractAsync(
string file,
OutputFormat format,
bool verbose,
CancellationToken ct)
{
try
{
file = Path.GetFullPath(file);
if (!File.Exists(file))
{
Console.Error.WriteLine($"Error: File not found: {file}");
return 1;
}
var extractor = new LicenseTextExtractor();
var result = await extractor.ExtractAsync(file, ct);
if (result is null)
{
Console.Error.WriteLine("Error: Could not extract license information.");
return 1;
}
if (format == OutputFormat.Json)
{
var output = new
{
File = Path.GetFileName(file),
DetectedLicense = result.DetectedLicenseId,
Confidence = result.Confidence.ToString(),
TextHash = result.TextHash,
CopyrightNotices = result.CopyrightNotices.Select(c => new
{
c.FullText,
c.Year,
c.Holder,
c.LineNumber
}).ToArray(),
TextPreview = result.FullText?.Length > 500
? result.FullText[..500] + "..."
: result.FullText
};
Console.WriteLine(JsonSerializer.Serialize(output, JsonOptions));
}
else
{
var table = new Table();
table.AddColumn("Property");
table.AddColumn("Value");
table.AddRow("File", Path.GetFileName(file));
table.AddRow("Detected License", result.DetectedLicenseId ?? "[dim]Unknown[/]");
table.AddRow("Confidence", result.Confidence.ToString());
table.AddRow("Text Hash", result.TextHash ?? "[dim]N/A[/]");
if (result.CopyrightNotices.Length > 0)
{
table.AddRow("Copyright Notices", string.Join("\n", result.CopyrightNotices.Select(c => c.FullText)));
}
else
{
table.AddRow("Copyright Notices", "[dim]None found[/]");
}
AnsiConsole.Write(table);
if (verbose && !string.IsNullOrWhiteSpace(result.FullText))
{
Console.WriteLine();
Console.WriteLine("License Text Preview:");
Console.WriteLine(new string('-', 40));
var preview = result.FullText.Length > 1000
? result.FullText[..1000] + "\n... (truncated)"
: result.FullText;
Console.WriteLine(preview);
}
}
return 0;
}
catch (Exception ex)
{
Console.Error.WriteLine($"Error: {ex.Message}");
return 1;
}
}
#endregion
#region Summary Command
/// <summary>
/// Build the 'license summary' command for aggregated license statistics.
/// </summary>
private static Command BuildSummaryCommand(Option<bool> verboseOption, CancellationToken cancellationToken)
{
var pathArg = new Argument<string>("path")
{
Description = "Path to directory to analyze"
};
pathArg.SetDefaultValue(".");
var formatOption = new Option<OutputFormat>("--format", "-f")
{
Description = "Output format: table or json"
};
formatOption.SetDefaultValue(OutputFormat.Table);
var summary = new Command("summary", "Show aggregated license statistics for a directory")
{
pathArg,
formatOption,
verboseOption
};
summary.SetAction(async (parseResult, ct) =>
{
var path = parseResult.GetValue(pathArg) ?? ".";
var format = parseResult.GetValue(formatOption);
var verbose = parseResult.GetValue(verboseOption);
return await ExecuteSummaryAsync(path, format, verbose, cancellationToken);
});
return summary;
}
private static async Task<int> ExecuteSummaryAsync(
string path,
OutputFormat format,
bool verbose,
CancellationToken ct)
{
try
{
path = Path.GetFullPath(path);
if (!Directory.Exists(path))
{
Console.Error.WriteLine($"Error: Directory not found: {path}");
return 1;
}
if (verbose)
{
Console.WriteLine($"Analyzing licenses in: {path}");
}
var extractor = new LicenseTextExtractor();
var categorizationService = new LicenseCategorizationService();
var aggregator = new LicenseDetectionAggregator();
var results = new List<LicenseDetectionResult>();
var licenseFiles = await extractor.ExtractFromDirectoryAsync(path, ct);
foreach (var result in licenseFiles)
{
if (!string.IsNullOrWhiteSpace(result.DetectedLicenseId))
{
var detection = new LicenseDetectionResult
{
SpdxId = result.DetectedLicenseId,
Confidence = result.Confidence,
Method = LicenseDetectionMethod.LicenseFile,
SourceFile = result.SourceFile,
LicenseTextHash = result.TextHash,
CopyrightNotice = result.CopyrightNotices.Length > 0
? result.CopyrightNotices[0].FullText
: null
};
results.Add(categorizationService.Enrich(detection));
}
}
var summary = aggregator.Aggregate(results);
var risk = aggregator.GetComplianceRisk(summary);
if (format == OutputFormat.Json)
{
var output = new
{
TotalLicenses = summary.TotalComponents,
UniqueCount = summary.DistinctLicenses.Length,
UnknownCount = summary.UnknownLicenses,
CopyleftCount = summary.CopyleftComponentCount,
ByCategory = summary.ByCategory.ToDictionary(k => k.Key.ToString(), k => k.Value),
BySpdxId = summary.BySpdxId,
DistinctLicenses = summary.DistinctLicenses,
CopyrightNotices = summary.AllCopyrightNotices,
Risk = new
{
risk.HasStrongCopyleft,
risk.HasNetworkCopyleft,
risk.UnknownLicensePercentage,
risk.CopyleftPercentage,
risk.RequiresReview
}
};
Console.WriteLine(JsonSerializer.Serialize(output, JsonOptions));
}
else
{
// Summary table
var summaryTable = new Table();
summaryTable.Title = new TableTitle("License Summary");
summaryTable.AddColumn("Metric");
summaryTable.AddColumn("Value");
summaryTable.AddRow("Total Licenses Found", summary.TotalComponents.ToString());
summaryTable.AddRow("Unique Licenses", summary.DistinctLicenses.Length.ToString());
summaryTable.AddRow("Unknown Licenses", summary.UnknownLicenses.ToString());
summaryTable.AddRow("Copyleft Licenses", summary.CopyleftComponentCount.ToString());
AnsiConsole.Write(summaryTable);
// Category breakdown
if (summary.ByCategory.Count > 0)
{
Console.WriteLine();
var categoryTable = new Table();
categoryTable.Title = new TableTitle("By Category");
categoryTable.AddColumn("Category");
categoryTable.AddColumn("Count");
foreach (var kvp in summary.ByCategory.OrderByDescending(k => k.Value))
{
categoryTable.AddRow(GetCategoryDisplay(kvp.Key), kvp.Value.ToString());
}
AnsiConsole.Write(categoryTable);
}
// Risk assessment
Console.WriteLine();
var riskPanel = new Panel(new Markup(
$"Strong Copyleft: {(risk.HasStrongCopyleft ? "[red]Yes[/]" : "[green]No[/]")}\n" +
$"Network Copyleft (AGPL): {(risk.HasNetworkCopyleft ? "[red]Yes[/]" : "[green]No[/]")}\n" +
$"Unknown License %: {risk.UnknownLicensePercentage:F1}%\n" +
$"Requires Review: {(risk.RequiresReview ? "[yellow]Yes[/]" : "[green]No[/]")}"));
riskPanel.Header = new PanelHeader("Compliance Risk");
AnsiConsole.Write(riskPanel);
// Distinct licenses
if (summary.DistinctLicenses.Length > 0 && verbose)
{
Console.WriteLine();
Console.WriteLine("Distinct Licenses:");
foreach (var license in summary.DistinctLicenses)
{
Console.WriteLine($" - {license}");
}
}
}
return 0;
}
catch (Exception ex)
{
Console.Error.WriteLine($"Error: {ex.Message}");
return 1;
}
}
#endregion
#region Output Helpers
private static void OutputResults(List<LicenseDetectionResult> results, OutputFormat format)
{
if (format == OutputFormat.Json)
{
Console.WriteLine(JsonSerializer.Serialize(results, JsonOptions));
}
else if (format == OutputFormat.Spdx)
{
// SPDX format output
Console.WriteLine("SPDXVersion: SPDX-2.3");
Console.WriteLine("DataLicense: CC0-1.0");
Console.WriteLine($"SPDXID: SPDXRef-DOCUMENT");
Console.WriteLine($"DocumentName: license-detection-{DateTime.UtcNow:yyyyMMdd}");
Console.WriteLine();
Console.WriteLine("# Detected Licenses");
foreach (var result in results)
{
Console.WriteLine($"LicenseID: {result.SpdxId}");
if (!string.IsNullOrWhiteSpace(result.SourceFile))
{
Console.WriteLine($"LicenseComment: Detected in {result.SourceFile}");
}
Console.WriteLine();
}
}
else
{
// Table format
var table = new Table();
table.AddColumn("License");
table.AddColumn("Category");
table.AddColumn("Confidence");
table.AddColumn("Source");
foreach (var result in results)
{
table.AddRow(
result.SpdxId,
GetCategoryDisplay(result.Category),
result.Confidence.ToString(),
result.SourceFile ?? "[dim]Unknown[/]");
}
AnsiConsole.Write(table);
}
}
private static string GetCategoryDisplay(LicenseCategory category)
{
return category switch
{
LicenseCategory.Permissive => "[green]Permissive[/]",
LicenseCategory.WeakCopyleft => "[yellow]Weak Copyleft[/]",
LicenseCategory.StrongCopyleft => "[red]Strong Copyleft[/]",
LicenseCategory.NetworkCopyleft => "[red]Network Copyleft[/]",
LicenseCategory.PublicDomain => "[blue]Public Domain[/]",
LicenseCategory.Proprietary => "[magenta]Proprietary[/]",
_ => "[dim]Unknown[/]"
};
}
private static string GetObligationDisplay(LicenseObligation obligation)
{
return obligation switch
{
LicenseObligation.Attribution => "Attribution",
LicenseObligation.SourceDisclosure => "Source Disclosure",
LicenseObligation.SameLicense => "Same License",
LicenseObligation.PatentGrant => "Patent Grant",
LicenseObligation.NoWarranty => "No Warranty",
LicenseObligation.StateChanges => "State Changes",
LicenseObligation.IncludeLicense => "Include License",
LicenseObligation.NetworkCopyleft => "Network Copyleft",
LicenseObligation.IncludeNotice => "Include Notice",
_ => obligation.ToString()
};
}
#endregion
}
/// <summary>
/// Output format for license commands.
/// </summary>
public enum OutputFormat
{
/// <summary>Table format for terminal display.</summary>
Table,
/// <summary>JSON format for programmatic use.</summary>
Json,
/// <summary>SPDX format for license documentation.</summary>
Spdx
}

View File

@@ -24,12 +24,11 @@ public static class MigrateArtifactsCommand
Option<bool> verboseOption,
CancellationToken cancellationToken)
{
var sourceOption = new Option<string>("--source", "-s")
var sourceOption = new Option<string>("--source", new[] { "-s" })
{
Description = "Source store type: evidence, attestor, vex, all",
IsRequired = true
Required = true
};
sourceOption.AddAlias("-s");
var dryRunOption = new Option<bool>("--dry-run")
{

View File

@@ -33,55 +33,62 @@ public class AnchorCommandGroup
private Command BuildListCommand()
{
var outputOption = new Option<string>(
name: "--output",
getDefaultValue: () => "text",
description: "Output format: text, json");
var outputOption = new Option<string>("--output")
{
Description = "Output format: text, json"
};
outputOption.SetDefaultValue("text");
var listCommand = new Command("list", "List trust anchors")
{
outputOption
};
listCommand.SetHandler(async (context) =>
{
var output = context.ParseResult.GetValueForOption(outputOption) ?? "text";
context.ExitCode = await ListAnchorsAsync(output, context.GetCancellationToken());
});
listCommand.SetAction(async (parseResult, ct) =>
await ListAnchorsAsync(
parseResult.GetValue(outputOption) ?? "text",
ct));
return listCommand;
}
private Command BuildShowCommand()
{
var anchorArg = new Argument<Guid>("anchorId", "Trust anchor ID");
var anchorArg = new Argument<Guid>("anchorId")
{
Description = "Trust anchor ID"
};
var showCommand = new Command("show", "Show trust anchor details")
{
anchorArg
};
showCommand.SetHandler(async (context) =>
{
var anchorId = context.ParseResult.GetValueForArgument(anchorArg);
context.ExitCode = await ShowAnchorAsync(anchorId, context.GetCancellationToken());
});
showCommand.SetAction(async (parseResult, ct) =>
await ShowAnchorAsync(
parseResult.GetValue(anchorArg),
ct));
return showCommand;
}
private Command BuildCreateCommand()
{
var patternArg = new Argument<string>("pattern", "PURL glob pattern (e.g., pkg:npm/*)");
var patternArg = new Argument<string>("pattern")
{
Description = "PURL glob pattern (e.g., pkg:npm/*)"
};
var keyIdsOption = new Option<string[]>(
aliases: ["-k", "--key-id"],
description: "Allowed key IDs (can be repeated)")
{ AllowMultipleArgumentsPerToken = true };
var keyIdsOption = new Option<string[]>("--key-id", "-k")
{
Description = "Allowed key IDs (can be repeated)",
AllowMultipleArgumentsPerToken = true
};
var policyVersionOption = new Option<string?>(
name: "--policy-version",
description: "Policy version for this anchor");
var policyVersionOption = new Option<string?>("--policy-version")
{
Description = "Policy version for this anchor"
};
var createCommand = new Command("create", "Create a new trust anchor")
{
@@ -90,26 +97,32 @@ public class AnchorCommandGroup
policyVersionOption
};
createCommand.SetHandler(async (context) =>
{
var pattern = context.ParseResult.GetValueForArgument(patternArg);
var keyIds = context.ParseResult.GetValueForOption(keyIdsOption) ?? [];
var policyVersion = context.ParseResult.GetValueForOption(policyVersionOption);
context.ExitCode = await CreateAnchorAsync(pattern, keyIds, policyVersion, context.GetCancellationToken());
});
createCommand.SetAction(async (parseResult, ct) =>
await CreateAnchorAsync(
parseResult.GetValue(patternArg),
parseResult.GetValue(keyIdsOption) ?? [],
parseResult.GetValue(policyVersionOption),
ct));
return createCommand;
}
private Command BuildRevokeKeyCommand()
{
var anchorArg = new Argument<Guid>("anchorId", "Trust anchor ID");
var keyArg = new Argument<string>("keyId", "Key ID to revoke");
var anchorArg = new Argument<Guid>("anchorId")
{
Description = "Trust anchor ID"
};
var keyArg = new Argument<string>("keyId")
{
Description = "Key ID to revoke"
};
var reasonOption = new Option<string>(
aliases: ["-r", "--reason"],
getDefaultValue: () => "manual-revocation",
description: "Reason for revocation");
var reasonOption = new Option<string>("--reason", "-r")
{
Description = "Reason for revocation"
};
reasonOption.SetDefaultValue("manual-revocation");
var revokeCommand = new Command("revoke-key", "Revoke a key in a trust anchor")
{
@@ -118,13 +131,12 @@ public class AnchorCommandGroup
reasonOption
};
revokeCommand.SetHandler(async (context) =>
{
var anchorId = context.ParseResult.GetValueForArgument(anchorArg);
var keyId = context.ParseResult.GetValueForArgument(keyArg);
var reason = context.ParseResult.GetValueForOption(reasonOption) ?? "manual-revocation";
context.ExitCode = await RevokeKeyAsync(anchorId, keyId, reason, context.GetCancellationToken());
});
revokeCommand.SetAction(async (parseResult, ct) =>
await RevokeKeyAsync(
parseResult.GetValue(anchorArg),
parseResult.GetValue(keyArg),
parseResult.GetValue(reasonOption) ?? "manual-revocation",
ct));
return revokeCommand;
}

View File

@@ -31,12 +31,16 @@ public class ReceiptCommandGroup
private Command BuildGetCommand()
{
var bundleArg = new Argument<string>("bundleId", "Proof bundle ID");
var bundleArg = new Argument<string>("bundleId")
{
Description = "Proof bundle ID"
};
var outputOption = new Option<string>(
name: "--output",
getDefaultValue: () => "text",
description: "Output format: text, json, cbor");
var outputOption = new Option<string>("--output")
{
Description = "Output format: text, json, cbor"
};
outputOption.SetDefaultValue("text");
var getCommand = new Command("get", "Get a verification receipt")
{
@@ -44,23 +48,26 @@ public class ReceiptCommandGroup
outputOption
};
getCommand.SetHandler(async (context) =>
{
var bundleId = context.ParseResult.GetValueForArgument(bundleArg);
var output = context.ParseResult.GetValueForOption(outputOption) ?? "text";
context.ExitCode = await GetReceiptAsync(bundleId, output, context.GetCancellationToken());
});
getCommand.SetAction(async (parseResult, ct) =>
await GetReceiptAsync(
parseResult.GetValue(bundleArg),
parseResult.GetValue(outputOption) ?? "text",
ct));
return getCommand;
}
private Command BuildVerifyCommand()
{
var receiptFileArg = new Argument<FileInfo>("receiptFile", "Path to receipt file");
var receiptFileArg = new Argument<FileInfo>("receiptFile")
{
Description = "Path to receipt file"
};
var offlineOption = new Option<bool>(
name: "--offline",
description: "Offline mode (skip Rekor verification)");
var offlineOption = new Option<bool>("--offline")
{
Description = "Offline mode (skip Rekor verification)"
};
var verifyCommand = new Command("verify", "Verify a stored receipt")
{
@@ -68,12 +75,11 @@ public class ReceiptCommandGroup
offlineOption
};
verifyCommand.SetHandler(async (context) =>
{
var receiptFile = context.ParseResult.GetValueForArgument(receiptFileArg);
var offline = context.ParseResult.GetValueForOption(offlineOption);
context.ExitCode = await VerifyReceiptAsync(receiptFile, offline, context.GetCancellationToken());
});
verifyCommand.SetAction(async (parseResult, ct) =>
await VerifyReceiptAsync(
parseResult.GetValue(receiptFileArg),
parseResult.GetValue(offlineOption),
ct));
return verifyCommand;
}

View File

@@ -6,7 +6,6 @@
// -----------------------------------------------------------------------------
using System.CommandLine;
using System.CommandLine.Invocation;
namespace StellaOps.Cli.Commands.Sbom;
@@ -43,35 +42,39 @@ public static class SbomCommandGroup
var generateCommand = new Command("generate", "Generate a deterministic SBOM from an image or directory");
// Options
var imageOption = new Option<string?>(
aliases: ["--image", "-i"],
description: "Container image reference (e.g., registry/repo@sha256:...)");
var directoryOption = new Option<string?>(
aliases: ["--directory", "-d"],
description: "Local directory to scan");
var formatOption = new Option<SbomOutputFormat>(
aliases: ["--format", "-f"],
getDefaultValue: () => SbomOutputFormat.CycloneDx,
description: "Output format: cyclonedx, spdx, or both");
var outputOption = new Option<string>(
aliases: ["--output", "-o"],
description: "Output file path or directory (for 'both' format)")
var imageOption = new Option<string?>("--image", "-i")
{
IsRequired = true
Description = "Container image reference (e.g., registry/repo@sha256:...)"
};
var forceOption = new Option<bool>(
aliases: ["--force"],
getDefaultValue: () => false,
description: "Overwrite existing output file");
var directoryOption = new Option<string?>("--directory", "-d")
{
Description = "Local directory to scan"
};
var showHashOption = new Option<bool>(
aliases: ["--show-hash"],
getDefaultValue: () => true,
description: "Display golden hash after generation");
var formatOption = new Option<SbomOutputFormat>("--format", "-f")
{
Description = "Output format: cyclonedx, spdx, or both"
};
formatOption.SetDefaultValue(SbomOutputFormat.CycloneDx);
var outputOption = new Option<string>("--output", "-o")
{
Description = "Output file path or directory (for 'both' format)",
Required = true
};
var forceOption = new Option<bool>("--force")
{
Description = "Overwrite existing output file"
};
forceOption.SetDefaultValue(false);
var showHashOption = new Option<bool>("--show-hash")
{
Description = "Display golden hash after generation"
};
showHashOption.SetDefaultValue(true);
generateCommand.AddOption(imageOption);
generateCommand.AddOption(directoryOption);
@@ -80,28 +83,26 @@ public static class SbomCommandGroup
generateCommand.AddOption(forceOption);
generateCommand.AddOption(showHashOption);
generateCommand.SetHandler(async (InvocationContext context) =>
generateCommand.SetAction(async (parseResult, ct) =>
{
var image = context.ParseResult.GetValueForOption(imageOption);
var directory = context.ParseResult.GetValueForOption(directoryOption);
var format = context.ParseResult.GetValueForOption(formatOption);
var output = context.ParseResult.GetValueForOption(outputOption)!;
var force = context.ParseResult.GetValueForOption(forceOption);
var showHash = context.ParseResult.GetValueForOption(showHashOption);
var image = parseResult.GetValue(imageOption);
var directory = parseResult.GetValue(directoryOption);
var format = parseResult.GetValue(formatOption);
var output = parseResult.GetValue(outputOption)!;
var force = parseResult.GetValue(forceOption);
var showHash = parseResult.GetValue(showHashOption);
// Validate input
if (string.IsNullOrEmpty(image) && string.IsNullOrEmpty(directory))
{
Console.Error.WriteLine("Error: Either --image or --directory must be specified.");
context.ExitCode = 1;
return;
return 1;
}
if (!string.IsNullOrEmpty(image) && !string.IsNullOrEmpty(directory))
{
Console.Error.WriteLine("Error: Specify either --image or --directory, not both.");
context.ExitCode = 1;
return;
return 1;
}
// Check output exists
@@ -109,19 +110,18 @@ public static class SbomCommandGroup
{
Console.Error.WriteLine($"Error: Output file already exists: {output}");
Console.Error.WriteLine("Use --force to overwrite.");
context.ExitCode = 1;
return;
return 1;
}
try
{
await GenerateSbomAsync(image, directory, format, output, showHash, context.GetCancellationToken());
context.ExitCode = 0;
await GenerateSbomAsync(image, directory, format, output, showHash, ct);
return 0;
}
catch (Exception ex)
{
Console.Error.WriteLine($"Error: {ex.Message}");
context.ExitCode = 1;
return 1;
}
});
@@ -139,36 +139,34 @@ public static class SbomCommandGroup
{
var hashCommand = new Command("hash", "Compute the golden hash of an SBOM file");
var inputOption = new Option<string>(
aliases: ["--input", "-i"],
description: "SBOM file to hash")
var inputOption = new Option<string>("--input", "-i")
{
IsRequired = true
Description = "SBOM file to hash",
Required = true
};
hashCommand.AddOption(inputOption);
hashCommand.SetHandler(async (InvocationContext context) =>
hashCommand.SetAction(async (parseResult, ct) =>
{
var input = context.ParseResult.GetValueForOption(inputOption)!;
var input = parseResult.GetValue(inputOption)!;
if (!File.Exists(input))
{
Console.Error.WriteLine($"Error: File not found: {input}");
context.ExitCode = 1;
return;
return 1;
}
try
{
var hash = await ComputeGoldenHashAsync(input, context.GetCancellationToken());
var hash = await ComputeGoldenHashAsync(input, ct);
Console.WriteLine($"Golden Hash (SHA-256): {hash}");
context.ExitCode = 0;
return 0;
}
catch (Exception ex)
{
Console.Error.WriteLine($"Error: {ex.Message}");
context.ExitCode = 1;
return 1;
}
});
@@ -182,57 +180,54 @@ public static class SbomCommandGroup
{
var verifyCommand = new Command("verify", "Verify an SBOM's golden hash matches expected value");
var inputOption = new Option<string>(
aliases: ["--input", "-i"],
description: "SBOM file to verify")
var inputOption = new Option<string>("--input", "-i")
{
IsRequired = true
Description = "SBOM file to verify",
Required = true
};
var expectedOption = new Option<string>(
aliases: ["--expected", "-e"],
description: "Expected golden hash (SHA-256)")
var expectedOption = new Option<string>("--expected", "-e")
{
IsRequired = true
Description = "Expected golden hash (SHA-256)",
Required = true
};
verifyCommand.AddOption(inputOption);
verifyCommand.AddOption(expectedOption);
verifyCommand.SetHandler(async (InvocationContext context) =>
verifyCommand.SetAction(async (parseResult, ct) =>
{
var input = context.ParseResult.GetValueForOption(inputOption)!;
var expected = context.ParseResult.GetValueForOption(expectedOption)!;
var input = parseResult.GetValue(inputOption)!;
var expected = parseResult.GetValue(expectedOption)!;
if (!File.Exists(input))
{
Console.Error.WriteLine($"Error: File not found: {input}");
context.ExitCode = 1;
return;
return 1;
}
try
{
var actual = await ComputeGoldenHashAsync(input, context.GetCancellationToken());
var actual = await ComputeGoldenHashAsync(input, ct);
var match = string.Equals(actual, expected, StringComparison.OrdinalIgnoreCase);
if (match)
{
Console.WriteLine("✓ Golden hash verified successfully.");
context.ExitCode = 0;
return 0;
}
else
{
Console.Error.WriteLine("✗ Golden hash mismatch!");
Console.Error.WriteLine($" Expected: {expected}");
Console.Error.WriteLine($" Actual: {actual}");
context.ExitCode = 1;
return 1;
}
}
catch (Exception ex)
{
Console.Error.WriteLine($"Error: {ex.Message}");
context.ExitCode = 1;
return 1;
}
});
@@ -329,3 +324,5 @@ public enum SbomOutputFormat
/// <summary>Both CycloneDX and SPDX.</summary>
Both
}

File diff suppressed because it is too large Load Diff

View File

@@ -108,7 +108,7 @@ public sealed class AgentsSetupStep : SetupStepBase
if (context.ConfigValues.TryGetValue("agents.count", out var countStr) &&
int.TryParse(countStr, out var count) && count > 0)
{
var agents = new List<AgentConfig>();
var preconfiguredAgents = new List<AgentConfig>();
for (var i = 0; i < count; i++)
{
var name = context.ConfigValues.GetValueOrDefault($"agents.{i}.name", $"agent-{i}");
@@ -116,9 +116,9 @@ public sealed class AgentsSetupStep : SetupStepBase
var type = context.ConfigValues.GetValueOrDefault($"agents.{i}.type", "docker");
var labels = context.ConfigValues.GetValueOrDefault($"agents.{i}.labels", "").Split(',', StringSplitOptions.RemoveEmptyEntries);
agents.Add(new AgentConfig(name, environment, type, new List<string>(labels)));
preconfiguredAgents.Add(new AgentConfig(name, environment, type, new List<string>(labels)));
}
return agents;
return preconfiguredAgents;
}
if (context.NonInteractive)

View File

@@ -20,23 +20,28 @@ public enum SetupCategory
/// </summary>
Integration = 2,
/// <summary>
/// Release orchestration (agents, environments).
/// </summary>
Orchestration = 3,
/// <summary>
/// Settings store and configuration.
/// </summary>
Configuration = 3,
Configuration = 4,
/// <summary>
/// Observability (telemetry, logging).
/// </summary>
Observability = 4,
Observability = 5,
/// <summary>
/// Optional features and enhancements.
/// </summary>
Optional = 5,
Optional = 6,
/// <summary>
/// Data sources (advisory feeds, CVE databases).
/// </summary>
Data = 6
Data = 7
}

View File

@@ -6,6 +6,7 @@ using Microsoft.Extensions.DependencyInjection;
using StellaOps.Cli.Services;
using StellaOps.Cli.Extensions;
using StellaOps.Infrastructure.Postgres.Migrations;
using InfraMigrationResult = StellaOps.Infrastructure.Postgres.Migrations.MigrationResult;
namespace StellaOps.Cli.Commands;
@@ -157,7 +158,7 @@ internal static class SystemCommandBuilder
return system;
}
private static void WriteRunResult(MigrationModuleInfo module, MigrationResult result, bool verbose)
private static void WriteRunResult(MigrationModuleInfo module, InfraMigrationResult result, bool verbose)
{
var prefix = $"[{module.Name}]";

View File

@@ -0,0 +1,480 @@
using System.Collections.Immutable;
using System.CommandLine;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.DependencyInjection;
using StellaOps.AirGap.Bundle.Models;
using StellaOps.AirGap.Bundle.Services;
namespace StellaOps.Cli.Commands;
public static class TrustProfileCommandGroup
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
WriteIndented = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
public static Command BuildTrustProfileCommand(
IServiceProvider services,
Option<bool> verboseOption,
CancellationToken cancellationToken)
{
var command = new Command("trust-profile", "Manage trust profiles for offline verification.");
command.Add(BuildListCommand(services, verboseOption, cancellationToken));
command.Add(BuildShowCommand(services, verboseOption, cancellationToken));
command.Add(BuildApplyCommand(services, verboseOption, cancellationToken));
return command;
}
private static Command BuildListCommand(
IServiceProvider services,
Option<bool> verboseOption,
CancellationToken cancellationToken)
{
var profilesDirOption = new Option<string?>("--profiles-dir")
{
Description = "Directory containing trust profile definitions."
};
var formatOption = new Option<string>("--format", "-f")
{
Description = "Output format: table (default), json"
};
formatOption.SetDefaultValue("table");
var list = new Command("list", "List available trust profiles")
{
profilesDirOption,
formatOption,
verboseOption
};
list.SetAction((parseResult, _) =>
{
var profilesDir = ResolveProfilesDirectory(parseResult.GetValue(profilesDirOption));
var format = parseResult.GetValue(formatOption) ?? "table";
var verbose = parseResult.GetValue(verboseOption);
return HandleListAsync(services, profilesDir, format, verbose, cancellationToken);
});
return list;
}
private static Command BuildShowCommand(
IServiceProvider services,
Option<bool> verboseOption,
CancellationToken cancellationToken)
{
var profileArg = new Argument<string>("profile-id")
{
Description = "Trust profile identifier."
};
var profilesDirOption = new Option<string?>("--profiles-dir")
{
Description = "Directory containing trust profile definitions."
};
var formatOption = new Option<string>("--format", "-f")
{
Description = "Output format: text (default), json"
};
formatOption.SetDefaultValue("text");
var show = new Command("show", "Show trust profile details")
{
profileArg,
profilesDirOption,
formatOption,
verboseOption
};
show.SetAction((parseResult, _) =>
{
var profileId = parseResult.GetValue(profileArg) ?? string.Empty;
var profilesDir = ResolveProfilesDirectory(parseResult.GetValue(profilesDirOption));
var format = parseResult.GetValue(formatOption) ?? "text";
var verbose = parseResult.GetValue(verboseOption);
return HandleShowAsync(services, profileId, profilesDir, format, verbose, cancellationToken);
});
return show;
}
private static Command BuildApplyCommand(
IServiceProvider services,
Option<bool> verboseOption,
CancellationToken cancellationToken)
{
var profileArg = new Argument<string>("profile-id")
{
Description = "Trust profile identifier."
};
var profilesDirOption = new Option<string?>("--profiles-dir")
{
Description = "Directory containing trust profile definitions."
};
var outputOption = new Option<string?>("--output", "-o")
{
Description = "Output directory for the applied trust store."
};
var overwriteOption = new Option<bool>("--overwrite")
{
Description = "Overwrite existing trust store directory."
};
var apply = new Command("apply", "Apply a trust profile to a local trust store")
{
profileArg,
profilesDirOption,
outputOption,
overwriteOption,
verboseOption
};
apply.SetAction((parseResult, _) =>
{
var profileId = parseResult.GetValue(profileArg) ?? string.Empty;
var profilesDir = ResolveProfilesDirectory(parseResult.GetValue(profilesDirOption));
var output = parseResult.GetValue(outputOption);
var overwrite = parseResult.GetValue(overwriteOption);
var verbose = parseResult.GetValue(verboseOption);
return HandleApplyAsync(services, profileId, profilesDir, output, overwrite, verbose, cancellationToken);
});
return apply;
}
private static Task<int> HandleListAsync(
IServiceProvider services,
string profilesDir,
string format,
bool verbose,
CancellationToken ct)
{
var loader = services.GetRequiredService<TrustProfileLoader>();
var profiles = loader.LoadProfiles(profilesDir);
if (profiles.Count == 0)
{
Console.WriteLine($"No trust profiles found in {profilesDir}.");
return Task.FromResult(0);
}
if (format.Equals("json", StringComparison.OrdinalIgnoreCase))
{
Console.WriteLine(JsonSerializer.Serialize(profiles, JsonOptions));
return Task.FromResult(0);
}
Console.WriteLine("Trust Profiles");
Console.WriteLine("==============");
Console.WriteLine();
Console.WriteLine($"Profiles directory: {profilesDir}");
Console.WriteLine();
Console.WriteLine("ID Name Roots Rekor TSA");
Console.WriteLine("---------------------------------------------------------------");
foreach (var profile in profiles)
{
Console.WriteLine(
$"{profile.ProfileId,-16} {profile.Name,-30} {profile.TrustRoots.Length,5} {profile.RekorKeys.Length,6} {profile.TsaRoots.Length,4}");
}
if (verbose)
{
Console.WriteLine();
foreach (var profile in profiles)
{
Console.WriteLine($"- {profile.ProfileId}: {profile.Description ?? "n/a"}");
}
}
return Task.FromResult(0);
}
private static Task<int> HandleShowAsync(
IServiceProvider services,
string profileId,
string profilesDir,
string format,
bool verbose,
CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(profileId))
{
Console.Error.WriteLine("Error: profile-id is required.");
return Task.FromResult(1);
}
var loader = services.GetRequiredService<TrustProfileLoader>();
var profile = loader.LoadProfiles(profilesDir)
.FirstOrDefault(p => p.ProfileId.Equals(profileId, StringComparison.OrdinalIgnoreCase));
if (profile is null)
{
Console.Error.WriteLine($"Error: trust profile not found: {profileId}");
return Task.FromResult(1);
}
if (format.Equals("json", StringComparison.OrdinalIgnoreCase))
{
Console.WriteLine(JsonSerializer.Serialize(profile, JsonOptions));
return Task.FromResult(0);
}
Console.WriteLine("Trust Profile");
Console.WriteLine("=============");
Console.WriteLine();
Console.WriteLine($"ID: {profile.ProfileId}");
Console.WriteLine($"Name: {profile.Name}");
if (!string.IsNullOrWhiteSpace(profile.Description))
{
Console.WriteLine($"Description: {profile.Description}");
}
PrintEntries("Trust Roots", profile.TrustRoots, verbose);
PrintEntries("Rekor Keys", profile.RekorKeys, verbose);
PrintEntries("TSA Roots", profile.TsaRoots, verbose);
return Task.FromResult(0);
}
private static Task<int> HandleApplyAsync(
IServiceProvider services,
string profileId,
string profilesDir,
string? output,
bool overwrite,
bool verbose,
CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(profileId))
{
Console.Error.WriteLine("Error: profile-id is required.");
return Task.FromResult(1);
}
var loader = services.GetRequiredService<TrustProfileLoader>();
var profile = loader.LoadProfiles(profilesDir)
.FirstOrDefault(p => p.ProfileId.Equals(profileId, StringComparison.OrdinalIgnoreCase));
if (profile is null)
{
Console.Error.WriteLine($"Error: trust profile not found: {profileId}");
return Task.FromResult(1);
}
var outputDir = output ?? GetDefaultApplyDirectory(profile.ProfileId);
outputDir = Path.GetFullPath(outputDir);
if (Directory.Exists(outputDir) && Directory.EnumerateFileSystemEntries(outputDir).Any() && !overwrite)
{
Console.Error.WriteLine($"Error: output directory is not empty: {outputDir}");
Console.Error.WriteLine("Use --overwrite to replace existing contents.");
return Task.FromResult(1);
}
Directory.CreateDirectory(outputDir);
var trustRootsDir = Path.Combine(outputDir, "trust-roots");
var rekorDir = Path.Combine(outputDir, "rekor");
var tsaDir = Path.Combine(outputDir, "tsa");
Directory.CreateDirectory(trustRootsDir);
Directory.CreateDirectory(rekorDir);
Directory.CreateDirectory(tsaDir);
var trustRoots = CopyEntries(loader, profile, profile.TrustRoots, trustRootsDir, verbose);
CopyEntries(loader, profile, profile.RekorKeys, rekorDir, verbose);
CopyEntries(loader, profile, profile.TsaRoots, tsaDir, verbose);
var manifest = new TrustManifest
{
Roots = trustRoots
};
var manifestPath = Path.Combine(outputDir, "trust-manifest.json");
File.WriteAllText(manifestPath, JsonSerializer.Serialize(manifest, JsonOptions));
var combinedRootPath = Path.Combine(outputDir, "trust-root.pem");
WriteCombinedTrustRoots(trustRoots, outputDir, combinedRootPath);
var profilePath = Path.Combine(outputDir, "trust-profile.json");
File.WriteAllText(profilePath, JsonSerializer.Serialize(profile, JsonOptions));
Console.WriteLine("Trust profile applied.");
Console.WriteLine($"Profile: {profile.ProfileId}");
Console.WriteLine($"Output: {outputDir}");
Console.WriteLine($"Roots: {trustRoots.Count}");
Console.WriteLine();
Console.WriteLine("Use the trust store with:");
Console.WriteLine($" stella bundle verify --trust-root \"{combinedRootPath}\"");
Console.WriteLine($" trust manifest: {manifestPath}");
return Task.FromResult(0);
}
private static List<TrustManifestEntry> CopyEntries(
TrustProfileLoader loader,
TrustProfile profile,
ImmutableArray<TrustProfileEntry> entries,
string targetDir,
bool verbose)
{
var manifestEntries = new List<TrustManifestEntry>();
foreach (var entry in entries)
{
var sourcePath = loader.ResolveEntryPath(profile, entry);
var fileName = Path.GetFileName(entry.Path);
if (string.IsNullOrWhiteSpace(fileName))
{
throw new InvalidOperationException($"Invalid entry path: {entry.Path}");
}
var targetPath = Path.Combine(targetDir, fileName);
File.Copy(sourcePath, targetPath, overwrite: true);
if (verbose)
{
Console.WriteLine($"Copied {entry.Id} -> {targetPath}");
}
manifestEntries.Add(new TrustManifestEntry
{
KeyId = entry.Id,
RelativePath = Path.GetRelativePath(Path.GetDirectoryName(targetDir)!, targetPath)
.Replace('\\', '/'),
Algorithm = entry.Algorithm,
ExpiresAt = entry.ValidUntil,
Purpose = entry.Purpose
});
}
return manifestEntries;
}
private static void WriteCombinedTrustRoots(
IReadOnlyList<TrustManifestEntry> trustRoots,
string outputDir,
string combinedPath)
{
if (trustRoots.Count == 0)
{
return;
}
var builder = new System.Text.StringBuilder();
foreach (var entry in trustRoots)
{
if (string.IsNullOrWhiteSpace(entry.RelativePath))
{
continue;
}
var fullPath = Path.Combine(
outputDir,
entry.RelativePath.Replace('/', Path.DirectorySeparatorChar));
if (!File.Exists(fullPath))
{
continue;
}
var pem = File.ReadAllText(fullPath).Trim();
if (pem.Length == 0)
{
continue;
}
if (builder.Length > 0)
{
builder.AppendLine();
}
builder.AppendLine(pem);
}
if (builder.Length > 0)
{
File.WriteAllText(combinedPath, builder.ToString());
}
}
private static void PrintEntries(string title, ImmutableArray<TrustProfileEntry> entries, bool verbose)
{
Console.WriteLine();
Console.WriteLine($"{title}: {entries.Length}");
foreach (var entry in entries)
{
Console.WriteLine($" - {entry.Id} ({entry.Path})");
if (verbose && (!string.IsNullOrWhiteSpace(entry.Algorithm) || !string.IsNullOrWhiteSpace(entry.Purpose)))
{
Console.WriteLine($" Algorithm: {entry.Algorithm ?? "n/a"}");
Console.WriteLine($" Purpose: {entry.Purpose ?? "n/a"}");
}
}
}
private static string ResolveProfilesDirectory(string? profilesDir)
{
if (!string.IsNullOrWhiteSpace(profilesDir))
{
return Path.GetFullPath(profilesDir);
}
var envOverride = Environment.GetEnvironmentVariable("STELLAOPS_TRUST_PROFILES");
if (!string.IsNullOrWhiteSpace(envOverride))
{
return Path.GetFullPath(envOverride);
}
var candidate = Path.Combine(Directory.GetCurrentDirectory(), "etc", "trust-profiles");
if (Directory.Exists(candidate))
{
return candidate;
}
var baseDirCandidate = Path.Combine(AppContext.BaseDirectory, "etc", "trust-profiles");
if (Directory.Exists(baseDirCandidate))
{
return baseDirCandidate;
}
return candidate;
}
private static string GetDefaultApplyDirectory(string profileId)
{
var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
if (string.IsNullOrWhiteSpace(home))
{
home = Directory.GetCurrentDirectory();
}
return Path.Combine(home, ".stellaops", "trust-profiles", profileId);
}
private sealed class TrustManifest
{
[JsonPropertyName("roots")]
public List<TrustManifestEntry> Roots { get; init; } = new();
}
private sealed class TrustManifestEntry
{
[JsonPropertyName("keyId")]
public string KeyId { get; init; } = string.Empty;
[JsonPropertyName("relativePath")]
public string? RelativePath { get; init; }
[JsonPropertyName("algorithm")]
public string? Algorithm { get; init; }
[JsonPropertyName("expiresAt")]
public DateTimeOffset? ExpiresAt { get; init; }
[JsonPropertyName("purpose")]
public string? Purpose { get; init; }
}
}

View File

@@ -576,7 +576,7 @@ public static class UnknownsCommandGroup
return 1;
}
var result = await response.Content.ReadFromJsonAsync<UnknownsListResponse>(JsonOptions, ct);
var result = await response.Content.ReadFromJsonAsync<LegacyUnknownsListResponse>(JsonOptions, ct);
if (result is null)
{
@@ -603,7 +603,7 @@ public static class UnknownsCommandGroup
}
}
private static void PrintUnknownsTable(UnknownsListResponse result)
private static void PrintUnknownsTable(LegacyUnknownsListResponse result)
{
Console.WriteLine($"Unknowns Registry ({result.TotalCount} total, showing {result.Items.Count})");
Console.WriteLine(new string('=', 80));
@@ -1269,10 +1269,10 @@ public static class UnknownsCommandGroup
}
var listResponse = await response.Content.ReadFromJsonAsync<UnknownsListResponse>(JsonOptions, ct);
unknowns = listResponse?.Items.Select(i => new BudgetUnknownDto
unknowns = listResponse?.Items?.Select(i => new BudgetUnknownDto
{
Id = i.Id,
ReasonCode = "Reachability" // Default if not provided
Id = i.Id.ToString("D"),
ReasonCode = "Reachability" // Default if not provided
}).ToList() ?? [];
}
else
@@ -1506,7 +1506,7 @@ public static class UnknownsCommandGroup
#region DTOs
private sealed record UnknownsListResponse(
private sealed record LegacyUnknownsListResponse(
IReadOnlyList<UnknownItem> Items,
int TotalCount,
int Offset,

View File

@@ -67,8 +67,8 @@ public static class VexCommandGroup
generate.Add(signOption);
generate.SetAction((parseResult, _) =>
{
var scan = parseResult.GetValue(scanOption);
var format = parseResult.GetValue(formatOption);
var scan = parseResult.GetValue(scanOption) ?? string.Empty;
var format = parseResult.GetValue(formatOption) ?? "openvex";
var output = parseResult.GetValue(outputOption);
var sign = parseResult.GetValue(signOption);

View File

@@ -0,0 +1,437 @@
using System;
using System.CommandLine;
using System.CommandLine.Parsing;
using System.Threading.Tasks;
namespace StellaOps.Cli.Extensions;
public static class CommandLineCompatExtensions
{
public static Command AddCommand(this Command command, Command subcommand)
{
ArgumentNullException.ThrowIfNull(command);
ArgumentNullException.ThrowIfNull(subcommand);
command.Add(subcommand);
return command;
}
public static Command AddOption(this Command command, Option option)
{
ArgumentNullException.ThrowIfNull(command);
ArgumentNullException.ThrowIfNull(option);
command.Add(option);
return command;
}
public static Command AddArgument(this Command command, Argument argument)
{
ArgumentNullException.ThrowIfNull(command);
ArgumentNullException.ThrowIfNull(argument);
command.Add(argument);
return command;
}
public static RootCommand AddGlobalOption(this RootCommand command, Option option)
{
ArgumentNullException.ThrowIfNull(command);
ArgumentNullException.ThrowIfNull(option);
option.Recursive = true;
command.Add(option);
return command;
}
public static void SetHandler(this Command command, Action handler)
{
ArgumentNullException.ThrowIfNull(command);
ArgumentNullException.ThrowIfNull(handler);
command.SetAction(_ => handler());
}
public static void SetHandler(this Command command, Func<Task> handler)
{
ArgumentNullException.ThrowIfNull(command);
ArgumentNullException.ThrowIfNull(handler);
command.SetAction(_ => handler());
}
public static void SetHandler(this Command command, Func<Task<int>> handler)
{
ArgumentNullException.ThrowIfNull(command);
ArgumentNullException.ThrowIfNull(handler);
command.SetAction(_ => handler());
}
public static void SetHandler<T1>(this Command command, Action<T1> handler, Symbol symbol1)
{
ArgumentNullException.ThrowIfNull(command);
ArgumentNullException.ThrowIfNull(handler);
command.SetAction(parseResult => handler(GetValue<T1>(parseResult, symbol1)));
}
public static void SetHandler<T1>(this Command command, Func<T1, Task> handler, Symbol symbol1)
{
ArgumentNullException.ThrowIfNull(command);
ArgumentNullException.ThrowIfNull(handler);
command.SetAction(parseResult => handler(GetValue<T1>(parseResult, symbol1)));
}
public static void SetHandler<T1>(this Command command, Func<T1, Task<int>> handler, Symbol symbol1)
{
ArgumentNullException.ThrowIfNull(command);
ArgumentNullException.ThrowIfNull(handler);
command.SetAction(parseResult => handler(GetValue<T1>(parseResult, symbol1)));
}
public static void SetHandler<T1, T2>(this Command command, Action<T1, T2> handler, Symbol symbol1, Symbol symbol2)
{
ArgumentNullException.ThrowIfNull(command);
ArgumentNullException.ThrowIfNull(handler);
command.SetAction(parseResult =>
handler(
GetValue<T1>(parseResult, symbol1),
GetValue<T2>(parseResult, symbol2)));
}
public static void SetHandler<T1, T2>(this Command command, Func<T1, T2, Task> handler, Symbol symbol1, Symbol symbol2)
{
ArgumentNullException.ThrowIfNull(command);
ArgumentNullException.ThrowIfNull(handler);
command.SetAction(parseResult =>
handler(
GetValue<T1>(parseResult, symbol1),
GetValue<T2>(parseResult, symbol2)));
}
public static void SetHandler<T1, T2>(this Command command, Func<T1, T2, Task<int>> handler, Symbol symbol1, Symbol symbol2)
{
ArgumentNullException.ThrowIfNull(command);
ArgumentNullException.ThrowIfNull(handler);
command.SetAction(parseResult =>
handler(
GetValue<T1>(parseResult, symbol1),
GetValue<T2>(parseResult, symbol2)));
}
public static void SetHandler<T1, T2, T3>(
this Command command,
Action<T1, T2, T3> handler,
Symbol symbol1,
Symbol symbol2,
Symbol symbol3)
{
ArgumentNullException.ThrowIfNull(command);
ArgumentNullException.ThrowIfNull(handler);
command.SetAction(parseResult =>
handler(
GetValue<T1>(parseResult, symbol1),
GetValue<T2>(parseResult, symbol2),
GetValue<T3>(parseResult, symbol3)));
}
public static void SetHandler<T1, T2, T3>(
this Command command,
Func<T1, T2, T3, Task> handler,
Symbol symbol1,
Symbol symbol2,
Symbol symbol3)
{
ArgumentNullException.ThrowIfNull(command);
ArgumentNullException.ThrowIfNull(handler);
command.SetAction(parseResult =>
handler(
GetValue<T1>(parseResult, symbol1),
GetValue<T2>(parseResult, symbol2),
GetValue<T3>(parseResult, symbol3)));
}
public static void SetHandler<T1, T2, T3>(
this Command command,
Func<T1, T2, T3, Task<int>> handler,
Symbol symbol1,
Symbol symbol2,
Symbol symbol3)
{
ArgumentNullException.ThrowIfNull(command);
ArgumentNullException.ThrowIfNull(handler);
command.SetAction(parseResult =>
handler(
GetValue<T1>(parseResult, symbol1),
GetValue<T2>(parseResult, symbol2),
GetValue<T3>(parseResult, symbol3)));
}
public static void SetHandler<T1, T2, T3, T4>(
this Command command,
Func<T1, T2, T3, T4, Task> handler,
Symbol symbol1,
Symbol symbol2,
Symbol symbol3,
Symbol symbol4)
{
ArgumentNullException.ThrowIfNull(command);
ArgumentNullException.ThrowIfNull(handler);
command.SetAction(parseResult =>
handler(
GetValue<T1>(parseResult, symbol1),
GetValue<T2>(parseResult, symbol2),
GetValue<T3>(parseResult, symbol3),
GetValue<T4>(parseResult, symbol4)));
}
public static void SetHandler<T1, T2, T3, T4>(
this Command command,
Func<T1, T2, T3, T4, Task<int>> handler,
Symbol symbol1,
Symbol symbol2,
Symbol symbol3,
Symbol symbol4)
{
ArgumentNullException.ThrowIfNull(command);
ArgumentNullException.ThrowIfNull(handler);
command.SetAction(parseResult =>
handler(
GetValue<T1>(parseResult, symbol1),
GetValue<T2>(parseResult, symbol2),
GetValue<T3>(parseResult, symbol3),
GetValue<T4>(parseResult, symbol4)));
}
public static void SetHandler<T1, T2, T3, T4, T5>(
this Command command,
Func<T1, T2, T3, T4, T5, Task> handler,
Symbol symbol1,
Symbol symbol2,
Symbol symbol3,
Symbol symbol4,
Symbol symbol5)
{
ArgumentNullException.ThrowIfNull(command);
ArgumentNullException.ThrowIfNull(handler);
command.SetAction(parseResult =>
handler(
GetValue<T1>(parseResult, symbol1),
GetValue<T2>(parseResult, symbol2),
GetValue<T3>(parseResult, symbol3),
GetValue<T4>(parseResult, symbol4),
GetValue<T5>(parseResult, symbol5)));
}
public static void SetHandler<T1, T2, T3, T4, T5>(
this Command command,
Func<T1, T2, T3, T4, T5, Task<int>> handler,
Symbol symbol1,
Symbol symbol2,
Symbol symbol3,
Symbol symbol4,
Symbol symbol5)
{
ArgumentNullException.ThrowIfNull(command);
ArgumentNullException.ThrowIfNull(handler);
command.SetAction(parseResult =>
handler(
GetValue<T1>(parseResult, symbol1),
GetValue<T2>(parseResult, symbol2),
GetValue<T3>(parseResult, symbol3),
GetValue<T4>(parseResult, symbol4),
GetValue<T5>(parseResult, symbol5)));
}
public static void SetHandler<T1, T2, T3, T4, T5, T6>(
this Command command,
Func<T1, T2, T3, T4, T5, T6, Task> handler,
Symbol symbol1,
Symbol symbol2,
Symbol symbol3,
Symbol symbol4,
Symbol symbol5,
Symbol symbol6)
{
ArgumentNullException.ThrowIfNull(command);
ArgumentNullException.ThrowIfNull(handler);
command.SetAction(parseResult =>
handler(
GetValue<T1>(parseResult, symbol1),
GetValue<T2>(parseResult, symbol2),
GetValue<T3>(parseResult, symbol3),
GetValue<T4>(parseResult, symbol4),
GetValue<T5>(parseResult, symbol5),
GetValue<T6>(parseResult, symbol6)));
}
public static void SetHandler<T1, T2, T3, T4, T5, T6>(
this Command command,
Func<T1, T2, T3, T4, T5, T6, Task<int>> handler,
Symbol symbol1,
Symbol symbol2,
Symbol symbol3,
Symbol symbol4,
Symbol symbol5,
Symbol symbol6)
{
ArgumentNullException.ThrowIfNull(command);
ArgumentNullException.ThrowIfNull(handler);
command.SetAction(parseResult =>
handler(
GetValue<T1>(parseResult, symbol1),
GetValue<T2>(parseResult, symbol2),
GetValue<T3>(parseResult, symbol3),
GetValue<T4>(parseResult, symbol4),
GetValue<T5>(parseResult, symbol5),
GetValue<T6>(parseResult, symbol6)));
}
public static void SetHandler(this Command command, Action<InvocationContext> handler)
{
ArgumentNullException.ThrowIfNull(command);
ArgumentNullException.ThrowIfNull(handler);
command.SetAction((parseResult, cancellationToken) =>
{
handler(new InvocationContext(parseResult, cancellationToken));
return Task.CompletedTask;
});
}
public static void SetHandler(this Command command, Func<InvocationContext, Task> handler)
{
ArgumentNullException.ThrowIfNull(command);
ArgumentNullException.ThrowIfNull(handler);
command.SetAction((parseResult, cancellationToken) => handler(new InvocationContext(parseResult, cancellationToken)));
}
public static void SetHandler<T1>(this Command command, Action<T1> handler, Option<T1> option1)
=> command.SetHandler(handler, (Symbol)option1);
public static void SetHandler<T1>(this Command command, Func<T1, Task> handler, Option<T1> option1)
=> command.SetHandler(handler, (Symbol)option1);
public static void SetHandler<T1>(this Command command, Func<T1, Task<int>> handler, Option<T1> option1)
=> command.SetHandler(handler, (Symbol)option1);
public static void SetHandler<T1, T2>(this Command command, Action<T1, T2> handler, Option<T1> option1, Option<T2> option2)
=> command.SetHandler(handler, (Symbol)option1, (Symbol)option2);
public static void SetHandler<T1, T2>(this Command command, Func<T1, T2, Task> handler, Option<T1> option1, Option<T2> option2)
=> command.SetHandler(handler, (Symbol)option1, (Symbol)option2);
public static void SetHandler<T1, T2>(this Command command, Func<T1, T2, Task<int>> handler, Option<T1> option1, Option<T2> option2)
=> command.SetHandler(handler, (Symbol)option1, (Symbol)option2);
public static void SetHandler<T1, T2, T3>(
this Command command,
Action<T1, T2, T3> handler,
Option<T1> option1,
Option<T2> option2,
Option<T3> option3)
=> command.SetHandler(handler, (Symbol)option1, (Symbol)option2, (Symbol)option3);
public static void SetHandler<T1, T2, T3>(
this Command command,
Func<T1, T2, T3, Task> handler,
Option<T1> option1,
Option<T2> option2,
Option<T3> option3)
=> command.SetHandler(handler, (Symbol)option1, (Symbol)option2, (Symbol)option3);
public static void SetHandler<T1, T2, T3>(
this Command command,
Func<T1, T2, T3, Task<int>> handler,
Option<T1> option1,
Option<T2> option2,
Option<T3> option3)
=> command.SetHandler(handler, (Symbol)option1, (Symbol)option2, (Symbol)option3);
public static void SetHandler<T1, T2, T3, T4>(
this Command command,
Func<T1, T2, T3, T4, Task> handler,
Option<T1> option1,
Option<T2> option2,
Option<T3> option3,
Option<T4> option4)
=> command.SetHandler(handler, (Symbol)option1, (Symbol)option2, (Symbol)option3, (Symbol)option4);
public static void SetHandler<T1, T2, T3, T4>(
this Command command,
Func<T1, T2, T3, T4, Task<int>> handler,
Option<T1> option1,
Option<T2> option2,
Option<T3> option3,
Option<T4> option4)
=> command.SetHandler(handler, (Symbol)option1, (Symbol)option2, (Symbol)option3, (Symbol)option4);
public static void SetHandler<T1, T2, T3, T4, T5>(
this Command command,
Func<T1, T2, T3, T4, T5, Task> handler,
Option<T1> option1,
Option<T2> option2,
Option<T3> option3,
Option<T4> option4,
Option<T5> option5)
=> command.SetHandler(handler, (Symbol)option1, (Symbol)option2, (Symbol)option3, (Symbol)option4, (Symbol)option5);
public static void SetHandler<T1, T2, T3, T4, T5>(
this Command command,
Func<T1, T2, T3, T4, T5, Task<int>> handler,
Option<T1> option1,
Option<T2> option2,
Option<T3> option3,
Option<T4> option4,
Option<T5> option5)
=> command.SetHandler(handler, (Symbol)option1, (Symbol)option2, (Symbol)option3, (Symbol)option4, (Symbol)option5);
public static void SetHandler<T1, T2, T3, T4, T5, T6>(
this Command command,
Func<T1, T2, T3, T4, T5, T6, Task> handler,
Option<T1> option1,
Option<T2> option2,
Option<T3> option3,
Option<T4> option4,
Option<T5> option5,
Option<T6> option6)
=> command.SetHandler(handler, (Symbol)option1, (Symbol)option2, (Symbol)option3, (Symbol)option4, (Symbol)option5, (Symbol)option6);
public static void SetHandler<T1, T2, T3, T4, T5, T6>(
this Command command,
Func<T1, T2, T3, T4, T5, T6, Task<int>> handler,
Option<T1> option1,
Option<T2> option2,
Option<T3> option3,
Option<T4> option4,
Option<T5> option5,
Option<T6> option6)
=> command.SetHandler(handler, (Symbol)option1, (Symbol)option2, (Symbol)option3, (Symbol)option4, (Symbol)option5, (Symbol)option6);
private static T GetValue<T>(ParseResult parseResult, Symbol symbol)
{
ArgumentNullException.ThrowIfNull(parseResult);
ArgumentNullException.ThrowIfNull(symbol);
if (symbol is Option<T> option)
{
return parseResult.GetValue(option)!;
}
if (symbol is Argument<T> argument)
{
return parseResult.GetValue(argument)!;
}
return parseResult.GetValue<T>(symbol.Name)!;
}
}
public sealed class InvocationContext
{
public InvocationContext(ParseResult parseResult, CancellationToken cancellationToken)
{
ParseResult = parseResult ?? throw new ArgumentNullException(nameof(parseResult));
CancellationToken = cancellationToken;
}
public ParseResult ParseResult { get; }
public CancellationToken CancellationToken { get; }
public int ExitCode { get; set; }
public CancellationToken GetCancellationToken() => CancellationToken;
}

View File

@@ -1,5 +1,6 @@
using System;
using System.CommandLine;
using System.CommandLine.Parsing;
namespace StellaOps.Cli.Extensions;
/// <summary>
@@ -18,6 +19,16 @@ public static class CommandLineExtensions
return option;
}
/// <summary>
/// Set a default value for an argument.
/// </summary>
public static Argument<T> SetDefaultValue<T>(this Argument<T> argument, T defaultValue)
{
ArgumentNullException.ThrowIfNull(argument);
argument.DefaultValueFactory = _ => defaultValue;
return argument;
}
/// <summary>
/// Restrict the option to a fixed set of values and add completions.
/// </summary>
@@ -41,4 +52,35 @@ public static class CommandLineExtensions
option.Required = isRequired;
return option;
}
/// <summary>
/// Add an alias to an option.
/// </summary>
public static Option<T> AddAlias<T>(this Option<T> option, string alias)
{
ArgumentNullException.ThrowIfNull(option);
ArgumentException.ThrowIfNullOrWhiteSpace(alias);
option.Aliases.Add(alias);
return option;
}
/// <summary>
/// Compatibility shim for GetValueForOption.
/// </summary>
public static T? GetValueForOption<T>(this ParseResult parseResult, Option<T> option)
{
ArgumentNullException.ThrowIfNull(parseResult);
ArgumentNullException.ThrowIfNull(option);
return parseResult.GetValue(option);
}
/// <summary>
/// Compatibility shim for GetValueForArgument.
/// </summary>
public static T? GetValueForArgument<T>(this ParseResult parseResult, Argument<T> argument)
{
ArgumentNullException.ThrowIfNull(parseResult);
ArgumentNullException.ThrowIfNull(argument);
return parseResult.GetValue(argument);
}
}

View File

@@ -123,7 +123,7 @@ public sealed class CommandGroupBuilder
{
var command = new Command(_name, _description)
{
IsHidden = _isHidden,
Hidden = _isHidden,
};
// Add all subcommands
@@ -159,7 +159,7 @@ public sealed class CommandGroupBuilder
{
var clone = new Command(newName, original.Description)
{
IsHidden = original.IsHidden,
Hidden = original.Hidden,
};
foreach (var option in original.Options)
@@ -177,9 +177,9 @@ public sealed class CommandGroupBuilder
clone.AddCommand(subcommand);
}
if (original.Handler is not null)
if (original.Action is not null)
{
clone.Handler = original.Handler;
clone.Action = original.Action;
}
return clone;

View File

@@ -103,7 +103,7 @@ public sealed class CommandRouter : ICommandRouter
var aliasCommand = new Command(aliasName, $"Alias for '{canonicalCommand.Name}'")
{
IsHidden = route?.IsDeprecated ?? false, // Hide deprecated commands from help
Hidden = route?.IsDeprecated ?? false, // Hide deprecated commands from help
};
// Copy all options from canonical command
@@ -119,7 +119,7 @@ public sealed class CommandRouter : ICommandRouter
}
// Set handler that shows warning (if deprecated) and delegates to canonical
aliasCommand.SetHandler(async (context) =>
aliasCommand.SetAction(async (parseResult, ct) =>
{
if (route?.IsDeprecated == true)
{
@@ -127,9 +127,13 @@ public sealed class CommandRouter : ICommandRouter
}
// Delegate to canonical command's handler
if (canonicalCommand.Handler is not null)
if (canonicalCommand.Action is AsynchronousCommandLineAction asyncAction)
{
await canonicalCommand.Handler.InvokeAsync(context);
await asyncAction.InvokeAsync(parseResult, ct);
}
else if (canonicalCommand.Action is SynchronousCommandLineAction syncAction)
{
syncAction.Invoke(parseResult);
}
});

View File

@@ -15,6 +15,7 @@ using StellaOps.Cli.Configuration;
using StellaOps.Cli.Services;
using StellaOps.Cli.Telemetry;
using StellaOps.AirGap.Policy;
using StellaOps.AirGap.Bundle.Services;
using StellaOps.Configuration;
using StellaOps.Attestor.StandardPredicates.BinaryDiff;
using StellaOps.Policy.Scoring.Engine;
@@ -29,9 +30,6 @@ using StellaOps.Doctor.DependencyInjection;
using StellaOps.Doctor.Plugins.Core.DependencyInjection;
using StellaOps.Doctor.Plugins.Database.DependencyInjection;
using StellaOps.Doctor.Plugin.BinaryAnalysis.DependencyInjection;
#if DEBUG || STELLAOPS_ENABLE_SIMULATOR
using StellaOps.Cryptography.Plugin.SimRemote.DependencyInjection;
#endif
namespace StellaOps.Cli;
@@ -42,6 +40,7 @@ internal static class Program
var (options, configuration) = CliBootstrapper.Build(args);
var services = new ServiceCollection();
services.AddSingleton<IConfiguration>(configuration);
services.AddSingleton(configuration);
services.AddSingleton(options);
services.AddOptions();
@@ -65,9 +64,6 @@ internal static class Program
services.AddSmRemoteCryptoProvider(configuration);
#endif
#if DEBUG || STELLAOPS_ENABLE_SIMULATOR
services.AddSimRemoteCryptoProvider(configuration);
#endif
// CLI-AIRGAP-56-002: Add sealed mode telemetry for air-gapped operation
services.AddSealedModeTelemetryIfOffline(
@@ -343,6 +339,7 @@ internal static class Program
services.AddSingleton<StellaOps.AirGap.Importer.Repositories.IBundleItemRepository,
StellaOps.AirGap.Importer.Repositories.InMemoryBundleItemRepository>();
services.AddSingleton<IMirrorBundleImportService, MirrorBundleImportService>();
services.AddSingleton<TrustProfileLoader>();
// CLI-CRYPTO-4100-001: Crypto profile validator
services.AddSingleton<CryptoProfileValidator>();

View File

@@ -896,6 +896,270 @@ internal sealed class BackendOperationsClient : IBackendOperationsClient
return MapPolicyFindingExplain(document);
}
public async Task<AnalyticsListResponse<AnalyticsSupplierConcentration>> GetAnalyticsSuppliersAsync(
int? limit,
string? environment,
CancellationToken cancellationToken)
{
EnsureBackendConfigured();
var query = BuildAnalyticsQueryString(environment: environment, limit: limit);
using var request = CreateRequest(HttpMethod.Get, $"api/analytics/suppliers{query}");
await AuthorizeRequestAsync(request, cancellationToken).ConfigureAwait(false);
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
var (message, _) = await CreateFailureDetailsAsync(response, cancellationToken).ConfigureAwait(false);
throw new InvalidOperationException(message);
}
AnalyticsListResponse<AnalyticsSupplierConcentration>? result;
try
{
result = await response.Content.ReadFromJsonAsync<AnalyticsListResponse<AnalyticsSupplierConcentration>>(SerializerOptions, cancellationToken).ConfigureAwait(false);
}
catch (JsonException ex)
{
var raw = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
throw new InvalidOperationException($"Failed to parse analytics suppliers response: {ex.Message}", ex)
{
Data = { ["payload"] = raw }
};
}
if (result is null)
{
throw new InvalidOperationException("Analytics suppliers response was empty.");
}
return result;
}
public async Task<AnalyticsListResponse<AnalyticsLicenseDistribution>> GetAnalyticsLicensesAsync(
string? environment,
CancellationToken cancellationToken)
{
EnsureBackendConfigured();
var query = BuildAnalyticsQueryString(environment: environment);
using var request = CreateRequest(HttpMethod.Get, $"api/analytics/licenses{query}");
await AuthorizeRequestAsync(request, cancellationToken).ConfigureAwait(false);
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
var (message, _) = await CreateFailureDetailsAsync(response, cancellationToken).ConfigureAwait(false);
throw new InvalidOperationException(message);
}
AnalyticsListResponse<AnalyticsLicenseDistribution>? result;
try
{
result = await response.Content.ReadFromJsonAsync<AnalyticsListResponse<AnalyticsLicenseDistribution>>(SerializerOptions, cancellationToken).ConfigureAwait(false);
}
catch (JsonException ex)
{
var raw = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
throw new InvalidOperationException($"Failed to parse analytics licenses response: {ex.Message}", ex)
{
Data = { ["payload"] = raw }
};
}
if (result is null)
{
throw new InvalidOperationException("Analytics licenses response was empty.");
}
return result;
}
public async Task<AnalyticsListResponse<AnalyticsVulnerabilityExposure>> GetAnalyticsVulnerabilitiesAsync(string? environment, string? minSeverity, CancellationToken cancellationToken)
{
EnsureBackendConfigured();
var query = BuildAnalyticsQueryString(environment: environment, minSeverity: minSeverity);
using var request = CreateRequest(HttpMethod.Get, $"api/analytics/vulnerabilities{query}");
await AuthorizeRequestAsync(request, cancellationToken).ConfigureAwait(false);
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
var (message, _) = await CreateFailureDetailsAsync(response, cancellationToken).ConfigureAwait(false);
throw new InvalidOperationException(message);
}
AnalyticsListResponse<AnalyticsVulnerabilityExposure>? result;
try
{
result = await response.Content.ReadFromJsonAsync<AnalyticsListResponse<AnalyticsVulnerabilityExposure>>(SerializerOptions, cancellationToken).ConfigureAwait(false);
}
catch (JsonException ex)
{
var raw = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
throw new InvalidOperationException($"Failed to parse analytics vulnerabilities response: {ex.Message}", ex)
{
Data = { ["payload"] = raw }
};
}
if (result is null)
{
throw new InvalidOperationException("Analytics vulnerabilities response was empty.");
}
return result;
}
public async Task<AnalyticsListResponse<AnalyticsFixableBacklogItem>> GetAnalyticsBacklogAsync(string? environment, CancellationToken cancellationToken)
{
EnsureBackendConfigured();
var query = BuildAnalyticsQueryString(environment: environment);
using var request = CreateRequest(HttpMethod.Get, $"api/analytics/backlog{query}");
await AuthorizeRequestAsync(request, cancellationToken).ConfigureAwait(false);
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
var (message, _) = await CreateFailureDetailsAsync(response, cancellationToken).ConfigureAwait(false);
throw new InvalidOperationException(message);
}
AnalyticsListResponse<AnalyticsFixableBacklogItem>? result;
try
{
result = await response.Content.ReadFromJsonAsync<AnalyticsListResponse<AnalyticsFixableBacklogItem>>(SerializerOptions, cancellationToken).ConfigureAwait(false);
}
catch (JsonException ex)
{
var raw = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
throw new InvalidOperationException($"Failed to parse analytics backlog response: {ex.Message}", ex)
{
Data = { ["payload"] = raw }
};
}
if (result is null)
{
throw new InvalidOperationException("Analytics backlog response was empty.");
}
return result;
}
public async Task<AnalyticsListResponse<AnalyticsAttestationCoverage>> GetAnalyticsAttestationCoverageAsync(string? environment, CancellationToken cancellationToken)
{
EnsureBackendConfigured();
var query = BuildAnalyticsQueryString(environment: environment);
using var request = CreateRequest(HttpMethod.Get, $"api/analytics/attestation-coverage{query}");
await AuthorizeRequestAsync(request, cancellationToken).ConfigureAwait(false);
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
var (message, _) = await CreateFailureDetailsAsync(response, cancellationToken).ConfigureAwait(false);
throw new InvalidOperationException(message);
}
AnalyticsListResponse<AnalyticsAttestationCoverage>? result;
try
{
result = await response.Content.ReadFromJsonAsync<AnalyticsListResponse<AnalyticsAttestationCoverage>>(SerializerOptions, cancellationToken).ConfigureAwait(false);
}
catch (JsonException ex)
{
var raw = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
throw new InvalidOperationException($"Failed to parse analytics attestation coverage response: {ex.Message}", ex)
{
Data = { ["payload"] = raw }
};
}
if (result is null)
{
throw new InvalidOperationException("Analytics attestation coverage response was empty.");
}
return result;
}
public async Task<AnalyticsListResponse<AnalyticsVulnerabilityTrendPoint>> GetAnalyticsVulnerabilityTrendsAsync(string? environment, int? days, CancellationToken cancellationToken)
{
EnsureBackendConfigured();
var query = BuildAnalyticsQueryString(environment: environment, days: days);
using var request = CreateRequest(HttpMethod.Get, $"api/analytics/trends/vulnerabilities{query}");
await AuthorizeRequestAsync(request, cancellationToken).ConfigureAwait(false);
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
var (message, _) = await CreateFailureDetailsAsync(response, cancellationToken).ConfigureAwait(false);
throw new InvalidOperationException(message);
}
AnalyticsListResponse<AnalyticsVulnerabilityTrendPoint>? result;
try
{
result = await response.Content.ReadFromJsonAsync<AnalyticsListResponse<AnalyticsVulnerabilityTrendPoint>>(SerializerOptions, cancellationToken).ConfigureAwait(false);
}
catch (JsonException ex)
{
var raw = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
throw new InvalidOperationException($"Failed to parse analytics vulnerability trends response: {ex.Message}", ex)
{
Data = { ["payload"] = raw }
};
}
if (result is null)
{
throw new InvalidOperationException("Analytics vulnerability trends response was empty.");
}
return result;
}
public async Task<AnalyticsListResponse<AnalyticsComponentTrendPoint>> GetAnalyticsComponentTrendsAsync(string? environment, int? days, CancellationToken cancellationToken)
{
EnsureBackendConfigured();
var query = BuildAnalyticsQueryString(environment: environment, days: days);
using var request = CreateRequest(HttpMethod.Get, $"api/analytics/trends/components{query}");
await AuthorizeRequestAsync(request, cancellationToken).ConfigureAwait(false);
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
var (message, _) = await CreateFailureDetailsAsync(response, cancellationToken).ConfigureAwait(false);
throw new InvalidOperationException(message);
}
AnalyticsListResponse<AnalyticsComponentTrendPoint>? result;
try
{
result = await response.Content.ReadFromJsonAsync<AnalyticsListResponse<AnalyticsComponentTrendPoint>>(SerializerOptions, cancellationToken).ConfigureAwait(false);
}
catch (JsonException ex)
{
var raw = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
throw new InvalidOperationException($"Failed to parse analytics component trends response: {ex.Message}", ex)
{
Data = { ["payload"] = raw }
};
}
if (result is null)
{
throw new InvalidOperationException("Analytics component trends response was empty.");
}
return result;
}
public async Task<EntryTraceResponseModel?> GetEntryTraceAsync(string scanId, CancellationToken cancellationToken)
{
EnsureBackendConfigured();
@@ -2055,6 +2319,21 @@ internal sealed class BackendOperationsClient : IBackendOperationsClient
request.Headers.TryAddWithoutValidation(AdvisoryScopesHeader, combined);
}
private static void ApplyTenantHeader(HttpRequestMessage request, string? tenantId)
{
if (request is null)
{
throw new ArgumentNullException(nameof(request));
}
if (string.IsNullOrWhiteSpace(tenantId))
{
return;
}
request.Headers.TryAddWithoutValidation("X-Tenant-Id", tenantId.Trim());
}
private HttpRequestMessage CreateRequest(HttpMethod method, string relativeUri)
{
if (!Uri.TryCreate(relativeUri, UriKind.RelativeOrAbsolute, out var requestUri))
@@ -2427,6 +2706,42 @@ internal sealed class BackendOperationsClient : IBackendOperationsClient
return "?" + string.Join("&", parameters);
}
private static string BuildAnalyticsQueryString(
string? environment = null,
string? minSeverity = null,
int? days = null,
int? limit = null)
{
var parameters = new List<string>();
if (!string.IsNullOrWhiteSpace(environment))
{
parameters.Add($"environment={Uri.EscapeDataString(environment.Trim())}");
}
if (!string.IsNullOrWhiteSpace(minSeverity))
{
parameters.Add($"minSeverity={Uri.EscapeDataString(minSeverity.Trim())}");
}
if (days.HasValue)
{
parameters.Add($"days={days.Value.ToString(CultureInfo.InvariantCulture)}");
}
if (limit.HasValue)
{
parameters.Add($"limit={limit.Value.ToString(CultureInfo.InvariantCulture)}");
}
if (parameters.Count == 0)
{
return string.Empty;
}
return "?" + string.Join("&", parameters);
}
private static PolicyFindingsPage MapPolicyFindings(PolicyFindingsResponseDocument document)
{
var items = document.Items is null

View File

@@ -48,6 +48,15 @@ internal interface IBackendOperationsClient
Task<PolicyFindingExplainResult> GetPolicyFindingExplainAsync(string policyId, string findingId, string? mode, CancellationToken cancellationToken);
// CLI-ANALYTICS-32-001: SBOM lake analytics endpoints
Task<AnalyticsListResponse<AnalyticsSupplierConcentration>> GetAnalyticsSuppliersAsync(int? limit, string? environment, CancellationToken cancellationToken);
Task<AnalyticsListResponse<AnalyticsLicenseDistribution>> GetAnalyticsLicensesAsync(string? environment, CancellationToken cancellationToken);
Task<AnalyticsListResponse<AnalyticsVulnerabilityExposure>> GetAnalyticsVulnerabilitiesAsync(string? environment, string? minSeverity, CancellationToken cancellationToken);
Task<AnalyticsListResponse<AnalyticsFixableBacklogItem>> GetAnalyticsBacklogAsync(string? environment, CancellationToken cancellationToken);
Task<AnalyticsListResponse<AnalyticsAttestationCoverage>> GetAnalyticsAttestationCoverageAsync(string? environment, CancellationToken cancellationToken);
Task<AnalyticsListResponse<AnalyticsVulnerabilityTrendPoint>> GetAnalyticsVulnerabilityTrendsAsync(string? environment, int? days, CancellationToken cancellationToken);
Task<AnalyticsListResponse<AnalyticsComponentTrendPoint>> GetAnalyticsComponentTrendsAsync(string? environment, int? days, CancellationToken cancellationToken);
Task<EntryTraceResponseModel?> GetEntryTraceAsync(string scanId, CancellationToken cancellationToken);
Task<RubyPackageInventoryModel?> GetRubyPackagesAsync(string scanId, CancellationToken cancellationToken);

View File

@@ -0,0 +1,81 @@
using System;
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace StellaOps.Cli.Services.Models;
// CLI-ANALYTICS-32-001: Analytics SBOM lake response models.
internal sealed record AnalyticsListResponse<T>(
[property: JsonPropertyName("tenantId")] string TenantId,
[property: JsonPropertyName("actorId")] string ActorId,
[property: JsonPropertyName("dataAsOf")] DateTimeOffset DataAsOf,
[property: JsonPropertyName("cached")] bool Cached,
[property: JsonPropertyName("cacheTtlSeconds")] int CacheTtlSeconds,
[property: JsonPropertyName("items")] IReadOnlyList<T> Items,
[property: JsonPropertyName("count")] int Count,
[property: JsonPropertyName("limit")] int? Limit = null,
[property: JsonPropertyName("offset")] int? Offset = null,
[property: JsonPropertyName("query")] string? Query = null);
internal sealed record AnalyticsSupplierConcentration(
[property: JsonPropertyName("supplier")] string Supplier,
[property: JsonPropertyName("componentCount")] int ComponentCount,
[property: JsonPropertyName("artifactCount")] int ArtifactCount,
[property: JsonPropertyName("teamCount")] int TeamCount,
[property: JsonPropertyName("criticalVulnCount")] int CriticalVulnCount,
[property: JsonPropertyName("highVulnCount")] int HighVulnCount,
[property: JsonPropertyName("environments")] IReadOnlyList<string>? Environments);
internal sealed record AnalyticsLicenseDistribution(
[property: JsonPropertyName("licenseConcluded")] string? LicenseConcluded,
[property: JsonPropertyName("licenseCategory")] string LicenseCategory,
[property: JsonPropertyName("componentCount")] int ComponentCount,
[property: JsonPropertyName("artifactCount")] int ArtifactCount,
[property: JsonPropertyName("ecosystems")] IReadOnlyList<string>? Ecosystems);
internal sealed record AnalyticsVulnerabilityExposure(
[property: JsonPropertyName("vulnId")] string VulnId,
[property: JsonPropertyName("severity")] string Severity,
[property: JsonPropertyName("cvssScore")] decimal? CvssScore,
[property: JsonPropertyName("epssScore")] decimal? EpssScore,
[property: JsonPropertyName("kevListed")] bool KevListed,
[property: JsonPropertyName("fixAvailable")] bool FixAvailable,
[property: JsonPropertyName("rawComponentCount")] int RawComponentCount,
[property: JsonPropertyName("rawArtifactCount")] int RawArtifactCount,
[property: JsonPropertyName("effectiveComponentCount")] int EffectiveComponentCount,
[property: JsonPropertyName("effectiveArtifactCount")] int EffectiveArtifactCount,
[property: JsonPropertyName("vexMitigated")] int VexMitigated);
internal sealed record AnalyticsFixableBacklogItem(
[property: JsonPropertyName("service")] string Service,
[property: JsonPropertyName("environment")] string Environment,
[property: JsonPropertyName("component")] string Component,
[property: JsonPropertyName("version")] string? Version,
[property: JsonPropertyName("vulnId")] string VulnId,
[property: JsonPropertyName("severity")] string Severity,
[property: JsonPropertyName("fixedVersion")] string? FixedVersion);
internal sealed record AnalyticsAttestationCoverage(
[property: JsonPropertyName("environment")] string Environment,
[property: JsonPropertyName("team")] string? Team,
[property: JsonPropertyName("totalArtifacts")] int TotalArtifacts,
[property: JsonPropertyName("withProvenance")] int WithProvenance,
[property: JsonPropertyName("provenancePct")] decimal? ProvenancePct,
[property: JsonPropertyName("slsaLevel2Plus")] int SlsaLevel2Plus,
[property: JsonPropertyName("slsa2Pct")] decimal? Slsa2Pct,
[property: JsonPropertyName("missingProvenance")] int MissingProvenance);
internal sealed record AnalyticsVulnerabilityTrendPoint(
[property: JsonPropertyName("snapshotDate")] DateTimeOffset SnapshotDate,
[property: JsonPropertyName("environment")] string Environment,
[property: JsonPropertyName("totalVulns")] int TotalVulns,
[property: JsonPropertyName("fixableVulns")] int FixableVulns,
[property: JsonPropertyName("vexMitigated")] int VexMitigated,
[property: JsonPropertyName("netExposure")] int NetExposure,
[property: JsonPropertyName("kevVulns")] int KevVulns);
internal sealed record AnalyticsComponentTrendPoint(
[property: JsonPropertyName("snapshotDate")] DateTimeOffset SnapshotDate,
[property: JsonPropertyName("environment")] string Environment,
[property: JsonPropertyName("totalComponents")] int TotalComponents,
[property: JsonPropertyName("uniqueSuppliers")] int UniqueSuppliers);

View File

@@ -26,6 +26,7 @@
<ItemGroup>
<Compile Remove="Commands\\BenchCommandBuilder.cs" />
<Compile Remove="Commands\\Agent\\BootstrapCommands.cs" />
<Compile Remove="Commands\\Proof\\AnchorCommandGroup.cs" />
<!-- ProofCommandGroup enabled for SPRINT_3500_0004_0001_cli_verbs T4 -->
<Compile Remove="Commands\\Proof\\ReceiptCommandGroup.cs" />
@@ -60,6 +61,7 @@
<ProjectReference Include="../../__Libraries/StellaOps.Canonical.Json/StellaOps.Canonical.Json.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.DeltaVerdict/StellaOps.DeltaVerdict.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Verdict/StellaOps.Verdict.csproj" />
<ProjectReference Include="../../AirGap/__Libraries/StellaOps.AirGap.Bundle/StellaOps.AirGap.Bundle.csproj" />
<ProjectReference Include="../../AirGap/StellaOps.AirGap.Policy/StellaOps.AirGap.Policy/StellaOps.AirGap.Policy.csproj" />
<ProjectReference Include="../../AirGap/StellaOps.AirGap.Importer/StellaOps.AirGap.Importer.csproj" />
<ProjectReference Include="../../Authority/StellaOps.Authority/StellaOps.Auth.Abstractions/StellaOps.Auth.Abstractions.csproj" />
@@ -76,6 +78,8 @@
<ProjectReference Include="../../Scanner/__Libraries/StellaOps.Scanner.Analyzers.Lang.Bun/StellaOps.Scanner.Analyzers.Lang.Bun.csproj" />
<ProjectReference Include="../../Scanner/__Libraries/StellaOps.Scanner.Surface.Env/StellaOps.Scanner.Surface.Env.csproj" />
<ProjectReference Include="../../Scanner/__Libraries/StellaOps.Scanner.Surface.Validation/StellaOps.Scanner.Surface.Validation.csproj" />
<!-- Sprint: SPRINT_20260119_022_Scanner_dependency_reachability (TASK-022-009) -->
<ProjectReference Include="../../Scanner/__Libraries/StellaOps.Scanner.Reachability/StellaOps.Scanner.Reachability.csproj" />
<ProjectReference Include="../../Policy/StellaOps.PolicyDsl/StellaOps.PolicyDsl.csproj" />
<ProjectReference Include="../../Policy/__Libraries/StellaOps.Policy/StellaOps.Policy.csproj" />
<ProjectReference Include="../../Policy/StellaOps.Policy.RiskProfile/StellaOps.Policy.RiskProfile.csproj" />
@@ -83,6 +87,7 @@
<ProjectReference Include="../../Attestor/StellaOps.Attestation/StellaOps.Attestation.csproj" />
<ProjectReference Include="../../Attestor/StellaOps.Attestor.Envelope/StellaOps.Attestor.Envelope.csproj" />
<ProjectReference Include="../../Attestor/StellaOps.Attestor/StellaOps.Attestor.Core/StellaOps.Attestor.Core.csproj" />
<ProjectReference Include="../../Attestor/__Libraries/StellaOps.Attestor.Timestamping/StellaOps.Attestor.Timestamping.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Infrastructure.Postgres/StellaOps.Infrastructure.Postgres.csproj" />
<ProjectReference Include="../../Authority/__Libraries/StellaOps.Authority.Persistence/StellaOps.Authority.Persistence.csproj" />
<ProjectReference Include="../../Scheduler/__Libraries/StellaOps.Scheduler.Persistence/StellaOps.Scheduler.Persistence.csproj" />
@@ -121,9 +126,12 @@
<ProjectReference Include="../../__Libraries/StellaOps.Doctor/StellaOps.Doctor.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Doctor.Plugins.Core/StellaOps.Doctor.Plugins.Core.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Doctor.Plugins.Database/StellaOps.Doctor.Plugins.Database.csproj" />
<ProjectReference Include="../Doctor/__Plugins/StellaOps.Doctor.Plugin.BinaryAnalysis/StellaOps.Doctor.Plugin.BinaryAnalysis.csproj" />
<ProjectReference Include="../../Doctor/__Plugins/StellaOps.Doctor.Plugin.BinaryAnalysis/StellaOps.Doctor.Plugin.BinaryAnalysis.csproj" />
<!-- Delta Scanning Engine (Sprint: SPRINT_20260118_026_Scanner_delta_scanning_engine) -->
<ProjectReference Include="../../Scanner/__Libraries/StellaOps.Scanner.Delta/StellaOps.Scanner.Delta.csproj" />
<!-- Ground-Truth Corpus (Sprint: SPRINT_20260121_036_BinaryIndex_golden_corpus_bundle_verification) -->
<ProjectReference Include="../../BinaryIndex/__Libraries/StellaOps.BinaryIndex.GroundTruth.Abstractions/StellaOps.BinaryIndex.GroundTruth.Abstractions.csproj" />
<ProjectReference Include="../../BinaryIndex/__Libraries/StellaOps.BinaryIndex.GroundTruth.Reproducible/StellaOps.BinaryIndex.GroundTruth.Reproducible.csproj" />
</ItemGroup>
<!-- GOST Crypto Plugins (Russia distribution) -->

View File

@@ -48,3 +48,11 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
| CLI-VEX-WEBHOOKS-0001 | DONE | SPRINT_20260117_009 - Add VEX webhooks commands. |
| CLI-BINARY-ANALYSIS-0001 | DONE | SPRINT_20260117_007 - Add binary fingerprint/diff tests. |
| ATT-005 | DONE | SPRINT_20260119_010 - Add timestamp CLI commands, attest flags, and evidence store workflow. |
| TASK-029-003 | DONE | SPRINT_20260120_029 - Add `stella bundle verify` report signing options. |
| TASK-029-004 | DONE | SPRINT_20260120_029 - Add trust profile commands and apply flow. |
| TASK-032-001 | BLOCKED | Analytics command tree delivered; validation blocked pending stable ingestion datasets. |
| TASK-032-002 | BLOCKED | Analytics handlers delivered; validation blocked pending endpoint stability. |
| TASK-032-003 | BLOCKED | Output formats delivered; validation blocked pending real datasets. |
| TASK-032-004 | BLOCKED | Fixtures/tests delivered; refresh blocked pending stabilized API responses. |
| TASK-032-005 | BLOCKED | Docs delivered; validation blocked pending stable API filters. |
| TASK-033-007 | DONE | Updated CLI compatibility shims; CLI + plugins build (SPRINT_20260120_033). |

View File

@@ -163,6 +163,9 @@ public sealed class LocalValidator
{
DirectoryPath = directoryPath,
IsValid = false,
TotalFiles = 0,
ValidFiles = 0,
InvalidFiles = 1,
Results = [new ValidationResult
{
IsValid = false,

View File

@@ -119,6 +119,15 @@
"type": "alias",
"reason": "Both paths remain valid"
},
// =============================================
// Analytics aliases (Sprint 032)
// =============================================
{
"old": "analytics sbom",
"new": "analytics sbom-lake",
"type": "alias",
"reason": "SBOM lake analytics group"
},
// =============================================
// Scanning consolidation (Sprint 013)
@@ -732,13 +741,6 @@
"removeIn": "3.0",
"reason": "Replay commands consolidated under evidence"
},
{
"old": "prove",
"new": "evidence proof",
"type": "deprecated",
"removeIn": "3.0",
"reason": "Proof commands consolidated under evidence"
},
{
"old": "proof",
"new": "evidence proof",