save progress
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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'
|
||||
|
||||
429
src/Cli/StellaOps.Cli/Commands/CommandHandlers.Secrets.cs
Normal file
429
src/Cli/StellaOps.Cli/Commands/CommandHandlers.Secrets.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
247
src/Cli/StellaOps.Cli/Commands/SecretsCommandGroup.cs
Normal file
247
src/Cli/StellaOps.Cli/Commands/SecretsCommandGroup.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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) -->
|
||||
|
||||
Reference in New Issue
Block a user