Files
git.stella-ops.org/src/Cli/StellaOps.Cli/Commands/CommandHandlers.Secrets.cs

441 lines
17 KiB
C#

// -----------------------------------------------------------------------------
// 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<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);
// 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<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", 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<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;
}
}
}