save progress
This commit is contained in:
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user