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:
StellaOps Bot
2025-12-22 23:21:21 +02:00
parent 3ba7157b00
commit 5146204f1b
529 changed files with 73579 additions and 5985 deletions

View 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);
}
}