save progress

This commit is contained in:
StellaOps Bot
2026-01-04 14:54:52 +02:00
parent c49b03a254
commit 3098e84de4
132 changed files with 19783 additions and 31 deletions

View File

@@ -33,6 +33,9 @@ internal static class BinaryCommandGroup
binary.Add(BuildLookupCommand(services, verboseOption, cancellationToken));
binary.Add(BuildFingerprintCommand(services, verboseOption, cancellationToken));
// Sprint: SPRINT_20260104_001_CLI - Binary call graph digest extraction
binary.Add(BuildCallGraphCommand(services, verboseOption, cancellationToken));
return binary;
}
@@ -188,6 +191,70 @@ internal static class BinaryCommandGroup
return command;
}
// CALLGRAPH-01: stella binary callgraph
private static Command BuildCallGraphCommand(
IServiceProvider services,
Option<bool> verboseOption,
CancellationToken cancellationToken)
{
var fileArg = new Argument<string>("file")
{
Description = "Path to binary file to analyze."
};
var formatOption = new Option<string>("--format", ["-f"])
{
Description = "Output format: digest (default), json, summary."
}.SetDefaultValue("digest").FromAmong("digest", "json", "summary");
var outputOption = new Option<string?>("--output", ["-o"])
{
Description = "Output file path (default: stdout)."
};
var emitSbomOption = new Option<string?>("--emit-sbom")
{
Description = "Path to SBOM file to inject callgraph digest as property."
};
var scanIdOption = new Option<string?>("--scan-id")
{
Description = "Scan ID for graph metadata (default: auto-generated)."
};
var command = new Command("callgraph", "Extract call graph and compute deterministic digest.")
{
fileArg,
formatOption,
outputOption,
emitSbomOption,
scanIdOption,
verboseOption
};
command.SetAction(parseResult =>
{
var file = parseResult.GetValue(fileArg)!;
var format = parseResult.GetValue(formatOption)!;
var output = parseResult.GetValue(outputOption);
var emitSbom = parseResult.GetValue(emitSbomOption);
var scanId = parseResult.GetValue(scanIdOption);
var verbose = parseResult.GetValue(verboseOption);
return BinaryCommandHandlers.HandleCallGraphAsync(
services,
file,
format,
output,
emitSbom,
scanId,
verbose,
cancellationToken);
});
return command;
}
private static Command BuildSubmitCommand(
IServiceProvider services,
Option<bool> verboseOption,

View File

@@ -5,10 +5,14 @@
// Description: Command handlers for binary reachability operations.
// -----------------------------------------------------------------------------
using System.Globalization;
using System.Text.Json;
using System.Text.Json.Nodes;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Spectre.Console;
using StellaOps.Scanner.CallGraph;
using StellaOps.Scanner.CallGraph.Binary;
namespace StellaOps.Cli.Commands.Binary;
@@ -632,6 +636,238 @@ internal static class BinaryCommandHandlers
}
}
/// <summary>
/// Handle 'stella binary callgraph' command (CALLGRAPH-01).
/// Extracts call graph from native binary and computes deterministic SHA-256 digest.
/// </summary>
public static async Task<int> HandleCallGraphAsync(
IServiceProvider services,
string filePath,
string format,
string? outputPath,
string? emitSbomPath,
string? scanId,
bool verbose,
CancellationToken cancellationToken)
{
var loggerFactory = services.GetRequiredService<ILoggerFactory>();
var logger = loggerFactory.CreateLogger("binary-callgraph");
try
{
if (!File.Exists(filePath))
{
AnsiConsole.MarkupLine($"[red]Error:[/] File not found: {filePath}");
return ExitCodes.FileNotFound;
}
// Resolve scan ID (auto-generate if not provided)
var effectiveScanId = scanId ?? $"cli-{Path.GetFileName(filePath)}-{DateTime.UtcNow:yyyyMMddHHmmss}";
CallGraphSnapshot snapshot = null!;
await AnsiConsole.Status()
.StartAsync("Extracting binary call graph...", async ctx =>
{
ctx.Status("Loading binary...");
// Get the binary call graph extractor
var timeProvider = services.GetService<TimeProvider>() ?? TimeProvider.System;
var extractorLogger = loggerFactory.CreateLogger<BinaryCallGraphExtractor>();
var extractor = new BinaryCallGraphExtractor(extractorLogger, timeProvider);
ctx.Status("Analyzing symbols and relocations...");
var request = new CallGraphExtractionRequest(
ScanId: effectiveScanId,
Language: "native",
TargetPath: filePath);
snapshot = await extractor.ExtractAsync(request, cancellationToken);
ctx.Status("Computing digest...");
});
// Format output based on requested format
string output;
switch (format)
{
case "digest":
output = snapshot.GraphDigest;
break;
case "json":
output = JsonSerializer.Serialize(new
{
scanId = snapshot.ScanId,
graphDigest = snapshot.GraphDigest,
language = snapshot.Language,
extractedAt = snapshot.ExtractedAt.ToString("O", CultureInfo.InvariantCulture),
nodeCount = snapshot.Nodes.Length,
edgeCount = snapshot.Edges.Length,
entrypointCount = snapshot.EntrypointIds.Length,
nodes = snapshot.Nodes,
edges = snapshot.Edges,
entrypointIds = snapshot.EntrypointIds
}, JsonOptions);
break;
case "summary":
default:
output = string.Join(Environment.NewLine,
[
$"GraphDigest: {snapshot.GraphDigest}",
$"ScanId: {snapshot.ScanId}",
$"Language: {snapshot.Language}",
$"ExtractedAt: {snapshot.ExtractedAt:O}",
$"Nodes: {snapshot.Nodes.Length}",
$"Edges: {snapshot.Edges.Length}",
$"Entrypoints: {snapshot.EntrypointIds.Length}"
]);
break;
}
// Write output
if (!string.IsNullOrWhiteSpace(outputPath))
{
await File.WriteAllTextAsync(outputPath, output, cancellationToken);
AnsiConsole.MarkupLine($"[green]Output written to:[/] {outputPath}");
}
else if (format == "digest")
{
// For digest-only format, just output the digest
Console.WriteLine(output);
}
else
{
AnsiConsole.WriteLine(output);
}
// Inject into SBOM if requested
if (!string.IsNullOrWhiteSpace(emitSbomPath))
{
var sbomResult = await InjectCallGraphDigestIntoSbomAsync(
emitSbomPath,
filePath,
snapshot,
cancellationToken);
if (sbomResult == 0)
{
AnsiConsole.MarkupLine($"[green]Callgraph digest injected into SBOM:[/] {emitSbomPath}");
}
else
{
AnsiConsole.MarkupLine($"[yellow]Warning:[/] Failed to inject digest into SBOM");
}
}
if (verbose)
{
logger.LogInformation(
"Extracted call graph for {Path}: {Nodes} nodes, {Edges} edges, digest={Digest}",
filePath,
snapshot.Nodes.Length,
snapshot.Edges.Length,
snapshot.GraphDigest);
}
return ExitCodes.Success;
}
catch (NotSupportedException ex)
{
AnsiConsole.MarkupLine($"[red]Error:[/] {ex.Message}");
logger.LogError(ex, "Unsupported binary format for {Path}", filePath);
return ExitCodes.InvalidArguments;
}
catch (Exception ex)
{
AnsiConsole.MarkupLine($"[red]Error:[/] {ex.Message}");
logger.LogError(ex, "Failed to extract call graph for {Path}", filePath);
return ExitCodes.GeneralError;
}
}
/// <summary>
/// Injects callgraph digest as a property into a CycloneDX SBOM.
/// </summary>
private static async Task<int> InjectCallGraphDigestIntoSbomAsync(
string sbomPath,
string binaryPath,
CallGraphSnapshot snapshot,
CancellationToken cancellationToken)
{
if (!File.Exists(sbomPath))
{
return 1;
}
try
{
var sbomJson = await File.ReadAllTextAsync(sbomPath, cancellationToken);
var doc = JsonNode.Parse(sbomJson) as JsonObject;
if (doc == null)
{
return 1;
}
// Ensure metadata.properties exists
var metadata = doc["metadata"] as JsonObject;
if (metadata == null)
{
metadata = new JsonObject();
doc["metadata"] = metadata;
}
var properties = metadata["properties"] as JsonArray;
if (properties == null)
{
properties = new JsonArray();
metadata["properties"] = properties;
}
var binaryName = Path.GetFileName(binaryPath);
// Add callgraph properties using stellaops namespace
properties.Add(new JsonObject
{
["name"] = $"stellaops:callgraph:digest:{binaryName}",
["value"] = snapshot.GraphDigest
});
properties.Add(new JsonObject
{
["name"] = $"stellaops:callgraph:nodeCount:{binaryName}",
["value"] = snapshot.Nodes.Length.ToString(CultureInfo.InvariantCulture)
});
properties.Add(new JsonObject
{
["name"] = $"stellaops:callgraph:edgeCount:{binaryName}",
["value"] = snapshot.Edges.Length.ToString(CultureInfo.InvariantCulture)
});
properties.Add(new JsonObject
{
["name"] = $"stellaops:callgraph:entrypointCount:{binaryName}",
["value"] = snapshot.EntrypointIds.Length.ToString(CultureInfo.InvariantCulture)
});
properties.Add(new JsonObject
{
["name"] = $"stellaops:callgraph:extractedAt:{binaryName}",
["value"] = snapshot.ExtractedAt.ToString("O", CultureInfo.InvariantCulture)
});
// Write updated SBOM
var updatedJson = doc.ToJsonString(new JsonSerializerOptions { WriteIndented = true });
await File.WriteAllTextAsync(sbomPath, updatedJson, cancellationToken);
return 0;
}
catch
{
return 1;
}
}
private static string DetectFormat(byte[] header)
{
// ELF magic: 0x7f 'E' 'L' 'F'

View File

@@ -0,0 +1,429 @@
// -----------------------------------------------------------------------------
// CommandHandlers.Secrets.cs
// Sprint: SPRINT_20260104_003_SCANNER (Secret Detection Rule Bundles)
// Tasks: RB-006, RB-007 - Command handlers for secrets bundle operations.
// Description: Implements bundle create, verify, and info commands.
// -----------------------------------------------------------------------------
using System.Text.Json;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Spectre.Console;
using StellaOps.Scanner.Analyzers.Secrets.Bundles;
namespace StellaOps.Cli.Commands;
internal static partial class CommandHandlers
{
private static readonly JsonSerializerOptions SecretsJsonOptions = new()
{
WriteIndented = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
internal static async Task<int> HandleSecretsBundleCreateAsync(
IServiceProvider services,
string sources,
string output,
string id,
string? version,
bool sign,
string? keyId,
string? secret,
string? secretFile,
string format,
bool verbose,
CancellationToken cancellationToken)
{
var logger = services.GetService<ILoggerFactory>()?.CreateLogger("Secrets.Bundle.Create");
if (!Directory.Exists(sources))
{
AnsiConsole.MarkupLine($"[red]Error: Source directory not found: {Markup.Escape(sources)}[/]");
return 1;
}
// Determine version (CalVer if not specified)
var bundleVersion = version ?? DateTimeOffset.UtcNow.ToString("yyyy.MM", System.Globalization.CultureInfo.InvariantCulture);
if (format == "text")
{
AnsiConsole.MarkupLine("[blue]Creating secrets detection rule bundle...[/]");
AnsiConsole.MarkupLine($" Source: [bold]{Markup.Escape(sources)}[/]");
AnsiConsole.MarkupLine($" Output: [bold]{Markup.Escape(output)}[/]");
AnsiConsole.MarkupLine($" ID: [bold]{Markup.Escape(id)}[/]");
AnsiConsole.MarkupLine($" Version: [bold]{Markup.Escape(bundleVersion)}[/]");
}
try
{
// Create output directory
Directory.CreateDirectory(output);
// Build the bundle
var builderLogger = services.GetService<ILoggerFactory>()?.CreateLogger<BundleBuilder>();
var validatorLogger = services.GetService<ILoggerFactory>()?.CreateLogger<RuleValidator>();
var validator = new RuleValidator(validatorLogger ?? Microsoft.Extensions.Logging.Abstractions.NullLogger<RuleValidator>.Instance);
var builder = new BundleBuilder(
validator,
builderLogger ?? Microsoft.Extensions.Logging.Abstractions.NullLogger<BundleBuilder>.Instance);
var buildOptions = new BundleBuildOptions
{
SourceDirectory = sources,
OutputDirectory = output,
BundleId = id,
Version = bundleVersion,
TimeProvider = TimeProvider.System
};
var artifact = await builder.BuildAsync(buildOptions, cancellationToken);
if (format == "text")
{
AnsiConsole.MarkupLine($"[green]Bundle created successfully![/]");
AnsiConsole.MarkupLine($" Manifest: [bold]{Markup.Escape(artifact.ManifestPath)}[/]");
AnsiConsole.MarkupLine($" Rules: [bold]{Markup.Escape(artifact.RulesPath)}[/]");
AnsiConsole.MarkupLine($" Total rules: [bold]{artifact.TotalRules}[/]");
AnsiConsole.MarkupLine($" Enabled rules: [bold]{artifact.EnabledRules}[/]");
AnsiConsole.MarkupLine($" SHA-256: [bold]{artifact.RulesSha256}[/]");
}
// Sign if requested
if (sign)
{
if (string.IsNullOrWhiteSpace(keyId))
{
AnsiConsole.MarkupLine("[red]Error: --key-id is required for signing[/]");
return 1;
}
if (string.IsNullOrWhiteSpace(secret) && string.IsNullOrWhiteSpace(secretFile))
{
AnsiConsole.MarkupLine("[red]Error: --secret or --secret-file is required for signing[/]");
return 1;
}
if (format == "text")
{
AnsiConsole.MarkupLine("[blue]Signing bundle...[/]");
}
var signerLogger = services.GetService<ILoggerFactory>()?.CreateLogger<BundleSigner>();
var signer = new BundleSigner(signerLogger ?? Microsoft.Extensions.Logging.Abstractions.NullLogger<BundleSigner>.Instance);
var signOptions = new BundleSigningOptions
{
KeyId = keyId,
SharedSecret = secret,
SharedSecretFile = secretFile,
TimeProvider = TimeProvider.System
};
var signResult = await signer.SignAsync(artifact, signOptions, cancellationToken);
if (format == "text")
{
AnsiConsole.MarkupLine($"[green]Bundle signed successfully![/]");
AnsiConsole.MarkupLine($" Envelope: [bold]{Markup.Escape(signResult.EnvelopePath)}[/]");
AnsiConsole.MarkupLine($" Key ID: [bold]{Markup.Escape(keyId)}[/]");
}
}
if (format == "json")
{
var result = new
{
success = true,
bundle = new
{
id,
version = bundleVersion,
manifestPath = artifact.ManifestPath,
rulesPath = artifact.RulesPath,
totalRules = artifact.TotalRules,
enabledRules = artifact.EnabledRules,
rulesSha256 = artifact.RulesSha256,
signed = sign
}
};
Console.WriteLine(JsonSerializer.Serialize(result, SecretsJsonOptions));
}
return 0;
}
catch (Exception ex)
{
if (format == "text")
{
AnsiConsole.MarkupLine($"[red]Error: {Markup.Escape(ex.Message)}[/]");
if (verbose)
{
AnsiConsole.WriteException(ex);
}
}
else
{
var result = new { success = false, error = ex.Message };
Console.WriteLine(JsonSerializer.Serialize(result, SecretsJsonOptions));
}
return 1;
}
}
internal static async Task<int> HandleSecretsBundleVerifyAsync(
IServiceProvider services,
string bundle,
string? secret,
string? secretFile,
string[] trustedKeys,
bool requireSignature,
string format,
bool verbose,
CancellationToken cancellationToken)
{
if (!Directory.Exists(bundle))
{
if (format == "text")
{
AnsiConsole.MarkupLine($"[red]Error: Bundle directory not found: {Markup.Escape(bundle)}[/]");
}
else
{
var result = new { success = false, error = $"Bundle directory not found: {bundle}" };
Console.WriteLine(JsonSerializer.Serialize(result, SecretsJsonOptions));
}
return 1;
}
if (format == "text")
{
AnsiConsole.MarkupLine("[blue]Verifying secrets detection rule bundle...[/]");
AnsiConsole.MarkupLine($" Bundle: [bold]{Markup.Escape(bundle)}[/]");
}
try
{
var verifierLogger = services.GetService<ILoggerFactory>()?.CreateLogger<BundleVerifier>();
var verifier = new BundleVerifier(verifierLogger ?? Microsoft.Extensions.Logging.Abstractions.NullLogger<BundleVerifier>.Instance);
var verifyOptions = new BundleVerificationOptions
{
SharedSecret = secret,
SharedSecretFile = secretFile,
TrustedKeyIds = trustedKeys.Length > 0 ? trustedKeys : null,
SkipSignatureVerification = !requireSignature && string.IsNullOrWhiteSpace(secret) && string.IsNullOrWhiteSpace(secretFile),
VerifyIntegrity = true
};
var result = await verifier.VerifyAsync(bundle, verifyOptions, cancellationToken);
if (format == "text")
{
if (result.IsValid)
{
AnsiConsole.MarkupLine("[green]Bundle verification passed![/]");
AnsiConsole.MarkupLine($" Bundle ID: [bold]{Markup.Escape(result.BundleId ?? "unknown")}[/]");
AnsiConsole.MarkupLine($" Version: [bold]{Markup.Escape(result.BundleVersion ?? "unknown")}[/]");
AnsiConsole.MarkupLine($" Rules: [bold]{result.RuleCount ?? 0}[/]");
if (result.SignerKeyId is not null)
{
AnsiConsole.MarkupLine($" Signed by: [bold]{Markup.Escape(result.SignerKeyId)}[/]");
}
if (result.SignedAt.HasValue)
{
AnsiConsole.MarkupLine($" Signed at: [bold]{result.SignedAt.Value:O}[/]");
}
foreach (var warning in result.ValidationWarnings)
{
AnsiConsole.MarkupLine($"[yellow]Warning: {Markup.Escape(warning)}[/]");
}
}
else
{
AnsiConsole.MarkupLine("[red]Bundle verification failed![/]");
foreach (var error in result.ValidationErrors)
{
AnsiConsole.MarkupLine($"[red] - {Markup.Escape(error)}[/]");
}
}
}
else
{
var jsonResult = new
{
success = result.IsValid,
bundleId = result.BundleId,
version = result.BundleVersion,
ruleCount = result.RuleCount,
signerKeyId = result.SignerKeyId,
signedAt = result.SignedAt?.ToString("O"),
errors = result.ValidationErrors,
warnings = result.ValidationWarnings
};
Console.WriteLine(JsonSerializer.Serialize(jsonResult, SecretsJsonOptions));
}
return result.IsValid ? 0 : 1;
}
catch (Exception ex)
{
if (format == "text")
{
AnsiConsole.MarkupLine($"[red]Error: {Markup.Escape(ex.Message)}[/]");
if (verbose)
{
AnsiConsole.WriteException(ex);
}
}
else
{
var result = new { success = false, error = ex.Message };
Console.WriteLine(JsonSerializer.Serialize(result, SecretsJsonOptions));
}
return 1;
}
}
internal static async Task<int> HandleSecretsBundleInfoAsync(
IServiceProvider services,
string bundle,
string format,
bool verbose,
CancellationToken cancellationToken)
{
var manifestPath = Path.Combine(bundle, "secrets.ruleset.manifest.json");
if (!File.Exists(manifestPath))
{
if (format == "text")
{
AnsiConsole.MarkupLine($"[red]Error: Bundle manifest not found: {Markup.Escape(manifestPath)}[/]");
}
else
{
var result = new { success = false, error = $"Bundle manifest not found: {manifestPath}" };
Console.WriteLine(JsonSerializer.Serialize(result, SecretsJsonOptions));
}
return 1;
}
try
{
var manifestJson = await File.ReadAllTextAsync(manifestPath, cancellationToken);
var manifest = JsonSerializer.Deserialize<BundleManifest>(manifestJson, new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true
});
if (manifest is null)
{
if (format == "text")
{
AnsiConsole.MarkupLine("[red]Error: Failed to parse bundle manifest[/]");
}
else
{
var result = new { success = false, error = "Failed to parse bundle manifest" };
Console.WriteLine(JsonSerializer.Serialize(result, SecretsJsonOptions));
}
return 1;
}
if (format == "text")
{
AnsiConsole.MarkupLine("[blue]Bundle Information[/]");
AnsiConsole.MarkupLine($" ID: [bold]{Markup.Escape(manifest.Id)}[/]");
AnsiConsole.MarkupLine($" Version: [bold]{Markup.Escape(manifest.Version)}[/]");
AnsiConsole.MarkupLine($" Schema: [bold]{Markup.Escape(manifest.SchemaVersion)}[/]");
AnsiConsole.MarkupLine($" Created: [bold]{manifest.CreatedAt:O}[/]");
if (!string.IsNullOrWhiteSpace(manifest.Description))
{
AnsiConsole.MarkupLine($" Description: [bold]{Markup.Escape(manifest.Description)}[/]");
}
AnsiConsole.MarkupLine("");
AnsiConsole.MarkupLine("[blue]Integrity[/]");
AnsiConsole.MarkupLine($" Rules file: [bold]{Markup.Escape(manifest.Integrity.RulesFile)}[/]");
AnsiConsole.MarkupLine($" SHA-256: [bold]{Markup.Escape(manifest.Integrity.RulesSha256)}[/]");
AnsiConsole.MarkupLine($" Total rules: [bold]{manifest.Integrity.TotalRules}[/]");
AnsiConsole.MarkupLine($" Enabled rules: [bold]{manifest.Integrity.EnabledRules}[/]");
if (manifest.Signatures is not null)
{
AnsiConsole.MarkupLine("");
AnsiConsole.MarkupLine("[blue]Signature[/]");
AnsiConsole.MarkupLine($" Envelope: [bold]{Markup.Escape(manifest.Signatures.DsseEnvelope)}[/]");
AnsiConsole.MarkupLine($" Key ID: [bold]{Markup.Escape(manifest.Signatures.KeyId ?? "unknown")}[/]");
if (manifest.Signatures.SignedAt.HasValue)
{
AnsiConsole.MarkupLine($" Signed at: [bold]{manifest.Signatures.SignedAt.Value:O}[/]");
}
if (!string.IsNullOrWhiteSpace(manifest.Signatures.RekorLogId))
{
AnsiConsole.MarkupLine($" Rekor log ID: [bold]{Markup.Escape(manifest.Signatures.RekorLogId)}[/]");
}
}
else
{
AnsiConsole.MarkupLine("");
AnsiConsole.MarkupLine("[yellow]Bundle is not signed[/]");
}
if (verbose && manifest.Rules.Length > 0)
{
AnsiConsole.MarkupLine("");
AnsiConsole.MarkupLine("[blue]Rules Summary[/]");
var table = new Table();
table.AddColumn("ID");
table.AddColumn("Version");
table.AddColumn("Severity");
table.AddColumn("Enabled");
foreach (var rule in manifest.Rules.Take(20))
{
table.AddRow(
Markup.Escape(rule.Id),
Markup.Escape(rule.Version),
Markup.Escape(rule.Severity),
rule.Enabled ? "[green]Yes[/]" : "[red]No[/]");
}
if (manifest.Rules.Length > 20)
{
table.AddRow($"... and {manifest.Rules.Length - 20} more", "", "", "");
}
AnsiConsole.Write(table);
}
}
else
{
Console.WriteLine(JsonSerializer.Serialize(manifest, SecretsJsonOptions));
}
return 0;
}
catch (Exception ex)
{
if (format == "text")
{
AnsiConsole.MarkupLine($"[red]Error: {Markup.Escape(ex.Message)}[/]");
if (verbose)
{
AnsiConsole.WriteException(ex);
}
}
else
{
var result = new { success = false, error = ex.Message };
Console.WriteLine(JsonSerializer.Serialize(result, SecretsJsonOptions));
}
return 1;
}
}
}

View File

@@ -0,0 +1,247 @@
// -----------------------------------------------------------------------------
// SecretsCommandGroup.cs
// Sprint: SPRINT_20260104_003_SCANNER (Secret Detection Rule Bundles)
// Tasks: RB-006, RB-007 - CLI commands for secrets rule bundle management.
// Description: CLI commands for building, signing, and verifying secrets bundles.
// -----------------------------------------------------------------------------
using System.CommandLine;
using StellaOps.Cli.Extensions;
namespace StellaOps.Cli.Commands;
internal static class SecretsCommandGroup
{
internal static Command BuildSecretsCommand(
IServiceProvider services,
Option<bool> verboseOption,
CancellationToken cancellationToken)
{
var secrets = new Command("secrets", "Secrets detection rule bundle management.");
secrets.Add(BuildBundleCommand(services, verboseOption, cancellationToken));
return secrets;
}
private static Command BuildBundleCommand(
IServiceProvider services,
Option<bool> verboseOption,
CancellationToken cancellationToken)
{
var bundle = new Command("bundle", "Secrets rule bundle operations.");
bundle.Add(BuildCreateCommand(services, verboseOption, cancellationToken));
bundle.Add(BuildVerifyCommand(services, verboseOption, cancellationToken));
bundle.Add(BuildInfoCommand(services, verboseOption, cancellationToken));
return bundle;
}
private static Command BuildCreateCommand(
IServiceProvider services,
Option<bool> verboseOption,
CancellationToken cancellationToken)
{
var sourcesArg = new Argument<string>("sources")
{
Description = "Path to directory containing rule JSON files"
};
var outputOption = new Option<string>("--output", "-o")
{
Description = "Output directory for the bundle (default: ./bundle)"
};
outputOption.SetDefaultValue("./bundle");
var idOption = new Option<string>("--id")
{
Description = "Bundle identifier (default: stellaops-secrets)"
};
idOption.SetDefaultValue("stellaops-secrets");
var versionOption = new Option<string>("--version", "-v")
{
Description = "Bundle version (CalVer, e.g., 2026.01)"
};
var signOption = new Option<bool>("--sign")
{
Description = "Sign the bundle after creation"
};
var keyIdOption = new Option<string?>("--key-id")
{
Description = "Key identifier for signing"
};
var secretOption = new Option<string?>("--secret")
{
Description = "Shared secret for HMAC signing (base64 or hex)"
};
var secretFileOption = new Option<string?>("--secret-file")
{
Description = "Path to file containing the shared secret"
};
var formatOption = new Option<string>("--format")
{
Description = "Output format: text, json"
}.SetDefaultValue("text").FromAmong("text", "json");
var command = new Command("create", "Create a secrets detection rule bundle from JSON rule definitions.")
{
sourcesArg,
outputOption,
idOption,
versionOption,
signOption,
keyIdOption,
secretOption,
secretFileOption,
formatOption,
verboseOption
};
command.SetAction(parseResult =>
{
var sources = parseResult.GetValue(sourcesArg) ?? string.Empty;
var output = parseResult.GetValue(outputOption) ?? "./bundle";
var id = parseResult.GetValue(idOption) ?? "stellaops-secrets";
var version = parseResult.GetValue(versionOption);
var sign = parseResult.GetValue(signOption);
var keyId = parseResult.GetValue(keyIdOption);
var secret = parseResult.GetValue(secretOption);
var secretFile = parseResult.GetValue(secretFileOption);
var format = parseResult.GetValue(formatOption) ?? "text";
var verbose = parseResult.GetValue(verboseOption);
return CommandHandlers.HandleSecretsBundleCreateAsync(
services,
sources,
output,
id,
version,
sign,
keyId,
secret,
secretFile,
format,
verbose,
cancellationToken);
});
return command;
}
private static Command BuildVerifyCommand(
IServiceProvider services,
Option<bool> verboseOption,
CancellationToken cancellationToken)
{
var bundleArg = new Argument<string>("bundle")
{
Description = "Path to the bundle directory"
};
var secretOption = new Option<string?>("--secret")
{
Description = "Shared secret for HMAC verification (base64 or hex)"
};
var secretFileOption = new Option<string?>("--secret-file")
{
Description = "Path to file containing the shared secret"
};
var trustedKeysOption = new Option<string[]>("--trusted-keys")
{
Description = "List of trusted key IDs for signature verification"
};
var requireSignatureOption = new Option<bool>("--require-signature")
{
Description = "Require a valid signature (fail if unsigned)"
};
var formatOption = new Option<string>("--format")
{
Description = "Output format: text, json"
}.SetDefaultValue("text").FromAmong("text", "json");
var command = new Command("verify", "Verify a secrets detection rule bundle's integrity and signature.")
{
bundleArg,
secretOption,
secretFileOption,
trustedKeysOption,
requireSignatureOption,
formatOption,
verboseOption
};
command.SetAction(parseResult =>
{
var bundle = parseResult.GetValue(bundleArg) ?? string.Empty;
var secret = parseResult.GetValue(secretOption);
var secretFile = parseResult.GetValue(secretFileOption);
var trustedKeys = parseResult.GetValue(trustedKeysOption) ?? Array.Empty<string>();
var requireSignature = parseResult.GetValue(requireSignatureOption);
var format = parseResult.GetValue(formatOption) ?? "text";
var verbose = parseResult.GetValue(verboseOption);
return CommandHandlers.HandleSecretsBundleVerifyAsync(
services,
bundle,
secret,
secretFile,
trustedKeys,
requireSignature,
format,
verbose,
cancellationToken);
});
return command;
}
private static Command BuildInfoCommand(
IServiceProvider services,
Option<bool> verboseOption,
CancellationToken cancellationToken)
{
var bundleArg = new Argument<string>("bundle")
{
Description = "Path to the bundle directory"
};
var formatOption = new Option<string>("--format")
{
Description = "Output format: text, json"
}.SetDefaultValue("text").FromAmong("text", "json");
var command = new Command("info", "Display information about a secrets detection rule bundle.")
{
bundleArg,
formatOption,
verboseOption
};
command.SetAction(parseResult =>
{
var bundle = parseResult.GetValue(bundleArg) ?? string.Empty;
var format = parseResult.GetValue(formatOption) ?? "text";
var verbose = parseResult.GetValue(verboseOption);
return CommandHandlers.HandleSecretsBundleInfoAsync(
services,
bundle,
format,
verbose,
cancellationToken);
});
return command;
}
}

View File

@@ -90,6 +90,10 @@
<ProjectReference Include="../../BinaryIndex/__Libraries/StellaOps.BinaryIndex.Disassembly/StellaOps.BinaryIndex.Disassembly.csproj" />
<ProjectReference Include="../../BinaryIndex/__Libraries/StellaOps.BinaryIndex.Normalization/StellaOps.BinaryIndex.Normalization.csproj" />
<ProjectReference Include="../../BinaryIndex/__Libraries/StellaOps.BinaryIndex.DeltaSig/StellaOps.BinaryIndex.DeltaSig.csproj" />
<!-- Binary Call Graph (SPRINT_20260104_001_CLI) -->
<ProjectReference Include="../../Scanner/__Libraries/StellaOps.Scanner.CallGraph/StellaOps.Scanner.CallGraph.csproj" />
<!-- Secrets Bundle CLI (SPRINT_20260104_003_SCANNER) -->
<ProjectReference Include="../../Scanner/__Libraries/StellaOps.Scanner.Analyzers.Secrets/StellaOps.Scanner.Analyzers.Secrets.csproj" />
</ItemGroup>
<!-- GOST Crypto Plugins (Russia distribution) -->