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