Add unit tests for AST parsing and security sink detection
- Created `StellaOps.AuditPack.Tests.csproj` for unit testing the AuditPack library. - Implemented comprehensive unit tests in `index.test.js` for AST parsing, covering various JavaScript and TypeScript constructs including functions, classes, decorators, and JSX. - Added `sink-detect.test.js` to test security sink detection patterns, validating command injection, SQL injection, file write, deserialization, SSRF, NoSQL injection, and more. - Included tests for taint source detection in various contexts such as Express, Koa, and AWS Lambda.
This commit is contained in:
@@ -36,7 +36,7 @@ public sealed class SliceQueryService : ISliceQueryService
|
||||
private readonly ISliceCache _cache;
|
||||
private readonly SliceExtractor _extractor;
|
||||
private readonly SliceCasStorage _casStorage;
|
||||
private readonly SliceDiffComputer _diffComputer;
|
||||
private readonly StellaOps.Scanner.Reachability.Slices.Replay.SliceDiffComputer _diffComputer;
|
||||
private readonly SliceHasher _hasher;
|
||||
private readonly IFileContentAddressableStore _cas;
|
||||
private readonly IScanMetadataRepository _scanRepo;
|
||||
@@ -47,7 +47,7 @@ public sealed class SliceQueryService : ISliceQueryService
|
||||
ISliceCache cache,
|
||||
SliceExtractor extractor,
|
||||
SliceCasStorage casStorage,
|
||||
SliceDiffComputer diffComputer,
|
||||
StellaOps.Scanner.Reachability.Slices.Replay.SliceDiffComputer diffComputer,
|
||||
SliceHasher hasher,
|
||||
IFileContentAddressableStore cas,
|
||||
IScanMetadataRepository scanRepo,
|
||||
|
||||
@@ -45,6 +45,7 @@
|
||||
<ProjectReference Include="../../Concelier/__Libraries/StellaOps.Concelier.Core/StellaOps.Concelier.Core.csproj" />
|
||||
<ProjectReference Include="../../Concelier/__Libraries/StellaOps.Concelier.Connector.Common/StellaOps.Concelier.Connector.Common.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Messaging/StellaOps.Messaging.csproj" />
|
||||
<ProjectReference Include="../__Libraries/StellaOps.Scanner.Orchestration/StellaOps.Scanner.Orchestration.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Sprint: SPRINT_6000_0004_0001 - Scanner Worker Integration
|
||||
// Task: T5 - Add Configuration and DI Registration
|
||||
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.BinaryIndex.Core.Services;
|
||||
using StellaOps.Scanner.Worker.Processing;
|
||||
|
||||
namespace StellaOps.Scanner.Worker.Extensions;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for registering BinaryIndex integration services.
|
||||
/// </summary>
|
||||
public static class BinaryIndexServiceExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds BinaryIndex integration services to the service collection.
|
||||
/// </summary>
|
||||
public static IServiceCollection AddBinaryIndexIntegration(
|
||||
this IServiceCollection services,
|
||||
IConfiguration configuration)
|
||||
{
|
||||
var options = configuration
|
||||
.GetSection("BinaryIndex")
|
||||
.Get<BinaryIndexOptions>() ?? new BinaryIndexOptions();
|
||||
|
||||
if (!options.Enabled)
|
||||
{
|
||||
services.AddSingleton<IBinaryVulnerabilityService, NullBinaryVulnerabilityService>();
|
||||
return services;
|
||||
}
|
||||
|
||||
services.AddSingleton(options);
|
||||
services.AddScoped<IBinaryVulnerabilityService, BinaryVulnerabilityService>();
|
||||
services.AddScoped<IBinaryFeatureExtractor, ElfFeatureExtractor>();
|
||||
services.AddScoped<BinaryVulnerabilityAnalyzer>();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for BinaryIndex integration.
|
||||
/// </summary>
|
||||
public sealed class BinaryIndexOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether binary vulnerability analysis is enabled.
|
||||
/// </summary>
|
||||
public bool Enabled { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Batch size for binary lookups.
|
||||
/// </summary>
|
||||
public int BatchSize { get; init; } = 100;
|
||||
|
||||
/// <summary>
|
||||
/// Timeout in milliseconds for binary lookups.
|
||||
/// </summary>
|
||||
public int TimeoutMs { get; init; } = 5000;
|
||||
|
||||
/// <summary>
|
||||
/// Minimum confidence threshold for reporting matches.
|
||||
/// </summary>
|
||||
public decimal MinConfidence { get; init; } = 0.7m;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Null implementation of IBinaryVulnerabilityService for when binary analysis is disabled.
|
||||
/// </summary>
|
||||
internal sealed class NullBinaryVulnerabilityService : IBinaryVulnerabilityService
|
||||
{
|
||||
public Task<System.Collections.Immutable.ImmutableArray<BinaryVulnMatch>> LookupByIdentityAsync(
|
||||
StellaOps.BinaryIndex.Core.Models.BinaryIdentity identity,
|
||||
LookupOptions? options = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
return Task.FromResult(System.Collections.Immutable.ImmutableArray<BinaryVulnMatch>.Empty);
|
||||
}
|
||||
|
||||
public Task<System.Collections.Immutable.ImmutableDictionary<string, System.Collections.Immutable.ImmutableArray<BinaryVulnMatch>>> LookupBatchAsync(
|
||||
IEnumerable<StellaOps.BinaryIndex.Core.Models.BinaryIdentity> identities,
|
||||
LookupOptions? options = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
return Task.FromResult(System.Collections.Immutable.ImmutableDictionary<string, System.Collections.Immutable.ImmutableArray<BinaryVulnMatch>>.Empty);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Sprint: SPRINT_6000_0004_0001 - Scanner Worker Integration
|
||||
// Task: T3 - Create Scanner.Worker Integration Point
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.BinaryIndex.Core.Models;
|
||||
using StellaOps.BinaryIndex.Core.Services;
|
||||
|
||||
namespace StellaOps.Scanner.Worker.Processing;
|
||||
|
||||
/// <summary>
|
||||
/// Analyzer that queries BinaryIndex for vulnerable binaries during scan.
|
||||
/// Integrates with the Scanner.Worker pipeline to detect binary vulnerabilities.
|
||||
/// </summary>
|
||||
public sealed class BinaryVulnerabilityAnalyzer
|
||||
{
|
||||
private readonly IBinaryVulnerabilityService _binaryVulnService;
|
||||
private readonly IBinaryFeatureExtractor _featureExtractor;
|
||||
private readonly ILogger<BinaryVulnerabilityAnalyzer> _logger;
|
||||
|
||||
public BinaryVulnerabilityAnalyzer(
|
||||
IBinaryVulnerabilityService binaryVulnService,
|
||||
IBinaryFeatureExtractor featureExtractor,
|
||||
ILogger<BinaryVulnerabilityAnalyzer> logger)
|
||||
{
|
||||
_binaryVulnService = binaryVulnService ?? throw new ArgumentNullException(nameof(binaryVulnService));
|
||||
_featureExtractor = featureExtractor ?? throw new ArgumentNullException(nameof(featureExtractor));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public string AnalyzerId => "binary-vulnerability";
|
||||
public int Priority => 100; // Run after package analyzers
|
||||
|
||||
/// <summary>
|
||||
/// Analyzes a layer for binary vulnerabilities.
|
||||
/// </summary>
|
||||
public async Task<BinaryAnalysisResult> AnalyzeLayerAsync(
|
||||
BinaryLayerContext context,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var findings = new List<BinaryVulnerabilityFinding>();
|
||||
var identities = new List<BinaryIdentity>();
|
||||
var extractionErrors = new List<string>();
|
||||
|
||||
_logger.LogDebug("Scanning layer {LayerDigest} for binary vulnerabilities", context.LayerDigest);
|
||||
|
||||
// Extract identities from all binaries in layer
|
||||
foreach (var filePath in context.BinaryPaths)
|
||||
{
|
||||
if (!IsBinaryFile(filePath))
|
||||
continue;
|
||||
|
||||
try
|
||||
{
|
||||
using var stream = context.OpenFile(filePath);
|
||||
if (stream == null)
|
||||
{
|
||||
_logger.LogDebug("Could not open file {Path}", filePath);
|
||||
continue;
|
||||
}
|
||||
|
||||
var identity = await _featureExtractor.ExtractIdentityAsync(stream, ct).ConfigureAwait(false);
|
||||
if (identity != null)
|
||||
{
|
||||
identities.Add(identity);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Failed to extract identity from {Path}", filePath);
|
||||
extractionErrors.Add($"{filePath}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
if (identities.Count == 0)
|
||||
{
|
||||
_logger.LogDebug("No binary identities extracted from layer {LayerDigest}", context.LayerDigest);
|
||||
return BinaryAnalysisResult.Empty(context.ScanId, context.LayerDigest);
|
||||
}
|
||||
|
||||
_logger.LogDebug("Extracted {Count} binary identities from layer {LayerDigest}",
|
||||
identities.Count, context.LayerDigest);
|
||||
|
||||
// Batch lookup
|
||||
var options = new LookupOptions
|
||||
{
|
||||
DistroHint = context.DetectedDistro,
|
||||
ReleaseHint = context.DetectedRelease,
|
||||
CheckFixIndex = true
|
||||
};
|
||||
|
||||
var matches = await _binaryVulnService.LookupBatchAsync(identities, options, ct).ConfigureAwait(false);
|
||||
|
||||
foreach (var (binaryKey, vulnMatches) in matches)
|
||||
{
|
||||
foreach (var match in vulnMatches)
|
||||
{
|
||||
findings.Add(new BinaryVulnerabilityFinding
|
||||
{
|
||||
ScanId = context.ScanId,
|
||||
LayerDigest = context.LayerDigest,
|
||||
BinaryKey = binaryKey,
|
||||
CveId = match.CveId,
|
||||
VulnerablePurl = match.VulnerablePurl,
|
||||
MatchMethod = match.Method.ToString(),
|
||||
Confidence = match.Confidence,
|
||||
Evidence = match.Evidence
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Found {FindingCount} binary vulnerability findings in layer {LayerDigest}",
|
||||
findings.Count, context.LayerDigest);
|
||||
|
||||
return new BinaryAnalysisResult
|
||||
{
|
||||
ScanId = context.ScanId,
|
||||
LayerDigest = context.LayerDigest,
|
||||
AnalyzerId = AnalyzerId,
|
||||
Findings = findings.ToImmutableArray(),
|
||||
ExtractedBinaryCount = identities.Count,
|
||||
ExtractionErrors = extractionErrors.ToImmutableArray()
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if a file path indicates a binary file.
|
||||
/// </summary>
|
||||
private static bool IsBinaryFile(string path)
|
||||
{
|
||||
// Check common binary paths
|
||||
if (path.StartsWith("/usr/lib/", StringComparison.OrdinalIgnoreCase) ||
|
||||
path.StartsWith("/lib/", StringComparison.OrdinalIgnoreCase) ||
|
||||
path.StartsWith("/lib64/", StringComparison.OrdinalIgnoreCase) ||
|
||||
path.StartsWith("/usr/lib64/", StringComparison.OrdinalIgnoreCase) ||
|
||||
path.StartsWith("/usr/bin/", StringComparison.OrdinalIgnoreCase) ||
|
||||
path.StartsWith("/bin/", StringComparison.OrdinalIgnoreCase) ||
|
||||
path.StartsWith("/usr/sbin/", StringComparison.OrdinalIgnoreCase) ||
|
||||
path.StartsWith("/sbin/", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check common binary extensions
|
||||
if (path.EndsWith(".so", StringComparison.OrdinalIgnoreCase) ||
|
||||
path.Contains(".so.", StringComparison.OrdinalIgnoreCase) ||
|
||||
path.EndsWith(".a", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Context for binary layer analysis.
|
||||
/// </summary>
|
||||
public sealed class BinaryLayerContext
|
||||
{
|
||||
public required Guid ScanId { get; init; }
|
||||
public required string LayerDigest { get; init; }
|
||||
public required IReadOnlyList<string> BinaryPaths { get; init; }
|
||||
public string? DetectedDistro { get; init; }
|
||||
public string? DetectedRelease { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Function to open a file for reading.
|
||||
/// </summary>
|
||||
public required Func<string, Stream?> OpenFile { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of binary vulnerability analysis.
|
||||
/// </summary>
|
||||
public sealed record BinaryAnalysisResult
|
||||
{
|
||||
public required Guid ScanId { get; init; }
|
||||
public required string LayerDigest { get; init; }
|
||||
public required string AnalyzerId { get; init; }
|
||||
public required ImmutableArray<BinaryVulnerabilityFinding> Findings { get; init; }
|
||||
public int ExtractedBinaryCount { get; init; }
|
||||
public ImmutableArray<string> ExtractionErrors { get; init; } = [];
|
||||
|
||||
public static BinaryAnalysisResult Empty(Guid scanId, string layerDigest) => new()
|
||||
{
|
||||
ScanId = scanId,
|
||||
LayerDigest = layerDigest,
|
||||
AnalyzerId = "binary-vulnerability",
|
||||
Findings = [],
|
||||
ExtractedBinaryCount = 0,
|
||||
ExtractionErrors = []
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A binary vulnerability finding.
|
||||
/// </summary>
|
||||
public sealed record BinaryVulnerabilityFinding
|
||||
{
|
||||
public Guid ScanId { get; init; }
|
||||
public required string LayerDigest { get; init; }
|
||||
public required string BinaryKey { get; init; }
|
||||
public required string CveId { get; init; }
|
||||
public required string VulnerablePurl { get; init; }
|
||||
public required string MatchMethod { get; init; }
|
||||
public required decimal Confidence { get; init; }
|
||||
public MatchEvidence? Evidence { get; init; }
|
||||
|
||||
public string FindingType => "binary-vulnerability";
|
||||
|
||||
public string GetSummary() =>
|
||||
$"{CveId} in {VulnerablePurl} (via {MatchMethod}, confidence {Confidence:P0})";
|
||||
}
|
||||
@@ -22,6 +22,7 @@ using StellaOps.Scanner.Surface.Env;
|
||||
using StellaOps.Scanner.Surface.FS;
|
||||
using StellaOps.Scanner.Surface.Validation;
|
||||
using StellaOps.Scanner.Worker.Options;
|
||||
using StellaOps.Scanner.Worker.Extensions;
|
||||
using StellaOps.Scanner.Worker.Diagnostics;
|
||||
using StellaOps.Cryptography;
|
||||
|
||||
@@ -96,6 +97,9 @@ internal sealed class CompositeScanAnalyzerDispatcher : IScanAnalyzerDispatcher
|
||||
{
|
||||
await ExecuteNativeAnalyzerAsync(context, services, rootfsPath, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// Binary vulnerability analysis (SPRINT_6000_0004_0001)
|
||||
await ExecuteBinaryAnalyzerAsync(context, services, rootfsPath, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task ExecuteOsAnalyzersAsync(
|
||||
@@ -382,6 +386,133 @@ internal sealed class CompositeScanAnalyzerDispatcher : IScanAnalyzerDispatcher
|
||||
context.Analysis.AppendLayerFragments(ImmutableArray.Create(fragment));
|
||||
}
|
||||
|
||||
private async Task ExecuteBinaryAnalyzerAsync(
|
||||
ScanJobContext context,
|
||||
IServiceProvider services,
|
||||
string? rootfsPath,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// Check if binary analysis is enabled via options
|
||||
var binaryOptions = services.GetService<BinaryIndexOptions>();
|
||||
if (binaryOptions is null || !binaryOptions.Enabled)
|
||||
{
|
||||
_logger.LogDebug("Binary vulnerability analysis is disabled for job {JobId}.", context.JobId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (rootfsPath is null)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Root filesystem path not available for job {JobId}; skipping binary vulnerability analysis.",
|
||||
context.JobId);
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var analyzer = services.GetService<BinaryVulnerabilityAnalyzer>();
|
||||
if (analyzer is null)
|
||||
{
|
||||
_logger.LogDebug("BinaryVulnerabilityAnalyzer not registered; skipping binary analysis.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Build list of binary paths from rootfs
|
||||
var binaryPaths = DiscoverBinaryPaths(rootfsPath);
|
||||
if (binaryPaths.Count == 0)
|
||||
{
|
||||
_logger.LogDebug("No binary files found in rootfs for job {JobId}.", context.JobId);
|
||||
return;
|
||||
}
|
||||
|
||||
var layerDigest = ComputeLayerDigest("binary");
|
||||
var scanIdGuid = Guid.TryParse(context.ScanId, out var parsedGuid) ? parsedGuid : Guid.Empty;
|
||||
var layerContext = new BinaryLayerContext
|
||||
{
|
||||
ScanId = scanIdGuid,
|
||||
LayerDigest = layerDigest,
|
||||
BinaryPaths = binaryPaths,
|
||||
DetectedDistro = null, // Could be enriched from OS analyzer results
|
||||
DetectedRelease = null,
|
||||
OpenFile = path =>
|
||||
{
|
||||
var fullPath = Path.Combine(rootfsPath, path.TrimStart('/'));
|
||||
return File.Exists(fullPath) ? File.OpenRead(fullPath) : null;
|
||||
}
|
||||
};
|
||||
|
||||
var result = await analyzer.AnalyzeLayerAsync(layerContext, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (result.Findings.Length > 0)
|
||||
{
|
||||
context.Analysis.Set(ScanAnalysisKeys.BinaryVulnerabilityFindings, result.Findings);
|
||||
_logger.LogInformation(
|
||||
"Binary vulnerability analysis found {Count} findings for job {JobId}.",
|
||||
result.Findings.Length, context.JobId);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Binary vulnerability analysis failed for job {JobId}.", context.JobId);
|
||||
}
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> DiscoverBinaryPaths(string rootfsPath)
|
||||
{
|
||||
var binaryPaths = new List<string>();
|
||||
var searchDirs = new[]
|
||||
{
|
||||
"usr/lib", "usr/lib64", "lib", "lib64",
|
||||
"usr/bin", "usr/sbin", "bin", "sbin"
|
||||
};
|
||||
|
||||
foreach (var dir in searchDirs)
|
||||
{
|
||||
var fullDir = Path.Combine(rootfsPath, dir);
|
||||
if (!Directory.Exists(fullDir))
|
||||
continue;
|
||||
|
||||
try
|
||||
{
|
||||
var files = Directory.EnumerateFiles(fullDir, "*", SearchOption.AllDirectories);
|
||||
foreach (var file in files)
|
||||
{
|
||||
var relativePath = "/" + Path.GetRelativePath(rootfsPath, file).Replace('\\', '/');
|
||||
if (IsPotentialBinary(file))
|
||||
{
|
||||
binaryPaths.Add(relativePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Directory access issues are expected in some scenarios
|
||||
}
|
||||
}
|
||||
|
||||
return binaryPaths;
|
||||
}
|
||||
|
||||
private static bool IsPotentialBinary(string filePath)
|
||||
{
|
||||
// Quick heuristic: check for .so files and executables
|
||||
var name = Path.GetFileName(filePath);
|
||||
if (name.EndsWith(".so", StringComparison.OrdinalIgnoreCase) ||
|
||||
name.Contains(".so.", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if file is executable by looking at extension (no extension often = binary)
|
||||
var ext = Path.GetExtension(filePath);
|
||||
if (string.IsNullOrEmpty(ext))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static string ComputeLayerDigest(string kind)
|
||||
{
|
||||
var normalized = $"stellaops:{kind.Trim().ToLowerInvariant()}";
|
||||
|
||||
@@ -185,7 +185,7 @@ if (workerOptions.VerdictPush.Enabled)
|
||||
client.Timeout = workerOptions.VerdictPush.Timeout;
|
||||
});
|
||||
builder.Services.AddSingleton<StellaOps.Scanner.Storage.Oci.VerdictOciPublisher>();
|
||||
builder.Services.AddSingleton<IScanStageExecutor, Processing.VerdictPushStageExecutor>();
|
||||
builder.Services.AddSingleton<IScanStageExecutor, VerdictPushStageExecutor>();
|
||||
}
|
||||
|
||||
builder.Services.AddSingleton<ScannerWorkerHostedService>();
|
||||
|
||||
@@ -32,5 +32,6 @@
|
||||
<ProjectReference Include="../__Libraries/StellaOps.Scanner.Emit/StellaOps.Scanner.Emit.csproj" />
|
||||
<ProjectReference Include="../StellaOps.Scanner.Analyzers.Native/StellaOps.Scanner.Analyzers.Native.csproj" />
|
||||
<ProjectReference Include="../../Unknowns/__Libraries/StellaOps.Unknowns.Core/StellaOps.Unknowns.Core.csproj" />
|
||||
<ProjectReference Include="../../BinaryIndex/__Libraries/StellaOps.BinaryIndex.Core/StellaOps.BinaryIndex.Core.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -43,4 +43,6 @@ public static class ScanAnalysisKeys
|
||||
public const string EpssNotFoundCves = "epss.not_found";
|
||||
|
||||
public const string ReplaySealedBundleMetadata = "analysis.replay.sealed.bundle";
|
||||
|
||||
public const string BinaryVulnerabilityFindings = "analysis.binary.findings";
|
||||
}
|
||||
|
||||
@@ -20,10 +20,10 @@ public sealed class CycloneDxComposer
|
||||
{
|
||||
private static readonly Guid SerialNamespace = new("0d3a422b-6e1b-4d9b-9c35-654b706c97e8");
|
||||
|
||||
private const string InventoryMediaTypeJson = "application/vnd.cyclonedx+json; version=1.6";
|
||||
private const string UsageMediaTypeJson = "application/vnd.cyclonedx+json; version=1.6; view=usage";
|
||||
private const string InventoryMediaTypeProtobuf = "application/vnd.cyclonedx+protobuf; version=1.6";
|
||||
private const string UsageMediaTypeProtobuf = "application/vnd.cyclonedx+protobuf; version=1.6; view=usage";
|
||||
private const string InventoryMediaTypeJson = CycloneDx17Extensions.MediaTypes.InventoryJson;
|
||||
private const string UsageMediaTypeJson = CycloneDx17Extensions.MediaTypes.UsageJson;
|
||||
private const string InventoryMediaTypeProtobuf = CycloneDx17Extensions.MediaTypes.InventoryProtobuf;
|
||||
private const string UsageMediaTypeProtobuf = CycloneDx17Extensions.MediaTypes.UsageProtobuf;
|
||||
|
||||
public SbomCompositionResult Compose(SbomCompositionRequest request)
|
||||
{
|
||||
@@ -101,7 +101,9 @@ public sealed class CycloneDxComposer
|
||||
string protobufMediaType)
|
||||
{
|
||||
var bom = BuildBom(request, graph, view, components, generatedAt);
|
||||
var json = JsonSerializer.Serialize(bom);
|
||||
var json16 = JsonSerializer.Serialize(bom);
|
||||
// Upgrade serialized JSON from 1.6 to 1.7 (CycloneDX.Core doesn't support v1_7 natively yet)
|
||||
var json = CycloneDx17Extensions.UpgradeJsonTo17(json16);
|
||||
var jsonBytes = Encoding.UTF8.GetBytes(json);
|
||||
var protobufBytes = ProtoSerializer.Serialize(bom);
|
||||
|
||||
@@ -169,6 +171,7 @@ public sealed class CycloneDxComposer
|
||||
ImmutableArray<AggregatedComponent> components,
|
||||
DateTimeOffset generatedAt)
|
||||
{
|
||||
// Use v1_6 for serialization; output is upgraded to 1.7 via CycloneDx17Extensions
|
||||
var bom = new Bom
|
||||
{
|
||||
SpecVersion = SpecificationVersion.v1_6,
|
||||
|
||||
@@ -19,8 +19,9 @@ public sealed class SbomDiffEngine
|
||||
SbomId toId,
|
||||
IReadOnlyList<ComponentRef> toComponents)
|
||||
{
|
||||
var fromByPurl = fromComponents.ToDictionary(c => c.Purl, c => c);
|
||||
var toByPurl = toComponents.ToDictionary(c => c.Purl, c => c);
|
||||
// Match by package identity (PURL without version) to detect version changes
|
||||
var fromByIdentity = fromComponents.ToDictionary(c => GetPackageIdentity(c), c => c);
|
||||
var toByIdentity = toComponents.ToDictionary(c => GetPackageIdentity(c), c => c);
|
||||
|
||||
var deltas = new List<ComponentDelta>();
|
||||
var added = 0;
|
||||
@@ -31,9 +32,9 @@ public sealed class SbomDiffEngine
|
||||
var isBreaking = false;
|
||||
|
||||
// Find added and modified components
|
||||
foreach (var (purl, toComp) in toByPurl)
|
||||
foreach (var (identity, toComp) in toByIdentity)
|
||||
{
|
||||
if (!fromByPurl.TryGetValue(purl, out var fromComp))
|
||||
if (!fromByIdentity.TryGetValue(identity, out var fromComp))
|
||||
{
|
||||
// Added
|
||||
deltas.Add(new ComponentDelta
|
||||
@@ -80,9 +81,9 @@ public sealed class SbomDiffEngine
|
||||
}
|
||||
|
||||
// Find removed components
|
||||
foreach (var (purl, fromComp) in fromByPurl)
|
||||
foreach (var (identity, fromComp) in fromByIdentity)
|
||||
{
|
||||
if (!toByPurl.ContainsKey(purl))
|
||||
if (!toByIdentity.ContainsKey(identity))
|
||||
{
|
||||
deltas.Add(new ComponentDelta
|
||||
{
|
||||
@@ -192,4 +193,25 @@ public sealed class SbomDiffEngine
|
||||
var hashBytes = SHA256.HashData(Encoding.UTF8.GetBytes(json));
|
||||
return Convert.ToHexStringLower(hashBytes);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the package identity (PURL without version) for matching.
|
||||
/// </summary>
|
||||
private static string GetPackageIdentity(ComponentRef component)
|
||||
{
|
||||
// Strip version from PURL to match by package identity
|
||||
// PURL format: pkg:type/namespace/name@version?qualifiers#subpath
|
||||
var purl = component.Purl;
|
||||
var atIndex = purl.IndexOf('@');
|
||||
if (atIndex > 0)
|
||||
{
|
||||
var beforeAt = purl[..atIndex];
|
||||
// Also preserve qualifiers/subpath after version if present
|
||||
var queryIndex = purl.IndexOf('?', atIndex);
|
||||
var hashIndex = purl.IndexOf('#', atIndex);
|
||||
var suffixIndex = queryIndex >= 0 ? queryIndex : hashIndex;
|
||||
return suffixIndex > 0 ? beforeAt + purl[suffixIndex..] : beforeAt;
|
||||
}
|
||||
return purl;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using CycloneDX;
|
||||
using CycloneDX.Models;
|
||||
using StellaOps.Scanner.Core.Utility;
|
||||
using StellaOps.Scanner.Emit.Spdx.Models;
|
||||
@@ -82,9 +83,10 @@ public static class SpdxCycloneDxConverter
|
||||
var rootPackage = packages.FirstOrDefault(pkg => string.Equals(pkg.SpdxId, rootId, StringComparison.Ordinal))
|
||||
?? packages.FirstOrDefault();
|
||||
|
||||
// Use v1_6 for Bom object; caller serializes and upgrades output to 1.7 via CycloneDx17Extensions
|
||||
var bom = new Bom
|
||||
{
|
||||
SpecVersion = SpecificationVersion.v1_7,
|
||||
SpecVersion = SpecificationVersion.v1_6,
|
||||
Version = 1,
|
||||
Metadata = new Metadata
|
||||
{
|
||||
|
||||
@@ -233,7 +233,7 @@ public static class SpdxLicenseExpressionParser
|
||||
public SpdxLicenseExpression ParseExpression()
|
||||
{
|
||||
var left = ParseWith();
|
||||
while (TryMatch(TokenType.And, out _) || TryMatch(TokenType.Or, out var op))
|
||||
while (TryMatch(TokenType.And, out var op) || TryMatch(TokenType.Or, out op))
|
||||
{
|
||||
var right = ParseWith();
|
||||
left = op!.Type == TokenType.And
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
|
||||
<RootNamespace>StellaOps.Scanner.Orchestration</RootNamespace>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0-preview.7.24407.6" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,319 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Copyright (c) StellaOps
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using FluentAssertions;
|
||||
using StellaOps.Scanner.Emit.Lineage;
|
||||
|
||||
namespace StellaOps.Scanner.Emit.Lineage.Tests;
|
||||
|
||||
public class RebuildProofTests
|
||||
{
|
||||
#region RebuildProof Model Tests
|
||||
|
||||
[Fact]
|
||||
public void RebuildProof_RequiredProperties_MustBeSet()
|
||||
{
|
||||
var proof = new RebuildProof
|
||||
{
|
||||
SbomId = SbomId.New(),
|
||||
ImageDigest = "sha256:abc123",
|
||||
StellaOpsVersion = "1.0.0",
|
||||
FeedSnapshots = [],
|
||||
AnalyzerVersions = [],
|
||||
PolicyHash = "sha256:policy",
|
||||
GeneratedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
proof.SbomId.Should().NotBe(default(SbomId));
|
||||
proof.ImageDigest.Should().NotBeNullOrEmpty();
|
||||
proof.StellaOpsVersion.Should().Be("1.0.0");
|
||||
proof.PolicyHash.Should().NotBeNullOrEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RebuildProof_WithFeedSnapshots_TracksAllFeeds()
|
||||
{
|
||||
var feeds = ImmutableArray.Create(
|
||||
new FeedSnapshot
|
||||
{
|
||||
FeedId = "nvd",
|
||||
FeedName = "NVD CVE Feed",
|
||||
SnapshotHash = "sha256:nvdhash",
|
||||
AsOf = DateTimeOffset.UtcNow,
|
||||
EntryCount = 200000
|
||||
},
|
||||
new FeedSnapshot
|
||||
{
|
||||
FeedId = "ghsa",
|
||||
FeedName = "GitHub Security Advisories",
|
||||
SnapshotHash = "sha256:ghsahash",
|
||||
AsOf = DateTimeOffset.UtcNow,
|
||||
EntryCount = 15000
|
||||
}
|
||||
);
|
||||
|
||||
var proof = new RebuildProof
|
||||
{
|
||||
SbomId = SbomId.New(),
|
||||
ImageDigest = "sha256:image",
|
||||
StellaOpsVersion = "1.0.0",
|
||||
FeedSnapshots = feeds,
|
||||
AnalyzerVersions = [],
|
||||
PolicyHash = "sha256:policy",
|
||||
GeneratedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
proof.FeedSnapshots.Should().HaveCount(2);
|
||||
proof.FeedSnapshots[0].FeedId.Should().Be("nvd");
|
||||
proof.FeedSnapshots[1].EntryCount.Should().Be(15000);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RebuildProof_WithAnalyzerVersions_TracksAllAnalyzers()
|
||||
{
|
||||
var analyzers = ImmutableArray.Create(
|
||||
new AnalyzerVersion
|
||||
{
|
||||
AnalyzerId = "npm-analyzer",
|
||||
AnalyzerName = "NPM Package Analyzer",
|
||||
Version = "2.0.0",
|
||||
CodeHash = "sha256:npmhash"
|
||||
},
|
||||
new AnalyzerVersion
|
||||
{
|
||||
AnalyzerId = "dotnet-analyzer",
|
||||
AnalyzerName = ".NET Package Analyzer",
|
||||
Version = "3.1.0"
|
||||
}
|
||||
);
|
||||
|
||||
var proof = new RebuildProof
|
||||
{
|
||||
SbomId = SbomId.New(),
|
||||
ImageDigest = "sha256:image",
|
||||
StellaOpsVersion = "1.0.0",
|
||||
FeedSnapshots = [],
|
||||
AnalyzerVersions = analyzers,
|
||||
PolicyHash = "sha256:policy",
|
||||
GeneratedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
proof.AnalyzerVersions.Should().HaveCount(2);
|
||||
proof.AnalyzerVersions[0].AnalyzerId.Should().Be("npm-analyzer");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RebuildProof_OptionalDsseSignature_IsNullByDefault()
|
||||
{
|
||||
var proof = new RebuildProof
|
||||
{
|
||||
SbomId = SbomId.New(),
|
||||
ImageDigest = "sha256:image",
|
||||
StellaOpsVersion = "1.0.0",
|
||||
FeedSnapshots = [],
|
||||
AnalyzerVersions = [],
|
||||
PolicyHash = "sha256:policy",
|
||||
GeneratedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
proof.DsseSignature.Should().BeNull();
|
||||
proof.ProofHash.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RebuildProof_WithSignature_StoresSignature()
|
||||
{
|
||||
var proof = new RebuildProof
|
||||
{
|
||||
SbomId = SbomId.New(),
|
||||
ImageDigest = "sha256:image",
|
||||
StellaOpsVersion = "1.0.0",
|
||||
FeedSnapshots = [],
|
||||
AnalyzerVersions = [],
|
||||
PolicyHash = "sha256:policy",
|
||||
GeneratedAt = DateTimeOffset.UtcNow,
|
||||
DsseSignature = "eyJwYXlsb2FkIjoiLi4uIn0=",
|
||||
ProofHash = "sha256:proofhash"
|
||||
};
|
||||
|
||||
proof.DsseSignature.Should().NotBeNullOrEmpty();
|
||||
proof.ProofHash.Should().StartWith("sha256:");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region FeedSnapshot Tests
|
||||
|
||||
[Fact]
|
||||
public void FeedSnapshot_RequiredProperties_MustBeSet()
|
||||
{
|
||||
var snapshot = new FeedSnapshot
|
||||
{
|
||||
FeedId = "nvd",
|
||||
FeedName = "NVD CVE Feed",
|
||||
SnapshotHash = "sha256:hash",
|
||||
AsOf = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
snapshot.FeedId.Should().Be("nvd");
|
||||
snapshot.FeedName.Should().Be("NVD CVE Feed");
|
||||
snapshot.SnapshotHash.Should().NotBeNullOrEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FeedSnapshot_OptionalProperties_AreNullByDefault()
|
||||
{
|
||||
var snapshot = new FeedSnapshot
|
||||
{
|
||||
FeedId = "nvd",
|
||||
FeedName = "NVD",
|
||||
SnapshotHash = "sha256:hash",
|
||||
AsOf = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
snapshot.EntryCount.Should().BeNull();
|
||||
snapshot.FeedVersion.Should().BeNull();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region AnalyzerVersion Tests
|
||||
|
||||
[Fact]
|
||||
public void AnalyzerVersion_RequiredProperties_MustBeSet()
|
||||
{
|
||||
var analyzer = new AnalyzerVersion
|
||||
{
|
||||
AnalyzerId = "npm-analyzer",
|
||||
AnalyzerName = "NPM Package Analyzer",
|
||||
Version = "2.0.0"
|
||||
};
|
||||
|
||||
analyzer.AnalyzerId.Should().Be("npm-analyzer");
|
||||
analyzer.AnalyzerName.Should().Be("NPM Package Analyzer");
|
||||
analyzer.Version.Should().Be("2.0.0");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AnalyzerVersion_OptionalHashes_AreNullByDefault()
|
||||
{
|
||||
var analyzer = new AnalyzerVersion
|
||||
{
|
||||
AnalyzerId = "test",
|
||||
AnalyzerName = "Test",
|
||||
Version = "1.0.0"
|
||||
};
|
||||
|
||||
analyzer.CodeHash.Should().BeNull();
|
||||
analyzer.ConfigHash.Should().BeNull();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region RebuildVerification Tests
|
||||
|
||||
[Fact]
|
||||
public void RebuildVerification_SuccessfulRebuild_HasMatchingHash()
|
||||
{
|
||||
var proof = new RebuildProof
|
||||
{
|
||||
SbomId = SbomId.New(),
|
||||
ImageDigest = "sha256:image",
|
||||
StellaOpsVersion = "1.0.0",
|
||||
FeedSnapshots = [],
|
||||
AnalyzerVersions = [],
|
||||
PolicyHash = "sha256:policy",
|
||||
GeneratedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
var verification = new RebuildVerification
|
||||
{
|
||||
Proof = proof,
|
||||
Success = true,
|
||||
RebuiltSbomId = SbomId.New(),
|
||||
HashMatches = true,
|
||||
VerifiedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
verification.Success.Should().BeTrue();
|
||||
verification.HashMatches.Should().BeTrue();
|
||||
verification.Differences.Should().BeNull();
|
||||
verification.ErrorMessage.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RebuildVerification_FailedRebuild_HasErrorMessage()
|
||||
{
|
||||
var proof = new RebuildProof
|
||||
{
|
||||
SbomId = SbomId.New(),
|
||||
ImageDigest = "sha256:image",
|
||||
StellaOpsVersion = "1.0.0",
|
||||
FeedSnapshots = [],
|
||||
AnalyzerVersions = [],
|
||||
PolicyHash = "sha256:policy",
|
||||
GeneratedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
var verification = new RebuildVerification
|
||||
{
|
||||
Proof = proof,
|
||||
Success = false,
|
||||
ErrorMessage = "Feed snapshot not available",
|
||||
VerifiedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
verification.Success.Should().BeFalse();
|
||||
verification.ErrorMessage.Should().Be("Feed snapshot not available");
|
||||
verification.RebuiltSbomId.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RebuildVerification_MismatchRebuild_HasDifferences()
|
||||
{
|
||||
var proof = new RebuildProof
|
||||
{
|
||||
SbomId = SbomId.New(),
|
||||
ImageDigest = "sha256:image",
|
||||
StellaOpsVersion = "1.0.0",
|
||||
FeedSnapshots = [],
|
||||
AnalyzerVersions = [],
|
||||
PolicyHash = "sha256:policy",
|
||||
GeneratedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
var diff = new SbomDiff
|
||||
{
|
||||
FromId = proof.SbomId,
|
||||
ToId = SbomId.New(),
|
||||
Deltas = [],
|
||||
Summary = new DiffSummary
|
||||
{
|
||||
Added = 1,
|
||||
Removed = 0,
|
||||
VersionChanged = 0,
|
||||
OtherModified = 0,
|
||||
Unchanged = 100
|
||||
},
|
||||
ComputedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
var verification = new RebuildVerification
|
||||
{
|
||||
Proof = proof,
|
||||
Success = true,
|
||||
RebuiltSbomId = SbomId.New(),
|
||||
HashMatches = false,
|
||||
Differences = diff,
|
||||
VerifiedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
verification.Success.Should().BeTrue();
|
||||
verification.HashMatches.Should().BeFalse();
|
||||
verification.Differences.Should().NotBeNull();
|
||||
verification.Differences!.Summary.Added.Should().Be(1);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Copyright (c) StellaOps
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using FluentAssertions;
|
||||
using StellaOps.Scanner.Emit.Lineage;
|
||||
|
||||
namespace StellaOps.Scanner.Emit.Lineage.Tests;
|
||||
|
||||
public class SbomDiffEngineTests
|
||||
{
|
||||
private readonly SbomDiffEngine _engine = new();
|
||||
|
||||
private static ComponentRef CreateComponent(string name, string version, string? license = null)
|
||||
{
|
||||
return new ComponentRef
|
||||
{
|
||||
Purl = $"pkg:npm/{name}@{version}",
|
||||
Name = name,
|
||||
Version = version,
|
||||
Type = "npm",
|
||||
License = license
|
||||
};
|
||||
}
|
||||
|
||||
#region Basic Diff Tests
|
||||
|
||||
[Fact]
|
||||
public void ComputeDiff_IdenticalComponents_ReturnsNoDelta()
|
||||
{
|
||||
var fromId = SbomId.New();
|
||||
var toId = SbomId.New();
|
||||
|
||||
var components = new[]
|
||||
{
|
||||
CreateComponent("lodash", "4.17.21"),
|
||||
CreateComponent("express", "4.18.2")
|
||||
};
|
||||
|
||||
var diff = _engine.ComputeDiff(fromId, components, toId, components);
|
||||
|
||||
diff.Deltas.Should().BeEmpty();
|
||||
diff.Summary.Added.Should().Be(0);
|
||||
diff.Summary.Removed.Should().Be(0);
|
||||
diff.Summary.VersionChanged.Should().Be(0);
|
||||
diff.Summary.Unchanged.Should().Be(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeDiff_AddedComponent_DetectsAddition()
|
||||
{
|
||||
var fromId = SbomId.New();
|
||||
var toId = SbomId.New();
|
||||
|
||||
var from = new[] { CreateComponent("lodash", "4.17.21") };
|
||||
var to = new[]
|
||||
{
|
||||
CreateComponent("lodash", "4.17.21"),
|
||||
CreateComponent("express", "4.18.2")
|
||||
};
|
||||
|
||||
var diff = _engine.ComputeDiff(fromId, from, toId, to);
|
||||
|
||||
diff.Deltas.Should().HaveCount(1);
|
||||
diff.Deltas[0].Type.Should().Be(ComponentDeltaType.Added);
|
||||
diff.Deltas[0].After!.Name.Should().Be("express");
|
||||
diff.Summary.Added.Should().Be(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeDiff_RemovedComponent_DetectsRemoval()
|
||||
{
|
||||
var fromId = SbomId.New();
|
||||
var toId = SbomId.New();
|
||||
|
||||
var from = new[]
|
||||
{
|
||||
CreateComponent("lodash", "4.17.21"),
|
||||
CreateComponent("express", "4.18.2")
|
||||
};
|
||||
var to = new[] { CreateComponent("lodash", "4.17.21") };
|
||||
|
||||
var diff = _engine.ComputeDiff(fromId, from, toId, to);
|
||||
|
||||
diff.Deltas.Should().HaveCount(1);
|
||||
diff.Deltas[0].Type.Should().Be(ComponentDeltaType.Removed);
|
||||
diff.Deltas[0].Before!.Name.Should().Be("express");
|
||||
diff.Summary.Removed.Should().Be(1);
|
||||
diff.Summary.IsBreaking.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeDiff_VersionUpgrade_DetectsVersionChange()
|
||||
{
|
||||
var fromId = SbomId.New();
|
||||
var toId = SbomId.New();
|
||||
|
||||
var from = new[] { CreateComponent("lodash", "4.17.20") };
|
||||
var to = new[] { CreateComponent("lodash", "4.17.21") };
|
||||
|
||||
var diff = _engine.ComputeDiff(fromId, from, toId, to);
|
||||
|
||||
diff.Deltas.Should().HaveCount(1);
|
||||
diff.Deltas[0].Type.Should().Be(ComponentDeltaType.VersionChanged);
|
||||
diff.Deltas[0].ChangedFields.Should().Contain("Version");
|
||||
diff.Summary.VersionChanged.Should().Be(1);
|
||||
diff.Summary.IsBreaking.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeDiff_VersionDowngrade_MarksAsBreaking()
|
||||
{
|
||||
var fromId = SbomId.New();
|
||||
var toId = SbomId.New();
|
||||
|
||||
var from = new[] { CreateComponent("lodash", "4.17.21") };
|
||||
var to = new[] { CreateComponent("lodash", "4.17.20") };
|
||||
|
||||
var diff = _engine.ComputeDiff(fromId, from, toId, to);
|
||||
|
||||
diff.Summary.IsBreaking.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeDiff_LicenseChange_DetectsLicenseChange()
|
||||
{
|
||||
var fromId = SbomId.New();
|
||||
var toId = SbomId.New();
|
||||
|
||||
var from = new[] { CreateComponent("lodash", "4.17.21", "MIT") };
|
||||
var to = new[] { CreateComponent("lodash", "4.17.21", "Apache-2.0") };
|
||||
|
||||
var diff = _engine.ComputeDiff(fromId, from, toId, to);
|
||||
|
||||
diff.Deltas.Should().HaveCount(1);
|
||||
diff.Deltas[0].Type.Should().Be(ComponentDeltaType.LicenseChanged);
|
||||
diff.Deltas[0].ChangedFields.Should().Contain("License");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Complex Diff Tests
|
||||
|
||||
[Fact]
|
||||
public void ComputeDiff_MultipleChanges_TracksAll()
|
||||
{
|
||||
var fromId = SbomId.New();
|
||||
var toId = SbomId.New();
|
||||
|
||||
var from = new[]
|
||||
{
|
||||
CreateComponent("lodash", "4.17.20"),
|
||||
CreateComponent("express", "4.18.1"),
|
||||
CreateComponent("removed-pkg", "1.0.0")
|
||||
};
|
||||
|
||||
var to = new[]
|
||||
{
|
||||
CreateComponent("lodash", "4.17.21"), // Version upgrade
|
||||
CreateComponent("express", "4.18.1"), // Unchanged
|
||||
CreateComponent("new-pkg", "2.0.0") // Added
|
||||
};
|
||||
|
||||
var diff = _engine.ComputeDiff(fromId, from, toId, to);
|
||||
|
||||
diff.Summary.Added.Should().Be(1);
|
||||
diff.Summary.Removed.Should().Be(1);
|
||||
diff.Summary.VersionChanged.Should().Be(1);
|
||||
diff.Summary.Unchanged.Should().Be(1);
|
||||
diff.Summary.IsBreaking.Should().BeTrue(); // Due to removal
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeDiff_EmptyFrom_AllAdditions()
|
||||
{
|
||||
var fromId = SbomId.New();
|
||||
var toId = SbomId.New();
|
||||
|
||||
var from = Array.Empty<ComponentRef>();
|
||||
var to = new[]
|
||||
{
|
||||
CreateComponent("lodash", "4.17.21"),
|
||||
CreateComponent("express", "4.18.2")
|
||||
};
|
||||
|
||||
var diff = _engine.ComputeDiff(fromId, from, toId, to);
|
||||
|
||||
diff.Summary.Added.Should().Be(2);
|
||||
diff.Summary.Removed.Should().Be(0);
|
||||
diff.Summary.Unchanged.Should().Be(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeDiff_EmptyTo_AllRemovals()
|
||||
{
|
||||
var fromId = SbomId.New();
|
||||
var toId = SbomId.New();
|
||||
|
||||
var from = new[]
|
||||
{
|
||||
CreateComponent("lodash", "4.17.21"),
|
||||
CreateComponent("express", "4.18.2")
|
||||
};
|
||||
var to = Array.Empty<ComponentRef>();
|
||||
|
||||
var diff = _engine.ComputeDiff(fromId, from, toId, to);
|
||||
|
||||
diff.Summary.Added.Should().Be(0);
|
||||
diff.Summary.Removed.Should().Be(2);
|
||||
diff.Summary.IsBreaking.Should().BeTrue();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Determinism Tests
|
||||
|
||||
[Fact]
|
||||
public void ComputeDiff_SameInputs_ProducesSameOutput()
|
||||
{
|
||||
var fromId = SbomId.New();
|
||||
var toId = SbomId.New();
|
||||
|
||||
var from = new[]
|
||||
{
|
||||
CreateComponent("lodash", "4.17.20"),
|
||||
CreateComponent("express", "4.18.1")
|
||||
};
|
||||
|
||||
var to = new[]
|
||||
{
|
||||
CreateComponent("lodash", "4.17.21"),
|
||||
CreateComponent("new-pkg", "1.0.0")
|
||||
};
|
||||
|
||||
var diff1 = _engine.ComputeDiff(fromId, from, toId, to);
|
||||
var diff2 = _engine.ComputeDiff(fromId, from, toId, to);
|
||||
|
||||
diff1.Summary.Should().BeEquivalentTo(diff2.Summary);
|
||||
diff1.Deltas.Should().HaveCount(diff2.Deltas.Length);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeDiff_DeltasAreSorted()
|
||||
{
|
||||
var fromId = SbomId.New();
|
||||
var toId = SbomId.New();
|
||||
|
||||
var from = new[]
|
||||
{
|
||||
CreateComponent("z-pkg", "1.0.0"),
|
||||
CreateComponent("a-pkg", "1.0.0")
|
||||
};
|
||||
|
||||
var to = new[]
|
||||
{
|
||||
CreateComponent("z-pkg", "2.0.0"),
|
||||
CreateComponent("m-pkg", "1.0.0")
|
||||
};
|
||||
|
||||
var diff = _engine.ComputeDiff(fromId, from, toId, to);
|
||||
|
||||
// Deltas should be sorted by type then by PURL
|
||||
diff.Deltas.Should().BeInAscendingOrder(d => d.Type);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region CreatePointer Tests
|
||||
|
||||
[Fact]
|
||||
public void CreatePointer_SumsCorrectly()
|
||||
{
|
||||
var fromId = SbomId.New();
|
||||
var toId = SbomId.New();
|
||||
|
||||
var from = new[]
|
||||
{
|
||||
CreateComponent("lodash", "4.17.20"),
|
||||
CreateComponent("removed", "1.0.0")
|
||||
};
|
||||
|
||||
var to = new[]
|
||||
{
|
||||
CreateComponent("lodash", "4.17.21"),
|
||||
CreateComponent("added", "1.0.0")
|
||||
};
|
||||
|
||||
var diff = _engine.ComputeDiff(fromId, from, toId, to);
|
||||
var pointer = _engine.CreatePointer(diff);
|
||||
|
||||
pointer.ComponentsAdded.Should().Be(1);
|
||||
pointer.ComponentsRemoved.Should().Be(1);
|
||||
pointer.ComponentsModified.Should().Be(1);
|
||||
pointer.DiffHash.Should().NotBeNullOrEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreatePointer_DiffHashIsDeterministic()
|
||||
{
|
||||
var fromId = SbomId.New();
|
||||
var toId = SbomId.New();
|
||||
|
||||
var from = new[] { CreateComponent("lodash", "4.17.20") };
|
||||
var to = new[] { CreateComponent("lodash", "4.17.21") };
|
||||
|
||||
var diff1 = _engine.ComputeDiff(fromId, from, toId, to);
|
||||
var diff2 = _engine.ComputeDiff(fromId, from, toId, to);
|
||||
|
||||
var pointer1 = _engine.CreatePointer(diff1);
|
||||
var pointer2 = _engine.CreatePointer(diff2);
|
||||
|
||||
pointer1.DiffHash.Should().Be(pointer2.DiffHash);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Summary Tests
|
||||
|
||||
[Fact]
|
||||
public void DiffSummary_TotalComponents_CalculatesCorrectly()
|
||||
{
|
||||
var summary = new DiffSummary
|
||||
{
|
||||
Added = 5,
|
||||
Removed = 2,
|
||||
VersionChanged = 3,
|
||||
OtherModified = 1,
|
||||
Unchanged = 10,
|
||||
IsBreaking = false
|
||||
};
|
||||
|
||||
// TotalComponents = Added + VersionChanged + OtherModified + Unchanged
|
||||
summary.TotalComponents.Should().Be(19);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Copyright (c) StellaOps
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using FluentAssertions;
|
||||
using StellaOps.Scanner.Emit.Lineage;
|
||||
|
||||
namespace StellaOps.Scanner.Emit.Lineage.Tests;
|
||||
|
||||
public class SbomLineageTests
|
||||
{
|
||||
#region SbomId Tests
|
||||
|
||||
[Fact]
|
||||
public void SbomId_New_CreatesUniqueId()
|
||||
{
|
||||
var id1 = SbomId.New();
|
||||
var id2 = SbomId.New();
|
||||
|
||||
id1.Should().NotBe(id2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SbomId_Parse_RoundTrips()
|
||||
{
|
||||
var original = SbomId.New();
|
||||
var parsed = SbomId.Parse(original.ToString());
|
||||
|
||||
parsed.Should().Be(original);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SbomId_ToString_ReturnsGuidString()
|
||||
{
|
||||
var id = SbomId.New();
|
||||
var str = id.ToString();
|
||||
|
||||
Guid.TryParse(str, out _).Should().BeTrue();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region SbomLineage Model Tests
|
||||
|
||||
[Fact]
|
||||
public void SbomLineage_RequiredProperties_MustBeSet()
|
||||
{
|
||||
var lineage = new SbomLineage
|
||||
{
|
||||
Id = SbomId.New(),
|
||||
ImageDigest = "sha256:abc123",
|
||||
ContentHash = "sha256:def456",
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
lineage.Id.Should().NotBe(default(SbomId));
|
||||
lineage.ImageDigest.Should().Be("sha256:abc123");
|
||||
lineage.ContentHash.Should().Be("sha256:def456");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SbomLineage_WithParent_TracksLineage()
|
||||
{
|
||||
var parentId = SbomId.New();
|
||||
var childId = SbomId.New();
|
||||
|
||||
var child = new SbomLineage
|
||||
{
|
||||
Id = childId,
|
||||
ParentId = parentId,
|
||||
ImageDigest = "sha256:child",
|
||||
ContentHash = "sha256:childhash",
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
Ancestors = [parentId]
|
||||
};
|
||||
|
||||
child.ParentId.Should().Be(parentId);
|
||||
child.Ancestors.Should().Contain(parentId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SbomLineage_WithDiffPointer_TracksChanges()
|
||||
{
|
||||
var diff = new SbomDiffPointer
|
||||
{
|
||||
ComponentsAdded = 5,
|
||||
ComponentsRemoved = 2,
|
||||
ComponentsModified = 3,
|
||||
DiffHash = "sha256:diffhash"
|
||||
};
|
||||
|
||||
var lineage = new SbomLineage
|
||||
{
|
||||
Id = SbomId.New(),
|
||||
ParentId = SbomId.New(),
|
||||
ImageDigest = "sha256:image",
|
||||
ContentHash = "sha256:content",
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
DiffFromParent = diff
|
||||
};
|
||||
|
||||
lineage.DiffFromParent.Should().NotBeNull();
|
||||
lineage.DiffFromParent!.TotalChanges.Should().Be(10);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SbomLineage_RootLineage_HasNoParent()
|
||||
{
|
||||
var root = new SbomLineage
|
||||
{
|
||||
Id = SbomId.New(),
|
||||
ImageDigest = "sha256:root",
|
||||
ContentHash = "sha256:roothash",
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
root.ParentId.Should().BeNull();
|
||||
root.Ancestors.Should().BeEmpty();
|
||||
root.DiffFromParent.Should().BeNull();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region SbomDiffPointer Tests
|
||||
|
||||
[Fact]
|
||||
public void SbomDiffPointer_TotalChanges_SumsAllCategories()
|
||||
{
|
||||
var pointer = new SbomDiffPointer
|
||||
{
|
||||
ComponentsAdded = 10,
|
||||
ComponentsRemoved = 5,
|
||||
ComponentsModified = 8,
|
||||
DiffHash = "sha256:hash"
|
||||
};
|
||||
|
||||
pointer.TotalChanges.Should().Be(23);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SbomDiffPointer_EmptyDiff_HasZeroChanges()
|
||||
{
|
||||
var pointer = new SbomDiffPointer
|
||||
{
|
||||
ComponentsAdded = 0,
|
||||
ComponentsRemoved = 0,
|
||||
ComponentsModified = 0,
|
||||
DiffHash = "sha256:empty"
|
||||
};
|
||||
|
||||
pointer.TotalChanges.Should().Be(0);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentAssertions" Version="6.12.0" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.1" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Scanner.Emit\StellaOps.Scanner.Emit.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -1,6 +1,7 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using CycloneDX;
|
||||
using CycloneDX.Models;
|
||||
using StellaOps.Scanner.Emit.Spdx.Conversion;
|
||||
using Xunit;
|
||||
@@ -53,9 +54,10 @@ public sealed class SpdxCycloneDxConversionTests
|
||||
Type = Component.Classification.Library
|
||||
};
|
||||
|
||||
// Use v1_6 for Bom object; serialized output is upgraded to 1.7 via CycloneDx17Extensions
|
||||
return new Bom
|
||||
{
|
||||
SpecVersion = SpecificationVersion.v1_7,
|
||||
SpecVersion = SpecificationVersion.v1_6,
|
||||
Version = 1,
|
||||
Metadata = new Metadata
|
||||
{
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
using System.Collections.Immutable;
|
||||
using FluentAssertions;
|
||||
using StellaOps.Scanner.Emit.Lineage;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.Emit.Tests.Lineage;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for SBOM lineage models.
|
||||
/// </summary>
|
||||
public class SbomLineageTests
|
||||
{
|
||||
[Fact]
|
||||
public void SbomId_New_CreatesUniqueIds()
|
||||
{
|
||||
var id1 = SbomId.New();
|
||||
var id2 = SbomId.New();
|
||||
|
||||
id1.Should().NotBe(id2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SbomId_Parse_RoundTrips()
|
||||
{
|
||||
var original = SbomId.New();
|
||||
var parsed = SbomId.Parse(original.ToString());
|
||||
|
||||
parsed.Should().Be(original);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SbomLineage_WithParent_TracksAncestry()
|
||||
{
|
||||
var parentId = SbomId.New();
|
||||
var childId = SbomId.New();
|
||||
|
||||
var lineage = new SbomLineage
|
||||
{
|
||||
Id = childId,
|
||||
ParentId = parentId,
|
||||
ImageDigest = "sha256:abc123",
|
||||
ContentHash = "sha256:def456",
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
Ancestors = [parentId]
|
||||
};
|
||||
|
||||
lineage.ParentId.Should().Be(parentId);
|
||||
lineage.Ancestors.Should().Contain(parentId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SbomDiffPointer_TotalChanges_SumsCorrectly()
|
||||
{
|
||||
var pointer = new SbomDiffPointer
|
||||
{
|
||||
ComponentsAdded = 5,
|
||||
ComponentsRemoved = 3,
|
||||
ComponentsModified = 7,
|
||||
DiffHash = "sha256:abc"
|
||||
};
|
||||
|
||||
pointer.TotalChanges.Should().Be(15);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests for SBOM diff engine.
|
||||
/// </summary>
|
||||
public class SbomDiffEngineTests
|
||||
{
|
||||
private readonly SbomDiffEngine _engine = new();
|
||||
|
||||
[Fact]
|
||||
public void ComputeDiff_NoChanges_ReturnsEmptyDeltas()
|
||||
{
|
||||
var components = new List<ComponentRef>
|
||||
{
|
||||
new() { Purl = "pkg:npm/lodash@4.17.21", Name = "lodash", Version = "4.17.21" }
|
||||
};
|
||||
|
||||
var fromId = SbomId.New();
|
||||
var toId = SbomId.New();
|
||||
|
||||
var diff = _engine.ComputeDiff(fromId, components, toId, components);
|
||||
|
||||
diff.Deltas.Should().BeEmpty();
|
||||
diff.Summary.Unchanged.Should().Be(1);
|
||||
diff.Summary.Added.Should().Be(0);
|
||||
diff.Summary.Removed.Should().Be(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeDiff_ComponentAdded_DetectsAddition()
|
||||
{
|
||||
var fromComponents = new List<ComponentRef>();
|
||||
var toComponents = new List<ComponentRef>
|
||||
{
|
||||
new() { Purl = "pkg:npm/lodash@4.17.21", Name = "lodash", Version = "4.17.21" }
|
||||
};
|
||||
|
||||
var diff = _engine.ComputeDiff(SbomId.New(), fromComponents, SbomId.New(), toComponents);
|
||||
|
||||
diff.Summary.Added.Should().Be(1);
|
||||
diff.Deltas.Should().ContainSingle()
|
||||
.Which.Type.Should().Be(ComponentDeltaType.Added);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeDiff_ComponentRemoved_DetectsRemovalAndBreaking()
|
||||
{
|
||||
var fromComponents = new List<ComponentRef>
|
||||
{
|
||||
new() { Purl = "pkg:npm/lodash@4.17.21", Name = "lodash", Version = "4.17.21" }
|
||||
};
|
||||
var toComponents = new List<ComponentRef>();
|
||||
|
||||
var diff = _engine.ComputeDiff(SbomId.New(), fromComponents, SbomId.New(), toComponents);
|
||||
|
||||
diff.Summary.Removed.Should().Be(1);
|
||||
diff.Summary.IsBreaking.Should().BeTrue();
|
||||
diff.Deltas.Should().ContainSingle()
|
||||
.Which.Type.Should().Be(ComponentDeltaType.Removed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeDiff_VersionChanged_DetectsVersionChange()
|
||||
{
|
||||
var fromComponents = new List<ComponentRef>
|
||||
{
|
||||
new() { Purl = "pkg:npm/lodash@4.17.20", Name = "lodash", Version = "4.17.20" }
|
||||
};
|
||||
var toComponents = new List<ComponentRef>
|
||||
{
|
||||
new() { Purl = "pkg:npm/lodash@4.17.20", Name = "lodash", Version = "4.17.21" }
|
||||
};
|
||||
|
||||
var diff = _engine.ComputeDiff(SbomId.New(), fromComponents, SbomId.New(), toComponents);
|
||||
|
||||
diff.Summary.VersionChanged.Should().Be(1);
|
||||
var delta = diff.Deltas.Should().ContainSingle().Subject;
|
||||
delta.Type.Should().Be(ComponentDeltaType.VersionChanged);
|
||||
delta.ChangedFields.Should().Contain("Version");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeDiff_VersionDowngrade_IsBreaking()
|
||||
{
|
||||
var fromComponents = new List<ComponentRef>
|
||||
{
|
||||
new() { Purl = "pkg:npm/lodash@4.17.21", Name = "lodash", Version = "4.17.21" }
|
||||
};
|
||||
var toComponents = new List<ComponentRef>
|
||||
{
|
||||
new() { Purl = "pkg:npm/lodash@4.17.21", Name = "lodash", Version = "4.17.20" }
|
||||
};
|
||||
|
||||
var diff = _engine.ComputeDiff(SbomId.New(), fromComponents, SbomId.New(), toComponents);
|
||||
|
||||
diff.Summary.IsBreaking.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeDiff_LicenseChanged_DetectsLicenseChange()
|
||||
{
|
||||
var fromComponents = new List<ComponentRef>
|
||||
{
|
||||
new() { Purl = "pkg:npm/lodash@4.17.21", Name = "lodash", Version = "4.17.21", License = "MIT" }
|
||||
};
|
||||
var toComponents = new List<ComponentRef>
|
||||
{
|
||||
new() { Purl = "pkg:npm/lodash@4.17.21", Name = "lodash", Version = "4.17.21", License = "Apache-2.0" }
|
||||
};
|
||||
|
||||
var diff = _engine.ComputeDiff(SbomId.New(), fromComponents, SbomId.New(), toComponents);
|
||||
|
||||
diff.Summary.OtherModified.Should().Be(1);
|
||||
var delta = diff.Deltas.Should().ContainSingle().Subject;
|
||||
delta.Type.Should().Be(ComponentDeltaType.LicenseChanged);
|
||||
delta.ChangedFields.Should().Contain("License");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeDiff_IsDeterministic()
|
||||
{
|
||||
var fromId = SbomId.New();
|
||||
var toId = SbomId.New();
|
||||
|
||||
var fromComponents = new List<ComponentRef>
|
||||
{
|
||||
new() { Purl = "pkg:npm/a@1.0.0", Name = "a", Version = "1.0.0" },
|
||||
new() { Purl = "pkg:npm/b@1.0.0", Name = "b", Version = "1.0.0" }
|
||||
};
|
||||
var toComponents = new List<ComponentRef>
|
||||
{
|
||||
new() { Purl = "pkg:npm/b@1.0.0", Name = "b", Version = "1.1.0" },
|
||||
new() { Purl = "pkg:npm/c@1.0.0", Name = "c", Version = "1.0.0" }
|
||||
};
|
||||
|
||||
var diff1 = _engine.ComputeDiff(fromId, fromComponents, toId, toComponents);
|
||||
var diff2 = _engine.ComputeDiff(fromId, fromComponents, toId, toComponents);
|
||||
|
||||
// Deltas should be in same order
|
||||
diff1.Deltas.Length.Should().Be(diff2.Deltas.Length);
|
||||
for (int i = 0; i < diff1.Deltas.Length; i++)
|
||||
{
|
||||
diff1.Deltas[i].Type.Should().Be(diff2.Deltas[i].Type);
|
||||
diff1.Deltas[i].Before?.Purl.Should().Be(diff2.Deltas[i].Before?.Purl);
|
||||
diff1.Deltas[i].After?.Purl.Should().Be(diff2.Deltas[i].After?.Purl);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreatePointer_SummarizesCorrectly()
|
||||
{
|
||||
var fromComponents = new List<ComponentRef>
|
||||
{
|
||||
new() { Purl = "pkg:npm/a@1.0.0", Name = "a", Version = "1.0.0" }
|
||||
};
|
||||
var toComponents = new List<ComponentRef>
|
||||
{
|
||||
new() { Purl = "pkg:npm/a@1.0.0", Name = "a", Version = "1.1.0" },
|
||||
new() { Purl = "pkg:npm/b@1.0.0", Name = "b", Version = "1.0.0" }
|
||||
};
|
||||
|
||||
var diff = _engine.ComputeDiff(SbomId.New(), fromComponents, SbomId.New(), toComponents);
|
||||
var pointer = _engine.CreatePointer(diff);
|
||||
|
||||
pointer.ComponentsAdded.Should().Be(1);
|
||||
pointer.ComponentsModified.Should().Be(1);
|
||||
pointer.ComponentsRemoved.Should().Be(0);
|
||||
pointer.DiffHash.Should().NotBeNullOrEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreatePointer_HashIsDeterministic()
|
||||
{
|
||||
var fromId = SbomId.New();
|
||||
var toId = SbomId.New();
|
||||
var fromComponents = new List<ComponentRef>
|
||||
{
|
||||
new() { Purl = "pkg:npm/lodash@4.17.20", Name = "lodash", Version = "4.17.20" }
|
||||
};
|
||||
var toComponents = new List<ComponentRef>
|
||||
{
|
||||
new() { Purl = "pkg:npm/lodash@4.17.20", Name = "lodash", Version = "4.17.21" }
|
||||
};
|
||||
|
||||
var diff1 = _engine.ComputeDiff(fromId, fromComponents, toId, toComponents);
|
||||
var diff2 = _engine.ComputeDiff(fromId, fromComponents, toId, toComponents);
|
||||
|
||||
var pointer1 = _engine.CreatePointer(diff1);
|
||||
var pointer2 = _engine.CreatePointer(diff2);
|
||||
|
||||
pointer1.DiffHash.Should().Be(pointer2.DiffHash);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests for DiffSummary calculations.
|
||||
/// </summary>
|
||||
public class DiffSummaryTests
|
||||
{
|
||||
[Fact]
|
||||
public void TotalComponents_CalculatesCorrectly()
|
||||
{
|
||||
var summary = new DiffSummary
|
||||
{
|
||||
Added = 5,
|
||||
Removed = 3,
|
||||
VersionChanged = 2,
|
||||
OtherModified = 1,
|
||||
Unchanged = 10
|
||||
};
|
||||
|
||||
// TotalComponents = Added + VersionChanged + OtherModified + Unchanged
|
||||
summary.TotalComponents.Should().Be(18);
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,8 @@
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
@@ -13,6 +15,12 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentAssertions" Version="6.12.0" />
|
||||
<PackageReference Include="JsonSchema.Net" Version="7.3.4" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Sprint: SPRINT_6000_0004_0001 - Scanner Integration
|
||||
// Task: T6 - Integration Tests
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
using StellaOps.BinaryIndex.Core.Services;
|
||||
using BinaryIdentity = StellaOps.BinaryIndex.Core.Models.BinaryIdentity;
|
||||
using BinaryFormat = StellaOps.BinaryIndex.Core.Models.BinaryFormat;
|
||||
using StellaOps.Scanner.Worker.Processing;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.Worker.Tests;
|
||||
|
||||
public sealed class BinaryVulnerabilityAnalyzerTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task AnalyzeLayerAsync_WithNoBinaryPaths_ReturnsEmptyResult()
|
||||
{
|
||||
// Arrange
|
||||
var mockVulnService = new Mock<IBinaryVulnerabilityService>();
|
||||
var mockExtractor = new Mock<IBinaryFeatureExtractor>();
|
||||
var mockLogger = new Mock<ILogger<BinaryVulnerabilityAnalyzer>>();
|
||||
|
||||
var analyzer = new BinaryVulnerabilityAnalyzer(
|
||||
mockVulnService.Object,
|
||||
mockExtractor.Object,
|
||||
mockLogger.Object);
|
||||
|
||||
var context = new BinaryLayerContext
|
||||
{
|
||||
ScanId = Guid.NewGuid(),
|
||||
LayerDigest = "sha256:test",
|
||||
BinaryPaths = Array.Empty<string>(),
|
||||
OpenFile = _ => null
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await analyzer.AnalyzeLayerAsync(context);
|
||||
|
||||
// Assert
|
||||
Assert.Empty(result.Findings);
|
||||
Assert.Equal(0, result.ExtractedBinaryCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnalyzeLayerAsync_WithBinaryPaths_ExtractsIdentitiesAndLooksUpVulnerabilities()
|
||||
{
|
||||
// Arrange
|
||||
var scanId = Guid.NewGuid();
|
||||
var layerDigest = "sha256:abc123";
|
||||
var buildId = "0123456789abcdef0123456789abcdef01234567";
|
||||
|
||||
var mockIdentity = new BinaryIdentity
|
||||
{
|
||||
BinaryKey = $"{buildId}:sha256test",
|
||||
BuildId = buildId,
|
||||
BuildIdType = "gnu-build-id",
|
||||
FileSha256 = "sha256test",
|
||||
Format = BinaryFormat.Elf,
|
||||
Architecture = "x86_64"
|
||||
};
|
||||
|
||||
var mockVulnMatch = new BinaryVulnMatch
|
||||
{
|
||||
CveId = "CVE-2024-1234",
|
||||
VulnerablePurl = "pkg:deb/debian/openssl@1.1.1k-1",
|
||||
Method = MatchMethod.BuildIdCatalog,
|
||||
Confidence = 0.95m,
|
||||
Evidence = new MatchEvidence { BuildId = buildId }
|
||||
};
|
||||
|
||||
var mockVulnService = new Mock<IBinaryVulnerabilityService>();
|
||||
mockVulnService
|
||||
.Setup(s => s.LookupBatchAsync(
|
||||
It.IsAny<IEnumerable<BinaryIdentity>>(),
|
||||
It.IsAny<LookupOptions?>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(ImmutableDictionary<string, ImmutableArray<BinaryVulnMatch>>.Empty
|
||||
.Add(mockIdentity.BinaryKey, [mockVulnMatch]));
|
||||
|
||||
var mockExtractor = new Mock<IBinaryFeatureExtractor>();
|
||||
mockExtractor
|
||||
.Setup(e => e.ExtractIdentityAsync(It.IsAny<Stream>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(mockIdentity);
|
||||
|
||||
var mockLogger = new Mock<ILogger<BinaryVulnerabilityAnalyzer>>();
|
||||
|
||||
var analyzer = new BinaryVulnerabilityAnalyzer(
|
||||
mockVulnService.Object,
|
||||
mockExtractor.Object,
|
||||
mockLogger.Object);
|
||||
|
||||
// Create a mock stream for the binary file
|
||||
using var testStream = new MemoryStream([0x7F, 0x45, 0x4C, 0x46]); // ELF magic
|
||||
|
||||
var context = new BinaryLayerContext
|
||||
{
|
||||
ScanId = scanId,
|
||||
LayerDigest = layerDigest,
|
||||
BinaryPaths = ["/usr/lib/libtest.so"],
|
||||
DetectedDistro = "debian",
|
||||
DetectedRelease = "12",
|
||||
OpenFile = path => path == "/usr/lib/libtest.so" ? new MemoryStream([0x7F, 0x45, 0x4C, 0x46]) : null
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await analyzer.AnalyzeLayerAsync(context);
|
||||
|
||||
// Assert
|
||||
Assert.Single(result.Findings);
|
||||
Assert.Equal("CVE-2024-1234", result.Findings[0].CveId);
|
||||
Assert.Equal("pkg:deb/debian/openssl@1.1.1k-1", result.Findings[0].VulnerablePurl);
|
||||
Assert.Equal("BuildIdCatalog", result.Findings[0].MatchMethod);
|
||||
Assert.Equal(0.95m, result.Findings[0].Confidence);
|
||||
Assert.Equal(1, result.ExtractedBinaryCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnalyzeLayerAsync_WithFailedExtraction_ContinuesWithOtherFiles()
|
||||
{
|
||||
// Arrange
|
||||
var mockVulnService = new Mock<IBinaryVulnerabilityService>();
|
||||
mockVulnService
|
||||
.Setup(s => s.LookupBatchAsync(
|
||||
It.IsAny<IEnumerable<BinaryIdentity>>(),
|
||||
It.IsAny<LookupOptions?>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(ImmutableDictionary<string, ImmutableArray<BinaryVulnMatch>>.Empty);
|
||||
|
||||
var goodIdentity = new BinaryIdentity
|
||||
{
|
||||
BinaryKey = "good-binary",
|
||||
FileSha256 = "sha256good",
|
||||
Format = BinaryFormat.Elf,
|
||||
Architecture = "x86_64"
|
||||
};
|
||||
|
||||
var mockExtractor = new Mock<IBinaryFeatureExtractor>();
|
||||
// First call throws, second call succeeds
|
||||
var callCount = 0;
|
||||
mockExtractor
|
||||
.Setup(e => e.ExtractIdentityAsync(It.IsAny<Stream>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(() =>
|
||||
{
|
||||
callCount++;
|
||||
if (callCount == 1)
|
||||
throw new InvalidDataException("Not a valid binary");
|
||||
return goodIdentity;
|
||||
});
|
||||
|
||||
var mockLogger = new Mock<ILogger<BinaryVulnerabilityAnalyzer>>();
|
||||
|
||||
var analyzer = new BinaryVulnerabilityAnalyzer(
|
||||
mockVulnService.Object,
|
||||
mockExtractor.Object,
|
||||
mockLogger.Object);
|
||||
|
||||
var context = new BinaryLayerContext
|
||||
{
|
||||
ScanId = Guid.NewGuid(),
|
||||
LayerDigest = "sha256:test",
|
||||
BinaryPaths = ["/usr/lib/bad.so", "/usr/lib/good.so"],
|
||||
OpenFile = _ => new MemoryStream([0x7F, 0x45, 0x4C, 0x46])
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await analyzer.AnalyzeLayerAsync(context);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(1, result.ExtractedBinaryCount);
|
||||
Assert.Single(result.ExtractionErrors);
|
||||
Assert.Contains("Not a valid binary", result.ExtractionErrors[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnalyzeLayerAsync_WithNoOpenableFiles_ReturnsEmptyResult()
|
||||
{
|
||||
// Arrange
|
||||
var mockVulnService = new Mock<IBinaryVulnerabilityService>();
|
||||
var mockExtractor = new Mock<IBinaryFeatureExtractor>();
|
||||
var mockLogger = new Mock<ILogger<BinaryVulnerabilityAnalyzer>>();
|
||||
|
||||
var analyzer = new BinaryVulnerabilityAnalyzer(
|
||||
mockVulnService.Object,
|
||||
mockExtractor.Object,
|
||||
mockLogger.Object);
|
||||
|
||||
var context = new BinaryLayerContext
|
||||
{
|
||||
ScanId = Guid.NewGuid(),
|
||||
LayerDigest = "sha256:test",
|
||||
BinaryPaths = ["/usr/lib/missing.so"],
|
||||
OpenFile = _ => null // All files fail to open
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await analyzer.AnalyzeLayerAsync(context);
|
||||
|
||||
// Assert
|
||||
Assert.Empty(result.Findings);
|
||||
Assert.Equal(0, result.ExtractedBinaryCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BinaryVulnerabilityFinding_GetSummary_FormatsCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var finding = new BinaryVulnerabilityFinding
|
||||
{
|
||||
ScanId = Guid.NewGuid(),
|
||||
LayerDigest = "sha256:test",
|
||||
BinaryKey = "testkey",
|
||||
CveId = "CVE-2024-5678",
|
||||
VulnerablePurl = "pkg:npm/lodash@4.17.20",
|
||||
MatchMethod = "FingerprintMatch",
|
||||
Confidence = 0.85m,
|
||||
Evidence = null
|
||||
};
|
||||
|
||||
// Act
|
||||
var summary = finding.GetSummary();
|
||||
|
||||
// Assert
|
||||
Assert.Contains("CVE-2024-5678", summary);
|
||||
Assert.Contains("pkg:npm/lodash@4.17.20", summary);
|
||||
Assert.Contains("FingerprintMatch", summary);
|
||||
Assert.Contains("85%", summary);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BinaryAnalysisResult_Empty_ReturnsValidEmptyResult()
|
||||
{
|
||||
// Arrange
|
||||
var scanId = Guid.NewGuid();
|
||||
var layerDigest = "sha256:empty";
|
||||
|
||||
// Act
|
||||
var result = BinaryAnalysisResult.Empty(scanId, layerDigest);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(scanId, result.ScanId);
|
||||
Assert.Equal(layerDigest, result.LayerDigest);
|
||||
Assert.Equal("binary-vulnerability", result.AnalyzerId);
|
||||
Assert.Empty(result.Findings);
|
||||
Assert.Equal(0, result.ExtractedBinaryCount);
|
||||
Assert.Empty(result.ExtractionErrors);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user