// ----------------------------------------------------------------------------- // 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.Globalization; 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 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()?.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()?.CreateLogger(); var validatorLogger = services.GetService()?.CreateLogger(); var validator = new RuleValidator(validatorLogger ?? Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); var builder = new BundleBuilder( validator, builderLogger ?? Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); // Enumerate rule files from source directory var ruleFiles = Directory.EnumerateFiles(sources, "*.json", SearchOption.AllDirectories) .ToList(); if (ruleFiles.Count == 0) { AnsiConsole.MarkupLine($"[red]Error: No rule files (*.json) found in {Markup.Escape(sources)}[/]"); return 1; } var buildOptions = new BundleBuildOptions { RuleFiles = ruleFiles, 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()?.CreateLogger(); var signer = new BundleSigner(signerLogger ?? Microsoft.Extensions.Logging.Abstractions.NullLogger.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 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()?.CreateLogger(); var verifier = new BundleVerifier(verifierLogger ?? Microsoft.Extensions.Logging.Abstractions.NullLogger.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", CultureInfo.InvariantCulture), 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 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(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; } } }