feat: add security sink detection patterns for JavaScript/TypeScript
- Introduced `sink-detect.js` with various security sink detection patterns categorized by type (e.g., command injection, SQL injection, file operations). - Implemented functions to build a lookup map for fast sink detection and to match sink calls against known patterns. - Added `package-lock.json` for dependency management.
This commit is contained in:
474
src/Cli/StellaOps.Cli/Commands/CommandHandlers.Audit.cs
Normal file
474
src/Cli/StellaOps.Cli/Commands/CommandHandlers.Audit.cs
Normal file
@@ -0,0 +1,474 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// CommandHandlers.Audit.cs
|
||||
// Sprint: SPRINT_4300_0001_0002_one_command_audit_replay
|
||||
// Description: Command handlers for audit pack export, replay, and verification.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Diagnostics;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.AuditPack.Models;
|
||||
using StellaOps.AuditPack.Services;
|
||||
using StellaOps.Cli.Configuration;
|
||||
using StellaOps.Cli.Telemetry;
|
||||
using Spectre.Console;
|
||||
|
||||
namespace StellaOps.Cli.Commands;
|
||||
|
||||
internal static partial class CommandHandlers
|
||||
{
|
||||
private static readonly JsonSerializerOptions AuditJsonOptions = new()
|
||||
{
|
||||
WriteIndented = true,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) }
|
||||
};
|
||||
|
||||
internal static async Task<int> HandleAuditExportAsync(
|
||||
IServiceProvider services,
|
||||
string scanId,
|
||||
string? output,
|
||||
string? name,
|
||||
bool sign,
|
||||
string? signingKey,
|
||||
bool includeFeeds,
|
||||
bool includePolicy,
|
||||
bool minimal,
|
||||
bool verbose,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using var scope = services.CreateAsyncScope();
|
||||
var loggerFactory = scope.ServiceProvider.GetRequiredService<ILoggerFactory>();
|
||||
var logger = loggerFactory.CreateLogger("audit-export");
|
||||
var options = scope.ServiceProvider.GetRequiredService<StellaOpsCliOptions>();
|
||||
|
||||
using var activity = CliActivitySource.Instance.StartActivity("cli.audit.export", ActivityKind.Client);
|
||||
using var duration = CliMetrics.MeasureCommandDuration("audit export");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(scanId))
|
||||
{
|
||||
AnsiConsole.MarkupLine("[red]Error:[/] --scan-id is required.");
|
||||
Environment.ExitCode = 2;
|
||||
return 2;
|
||||
}
|
||||
|
||||
var outputPath = output ?? $"audit-{scanId}.tar.gz";
|
||||
|
||||
try
|
||||
{
|
||||
AnsiConsole.MarkupLine($"Exporting audit pack for scan [bold]{Markup.Escape(scanId)}[/]...");
|
||||
|
||||
var builder = scope.ServiceProvider.GetService<IAuditPackBuilder>();
|
||||
if (builder is null)
|
||||
{
|
||||
AnsiConsole.MarkupLine("[red]Error:[/] Audit pack builder not available.");
|
||||
Environment.ExitCode = 2;
|
||||
return 2;
|
||||
}
|
||||
|
||||
// Build the audit pack
|
||||
var packOptions = new AuditPackOptions
|
||||
{
|
||||
Name = name,
|
||||
IncludeFeeds = includeFeeds,
|
||||
IncludePolicies = includePolicy,
|
||||
MinimizeSize = minimal
|
||||
};
|
||||
|
||||
var scanResult = new ScanResult(scanId);
|
||||
var pack = await builder.BuildAsync(scanResult, packOptions, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Export to archive
|
||||
var exportOptions = new ExportOptions
|
||||
{
|
||||
Sign = sign,
|
||||
SigningKey = signingKey,
|
||||
Compress = true
|
||||
};
|
||||
|
||||
await builder.ExportAsync(pack, outputPath, exportOptions, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
AnsiConsole.WriteLine();
|
||||
AnsiConsole.MarkupLine($"[green]Success![/] Audit pack exported to: [bold]{Markup.Escape(outputPath)}[/]");
|
||||
AnsiConsole.MarkupLine($"Pack ID: {Markup.Escape(pack.PackId)}");
|
||||
AnsiConsole.MarkupLine($"Pack digest: {Markup.Escape(pack.PackDigest ?? "unsigned")}");
|
||||
|
||||
if (verbose)
|
||||
{
|
||||
AnsiConsole.WriteLine();
|
||||
AnsiConsole.MarkupLine("Contents:");
|
||||
AnsiConsole.MarkupLine($" Files: {pack.Contents.FileCount}");
|
||||
AnsiConsole.MarkupLine($" Size: {FormatBytes(pack.Contents.TotalSizeBytes)}");
|
||||
AnsiConsole.MarkupLine($" Attestations: {pack.Attestations.Length}");
|
||||
AnsiConsole.MarkupLine($" SBOMs: {pack.Sboms.Length}");
|
||||
AnsiConsole.MarkupLine($" VEX documents: {pack.VexDocuments.Length}");
|
||||
}
|
||||
|
||||
Environment.ExitCode = 0;
|
||||
return 0;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Audit export failed for scan {ScanId}", scanId);
|
||||
AnsiConsole.MarkupLine($"[red]Error:[/] {Markup.Escape(ex.Message)}");
|
||||
Environment.ExitCode = 2;
|
||||
return 2;
|
||||
}
|
||||
}
|
||||
|
||||
internal static async Task<int> HandleAuditReplayAsync(
|
||||
IServiceProvider services,
|
||||
string bundlePath,
|
||||
string? outputDir,
|
||||
string format,
|
||||
bool strict,
|
||||
bool offline,
|
||||
string? trustStore,
|
||||
string? timeAnchor,
|
||||
bool verbose,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using var scope = services.CreateAsyncScope();
|
||||
var loggerFactory = scope.ServiceProvider.GetRequiredService<ILoggerFactory>();
|
||||
var logger = loggerFactory.CreateLogger("audit-replay");
|
||||
var options = scope.ServiceProvider.GetRequiredService<StellaOpsCliOptions>();
|
||||
|
||||
using var activity = CliActivitySource.Instance.StartActivity("cli.audit.replay", ActivityKind.Client);
|
||||
using var duration = CliMetrics.MeasureCommandDuration("audit replay");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(bundlePath))
|
||||
{
|
||||
WriteAuditError("Bundle path is required.", format);
|
||||
Environment.ExitCode = 2;
|
||||
return 2;
|
||||
}
|
||||
|
||||
if (!File.Exists(bundlePath))
|
||||
{
|
||||
WriteAuditError($"Bundle not found: {bundlePath}", format);
|
||||
Environment.ExitCode = 2;
|
||||
return 2;
|
||||
}
|
||||
|
||||
// Enforce offline mode if requested
|
||||
if (offline && !OfflineModeGuard.IsNetworkAllowed(options, "audit replay", forceOffline: true))
|
||||
{
|
||||
// This is expected - we're in offline mode
|
||||
logger.LogDebug("Running in offline mode as requested.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var importer = scope.ServiceProvider.GetService<IAuditPackImporter>();
|
||||
var replayer = scope.ServiceProvider.GetService<IAuditPackReplayer>();
|
||||
|
||||
if (importer is null || replayer is null)
|
||||
{
|
||||
WriteAuditError("Audit pack services not available.", format);
|
||||
Environment.ExitCode = 2;
|
||||
return 2;
|
||||
}
|
||||
|
||||
// Parse time anchor if provided
|
||||
DateTimeOffset? timeAnchorParsed = null;
|
||||
if (!string.IsNullOrWhiteSpace(timeAnchor))
|
||||
{
|
||||
if (DateTimeOffset.TryParse(timeAnchor, out var parsed))
|
||||
{
|
||||
timeAnchorParsed = parsed;
|
||||
}
|
||||
else
|
||||
{
|
||||
WriteAuditError($"Invalid time anchor format: {timeAnchor}", format);
|
||||
Environment.ExitCode = 2;
|
||||
return 2;
|
||||
}
|
||||
}
|
||||
|
||||
// Import the audit pack
|
||||
if (!string.Equals(format, "json", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
AnsiConsole.MarkupLine($"Loading audit pack: [bold]{Markup.Escape(bundlePath)}[/]...");
|
||||
}
|
||||
|
||||
var importOptions = new ImportOptions
|
||||
{
|
||||
TrustStorePath = trustStore,
|
||||
OutputDirectory = outputDir
|
||||
};
|
||||
var pack = await importer.ImportAsync(bundlePath, importOptions, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Execute replay
|
||||
if (!string.Equals(format, "json", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
AnsiConsole.MarkupLine("Executing replay...");
|
||||
}
|
||||
|
||||
var replayOptions = new ReplayOptions
|
||||
{
|
||||
Strict = strict,
|
||||
Offline = offline,
|
||||
TimeAnchor = timeAnchorParsed,
|
||||
OutputDirectory = outputDir
|
||||
};
|
||||
var result = await replayer.ReplayAsync(pack, replayOptions, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Output results
|
||||
WriteAuditReplayResult(result, format, verbose);
|
||||
|
||||
// Exit code based on result
|
||||
var exitCode = result.Status switch
|
||||
{
|
||||
AuditReplayStatus.Match => 0,
|
||||
AuditReplayStatus.Drift => 1,
|
||||
_ => 2
|
||||
};
|
||||
|
||||
Environment.ExitCode = exitCode;
|
||||
return exitCode;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Audit replay failed for bundle {BundlePath}", bundlePath);
|
||||
WriteAuditError($"Replay failed: {ex.Message}", format);
|
||||
Environment.ExitCode = 2;
|
||||
return 2;
|
||||
}
|
||||
}
|
||||
|
||||
internal static async Task<int> HandleAuditVerifyAsync(
|
||||
IServiceProvider services,
|
||||
string bundlePath,
|
||||
string format,
|
||||
bool verbose,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using var scope = services.CreateAsyncScope();
|
||||
var loggerFactory = scope.ServiceProvider.GetRequiredService<ILoggerFactory>();
|
||||
var logger = loggerFactory.CreateLogger("audit-verify");
|
||||
|
||||
using var activity = CliActivitySource.Instance.StartActivity("cli.audit.verify", ActivityKind.Client);
|
||||
using var duration = CliMetrics.MeasureCommandDuration("audit verify");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(bundlePath))
|
||||
{
|
||||
WriteAuditError("Bundle path is required.", format);
|
||||
Environment.ExitCode = 2;
|
||||
return 2;
|
||||
}
|
||||
|
||||
if (!File.Exists(bundlePath))
|
||||
{
|
||||
WriteAuditError($"Bundle not found: {bundlePath}", format);
|
||||
Environment.ExitCode = 2;
|
||||
return 2;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var importer = scope.ServiceProvider.GetService<IAuditPackImporter>();
|
||||
if (importer is null)
|
||||
{
|
||||
WriteAuditError("Audit pack importer not available.", format);
|
||||
Environment.ExitCode = 2;
|
||||
return 2;
|
||||
}
|
||||
|
||||
var importOptions = new ImportOptions { VerifyOnly = true };
|
||||
var pack = await importer.ImportAsync(bundlePath, importOptions, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (string.Equals(format, "json", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var result = new
|
||||
{
|
||||
status = "valid",
|
||||
packId = pack.PackId,
|
||||
packDigest = pack.PackDigest,
|
||||
createdAt = pack.CreatedAt,
|
||||
fileCount = pack.Contents.FileCount,
|
||||
signatureValid = !string.IsNullOrWhiteSpace(pack.Signature)
|
||||
};
|
||||
AnsiConsole.WriteLine(JsonSerializer.Serialize(result, AuditJsonOptions));
|
||||
}
|
||||
else
|
||||
{
|
||||
AnsiConsole.MarkupLine("[green]Bundle verification passed![/]");
|
||||
AnsiConsole.WriteLine();
|
||||
AnsiConsole.MarkupLine($"Pack ID: {Markup.Escape(pack.PackId)}");
|
||||
AnsiConsole.MarkupLine($"Pack digest: {Markup.Escape(pack.PackDigest ?? "N/A")}");
|
||||
AnsiConsole.MarkupLine($"Created: {pack.CreatedAt:u}");
|
||||
AnsiConsole.MarkupLine($"Files: {pack.Contents.FileCount}");
|
||||
AnsiConsole.MarkupLine($"Signed: {(!string.IsNullOrWhiteSpace(pack.Signature) ? "[green]Yes[/]" : "[yellow]No[/]")}");
|
||||
|
||||
if (verbose)
|
||||
{
|
||||
AnsiConsole.WriteLine();
|
||||
AnsiConsole.MarkupLine("Contents:");
|
||||
AnsiConsole.MarkupLine($" Attestations: {pack.Attestations.Length}");
|
||||
AnsiConsole.MarkupLine($" SBOMs: {pack.Sboms.Length}");
|
||||
AnsiConsole.MarkupLine($" VEX documents: {pack.VexDocuments.Length}");
|
||||
AnsiConsole.MarkupLine($" Trust roots: {pack.TrustRoots.Length}");
|
||||
}
|
||||
}
|
||||
|
||||
Environment.ExitCode = 0;
|
||||
return 0;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Bundle verification failed for {BundlePath}", bundlePath);
|
||||
WriteAuditError($"Verification failed: {ex.Message}", format);
|
||||
Environment.ExitCode = 2;
|
||||
return 2;
|
||||
}
|
||||
}
|
||||
|
||||
private static void WriteAuditReplayResult(AuditReplayResult result, string format, bool verbose)
|
||||
{
|
||||
if (string.Equals(format, "json", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
AnsiConsole.WriteLine(JsonSerializer.Serialize(result, AuditJsonOptions));
|
||||
return;
|
||||
}
|
||||
|
||||
AnsiConsole.WriteLine();
|
||||
var statusColor = result.Status switch
|
||||
{
|
||||
AuditReplayStatus.Match => "green",
|
||||
AuditReplayStatus.Drift => "yellow",
|
||||
_ => "red"
|
||||
};
|
||||
|
||||
AnsiConsole.MarkupLine($"Replay Status: [{statusColor}]{result.Status}[/]");
|
||||
AnsiConsole.WriteLine();
|
||||
|
||||
// Input validation table
|
||||
var inputTable = new Table().AddColumns("Input", "Expected", "Actual", "Match");
|
||||
inputTable.AddRow(
|
||||
"SBOM Digest",
|
||||
TruncateDigest(result.ExpectedSbomDigest),
|
||||
TruncateDigest(result.ActualSbomDigest),
|
||||
FormatMatch(result.SbomMatches));
|
||||
inputTable.AddRow(
|
||||
"Feeds Digest",
|
||||
TruncateDigest(result.ExpectedFeedsDigest),
|
||||
TruncateDigest(result.ActualFeedsDigest),
|
||||
FormatMatch(result.FeedsMatches));
|
||||
inputTable.AddRow(
|
||||
"Policy Digest",
|
||||
TruncateDigest(result.ExpectedPolicyDigest),
|
||||
TruncateDigest(result.ActualPolicyDigest),
|
||||
FormatMatch(result.PolicyMatches));
|
||||
|
||||
AnsiConsole.Write(inputTable);
|
||||
AnsiConsole.WriteLine();
|
||||
|
||||
// Verdict comparison
|
||||
AnsiConsole.MarkupLine($"Original Verdict: [bold]{Markup.Escape(result.OriginalVerdictDigest ?? "-")}[/]");
|
||||
AnsiConsole.MarkupLine($"Replayed Verdict: [bold]{Markup.Escape(result.ReplayedVerdictDigest ?? "-")}[/]");
|
||||
AnsiConsole.MarkupLine($"Verdict Match: {FormatMatch(result.VerdictMatches)}");
|
||||
|
||||
if (verbose && result.Drifts.Count > 0)
|
||||
{
|
||||
AnsiConsole.WriteLine();
|
||||
AnsiConsole.MarkupLine("[yellow]Detected Drifts:[/]");
|
||||
foreach (var drift in result.Drifts)
|
||||
{
|
||||
AnsiConsole.MarkupLine($" - {Markup.Escape(drift)}");
|
||||
}
|
||||
}
|
||||
|
||||
if (result.Errors.Count > 0)
|
||||
{
|
||||
AnsiConsole.WriteLine();
|
||||
AnsiConsole.MarkupLine("[red]Errors:[/]");
|
||||
foreach (var error in result.Errors)
|
||||
{
|
||||
AnsiConsole.MarkupLine($" - {Markup.Escape(error)}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void WriteAuditError(string message, string format)
|
||||
{
|
||||
if (string.Equals(format, "json", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var payload = new { status = "error", message };
|
||||
AnsiConsole.WriteLine(JsonSerializer.Serialize(payload, AuditJsonOptions));
|
||||
return;
|
||||
}
|
||||
|
||||
AnsiConsole.MarkupLine($"[red]Error:[/] {Markup.Escape(message)}");
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of an audit pack replay operation.
|
||||
/// </summary>
|
||||
public sealed record AuditReplayResult
|
||||
{
|
||||
public required string PackId { get; init; }
|
||||
public required AuditReplayStatus Status { get; init; }
|
||||
public string? ExpectedSbomDigest { get; init; }
|
||||
public string? ActualSbomDigest { get; init; }
|
||||
public bool? SbomMatches { get; init; }
|
||||
public string? ExpectedFeedsDigest { get; init; }
|
||||
public string? ActualFeedsDigest { get; init; }
|
||||
public bool? FeedsMatches { get; init; }
|
||||
public string? ExpectedPolicyDigest { get; init; }
|
||||
public string? ActualPolicyDigest { get; init; }
|
||||
public bool? PolicyMatches { get; init; }
|
||||
public string? OriginalVerdictDigest { get; init; }
|
||||
public string? ReplayedVerdictDigest { get; init; }
|
||||
public bool? VerdictMatches { get; init; }
|
||||
public IReadOnlyList<string> Drifts { get; init; } = Array.Empty<string>();
|
||||
public IReadOnlyList<string> Errors { get; init; } = Array.Empty<string>();
|
||||
public DateTimeOffset ReplayedAt { get; init; }
|
||||
}
|
||||
|
||||
public enum AuditReplayStatus
|
||||
{
|
||||
Match,
|
||||
Drift,
|
||||
Error
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Options for replay operation.
|
||||
/// </summary>
|
||||
public sealed record ReplayOptions
|
||||
{
|
||||
public bool Strict { get; init; }
|
||||
public bool Offline { get; init; }
|
||||
public DateTimeOffset? TimeAnchor { get; init; }
|
||||
public string? OutputDirectory { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Options for import operation.
|
||||
/// </summary>
|
||||
public sealed record ImportOptions
|
||||
{
|
||||
public string? TrustStorePath { get; init; }
|
||||
public string? OutputDirectory { get; init; }
|
||||
public bool VerifyOnly { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interface for audit pack import.
|
||||
/// </summary>
|
||||
public interface IAuditPackImporter
|
||||
{
|
||||
Task<AuditPack> ImportAsync(string bundlePath, ImportOptions options, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interface for audit pack replay.
|
||||
/// </summary>
|
||||
public interface IAuditPackReplayer
|
||||
{
|
||||
Task<AuditReplayResult> ReplayAsync(AuditPack pack, ReplayOptions options, CancellationToken ct = default);
|
||||
}
|
||||
Reference in New Issue
Block a user