Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
Export Center CI / export-ci (push) Has been cancelled
Notify Smoke Test / Notify Unit Tests (push) Has been cancelled
Notify Smoke Test / Notifier Service Tests (push) Has been cancelled
Notify Smoke Test / Notification Smoke Test (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Scanner Analyzers / Discover Analyzers (push) Has been cancelled
Scanner Analyzers / Build Analyzers (push) Has been cancelled
Scanner Analyzers / Test Language Analyzers (push) Has been cancelled
Scanner Analyzers / Validate Test Fixtures (push) Has been cancelled
Scanner Analyzers / Verify Deterministic Output (push) Has been cancelled
Signals CI & Image / signals-ci (push) Has been cancelled
Signals Reachability Scoring & Events / reachability-smoke (push) Has been cancelled
Signals Reachability Scoring & Events / sign-and-upload (push) Has been cancelled
430 lines
18 KiB
C#
430 lines
18 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Collections.Immutable;
|
|
using System.Collections.ObjectModel;
|
|
using System.Diagnostics.Metrics;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using System.Text;
|
|
using System.Threading;
|
|
using Microsoft.Extensions.Configuration;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using Microsoft.Extensions.Logging;
|
|
using Microsoft.Extensions.Options;
|
|
using StellaOps.Scanner.Analyzers.Lang;
|
|
using StellaOps.Scanner.Analyzers.Lang.Plugin;
|
|
using StellaOps.Scanner.Analyzers.OS;
|
|
using StellaOps.Scanner.Analyzers.OS.Abstractions;
|
|
using StellaOps.Scanner.Analyzers.OS.Plugin;
|
|
using StellaOps.Scanner.Core.Contracts;
|
|
using StellaOps.Scanner.Surface.Env;
|
|
using StellaOps.Scanner.Surface.FS;
|
|
using StellaOps.Scanner.Surface.Secrets;
|
|
using StellaOps.Scanner.Surface.Validation;
|
|
using StellaOps.Scanner.Worker.Diagnostics;
|
|
using StellaOps.Scanner.Worker.Processing;
|
|
using StellaOps.Scanner.Worker.Tests.TestInfrastructure;
|
|
using Xunit;
|
|
using WorkerOptions = StellaOps.Scanner.Worker.Options.ScannerWorkerOptions;
|
|
|
|
namespace StellaOps.Scanner.Worker.Tests;
|
|
|
|
public sealed class CompositeScanAnalyzerDispatcherTests
|
|
{
|
|
[Fact]
|
|
public async Task ExecuteAsync_RunsLanguageAnalyzers_StoresResults()
|
|
{
|
|
using var workspace = new TempDirectory();
|
|
using var cacheRoot = new TempDirectory();
|
|
|
|
Environment.SetEnvironmentVariable("SCANNER_SURFACE_FS_ENDPOINT", "https://surface.test");
|
|
Environment.SetEnvironmentVariable("SCANNER_SURFACE_FS_BUCKET", "unit-test-bucket");
|
|
Environment.SetEnvironmentVariable("SCANNER_SURFACE_CACHE_ROOT", cacheRoot.Path);
|
|
Environment.SetEnvironmentVariable("SCANNER_SURFACE_SECRETS_PROVIDER", "inline");
|
|
Environment.SetEnvironmentVariable("SCANNER_SURFACE_SECRETS_TENANT", "testtenant");
|
|
Environment.SetEnvironmentVariable(
|
|
"SURFACE_SECRET_TESTTENANT_SCANNERWORKERLANGUAGEANALYZERS_REGISTRY_DEFAULT",
|
|
Convert.ToBase64String(Encoding.UTF8.GetBytes("token-placeholder")));
|
|
|
|
var metadata = new Dictionary<string, string>(StringComparer.Ordinal)
|
|
{
|
|
{ ScanMetadataKeys.RootFilesystemPath, workspace.Path },
|
|
{ ScanMetadataKeys.WorkspacePath, workspace.Path },
|
|
};
|
|
|
|
var osCatalog = new FakeOsCatalog();
|
|
var analyzer = new FakeLanguageAnalyzer();
|
|
var languageCatalog = new FakeLanguageCatalog(analyzer);
|
|
|
|
long hits = 0;
|
|
long misses = 0;
|
|
MeterListener? meterListener = null;
|
|
ServiceProvider? services = null;
|
|
try
|
|
{
|
|
var serviceCollection = new ServiceCollection();
|
|
serviceCollection.AddSingleton<IConfiguration>(new ConfigurationBuilder().Build());
|
|
serviceCollection.AddLogging(builder => builder.SetMinimumLevel(LogLevel.Debug));
|
|
serviceCollection.AddSingleton(TimeProvider.System);
|
|
serviceCollection.AddSurfaceEnvironment(options => options.ComponentName = "Scanner.Worker");
|
|
serviceCollection.AddSurfaceValidation();
|
|
serviceCollection.AddSingleton<ISurfaceValidatorRunner, NoopSurfaceValidatorRunner>();
|
|
serviceCollection.AddSurfaceFileCache(options => options.RootDirectory = cacheRoot.Path);
|
|
serviceCollection.AddSurfaceSecrets();
|
|
|
|
var metrics = new ScannerWorkerMetrics();
|
|
serviceCollection.AddSingleton(metrics);
|
|
|
|
meterListener = new MeterListener();
|
|
|
|
meterListener.InstrumentPublished = (instrument, listener) =>
|
|
{
|
|
if (instrument.Meter.Name == ScannerWorkerInstrumentation.MeterName &&
|
|
(instrument.Name == "scanner_worker_language_cache_hits_total" || instrument.Name == "scanner_worker_language_cache_misses_total"))
|
|
{
|
|
listener.EnableMeasurementEvents(instrument);
|
|
}
|
|
};
|
|
|
|
meterListener.SetMeasurementEventCallback<long>((instrument, measurement, tags, state) =>
|
|
{
|
|
if (instrument.Name == "scanner_worker_language_cache_hits_total")
|
|
{
|
|
Interlocked.Add(ref hits, measurement);
|
|
}
|
|
else if (instrument.Name == "scanner_worker_language_cache_misses_total")
|
|
{
|
|
Interlocked.Add(ref misses, measurement);
|
|
}
|
|
});
|
|
|
|
meterListener.Start();
|
|
|
|
services = serviceCollection.BuildServiceProvider();
|
|
|
|
var scopeFactory = services.GetRequiredService<IServiceScopeFactory>();
|
|
var loggerFactory = services.GetRequiredService<ILoggerFactory>();
|
|
var options = Microsoft.Extensions.Options.Options.Create(new WorkerOptions());
|
|
var dispatcher = new CompositeScanAnalyzerDispatcher(
|
|
scopeFactory,
|
|
osCatalog,
|
|
languageCatalog,
|
|
options,
|
|
loggerFactory.CreateLogger<CompositeScanAnalyzerDispatcher>(),
|
|
metrics,
|
|
new TestCryptoHash());
|
|
|
|
var lease = new TestJobLease(metadata);
|
|
var context = new ScanJobContext(lease, TimeProvider.System, TimeProvider.System.GetUtcNow(), CancellationToken.None);
|
|
|
|
await dispatcher.ExecuteAsync(context, CancellationToken.None);
|
|
|
|
// Re-run with a new context to exercise cache reuse.
|
|
var leaseSecond = new TestJobLease(metadata);
|
|
var contextSecond = new ScanJobContext(leaseSecond, TimeProvider.System, TimeProvider.System.GetUtcNow(), CancellationToken.None);
|
|
await dispatcher.ExecuteAsync(contextSecond, CancellationToken.None);
|
|
|
|
meterListener.RecordObservableInstruments();
|
|
|
|
Assert.Equal(1, analyzer.InvocationCount);
|
|
Assert.True(context.Analysis.TryGet<ReadOnlyDictionary<string, LanguageAnalyzerResult>>(ScanAnalysisKeys.LanguageAnalyzerResults, out var results));
|
|
Assert.Single(results);
|
|
Assert.True(context.Analysis.TryGet<ImmutableArray<LayerComponentFragment>>(ScanAnalysisKeys.LanguageComponentFragments, out var fragments));
|
|
Assert.False(fragments.IsDefaultOrEmpty);
|
|
Assert.True(context.Analysis.GetLayerFragments().Any(fragment => fragment.Components.Any(component => component.Identity.Name == "demo-package")));
|
|
Assert.Equal(1, hits);
|
|
Assert.Equal(1, misses);
|
|
}
|
|
finally
|
|
{
|
|
Environment.SetEnvironmentVariable("SCANNER_SURFACE_FS_ENDPOINT", null);
|
|
Environment.SetEnvironmentVariable("SCANNER_SURFACE_FS_BUCKET", null);
|
|
Environment.SetEnvironmentVariable("SCANNER_SURFACE_CACHE_ROOT", null);
|
|
Environment.SetEnvironmentVariable("SCANNER_SURFACE_SECRETS_PROVIDER", null);
|
|
Environment.SetEnvironmentVariable("SCANNER_SURFACE_SECRETS_TENANT", null);
|
|
Environment.SetEnvironmentVariable("SURFACE_SECRET_TESTTENANT_SCANNERWORKERLANGUAGEANALYZERS_REGISTRY_DEFAULT", null);
|
|
meterListener?.Dispose();
|
|
services?.Dispose();
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ExecuteAsync_RunsOsAnalyzers_UsesSurfaceCache()
|
|
{
|
|
using var rootfs = new TempDirectory();
|
|
using var cacheRoot = new TempDirectory();
|
|
|
|
Environment.SetEnvironmentVariable("SCANNER_SURFACE_FS_ENDPOINT", "https://surface.test");
|
|
Environment.SetEnvironmentVariable("SCANNER_SURFACE_FS_BUCKET", "unit-test-bucket");
|
|
Environment.SetEnvironmentVariable("SCANNER_SURFACE_CACHE_ROOT", cacheRoot.Path);
|
|
Environment.SetEnvironmentVariable("SCANNER_SURFACE_SECRETS_PROVIDER", "inline");
|
|
Environment.SetEnvironmentVariable("SCANNER_SURFACE_SECRETS_TENANT", "testtenant");
|
|
Environment.SetEnvironmentVariable(
|
|
"SURFACE_SECRET_TESTTENANT_SCANNERWORKEROSANALYZERS_REGISTRY_DEFAULT",
|
|
Convert.ToBase64String(Encoding.UTF8.GetBytes("token-placeholder")));
|
|
|
|
var dpkgStatusPath = Path.Combine(rootfs.Path, "var", "lib", "dpkg", "status");
|
|
Directory.CreateDirectory(Path.GetDirectoryName(dpkgStatusPath)!);
|
|
await File.WriteAllTextAsync(dpkgStatusPath, "Package: demo\nStatus: install ok installed\n", CancellationToken.None);
|
|
|
|
var metadata = new Dictionary<string, string>(StringComparer.Ordinal)
|
|
{
|
|
{ ScanMetadataKeys.RootFilesystemPath, rootfs.Path },
|
|
{ ScanMetadataKeys.WorkspacePath, rootfs.Path },
|
|
};
|
|
|
|
var analyzer = new FakeOsAnalyzer();
|
|
var osCatalog = new FakeOsCatalog(analyzer);
|
|
var languageCatalog = new FakeLanguageCatalog();
|
|
|
|
long hits = 0;
|
|
long misses = 0;
|
|
MeterListener? meterListener = null;
|
|
ServiceProvider? services = null;
|
|
try
|
|
{
|
|
var serviceCollection = new ServiceCollection();
|
|
serviceCollection.AddSingleton<IConfiguration>(new ConfigurationBuilder().Build());
|
|
serviceCollection.AddLogging(builder => builder.SetMinimumLevel(LogLevel.Debug));
|
|
serviceCollection.AddSingleton(TimeProvider.System);
|
|
serviceCollection.AddSurfaceEnvironment(options => options.ComponentName = "Scanner.Worker");
|
|
serviceCollection.AddSurfaceValidation();
|
|
serviceCollection.AddSingleton<ISurfaceValidatorRunner, NoopSurfaceValidatorRunner>();
|
|
serviceCollection.AddSurfaceFileCache(options => options.RootDirectory = cacheRoot.Path);
|
|
serviceCollection.AddSurfaceSecrets();
|
|
|
|
var metrics = new ScannerWorkerMetrics();
|
|
serviceCollection.AddSingleton(metrics);
|
|
|
|
meterListener = new MeterListener();
|
|
|
|
meterListener.InstrumentPublished = (instrument, listener) =>
|
|
{
|
|
if (instrument.Meter.Name == ScannerWorkerInstrumentation.MeterName &&
|
|
(instrument.Name == "scanner_worker_os_cache_hits_total" || instrument.Name == "scanner_worker_os_cache_misses_total"))
|
|
{
|
|
listener.EnableMeasurementEvents(instrument);
|
|
}
|
|
};
|
|
|
|
meterListener.SetMeasurementEventCallback<long>((instrument, measurement, tags, state) =>
|
|
{
|
|
if (instrument.Name == "scanner_worker_os_cache_hits_total")
|
|
{
|
|
Interlocked.Add(ref hits, measurement);
|
|
}
|
|
else if (instrument.Name == "scanner_worker_os_cache_misses_total")
|
|
{
|
|
Interlocked.Add(ref misses, measurement);
|
|
}
|
|
});
|
|
|
|
meterListener.Start();
|
|
|
|
services = serviceCollection.BuildServiceProvider();
|
|
|
|
var scopeFactory = services.GetRequiredService<IServiceScopeFactory>();
|
|
var loggerFactory = services.GetRequiredService<ILoggerFactory>();
|
|
var options = Microsoft.Extensions.Options.Options.Create(new WorkerOptions());
|
|
var dispatcher = new CompositeScanAnalyzerDispatcher(
|
|
scopeFactory,
|
|
osCatalog,
|
|
languageCatalog,
|
|
options,
|
|
loggerFactory.CreateLogger<CompositeScanAnalyzerDispatcher>(),
|
|
metrics,
|
|
new TestCryptoHash());
|
|
|
|
var lease = new TestJobLease(metadata);
|
|
var context = new ScanJobContext(lease, TimeProvider.System, TimeProvider.System.GetUtcNow(), CancellationToken.None);
|
|
|
|
await dispatcher.ExecuteAsync(context, CancellationToken.None);
|
|
|
|
var leaseSecond = new TestJobLease(metadata);
|
|
var contextSecond = new ScanJobContext(leaseSecond, TimeProvider.System, TimeProvider.System.GetUtcNow(), CancellationToken.None);
|
|
await dispatcher.ExecuteAsync(contextSecond, CancellationToken.None);
|
|
|
|
Assert.Equal(1, analyzer.InvocationCount);
|
|
Assert.True(context.Analysis.TryGet<Dictionary<string, OSPackageAnalyzerResult>>(ScanAnalysisKeys.OsPackageAnalyzers, out var results));
|
|
Assert.Single(results);
|
|
Assert.True(context.Analysis.TryGet<ImmutableArray<LayerComponentFragment>>(ScanAnalysisKeys.OsComponentFragments, out var fragments));
|
|
Assert.False(fragments.IsDefaultOrEmpty);
|
|
|
|
Assert.Equal(1, hits);
|
|
Assert.Equal(1, misses);
|
|
}
|
|
finally
|
|
{
|
|
Environment.SetEnvironmentVariable("SCANNER_SURFACE_FS_ENDPOINT", null);
|
|
Environment.SetEnvironmentVariable("SCANNER_SURFACE_FS_BUCKET", null);
|
|
Environment.SetEnvironmentVariable("SCANNER_SURFACE_CACHE_ROOT", null);
|
|
Environment.SetEnvironmentVariable("SCANNER_SURFACE_SECRETS_PROVIDER", null);
|
|
Environment.SetEnvironmentVariable("SCANNER_SURFACE_SECRETS_TENANT", null);
|
|
Environment.SetEnvironmentVariable("SURFACE_SECRET_TESTTENANT_SCANNERWORKEROSANALYZERS_REGISTRY_DEFAULT", null);
|
|
meterListener?.Dispose();
|
|
services?.Dispose();
|
|
}
|
|
}
|
|
|
|
private sealed class FakeOsCatalog : IOSAnalyzerPluginCatalog
|
|
{
|
|
private readonly IReadOnlyList<IOSPackageAnalyzer> _analyzers;
|
|
|
|
public FakeOsCatalog(params IOSPackageAnalyzer[] analyzers)
|
|
{
|
|
_analyzers = analyzers ?? Array.Empty<IOSPackageAnalyzer>();
|
|
}
|
|
|
|
public IReadOnlyCollection<IOSAnalyzerPlugin> Plugins => Array.Empty<IOSAnalyzerPlugin>();
|
|
|
|
public void LoadFromDirectory(string directory, bool seal = true)
|
|
{
|
|
}
|
|
|
|
public IReadOnlyList<IOSPackageAnalyzer> CreateAnalyzers(IServiceProvider services) => _analyzers;
|
|
}
|
|
|
|
private sealed class FakeLanguageCatalog : ILanguageAnalyzerPluginCatalog
|
|
{
|
|
private readonly IReadOnlyList<ILanguageAnalyzer> _analyzers;
|
|
|
|
public FakeLanguageCatalog(params ILanguageAnalyzer[] analyzers)
|
|
{
|
|
_analyzers = analyzers ?? Array.Empty<ILanguageAnalyzer>();
|
|
}
|
|
|
|
public IReadOnlyCollection<ILanguageAnalyzerPlugin> Plugins => Array.Empty<ILanguageAnalyzerPlugin>();
|
|
|
|
public void LoadFromDirectory(string directory, bool seal = true)
|
|
{
|
|
}
|
|
|
|
public IReadOnlyList<ILanguageAnalyzer> CreateAnalyzers(IServiceProvider services) => _analyzers;
|
|
}
|
|
|
|
private sealed class NoopSurfaceValidatorRunner : ISurfaceValidatorRunner
|
|
{
|
|
public ValueTask<SurfaceValidationResult> RunAllAsync(SurfaceValidationContext context, CancellationToken cancellationToken = default)
|
|
=> ValueTask.FromResult(SurfaceValidationResult.Success());
|
|
|
|
public ValueTask EnsureAsync(SurfaceValidationContext context, CancellationToken cancellationToken = default)
|
|
=> ValueTask.CompletedTask;
|
|
}
|
|
|
|
private sealed class FakeOsAnalyzer : IOSPackageAnalyzer
|
|
{
|
|
public string AnalyzerId => "dpkg";
|
|
|
|
public int InvocationCount { get; private set; }
|
|
|
|
private int _invocationCount;
|
|
|
|
public ValueTask<OSPackageAnalyzerResult> AnalyzeAsync(OSPackageAnalyzerContext context, CancellationToken cancellationToken)
|
|
{
|
|
Interlocked.Increment(ref _invocationCount);
|
|
InvocationCount = _invocationCount;
|
|
|
|
var package = new OSPackageRecord(
|
|
analyzerId: AnalyzerId,
|
|
packageUrl: "pkg:deb/debian/demo@1.0?arch=amd64",
|
|
name: "demo",
|
|
version: "1.0",
|
|
architecture: "amd64",
|
|
evidenceSource: PackageEvidenceSource.DpkgStatus,
|
|
files: new[] { new OSPackageFileEvidence("/usr/bin/demo") });
|
|
|
|
var telemetry = new OSAnalyzerTelemetry(TimeSpan.Zero, 1, 1);
|
|
return ValueTask.FromResult(new OSPackageAnalyzerResult(AnalyzerId, new[] { package }, telemetry));
|
|
}
|
|
}
|
|
|
|
private sealed class FakeLanguageAnalyzer : ILanguageAnalyzer
|
|
{
|
|
public string Id => "lang.fake";
|
|
|
|
public string DisplayName => "Fake Language Analyzer";
|
|
|
|
public int InvocationCount { get; private set; }
|
|
|
|
public ValueTask AnalyzeAsync(LanguageAnalyzerContext context, LanguageComponentWriter writer, CancellationToken cancellationToken)
|
|
{
|
|
Interlocked.Increment(ref _invocationCount);
|
|
InvocationCount = _invocationCount;
|
|
writer.AddFromPurl(
|
|
analyzerId: Id,
|
|
purl: "pkg:npm/demo-package@1.0.0",
|
|
name: "demo-package",
|
|
version: "1.0.0",
|
|
type: "npm");
|
|
return ValueTask.CompletedTask;
|
|
}
|
|
|
|
private int _invocationCount;
|
|
}
|
|
|
|
private sealed class TestJobLease : IScanJobLease
|
|
{
|
|
private readonly Dictionary<string, string> _metadata;
|
|
|
|
public TestJobLease(Dictionary<string, string> metadata)
|
|
{
|
|
_metadata = metadata;
|
|
JobId = Guid.NewGuid().ToString("n");
|
|
ScanId = $"scan-{Guid.NewGuid():n}";
|
|
Attempt = 1;
|
|
EnqueuedAtUtc = DateTimeOffset.UtcNow.AddSeconds(-1);
|
|
LeasedAtUtc = DateTimeOffset.UtcNow;
|
|
LeaseDuration = TimeSpan.FromMinutes(5);
|
|
}
|
|
|
|
public string JobId { get; }
|
|
|
|
public string ScanId { get; }
|
|
|
|
public int Attempt { get; }
|
|
|
|
public DateTimeOffset EnqueuedAtUtc { get; }
|
|
|
|
public DateTimeOffset LeasedAtUtc { get; }
|
|
|
|
public TimeSpan LeaseDuration { get; }
|
|
|
|
public IReadOnlyDictionary<string, string> Metadata => _metadata;
|
|
|
|
public ValueTask RenewAsync(CancellationToken cancellationToken) => ValueTask.CompletedTask;
|
|
|
|
public ValueTask CompleteAsync(CancellationToken cancellationToken) => ValueTask.CompletedTask;
|
|
|
|
public ValueTask AbandonAsync(string reason, CancellationToken cancellationToken) => ValueTask.CompletedTask;
|
|
|
|
public ValueTask PoisonAsync(string reason, CancellationToken cancellationToken) => ValueTask.CompletedTask;
|
|
|
|
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
|
}
|
|
|
|
private sealed class TempDirectory : IDisposable
|
|
{
|
|
public TempDirectory()
|
|
{
|
|
Path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), $"stellaops-test-{Guid.NewGuid():n}");
|
|
Directory.CreateDirectory(Path);
|
|
}
|
|
|
|
public string Path { get; }
|
|
|
|
public void Dispose()
|
|
{
|
|
try
|
|
{
|
|
if (Directory.Exists(Path))
|
|
{
|
|
Directory.Delete(Path, recursive: true);
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
}
|
|
}
|
|
}
|
|
}
|