Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
- Added `PolicyFindings` property to `SbomCompositionRequest` to include policy findings in SBOM. - Implemented `NormalizePolicyFindings` method to process and validate policy findings. - Updated `SbomCompositionRequest.Create` method to accept policy findings as an argument. - Upgraded CycloneDX.Core package from version 5.1.0 to 10.0.1. - Marked several tasks as DONE in TASKS.md, reflecting completion of SBOM-related features. - Introduced telemetry metrics for Go analyzer to track heuristic fallbacks. - Added performance benchmarks for .NET and Go analyzers. - Created new test fixtures for .NET applications, including dependencies and runtime configurations. - Added licenses and nuspec files for logging and toolkit packages used in tests. - Implemented `SbomPolicyFinding` record to encapsulate policy finding details and normalization logic.
284 lines
9.7 KiB
C#
284 lines
9.7 KiB
C#
using System.Diagnostics;
|
|
using System.Text;
|
|
using System.Linq;
|
|
using System.Text.Json;
|
|
using System.Text.RegularExpressions;
|
|
using StellaOps.Scanner.Analyzers.Lang;
|
|
using StellaOps.Scanner.Analyzers.Lang.Go;
|
|
using StellaOps.Scanner.Analyzers.Lang.Java;
|
|
using StellaOps.Scanner.Analyzers.Lang.Node;
|
|
using StellaOps.Scanner.Analyzers.Lang.DotNet;
|
|
|
|
namespace StellaOps.Bench.ScannerAnalyzers.Scenarios;
|
|
|
|
internal interface IScenarioRunner
|
|
{
|
|
Task<ScenarioExecutionResult> ExecuteAsync(string rootPath, int iterations, CancellationToken cancellationToken);
|
|
}
|
|
|
|
internal sealed record ScenarioExecutionResult(double[] Durations, int SampleCount);
|
|
|
|
internal static class ScenarioRunnerFactory
|
|
{
|
|
public static IScenarioRunner Create(BenchmarkScenarioConfig scenario)
|
|
{
|
|
if (scenario.HasAnalyzers)
|
|
{
|
|
return new LanguageAnalyzerScenarioRunner(scenario.Analyzers!);
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(scenario.Parser) || string.IsNullOrWhiteSpace(scenario.Matcher))
|
|
{
|
|
throw new InvalidOperationException($"Scenario '{scenario.Id}' missing parser or matcher configuration.");
|
|
}
|
|
|
|
return new MetadataWalkScenarioRunner(scenario.Parser, scenario.Matcher);
|
|
}
|
|
}
|
|
|
|
internal sealed class LanguageAnalyzerScenarioRunner : IScenarioRunner
|
|
{
|
|
private readonly IReadOnlyList<Func<ILanguageAnalyzer>> _analyzerFactories;
|
|
|
|
public LanguageAnalyzerScenarioRunner(IEnumerable<string> analyzerIds)
|
|
{
|
|
if (analyzerIds is null)
|
|
{
|
|
throw new ArgumentNullException(nameof(analyzerIds));
|
|
}
|
|
|
|
_analyzerFactories = analyzerIds
|
|
.Where(static id => !string.IsNullOrWhiteSpace(id))
|
|
.Select(CreateFactory)
|
|
.ToArray();
|
|
|
|
if (_analyzerFactories.Count == 0)
|
|
{
|
|
throw new InvalidOperationException("At least one analyzer id must be provided.");
|
|
}
|
|
}
|
|
|
|
public async Task<ScenarioExecutionResult> ExecuteAsync(string rootPath, int iterations, CancellationToken cancellationToken)
|
|
{
|
|
if (iterations <= 0)
|
|
{
|
|
throw new ArgumentOutOfRangeException(nameof(iterations), iterations, "Iterations must be positive.");
|
|
}
|
|
|
|
var analyzers = _analyzerFactories.Select(factory => factory()).ToArray();
|
|
var engine = new LanguageAnalyzerEngine(analyzers);
|
|
var durations = new double[iterations];
|
|
var componentCount = -1;
|
|
|
|
for (var i = 0; i < iterations; i++)
|
|
{
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
|
|
var context = new LanguageAnalyzerContext(rootPath, TimeProvider.System);
|
|
var stopwatch = Stopwatch.StartNew();
|
|
var result = await engine.AnalyzeAsync(context, cancellationToken).ConfigureAwait(false);
|
|
stopwatch.Stop();
|
|
|
|
durations[i] = stopwatch.Elapsed.TotalMilliseconds;
|
|
|
|
var currentCount = result.Components.Count;
|
|
if (componentCount < 0)
|
|
{
|
|
componentCount = currentCount;
|
|
}
|
|
else if (componentCount != currentCount)
|
|
{
|
|
throw new InvalidOperationException($"Analyzer output count changed between iterations ({componentCount} vs {currentCount}).");
|
|
}
|
|
}
|
|
|
|
if (componentCount < 0)
|
|
{
|
|
componentCount = 0;
|
|
}
|
|
|
|
return new ScenarioExecutionResult(durations, componentCount);
|
|
}
|
|
|
|
private static Func<ILanguageAnalyzer> CreateFactory(string analyzerId)
|
|
{
|
|
var id = analyzerId.Trim().ToLowerInvariant();
|
|
return id switch
|
|
{
|
|
"java" => static () => new JavaLanguageAnalyzer(),
|
|
"go" => static () => new GoLanguageAnalyzer(),
|
|
"node" => static () => new NodeLanguageAnalyzer(),
|
|
"dotnet" => static () => new DotNetLanguageAnalyzer(),
|
|
_ => throw new InvalidOperationException($"Unsupported analyzer '{analyzerId}'."),
|
|
};
|
|
}
|
|
}
|
|
|
|
internal sealed class MetadataWalkScenarioRunner : IScenarioRunner
|
|
{
|
|
private readonly Regex _matcher;
|
|
private readonly string _parserKind;
|
|
|
|
public MetadataWalkScenarioRunner(string parserKind, string globPattern)
|
|
{
|
|
_parserKind = parserKind?.Trim().ToLowerInvariant() ?? throw new ArgumentNullException(nameof(parserKind));
|
|
_matcher = GlobToRegex(globPattern ?? throw new ArgumentNullException(nameof(globPattern)));
|
|
}
|
|
|
|
public async Task<ScenarioExecutionResult> ExecuteAsync(string rootPath, int iterations, CancellationToken cancellationToken)
|
|
{
|
|
if (iterations <= 0)
|
|
{
|
|
throw new ArgumentOutOfRangeException(nameof(iterations), iterations, "Iterations must be positive.");
|
|
}
|
|
|
|
var durations = new double[iterations];
|
|
var sampleCount = -1;
|
|
|
|
for (var i = 0; i < iterations; i++)
|
|
{
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
|
|
var stopwatch = Stopwatch.StartNew();
|
|
var files = EnumerateMatchingFiles(rootPath);
|
|
if (files.Count == 0)
|
|
{
|
|
throw new InvalidOperationException($"Parser '{_parserKind}' matched zero files under '{rootPath}'.");
|
|
}
|
|
|
|
foreach (var file in files)
|
|
{
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
await ParseAsync(file).ConfigureAwait(false);
|
|
}
|
|
|
|
stopwatch.Stop();
|
|
durations[i] = stopwatch.Elapsed.TotalMilliseconds;
|
|
|
|
if (sampleCount < 0)
|
|
{
|
|
sampleCount = files.Count;
|
|
}
|
|
else if (sampleCount != files.Count)
|
|
{
|
|
throw new InvalidOperationException($"File count changed between iterations ({sampleCount} vs {files.Count}).");
|
|
}
|
|
}
|
|
|
|
if (sampleCount < 0)
|
|
{
|
|
sampleCount = 0;
|
|
}
|
|
|
|
return new ScenarioExecutionResult(durations, sampleCount);
|
|
}
|
|
|
|
private async ValueTask ParseAsync(string filePath)
|
|
{
|
|
switch (_parserKind)
|
|
{
|
|
case "node":
|
|
{
|
|
using var stream = File.OpenRead(filePath);
|
|
using var document = await JsonDocument.ParseAsync(stream).ConfigureAwait(false);
|
|
|
|
if (!document.RootElement.TryGetProperty("name", out var name) || name.ValueKind != JsonValueKind.String)
|
|
{
|
|
throw new InvalidOperationException($"package.json '{filePath}' missing name.");
|
|
}
|
|
|
|
if (!document.RootElement.TryGetProperty("version", out var version) || version.ValueKind != JsonValueKind.String)
|
|
{
|
|
throw new InvalidOperationException($"package.json '{filePath}' missing version.");
|
|
}
|
|
}
|
|
break;
|
|
case "python":
|
|
{
|
|
var (name, version) = await ParsePythonMetadataAsync(filePath).ConfigureAwait(false);
|
|
if (string.IsNullOrEmpty(name) || string.IsNullOrEmpty(version))
|
|
{
|
|
throw new InvalidOperationException($"METADATA '{filePath}' missing Name/Version.");
|
|
}
|
|
}
|
|
break;
|
|
default:
|
|
throw new InvalidOperationException($"Unknown parser '{_parserKind}'.");
|
|
}
|
|
}
|
|
|
|
private static async Task<(string? Name, string? Version)> ParsePythonMetadataAsync(string filePath)
|
|
{
|
|
using var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read | FileShare.Delete);
|
|
using var reader = new StreamReader(stream);
|
|
|
|
string? name = null;
|
|
string? version = null;
|
|
|
|
while (await reader.ReadLineAsync().ConfigureAwait(false) is { } line)
|
|
{
|
|
if (line.StartsWith("Name:", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
name ??= line[5..].Trim();
|
|
}
|
|
else if (line.StartsWith("Version:", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
version ??= line[8..].Trim();
|
|
}
|
|
|
|
if (!string.IsNullOrEmpty(name) && !string.IsNullOrEmpty(version))
|
|
{
|
|
break;
|
|
}
|
|
}
|
|
|
|
return (name, version);
|
|
}
|
|
|
|
private IReadOnlyList<string> EnumerateMatchingFiles(string rootPath)
|
|
{
|
|
var files = new List<string>();
|
|
var stack = new Stack<string>();
|
|
stack.Push(rootPath);
|
|
|
|
while (stack.Count > 0)
|
|
{
|
|
var current = stack.Pop();
|
|
foreach (var directory in Directory.EnumerateDirectories(current))
|
|
{
|
|
stack.Push(directory);
|
|
}
|
|
|
|
foreach (var file in Directory.EnumerateFiles(current))
|
|
{
|
|
var relative = Path.GetRelativePath(rootPath, file).Replace('\\', '/');
|
|
if (_matcher.IsMatch(relative))
|
|
{
|
|
files.Add(file);
|
|
}
|
|
}
|
|
}
|
|
|
|
return files;
|
|
}
|
|
|
|
private static Regex GlobToRegex(string pattern)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(pattern))
|
|
{
|
|
throw new ArgumentException("Glob pattern is required.", nameof(pattern));
|
|
}
|
|
|
|
var normalized = pattern.Replace("\\", "/");
|
|
normalized = normalized.Replace("**", "\u0001");
|
|
normalized = normalized.Replace("*", "\u0002");
|
|
|
|
var escaped = Regex.Escape(normalized);
|
|
escaped = escaped.Replace("\u0001/", "(?:.*/)?", StringComparison.Ordinal);
|
|
escaped = escaped.Replace("\u0001", ".*", StringComparison.Ordinal);
|
|
escaped = escaped.Replace("\u0002", "[^/]*", StringComparison.Ordinal);
|
|
|
|
return new Regex("^" + escaped + "$", RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
|
}
|
|
}
|