441 lines
17 KiB
C#
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;
|
|
}
|
|
}
|
|
}
|