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:
533
src/Cli/StellaOps.Cli/Commands/Compare/CompareCommandBuilder.cs
Normal file
533
src/Cli/StellaOps.Cli/Commands/Compare/CompareCommandBuilder.cs
Normal file
@@ -0,0 +1,533 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// CompareCommandBuilder.cs
|
||||
// Sprint: SPRINT_4200_0002_0004_cli_compare
|
||||
// Description: CLI commands for comparing scan snapshots.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.CommandLine;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Cli.Output;
|
||||
|
||||
namespace StellaOps.Cli.Commands.Compare;
|
||||
|
||||
/// <summary>
|
||||
/// Builds CLI commands for comparing scan snapshots.
|
||||
/// Per SPRINT_4200_0002_0004.
|
||||
/// </summary>
|
||||
internal static class CompareCommandBuilder
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
WriteIndented = true,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
Converters = { new JsonStringEnumConverter() }
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Builds the compare command group.
|
||||
/// </summary>
|
||||
internal static Command BuildCompareCommand(
|
||||
IServiceProvider services,
|
||||
Option<bool> verboseOption,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var baseDigestOption = new Option<string>("--base", "Base snapshot digest (the 'before' state)")
|
||||
{
|
||||
IsRequired = true
|
||||
};
|
||||
baseDigestOption.AddAlias("-b");
|
||||
|
||||
var targetDigestOption = new Option<string>("--target", "Target snapshot digest (the 'after' state)")
|
||||
{
|
||||
IsRequired = true
|
||||
};
|
||||
targetDigestOption.AddAlias("-t");
|
||||
|
||||
var outputOption = new Option<string?>("--output", "Output format (table, json, sarif)")
|
||||
{
|
||||
ArgumentHelpName = "format"
|
||||
};
|
||||
outputOption.AddAlias("-o");
|
||||
|
||||
var outputFileOption = new Option<string?>("--output-file", "Write output to file instead of stdout")
|
||||
{
|
||||
ArgumentHelpName = "path"
|
||||
};
|
||||
outputFileOption.AddAlias("-f");
|
||||
|
||||
var includeUnchangedOption = new Option<bool>("--include-unchanged", "Include findings that are unchanged");
|
||||
|
||||
var severityFilterOption = new Option<string?>("--severity", "Filter by severity (critical, high, medium, low)")
|
||||
{
|
||||
ArgumentHelpName = "level"
|
||||
};
|
||||
severityFilterOption.AddAlias("-s");
|
||||
|
||||
var backendUrlOption = new Option<string?>("--backend-url", "Scanner WebService URL override");
|
||||
|
||||
// compare diff - Full comparison
|
||||
var diffCommand = new Command("diff", "Compare two scan snapshots and show detailed diff.");
|
||||
diffCommand.Add(baseDigestOption);
|
||||
diffCommand.Add(targetDigestOption);
|
||||
diffCommand.Add(outputOption);
|
||||
diffCommand.Add(outputFileOption);
|
||||
diffCommand.Add(includeUnchangedOption);
|
||||
diffCommand.Add(severityFilterOption);
|
||||
diffCommand.Add(backendUrlOption);
|
||||
diffCommand.SetAction(async parseResult =>
|
||||
{
|
||||
var baseDigest = parseResult.GetValue(baseDigestOption)!;
|
||||
var targetDigest = parseResult.GetValue(targetDigestOption)!;
|
||||
var output = parseResult.GetValue(outputOption) ?? "table";
|
||||
var outputFile = parseResult.GetValue(outputFileOption);
|
||||
var includeUnchanged = parseResult.GetValue(includeUnchangedOption);
|
||||
var severity = parseResult.GetValue(severityFilterOption);
|
||||
var backendUrl = parseResult.GetValue(backendUrlOption);
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
|
||||
var renderer = services.GetService<IOutputRenderer>() ?? new OutputRenderer();
|
||||
var client = services.GetService<ICompareClient>()
|
||||
?? new LocalCompareClient();
|
||||
|
||||
var request = new CompareRequest
|
||||
{
|
||||
BaseDigest = baseDigest,
|
||||
TargetDigest = targetDigest,
|
||||
IncludeUnchanged = includeUnchanged,
|
||||
SeverityFilter = severity,
|
||||
BackendUrl = backendUrl
|
||||
};
|
||||
|
||||
var result = await client.CompareAsync(request, cancellationToken);
|
||||
|
||||
await WriteOutputAsync(result, output, outputFile, renderer, verbose);
|
||||
});
|
||||
|
||||
// compare summary - Quick summary
|
||||
var summaryCommand = new Command("summary", "Show quick summary of changes between snapshots.");
|
||||
summaryCommand.Add(baseDigestOption);
|
||||
summaryCommand.Add(targetDigestOption);
|
||||
summaryCommand.Add(outputOption);
|
||||
summaryCommand.Add(backendUrlOption);
|
||||
summaryCommand.SetAction(async parseResult =>
|
||||
{
|
||||
var baseDigest = parseResult.GetValue(baseDigestOption)!;
|
||||
var targetDigest = parseResult.GetValue(targetDigestOption)!;
|
||||
var output = parseResult.GetValue(outputOption) ?? "table";
|
||||
var backendUrl = parseResult.GetValue(backendUrlOption);
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
|
||||
var renderer = services.GetService<IOutputRenderer>() ?? new OutputRenderer();
|
||||
var client = services.GetService<ICompareClient>()
|
||||
?? new LocalCompareClient();
|
||||
|
||||
var result = await client.GetSummaryAsync(baseDigest, targetDigest, backendUrl, cancellationToken);
|
||||
|
||||
WriteSummary(result, output, renderer, verbose);
|
||||
});
|
||||
|
||||
// compare can-ship - Quick check if target can ship
|
||||
var canShipCommand = new Command("can-ship", "Check if target snapshot can ship relative to base.");
|
||||
canShipCommand.Add(baseDigestOption);
|
||||
canShipCommand.Add(targetDigestOption);
|
||||
canShipCommand.Add(backendUrlOption);
|
||||
canShipCommand.SetAction(async parseResult =>
|
||||
{
|
||||
var baseDigest = parseResult.GetValue(baseDigestOption)!;
|
||||
var targetDigest = parseResult.GetValue(targetDigestOption)!;
|
||||
var backendUrl = parseResult.GetValue(backendUrlOption);
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
|
||||
var client = services.GetService<ICompareClient>()
|
||||
?? new LocalCompareClient();
|
||||
|
||||
var result = await client.GetSummaryAsync(baseDigest, targetDigest, backendUrl, cancellationToken);
|
||||
|
||||
WriteCanShipResult(result, verbose);
|
||||
|
||||
if (!result.CanShip)
|
||||
{
|
||||
Environment.ExitCode = 1;
|
||||
}
|
||||
});
|
||||
|
||||
// compare vulns - List vulnerability changes only
|
||||
var vulnsCommand = new Command("vulns", "List vulnerability changes between snapshots.");
|
||||
vulnsCommand.Add(baseDigestOption);
|
||||
vulnsCommand.Add(targetDigestOption);
|
||||
vulnsCommand.Add(outputOption);
|
||||
vulnsCommand.Add(severityFilterOption);
|
||||
vulnsCommand.Add(backendUrlOption);
|
||||
vulnsCommand.SetAction(async parseResult =>
|
||||
{
|
||||
var baseDigest = parseResult.GetValue(baseDigestOption)!;
|
||||
var targetDigest = parseResult.GetValue(targetDigestOption)!;
|
||||
var output = parseResult.GetValue(outputOption) ?? "table";
|
||||
var severity = parseResult.GetValue(severityFilterOption);
|
||||
var backendUrl = parseResult.GetValue(backendUrlOption);
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
|
||||
var renderer = services.GetService<IOutputRenderer>() ?? new OutputRenderer();
|
||||
var client = services.GetService<ICompareClient>()
|
||||
?? new LocalCompareClient();
|
||||
|
||||
var request = new CompareRequest
|
||||
{
|
||||
BaseDigest = baseDigest,
|
||||
TargetDigest = targetDigest,
|
||||
SeverityFilter = severity,
|
||||
BackendUrl = backendUrl
|
||||
};
|
||||
|
||||
var result = await client.CompareAsync(request, cancellationToken);
|
||||
|
||||
WriteVulnChanges(result, output, renderer, verbose);
|
||||
});
|
||||
|
||||
// Main compare command
|
||||
var compareCommand = new Command("compare", "Compare scan snapshots (SBOM/vulnerability diff).");
|
||||
compareCommand.AddCommand(diffCommand);
|
||||
compareCommand.AddCommand(summaryCommand);
|
||||
compareCommand.AddCommand(canShipCommand);
|
||||
compareCommand.AddCommand(vulnsCommand);
|
||||
|
||||
return compareCommand;
|
||||
}
|
||||
|
||||
private static async Task WriteOutputAsync(
|
||||
CompareResult result,
|
||||
string format,
|
||||
string? outputFile,
|
||||
IOutputRenderer renderer,
|
||||
bool verbose)
|
||||
{
|
||||
string content;
|
||||
|
||||
switch (format.ToLowerInvariant())
|
||||
{
|
||||
case "json":
|
||||
content = JsonSerializer.Serialize(result, JsonOptions);
|
||||
break;
|
||||
case "sarif":
|
||||
content = GenerateSarif(result);
|
||||
break;
|
||||
case "table":
|
||||
default:
|
||||
WriteTableOutput(result, renderer, verbose);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(outputFile))
|
||||
{
|
||||
await File.WriteAllTextAsync(outputFile, content);
|
||||
Console.WriteLine($"Output written to: {outputFile}");
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine(content);
|
||||
}
|
||||
}
|
||||
|
||||
private static void WriteTableOutput(CompareResult result, IOutputRenderer renderer, bool verbose)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine($"Comparison: {result.BaseDigest[..12]}... -> {result.TargetDigest[..12]}...");
|
||||
Console.WriteLine($"Risk Direction: {result.RiskDirection}");
|
||||
Console.WriteLine();
|
||||
|
||||
Console.WriteLine("Summary:");
|
||||
Console.WriteLine($" Added: {result.Summary.Added}");
|
||||
Console.WriteLine($" Removed: {result.Summary.Removed}");
|
||||
Console.WriteLine($" Modified: {result.Summary.Modified}");
|
||||
Console.WriteLine($" Unchanged: {result.Summary.Unchanged}");
|
||||
Console.WriteLine();
|
||||
|
||||
Console.WriteLine("Severity Changes:");
|
||||
Console.WriteLine($" Critical: +{result.Summary.CriticalAdded} / -{result.Summary.CriticalRemoved}");
|
||||
Console.WriteLine($" High: +{result.Summary.HighAdded} / -{result.Summary.HighRemoved}");
|
||||
Console.WriteLine($" Medium: +{result.Summary.MediumAdded} / -{result.Summary.MediumRemoved}");
|
||||
Console.WriteLine($" Low: +{result.Summary.LowAdded} / -{result.Summary.LowRemoved}");
|
||||
Console.WriteLine();
|
||||
|
||||
if (result.VerdictChanged)
|
||||
{
|
||||
Console.WriteLine($"Policy Verdict: {result.BaseVerdict} -> {result.TargetVerdict}");
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine($"Policy Verdict: {result.TargetVerdict} (unchanged)");
|
||||
}
|
||||
}
|
||||
|
||||
private static void WriteSummary(CompareSummary summary, string format, IOutputRenderer renderer, bool verbose)
|
||||
{
|
||||
if (format.Equals("json", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
Console.WriteLine(JsonSerializer.Serialize(summary, JsonOptions));
|
||||
return;
|
||||
}
|
||||
|
||||
var canShipText = summary.CanShip ? "YES" : "NO";
|
||||
var directionSymbol = summary.RiskDirection switch
|
||||
{
|
||||
"improved" => "[+]",
|
||||
"degraded" => "[-]",
|
||||
_ => "[=]"
|
||||
};
|
||||
|
||||
Console.WriteLine();
|
||||
Console.WriteLine($"Can Ship: {canShipText}");
|
||||
Console.WriteLine($"Risk: {directionSymbol} {summary.RiskDirection}");
|
||||
Console.WriteLine($"Net Blocking: {(summary.NetBlockingChange >= 0 ? "+" : "")}{summary.NetBlockingChange}");
|
||||
Console.WriteLine($"Critical: +{summary.CriticalAdded}/-{summary.CriticalRemoved}");
|
||||
Console.WriteLine($"High: +{summary.HighAdded}/-{summary.HighRemoved}");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine(summary.Summary);
|
||||
}
|
||||
|
||||
private static void WriteCanShipResult(CompareSummary summary, bool verbose)
|
||||
{
|
||||
if (summary.CanShip)
|
||||
{
|
||||
Console.WriteLine("CAN SHIP: Target passes policy requirements.");
|
||||
if (verbose)
|
||||
{
|
||||
Console.WriteLine($" Risk direction: {summary.RiskDirection}");
|
||||
Console.WriteLine($" Summary: {summary.Summary}");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.Error.WriteLine("CANNOT SHIP: Target does not pass policy requirements.");
|
||||
if (verbose)
|
||||
{
|
||||
Console.Error.WriteLine($" Risk direction: {summary.RiskDirection}");
|
||||
Console.Error.WriteLine($" Net blocking change: {summary.NetBlockingChange}");
|
||||
Console.Error.WriteLine($" Summary: {summary.Summary}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void WriteVulnChanges(CompareResult result, string format, IOutputRenderer renderer, bool verbose)
|
||||
{
|
||||
if (format.Equals("json", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
Console.WriteLine(JsonSerializer.Serialize(result.Vulnerabilities, JsonOptions));
|
||||
return;
|
||||
}
|
||||
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("Vulnerability Changes:");
|
||||
Console.WriteLine(new string('-', 80));
|
||||
|
||||
var added = result.Vulnerabilities.Where(v => v.ChangeType == "Added").ToList();
|
||||
var removed = result.Vulnerabilities.Where(v => v.ChangeType == "Removed").ToList();
|
||||
var modified = result.Vulnerabilities.Where(v => v.ChangeType == "Modified").ToList();
|
||||
|
||||
if (added.Count > 0)
|
||||
{
|
||||
Console.WriteLine($"\nADDED ({added.Count}):");
|
||||
foreach (var vuln in added.OrderByDescending(v => GetSeverityOrder(v.Severity)))
|
||||
{
|
||||
Console.WriteLine($" + [{vuln.Severity}] {vuln.VulnId} in {vuln.Purl}");
|
||||
}
|
||||
}
|
||||
|
||||
if (removed.Count > 0)
|
||||
{
|
||||
Console.WriteLine($"\nREMOVED ({removed.Count}):");
|
||||
foreach (var vuln in removed.OrderByDescending(v => GetSeverityOrder(v.Severity)))
|
||||
{
|
||||
Console.WriteLine($" - [{vuln.Severity}] {vuln.VulnId} in {vuln.Purl}");
|
||||
}
|
||||
}
|
||||
|
||||
if (modified.Count > 0)
|
||||
{
|
||||
Console.WriteLine($"\nMODIFIED ({modified.Count}):");
|
||||
foreach (var vuln in modified)
|
||||
{
|
||||
Console.WriteLine($" ~ [{vuln.Severity}] {vuln.VulnId} in {vuln.Purl}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static int GetSeverityOrder(string severity)
|
||||
{
|
||||
return severity.ToLowerInvariant() switch
|
||||
{
|
||||
"critical" => 4,
|
||||
"high" => 3,
|
||||
"medium" => 2,
|
||||
"low" => 1,
|
||||
_ => 0
|
||||
};
|
||||
}
|
||||
|
||||
private static string GenerateSarif(CompareResult result)
|
||||
{
|
||||
// Simplified SARIF output
|
||||
var sarif = new
|
||||
{
|
||||
version = "2.1.0",
|
||||
runs = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
tool = new
|
||||
{
|
||||
driver = new
|
||||
{
|
||||
name = "stellaops-compare",
|
||||
version = "1.0.0"
|
||||
}
|
||||
},
|
||||
results = result.Vulnerabilities.Select(v => new
|
||||
{
|
||||
ruleId = v.VulnId,
|
||||
level = MapSeverityToSarif(v.Severity),
|
||||
message = new { text = $"{v.ChangeType}: {v.VulnId} in {v.Purl}" },
|
||||
properties = new
|
||||
{
|
||||
changeType = v.ChangeType,
|
||||
severity = v.Severity,
|
||||
purl = v.Purl
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return JsonSerializer.Serialize(sarif, JsonOptions);
|
||||
}
|
||||
|
||||
private static string MapSeverityToSarif(string severity)
|
||||
{
|
||||
return severity.ToLowerInvariant() switch
|
||||
{
|
||||
"critical" => "error",
|
||||
"high" => "error",
|
||||
"medium" => "warning",
|
||||
"low" => "note",
|
||||
_ => "none"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Compare request parameters.
|
||||
/// </summary>
|
||||
public sealed record CompareRequest
|
||||
{
|
||||
public required string BaseDigest { get; init; }
|
||||
public required string TargetDigest { get; init; }
|
||||
public bool IncludeUnchanged { get; init; }
|
||||
public string? SeverityFilter { get; init; }
|
||||
public string? BackendUrl { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Full compare result.
|
||||
/// </summary>
|
||||
public sealed record CompareResult
|
||||
{
|
||||
public required string BaseDigest { get; init; }
|
||||
public required string TargetDigest { get; init; }
|
||||
public required string RiskDirection { get; init; }
|
||||
public required CompareSummary Summary { get; init; }
|
||||
public bool VerdictChanged { get; init; }
|
||||
public string? BaseVerdict { get; init; }
|
||||
public string? TargetVerdict { get; init; }
|
||||
public required IReadOnlyList<VulnChange> Vulnerabilities { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Compare summary.
|
||||
/// </summary>
|
||||
public sealed record CompareSummary
|
||||
{
|
||||
public bool CanShip { get; init; }
|
||||
public required string RiskDirection { get; init; }
|
||||
public int NetBlockingChange { get; init; }
|
||||
public int Added { get; init; }
|
||||
public int Removed { get; init; }
|
||||
public int Modified { get; init; }
|
||||
public int Unchanged { get; init; }
|
||||
public int CriticalAdded { get; init; }
|
||||
public int CriticalRemoved { get; init; }
|
||||
public int HighAdded { get; init; }
|
||||
public int HighRemoved { get; init; }
|
||||
public int MediumAdded { get; init; }
|
||||
public int MediumRemoved { get; init; }
|
||||
public int LowAdded { get; init; }
|
||||
public int LowRemoved { get; init; }
|
||||
public required string Summary { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Individual vulnerability change.
|
||||
/// </summary>
|
||||
public sealed record VulnChange
|
||||
{
|
||||
public required string VulnId { get; init; }
|
||||
public required string Purl { get; init; }
|
||||
public required string ChangeType { get; init; }
|
||||
public required string Severity { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interface for compare client.
|
||||
/// </summary>
|
||||
public interface ICompareClient
|
||||
{
|
||||
Task<CompareResult> CompareAsync(CompareRequest request, CancellationToken ct = default);
|
||||
Task<CompareSummary> GetSummaryAsync(string baseDigest, string targetDigest, string? backendUrl, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Local compare client implementation for offline use.
|
||||
/// </summary>
|
||||
public sealed class LocalCompareClient : ICompareClient
|
||||
{
|
||||
public Task<CompareResult> CompareAsync(CompareRequest request, CancellationToken ct = default)
|
||||
{
|
||||
// In a full implementation, this would:
|
||||
// 1. Call the backend API if available
|
||||
// 2. Or compute locally from cached data
|
||||
|
||||
var result = new CompareResult
|
||||
{
|
||||
BaseDigest = request.BaseDigest,
|
||||
TargetDigest = request.TargetDigest,
|
||||
RiskDirection = "unchanged",
|
||||
Summary = new CompareSummary
|
||||
{
|
||||
CanShip = true,
|
||||
RiskDirection = "unchanged",
|
||||
NetBlockingChange = 0,
|
||||
Summary = "No data available - connect to backend for comparison"
|
||||
},
|
||||
VerdictChanged = false,
|
||||
BaseVerdict = "Unknown",
|
||||
TargetVerdict = "Unknown",
|
||||
Vulnerabilities = []
|
||||
};
|
||||
|
||||
return Task.FromResult(result);
|
||||
}
|
||||
|
||||
public Task<CompareSummary> GetSummaryAsync(string baseDigest, string targetDigest, string? backendUrl, CancellationToken ct = default)
|
||||
{
|
||||
var summary = new CompareSummary
|
||||
{
|
||||
CanShip = true,
|
||||
RiskDirection = "unchanged",
|
||||
NetBlockingChange = 0,
|
||||
Summary = "No data available - connect to backend for comparison"
|
||||
};
|
||||
|
||||
return Task.FromResult(summary);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user