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:
@@ -1,8 +1,8 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// UnknownsCommandGroup.cs
|
||||
// Sprint: SPRINT_3500_0004_0001_cli_verbs
|
||||
// Task: T3 - Unknowns List Command
|
||||
// Description: CLI commands for unknowns registry operations
|
||||
// Sprint: SPRINT_3500_0004_0001_cli_verbs, SPRINT_5100_0004_0001_unknowns_budget_ci_gates
|
||||
// Task: T3 - Unknowns List Command, T1 - CLI Budget Check Command
|
||||
// Description: CLI commands for unknowns registry operations and budget checking
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.CommandLine;
|
||||
@@ -11,6 +11,7 @@ using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Policy.Unknowns.Models;
|
||||
|
||||
namespace StellaOps.Cli.Commands;
|
||||
|
||||
@@ -40,10 +41,137 @@ public static class UnknownsCommandGroup
|
||||
unknownsCommand.Add(BuildListCommand(services, verboseOption, cancellationToken));
|
||||
unknownsCommand.Add(BuildEscalateCommand(services, verboseOption, cancellationToken));
|
||||
unknownsCommand.Add(BuildResolveCommand(services, verboseOption, cancellationToken));
|
||||
unknownsCommand.Add(BuildBudgetCommand(services, verboseOption, cancellationToken));
|
||||
|
||||
return unknownsCommand;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Build the budget subcommand tree (stella unknowns budget).
|
||||
/// Sprint: SPRINT_5100_0004_0001 Task T1
|
||||
/// </summary>
|
||||
private static Command BuildBudgetCommand(
|
||||
IServiceProvider services,
|
||||
Option<bool> verboseOption,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var budgetCommand = new Command("budget", "Unknowns budget operations for CI gates");
|
||||
budgetCommand.Add(BuildBudgetCheckCommand(services, verboseOption, cancellationToken));
|
||||
budgetCommand.Add(BuildBudgetStatusCommand(services, verboseOption, cancellationToken));
|
||||
return budgetCommand;
|
||||
}
|
||||
|
||||
private static Command BuildBudgetCheckCommand(
|
||||
IServiceProvider services,
|
||||
Option<bool> verboseOption,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var scanIdOption = new Option<string?>("--scan-id", "-s")
|
||||
{
|
||||
Description = "Scan ID to check budget against"
|
||||
};
|
||||
|
||||
var verdictPathOption = new Option<string?>("--verdict", "-v")
|
||||
{
|
||||
Description = "Path to verdict JSON file"
|
||||
};
|
||||
|
||||
var environmentOption = new Option<string>("--environment", "-e")
|
||||
{
|
||||
Description = "Environment budget to use (prod, stage, dev)"
|
||||
};
|
||||
environmentOption.SetDefaultValue("prod");
|
||||
|
||||
var configOption = new Option<string?>("--config", "-c")
|
||||
{
|
||||
Description = "Path to budget configuration file"
|
||||
};
|
||||
|
||||
var failOnExceedOption = new Option<bool>("--fail-on-exceed")
|
||||
{
|
||||
Description = "Exit with error code if budget exceeded"
|
||||
};
|
||||
failOnExceedOption.SetDefaultValue(true);
|
||||
|
||||
var outputOption = new Option<string>("--output", "-o")
|
||||
{
|
||||
Description = "Output format: text, json, sarif"
|
||||
};
|
||||
outputOption.SetDefaultValue("text");
|
||||
|
||||
var checkCommand = new Command("check", "Check scan results against unknowns budget");
|
||||
checkCommand.Add(scanIdOption);
|
||||
checkCommand.Add(verdictPathOption);
|
||||
checkCommand.Add(environmentOption);
|
||||
checkCommand.Add(configOption);
|
||||
checkCommand.Add(failOnExceedOption);
|
||||
checkCommand.Add(outputOption);
|
||||
checkCommand.Add(verboseOption);
|
||||
|
||||
checkCommand.SetAction(async (parseResult, ct) =>
|
||||
{
|
||||
var scanId = parseResult.GetValue(scanIdOption);
|
||||
var verdictPath = parseResult.GetValue(verdictPathOption);
|
||||
var environment = parseResult.GetValue(environmentOption) ?? "prod";
|
||||
var config = parseResult.GetValue(configOption);
|
||||
var failOnExceed = parseResult.GetValue(failOnExceedOption);
|
||||
var output = parseResult.GetValue(outputOption) ?? "text";
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
|
||||
return await HandleBudgetCheckAsync(
|
||||
services,
|
||||
scanId,
|
||||
verdictPath,
|
||||
environment,
|
||||
config,
|
||||
failOnExceed,
|
||||
output,
|
||||
verbose,
|
||||
cancellationToken);
|
||||
});
|
||||
|
||||
return checkCommand;
|
||||
}
|
||||
|
||||
private static Command BuildBudgetStatusCommand(
|
||||
IServiceProvider services,
|
||||
Option<bool> verboseOption,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var environmentOption = new Option<string>("--environment", "-e")
|
||||
{
|
||||
Description = "Environment to show budget status for"
|
||||
};
|
||||
environmentOption.SetDefaultValue("prod");
|
||||
|
||||
var outputOption = new Option<string>("--output", "-o")
|
||||
{
|
||||
Description = "Output format: text, json"
|
||||
};
|
||||
outputOption.SetDefaultValue("text");
|
||||
|
||||
var statusCommand = new Command("status", "Show current budget status for an environment");
|
||||
statusCommand.Add(environmentOption);
|
||||
statusCommand.Add(outputOption);
|
||||
statusCommand.Add(verboseOption);
|
||||
|
||||
statusCommand.SetAction(async (parseResult, ct) =>
|
||||
{
|
||||
var environment = parseResult.GetValue(environmentOption) ?? "prod";
|
||||
var output = parseResult.GetValue(outputOption) ?? "text";
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
|
||||
return await HandleBudgetStatusAsync(
|
||||
services,
|
||||
environment,
|
||||
output,
|
||||
verbose,
|
||||
cancellationToken);
|
||||
});
|
||||
|
||||
return statusCommand;
|
||||
}
|
||||
|
||||
private static Command BuildListCommand(
|
||||
IServiceProvider services,
|
||||
Option<bool> verboseOption,
|
||||
@@ -429,6 +557,311 @@ public static class UnknownsCommandGroup
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handle budget check command.
|
||||
/// Sprint: SPRINT_5100_0004_0001 Task T1
|
||||
/// Exit codes: 0=pass, 1=error, 2=budget exceeded
|
||||
/// </summary>
|
||||
private static async Task<int> HandleBudgetCheckAsync(
|
||||
IServiceProvider services,
|
||||
string? scanId,
|
||||
string? verdictPath,
|
||||
string environment,
|
||||
string? configPath,
|
||||
bool failOnExceed,
|
||||
string output,
|
||||
bool verbose,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var loggerFactory = services.GetService<ILoggerFactory>();
|
||||
var logger = loggerFactory?.CreateLogger(typeof(UnknownsCommandGroup));
|
||||
var httpClientFactory = services.GetService<IHttpClientFactory>();
|
||||
|
||||
if (httpClientFactory is null)
|
||||
{
|
||||
logger?.LogError("HTTP client factory not available");
|
||||
return 1;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (verbose)
|
||||
{
|
||||
logger?.LogDebug("Checking budget for environment {Environment}", environment);
|
||||
}
|
||||
|
||||
// Load unknowns from verdict file or API
|
||||
IReadOnlyList<BudgetUnknownDto> unknowns;
|
||||
|
||||
if (!string.IsNullOrEmpty(verdictPath))
|
||||
{
|
||||
// Load from local verdict file
|
||||
if (!File.Exists(verdictPath))
|
||||
{
|
||||
Console.WriteLine($"Error: Verdict file not found: {verdictPath}");
|
||||
return 1;
|
||||
}
|
||||
|
||||
var json = await File.ReadAllTextAsync(verdictPath, ct);
|
||||
var verdict = JsonSerializer.Deserialize<VerdictFileDto>(json, JsonOptions);
|
||||
|
||||
if (verdict?.Unknowns is null)
|
||||
{
|
||||
Console.WriteLine("Error: No unknowns found in verdict file");
|
||||
return 1;
|
||||
}
|
||||
|
||||
unknowns = verdict.Unknowns;
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(scanId))
|
||||
{
|
||||
// Fetch from API
|
||||
var client = httpClientFactory.CreateClient("PolicyApi");
|
||||
var response = await client.GetAsync($"/api/v1/policy/unknowns?scanId={scanId}&limit=1000", ct);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
logger?.LogError("Failed to fetch unknowns: {Status}", response.StatusCode);
|
||||
Console.WriteLine($"Error: Failed to fetch unknowns ({response.StatusCode})");
|
||||
return 1;
|
||||
}
|
||||
|
||||
var listResponse = await response.Content.ReadFromJsonAsync<UnknownsListResponse>(JsonOptions, ct);
|
||||
unknowns = listResponse?.Items.Select(i => new BudgetUnknownDto
|
||||
{
|
||||
Id = i.Id,
|
||||
ReasonCode = "Reachability" // Default if not provided
|
||||
}).ToList() ?? [];
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine("Error: Either --scan-id or --verdict must be specified");
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Check budget via API
|
||||
var budgetClient = httpClientFactory.CreateClient("PolicyApi");
|
||||
var checkRequest = new BudgetCheckRequest(environment, unknowns);
|
||||
|
||||
var checkResponse = await budgetClient.PostAsJsonAsync(
|
||||
"/api/v1/policy/unknowns/budget/check",
|
||||
checkRequest,
|
||||
JsonOptions,
|
||||
ct);
|
||||
|
||||
BudgetCheckResultDto result;
|
||||
|
||||
if (checkResponse.IsSuccessStatusCode)
|
||||
{
|
||||
result = await checkResponse.Content.ReadFromJsonAsync<BudgetCheckResultDto>(JsonOptions, ct)
|
||||
?? new BudgetCheckResultDto
|
||||
{
|
||||
IsWithinBudget = true,
|
||||
Environment = environment,
|
||||
TotalUnknowns = unknowns.Count
|
||||
};
|
||||
}
|
||||
else
|
||||
{
|
||||
// Fallback to local check if API unavailable
|
||||
result = PerformLocalBudgetCheck(environment, unknowns.Count);
|
||||
}
|
||||
|
||||
// Output result
|
||||
OutputBudgetResult(result, output);
|
||||
|
||||
// Return exit code
|
||||
if (failOnExceed && !result.IsWithinBudget)
|
||||
{
|
||||
Console.Error.WriteLine($"Budget exceeded: {result.Message ?? "Unknown budget exceeded"}");
|
||||
return 2; // Distinct exit code for budget failure
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger?.LogError(ex, "Budget check failed unexpectedly");
|
||||
Console.WriteLine($"Error: {ex.Message}");
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
private static BudgetCheckResultDto PerformLocalBudgetCheck(string environment, int unknownCount)
|
||||
{
|
||||
// Default budgets if API unavailable
|
||||
var limits = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["prod"] = 0,
|
||||
["stage"] = 5,
|
||||
["dev"] = 20
|
||||
};
|
||||
|
||||
var limit = limits.TryGetValue(environment, out var l) ? l : 10;
|
||||
var exceeded = unknownCount > limit;
|
||||
|
||||
return new BudgetCheckResultDto
|
||||
{
|
||||
IsWithinBudget = !exceeded,
|
||||
Environment = environment,
|
||||
TotalUnknowns = unknownCount,
|
||||
TotalLimit = limit,
|
||||
Message = exceeded ? $"Budget exceeded: {unknownCount} unknowns exceed limit of {limit}" : null
|
||||
};
|
||||
}
|
||||
|
||||
private static void OutputBudgetResult(BudgetCheckResultDto result, string format)
|
||||
{
|
||||
switch (format.ToLowerInvariant())
|
||||
{
|
||||
case "json":
|
||||
Console.WriteLine(JsonSerializer.Serialize(result, JsonOptions));
|
||||
break;
|
||||
|
||||
case "sarif":
|
||||
OutputSarifResult(result);
|
||||
break;
|
||||
|
||||
default:
|
||||
OutputTextResult(result);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private static void OutputTextResult(BudgetCheckResultDto result)
|
||||
{
|
||||
var status = result.IsWithinBudget ? "[PASS]" : "[FAIL]";
|
||||
Console.WriteLine($"{status} Unknowns Budget Check");
|
||||
Console.WriteLine($" Environment: {result.Environment}");
|
||||
Console.WriteLine($" Total Unknowns: {result.TotalUnknowns}");
|
||||
|
||||
if (result.TotalLimit.HasValue)
|
||||
Console.WriteLine($" Budget Limit: {result.TotalLimit}");
|
||||
|
||||
if (result.Violations?.Count > 0)
|
||||
{
|
||||
Console.WriteLine("\n Violations:");
|
||||
foreach (var violation in result.Violations)
|
||||
{
|
||||
Console.WriteLine($" - {violation.ReasonCode}: {violation.Count}/{violation.Limit}");
|
||||
}
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(result.Message))
|
||||
Console.WriteLine($"\n Message: {result.Message}");
|
||||
}
|
||||
|
||||
private static void OutputSarifResult(BudgetCheckResultDto result)
|
||||
{
|
||||
var violations = result.Violations ?? [];
|
||||
var sarif = new
|
||||
{
|
||||
version = "2.1.0",
|
||||
schema = "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json",
|
||||
runs = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
tool = new
|
||||
{
|
||||
driver = new
|
||||
{
|
||||
name = "StellaOps Budget Check",
|
||||
version = "1.0.0",
|
||||
informationUri = "https://stellaops.io"
|
||||
}
|
||||
},
|
||||
results = violations.Select(v => new
|
||||
{
|
||||
ruleId = $"UNKNOWN_{v.ReasonCode}",
|
||||
level = "error",
|
||||
message = new
|
||||
{
|
||||
text = $"{v.ReasonCode}: {v.Count} unknowns exceed limit of {v.Limit}"
|
||||
}
|
||||
}).ToArray()
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
Console.WriteLine(JsonSerializer.Serialize(sarif, JsonOptions));
|
||||
}
|
||||
|
||||
private static async Task<int> HandleBudgetStatusAsync(
|
||||
IServiceProvider services,
|
||||
string environment,
|
||||
string output,
|
||||
bool verbose,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var loggerFactory = services.GetService<ILoggerFactory>();
|
||||
var logger = loggerFactory?.CreateLogger(typeof(UnknownsCommandGroup));
|
||||
var httpClientFactory = services.GetService<IHttpClientFactory>();
|
||||
|
||||
if (httpClientFactory is null)
|
||||
{
|
||||
logger?.LogError("HTTP client factory not available");
|
||||
return 1;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (verbose)
|
||||
{
|
||||
logger?.LogDebug("Getting budget status for environment {Environment}", environment);
|
||||
}
|
||||
|
||||
var client = httpClientFactory.CreateClient("PolicyApi");
|
||||
var response = await client.GetAsync($"/api/v1/policy/unknowns/budget/status?environment={environment}", ct);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
logger?.LogError("Failed to get budget status: {Status}", response.StatusCode);
|
||||
Console.WriteLine($"Error: Failed to get budget status ({response.StatusCode})");
|
||||
return 1;
|
||||
}
|
||||
|
||||
var status = await response.Content.ReadFromJsonAsync<BudgetStatusDto>(JsonOptions, ct);
|
||||
|
||||
if (status is null)
|
||||
{
|
||||
Console.WriteLine("Error: Empty response from budget status");
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (output == "json")
|
||||
{
|
||||
Console.WriteLine(JsonSerializer.Serialize(status, JsonOptions));
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine($"Budget Status: {status.Environment}");
|
||||
Console.WriteLine(new string('=', 40));
|
||||
Console.WriteLine($" Total Unknowns: {status.TotalUnknowns}");
|
||||
Console.WriteLine($" Budget Limit: {status.TotalLimit?.ToString() ?? "Unlimited"}");
|
||||
Console.WriteLine($" Usage: {status.PercentageUsed:F1}%");
|
||||
Console.WriteLine($" Status: {(status.IsExceeded ? "EXCEEDED" : "OK")}");
|
||||
|
||||
if (status.ByReasonCode?.Count > 0)
|
||||
{
|
||||
Console.WriteLine("\n By Reason Code:");
|
||||
foreach (var kvp in status.ByReasonCode)
|
||||
{
|
||||
Console.WriteLine($" - {kvp.Key}: {kvp.Value}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger?.LogError(ex, "Budget status failed unexpectedly");
|
||||
Console.WriteLine($"Error: {ex.Message}");
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
#region DTOs
|
||||
|
||||
private sealed record UnknownsListResponse(
|
||||
@@ -450,5 +883,48 @@ public static class UnknownsCommandGroup
|
||||
|
||||
private sealed record ResolveRequest(string Resolution, string? Note);
|
||||
|
||||
// Budget DTOs - Sprint: SPRINT_5100_0004_0001 Task T1
|
||||
private sealed record VerdictFileDto
|
||||
{
|
||||
public IReadOnlyList<BudgetUnknownDto>? Unknowns { get; init; }
|
||||
}
|
||||
|
||||
private sealed record BudgetUnknownDto
|
||||
{
|
||||
public string Id { get; init; } = string.Empty;
|
||||
public string ReasonCode { get; init; } = "Reachability";
|
||||
}
|
||||
|
||||
private sealed record BudgetCheckRequest(
|
||||
string Environment,
|
||||
IReadOnlyList<BudgetUnknownDto> Unknowns);
|
||||
|
||||
private sealed record BudgetCheckResultDto
|
||||
{
|
||||
public bool IsWithinBudget { get; init; }
|
||||
public string Environment { get; init; } = string.Empty;
|
||||
public int TotalUnknowns { get; init; }
|
||||
public int? TotalLimit { get; init; }
|
||||
public IReadOnlyList<BudgetViolationDto>? Violations { get; init; }
|
||||
public string? Message { get; init; }
|
||||
}
|
||||
|
||||
private sealed record BudgetViolationDto
|
||||
{
|
||||
public string ReasonCode { get; init; } = string.Empty;
|
||||
public int Count { get; init; }
|
||||
public int Limit { get; init; }
|
||||
}
|
||||
|
||||
private sealed record BudgetStatusDto
|
||||
{
|
||||
public string Environment { get; init; } = string.Empty;
|
||||
public int TotalUnknowns { get; init; }
|
||||
public int? TotalLimit { get; init; }
|
||||
public decimal PercentageUsed { get; init; }
|
||||
public bool IsExceeded { get; init; }
|
||||
public IReadOnlyDictionary<string, int>? ByReasonCode { get; init; }
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user