Add tests and implement StubBearer authentication for Signer endpoints
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled

- Created SignerEndpointsTests to validate the SignDsse and VerifyReferrers endpoints.
- Implemented StubBearerAuthenticationDefaults and StubBearerAuthenticationHandler for token-based authentication.
- Developed ConcelierExporterClient for managing Trivy DB settings and export operations.
- Added TrivyDbSettingsPageComponent for UI interactions with Trivy DB settings, including form handling and export triggering.
- Implemented styles and HTML structure for Trivy DB settings page.
- Created NotifySmokeCheck tool for validating Redis event streams and Notify deliveries.
This commit is contained in:
2025-10-21 09:37:07 +03:00
parent 2b6304c9c3
commit 791e12baab
298 changed files with 20490 additions and 5751 deletions

View File

@@ -0,0 +1,104 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace StellaOps.Bench.ScannerAnalyzers;
internal sealed record BenchmarkConfig
{
[JsonPropertyName("iterations")]
public int? Iterations { get; init; }
[JsonPropertyName("thresholdMs")]
public double? ThresholdMs { get; init; }
[JsonPropertyName("scenarios")]
public List<BenchmarkScenarioConfig> Scenarios { get; init; } = new();
public static async Task<BenchmarkConfig> LoadAsync(string path)
{
if (string.IsNullOrWhiteSpace(path))
{
throw new ArgumentException("Config path is required.", nameof(path));
}
await using var stream = File.OpenRead(path);
var config = await JsonSerializer.DeserializeAsync<BenchmarkConfig>(stream, SerializerOptions).ConfigureAwait(false);
if (config is null)
{
throw new InvalidOperationException($"Failed to parse benchmark config '{path}'.");
}
if (config.Scenarios.Count == 0)
{
throw new InvalidOperationException("config.scenarios must declare at least one scenario.");
}
foreach (var scenario in config.Scenarios)
{
scenario.Validate();
}
return config;
}
private static JsonSerializerOptions SerializerOptions => new()
{
PropertyNameCaseInsensitive = true,
ReadCommentHandling = JsonCommentHandling.Skip,
AllowTrailingCommas = true,
};
}
internal sealed record BenchmarkScenarioConfig
{
[JsonPropertyName("id")]
public string? Id { get; init; }
[JsonPropertyName("label")]
public string? Label { get; init; }
[JsonPropertyName("root")]
public string? Root { get; init; }
[JsonPropertyName("analyzers")]
public List<string>? Analyzers { get; init; }
[JsonPropertyName("matcher")]
public string? Matcher { get; init; }
[JsonPropertyName("parser")]
public string? Parser { get; init; }
[JsonPropertyName("thresholdMs")]
public double? ThresholdMs { get; init; }
public bool HasAnalyzers => Analyzers is { Count: > 0 };
public void Validate()
{
if (string.IsNullOrWhiteSpace(Id))
{
throw new InvalidOperationException("scenario.id is required.");
}
if (string.IsNullOrWhiteSpace(Root))
{
throw new InvalidOperationException($"Scenario '{Id}' must specify a root path.");
}
if (HasAnalyzers)
{
return;
}
if (string.IsNullOrWhiteSpace(Parser))
{
throw new InvalidOperationException($"Scenario '{Id}' must specify parser or analyzers.");
}
if (string.IsNullOrWhiteSpace(Matcher))
{
throw new InvalidOperationException($"Scenario '{Id}' must specify matcher when parser is used.");
}
}
}

View File

@@ -0,0 +1,302 @@
using System.Globalization;
using StellaOps.Bench.ScannerAnalyzers.Scenarios;
namespace StellaOps.Bench.ScannerAnalyzers;
internal static class Program
{
public static async Task<int> Main(string[] args)
{
try
{
var options = ProgramOptions.Parse(args);
var config = await BenchmarkConfig.LoadAsync(options.ConfigPath).ConfigureAwait(false);
var iterations = options.Iterations ?? config.Iterations ?? 5;
var thresholdMs = options.ThresholdMs ?? config.ThresholdMs ?? 5000;
var repoRoot = ResolveRepoRoot(options.RepoRoot, options.ConfigPath);
var results = new List<ScenarioResult>();
var failures = new List<string>();
foreach (var scenario in config.Scenarios)
{
var runner = ScenarioRunnerFactory.Create(scenario);
var scenarioRoot = ResolveScenarioRoot(repoRoot, scenario.Root!);
var execution = await runner.ExecuteAsync(scenarioRoot, iterations, CancellationToken.None).ConfigureAwait(false);
var stats = ScenarioStatistics.FromDurations(execution.Durations);
var scenarioThreshold = scenario.ThresholdMs ?? thresholdMs;
results.Add(new ScenarioResult(
scenario.Id!,
scenario.Label ?? scenario.Id!,
execution.SampleCount,
stats.MeanMs,
stats.P95Ms,
stats.MaxMs,
iterations));
if (stats.MaxMs > scenarioThreshold)
{
failures.Add($"{scenario.Id} exceeded threshold: {stats.MaxMs:F2} ms > {scenarioThreshold:F2} ms");
}
}
TablePrinter.Print(results);
if (!string.IsNullOrWhiteSpace(options.OutPath))
{
CsvWriter.Write(options.OutPath!, results);
}
if (failures.Count > 0)
{
Console.Error.WriteLine();
Console.Error.WriteLine("Performance threshold exceeded:");
foreach (var failure in failures)
{
Console.Error.WriteLine($" - {failure}");
}
return 1;
}
return 0;
}
catch (Exception ex)
{
Console.Error.WriteLine(ex.Message);
return 1;
}
}
private static string ResolveRepoRoot(string? overridePath, string configPath)
{
if (!string.IsNullOrWhiteSpace(overridePath))
{
return Path.GetFullPath(overridePath);
}
var configDirectory = Path.GetDirectoryName(configPath);
if (string.IsNullOrWhiteSpace(configDirectory))
{
return Directory.GetCurrentDirectory();
}
return Path.GetFullPath(Path.Combine(configDirectory, "..", ".."));
}
private static string ResolveScenarioRoot(string repoRoot, string relativeRoot)
{
if (string.IsNullOrWhiteSpace(relativeRoot))
{
throw new InvalidOperationException("Scenario root is required.");
}
var combined = Path.GetFullPath(Path.Combine(repoRoot, relativeRoot));
if (!PathUtilities.IsWithinRoot(repoRoot, combined))
{
throw new InvalidOperationException($"Scenario root '{relativeRoot}' escapes repository root '{repoRoot}'.");
}
if (!Directory.Exists(combined))
{
throw new DirectoryNotFoundException($"Scenario root '{combined}' does not exist.");
}
return combined;
}
private sealed record ProgramOptions(string ConfigPath, int? Iterations, double? ThresholdMs, string? OutPath, string? RepoRoot)
{
public static ProgramOptions Parse(string[] args)
{
var configPath = DefaultConfigPath();
int? iterations = null;
double? thresholdMs = null;
string? outPath = null;
string? repoRoot = null;
for (var index = 0; index < args.Length; index++)
{
var current = args[index];
switch (current)
{
case "--config":
EnsureNext(args, index);
configPath = Path.GetFullPath(args[++index]);
break;
case "--iterations":
EnsureNext(args, index);
iterations = int.Parse(args[++index], CultureInfo.InvariantCulture);
break;
case "--threshold-ms":
EnsureNext(args, index);
thresholdMs = double.Parse(args[++index], CultureInfo.InvariantCulture);
break;
case "--out":
EnsureNext(args, index);
outPath = args[++index];
break;
case "--repo-root":
case "--samples":
EnsureNext(args, index);
repoRoot = args[++index];
break;
default:
throw new ArgumentException($"Unknown argument: {current}", nameof(args));
}
}
return new ProgramOptions(configPath, iterations, thresholdMs, outPath, repoRoot);
}
private static string DefaultConfigPath()
{
var binaryDir = AppContext.BaseDirectory;
var projectRoot = Path.GetFullPath(Path.Combine(binaryDir, "..", "..", ".."));
var configDirectory = Path.GetFullPath(Path.Combine(projectRoot, ".."));
return Path.Combine(configDirectory, "config.json");
}
private static void EnsureNext(string[] args, int index)
{
if (index + 1 >= args.Length)
{
throw new ArgumentException("Missing value for argument.", nameof(args));
}
}
}
private sealed record ScenarioResult(
string Id,
string Label,
int SampleCount,
double MeanMs,
double P95Ms,
double MaxMs,
int Iterations);
private sealed record ScenarioStatistics(double MeanMs, double P95Ms, double MaxMs)
{
public static ScenarioStatistics FromDurations(IReadOnlyList<double> durations)
{
if (durations.Count == 0)
{
return new ScenarioStatistics(0, 0, 0);
}
var sorted = durations.ToArray();
Array.Sort(sorted);
var total = 0d;
foreach (var value in durations)
{
total += value;
}
var mean = total / durations.Count;
var p95 = Percentile(sorted, 95);
var max = sorted[^1];
return new ScenarioStatistics(mean, p95, max);
}
private static double Percentile(IReadOnlyList<double> sorted, double percentile)
{
if (sorted.Count == 0)
{
return 0;
}
var rank = (percentile / 100d) * (sorted.Count - 1);
var lower = (int)Math.Floor(rank);
var upper = (int)Math.Ceiling(rank);
var weight = rank - lower;
if (upper >= sorted.Count)
{
return sorted[lower];
}
return sorted[lower] + weight * (sorted[upper] - sorted[lower]);
}
}
private static class TablePrinter
{
public static void Print(IEnumerable<ScenarioResult> results)
{
Console.WriteLine("Scenario | Count | Mean(ms) | P95(ms) | Max(ms)");
Console.WriteLine("---------------------------- | ----- | --------- | --------- | ----------");
foreach (var row in results)
{
Console.WriteLine(FormatRow(row));
}
}
private static string FormatRow(ScenarioResult row)
{
var idColumn = row.Id.Length <= 28
? row.Id.PadRight(28)
: row.Id[..28];
return string.Join(" | ", new[]
{
idColumn,
row.SampleCount.ToString(CultureInfo.InvariantCulture).PadLeft(5),
row.MeanMs.ToString("F2", CultureInfo.InvariantCulture).PadLeft(9),
row.P95Ms.ToString("F2", CultureInfo.InvariantCulture).PadLeft(9),
row.MaxMs.ToString("F2", CultureInfo.InvariantCulture).PadLeft(10),
});
}
}
private static class CsvWriter
{
public static void Write(string path, IEnumerable<ScenarioResult> results)
{
var resolvedPath = Path.GetFullPath(path);
var directory = Path.GetDirectoryName(resolvedPath);
if (!string.IsNullOrEmpty(directory))
{
Directory.CreateDirectory(directory);
}
using var stream = new FileStream(resolvedPath, FileMode.Create, FileAccess.Write, FileShare.None);
using var writer = new StreamWriter(stream);
writer.WriteLine("scenario,iterations,sample_count,mean_ms,p95_ms,max_ms");
foreach (var row in results)
{
writer.Write(row.Id);
writer.Write(',');
writer.Write(row.Iterations.ToString(CultureInfo.InvariantCulture));
writer.Write(',');
writer.Write(row.SampleCount.ToString(CultureInfo.InvariantCulture));
writer.Write(',');
writer.Write(row.MeanMs.ToString("F4", CultureInfo.InvariantCulture));
writer.Write(',');
writer.Write(row.P95Ms.ToString("F4", CultureInfo.InvariantCulture));
writer.Write(',');
writer.Write(row.MaxMs.ToString("F4", CultureInfo.InvariantCulture));
writer.WriteLine();
}
}
}
internal static class PathUtilities
{
public static bool IsWithinRoot(string root, string candidate)
{
var relative = Path.GetRelativePath(root, candidate);
if (string.IsNullOrEmpty(relative) || relative == ".")
{
return true;
}
return !relative.StartsWith("..", StringComparison.Ordinal) && !Path.IsPathRooted(relative);
}
}
}

View File

@@ -0,0 +1,279 @@
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.Java;
using StellaOps.Scanner.Analyzers.Lang.Node;
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(),
"node" => static () => new NodeLanguageAnalyzer(),
_ => 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);
}
}

View File

@@ -0,0 +1,16 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<LangVersion>preview</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\src\StellaOps.Scanner.Analyzers.Lang\StellaOps.Scanner.Analyzers.Lang.csproj" />
<ProjectReference Include="..\..\..\src\StellaOps.Scanner.Analyzers.Lang.Node\StellaOps.Scanner.Analyzers.Lang.Node.csproj" />
<ProjectReference Include="..\..\..\src\StellaOps.Scanner.Analyzers.Lang.Java\StellaOps.Scanner.Analyzers.Lang.Java.csproj" />
</ItemGroup>
</Project>