save checkpoint
This commit is contained in:
@@ -5,6 +5,7 @@ Source of truth: `docs/implplan/SPRINT_20260112_003_BE_csproj_audit_pending_appl
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| QA-SCANNER-VERIFY-008 | DONE | `SPRINT_20260212_002_Scanner_unchecked_feature_verification_batch1.md`: verified entry-trace response contract carries `graph.binaryIntelligence` for scanner binary intelligence feature (run-002, 2026-02-12). |
|
||||
| TODO-WEB-001 | TODO | Load tenant-specific policy configuration in `src/Scanner/StellaOps.Scanner.WebService/Services/VexGateQueryService.cs`. |
|
||||
| TODO-WEB-002 | TODO | Implement CAS retrieval for slices in `src/Scanner/StellaOps.Scanner.WebService/Services/SliceQueryService.cs`. |
|
||||
| TODO-WEB-003 | TODO | Add VEX expiry once integrated in `src/Scanner/StellaOps.Scanner.WebService/Services/EvidenceCompositionService.cs`. |
|
||||
|
||||
@@ -7,6 +7,7 @@ using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.BinaryIndex.Core.Services;
|
||||
using StellaOps.BinaryIndex.Persistence.Services;
|
||||
using StellaOps.Scanner.PatchVerification.DependencyInjection;
|
||||
using StellaOps.Scanner.Worker.Processing;
|
||||
|
||||
namespace StellaOps.Scanner.Worker.Extensions;
|
||||
@@ -28,6 +29,7 @@ public static class BinaryIndexServiceExtensions
|
||||
.Get<BinaryIndexOptions>() ?? new BinaryIndexOptions();
|
||||
|
||||
services.AddSingleton(options);
|
||||
services.AddPatchVerification();
|
||||
|
||||
if (!options.Enabled)
|
||||
{
|
||||
|
||||
@@ -6,11 +6,18 @@
|
||||
|
||||
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.BinaryIndex.Core.Models;
|
||||
using StellaOps.BinaryIndex.Core.Services;
|
||||
using StellaOps.Scanner.Analyzers.Native.Index;
|
||||
using StellaOps.Scanner.Core.Contracts;
|
||||
using StellaOps.Scanner.PatchVerification;
|
||||
using StellaOps.Scanner.PatchVerification.Models;
|
||||
using StellaOps.Scanner.Worker.Extensions;
|
||||
using System.Collections.Immutable;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace StellaOps.Scanner.Worker.Processing;
|
||||
|
||||
@@ -21,15 +28,24 @@ namespace StellaOps.Scanner.Worker.Processing;
|
||||
public sealed class BinaryLookupStageExecutor : IScanStageExecutor
|
||||
{
|
||||
private readonly BinaryVulnerabilityAnalyzer _analyzer;
|
||||
private readonly BinaryFindingMapper _findingMapper;
|
||||
private readonly IBuildIdIndex _buildIdIndex;
|
||||
private readonly IServiceScopeFactory _scopeFactory;
|
||||
private readonly BinaryIndexOptions _options;
|
||||
private readonly ILogger<BinaryLookupStageExecutor> _logger;
|
||||
|
||||
public BinaryLookupStageExecutor(
|
||||
BinaryVulnerabilityAnalyzer analyzer,
|
||||
BinaryFindingMapper findingMapper,
|
||||
IBuildIdIndex buildIdIndex,
|
||||
IServiceScopeFactory scopeFactory,
|
||||
BinaryIndexOptions options,
|
||||
ILogger<BinaryLookupStageExecutor> logger)
|
||||
{
|
||||
_analyzer = analyzer ?? throw new ArgumentNullException(nameof(analyzer));
|
||||
_findingMapper = findingMapper ?? throw new ArgumentNullException(nameof(findingMapper));
|
||||
_buildIdIndex = buildIdIndex ?? throw new ArgumentNullException(nameof(buildIdIndex));
|
||||
_scopeFactory = scopeFactory ?? throw new ArgumentNullException(nameof(scopeFactory));
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
@@ -76,8 +92,19 @@ public sealed class BinaryLookupStageExecutor : IScanStageExecutor
|
||||
}
|
||||
}
|
||||
|
||||
// Store findings in analysis context for downstream stages
|
||||
context.Analysis.SetBinaryFindings(allFindings.ToImmutableArray());
|
||||
var immutableFindings = allFindings.ToImmutableArray();
|
||||
context.Analysis.SetBinaryFindings(immutableFindings);
|
||||
|
||||
if (!immutableFindings.IsDefaultOrEmpty)
|
||||
{
|
||||
await StoreMappedFindingsAsync(context, immutableFindings, cancellationToken).ConfigureAwait(false);
|
||||
await StoreBuildIdMappingsAsync(context, immutableFindings, cancellationToken).ConfigureAwait(false);
|
||||
await StorePatchVerificationResultAsync(
|
||||
context,
|
||||
immutableFindings,
|
||||
layerContexts,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Binary vulnerability lookup complete for scan {ScanId}: {Count} findings",
|
||||
@@ -121,6 +148,159 @@ public sealed class BinaryLookupStageExecutor : IScanStageExecutor
|
||||
|
||||
return contexts;
|
||||
}
|
||||
|
||||
private async Task StoreMappedFindingsAsync(
|
||||
ScanJobContext context,
|
||||
ImmutableArray<BinaryVulnerabilityFinding> findings,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var mappedFindings = await _findingMapper.MapToFindingsAsync(
|
||||
findings,
|
||||
context.Analysis.GetDetectedDistro(),
|
||||
context.Analysis.GetDetectedRelease(),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
context.Analysis.Set(ScanAnalysisKeys.BinaryVulnerabilityFindings, mappedFindings.Cast<object>().ToArray());
|
||||
}
|
||||
|
||||
private async Task StoreBuildIdMappingsAsync(
|
||||
ScanJobContext context,
|
||||
ImmutableArray<BinaryVulnerabilityFinding> findings,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var buildIds = findings
|
||||
.Select(finding => finding.Evidence?.BuildId)
|
||||
.Where(buildId => !string.IsNullOrWhiteSpace(buildId))
|
||||
.Select(buildId => buildId!.Trim())
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
|
||||
if (buildIds.Length == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!_buildIdIndex.IsLoaded)
|
||||
{
|
||||
await _buildIdIndex.LoadAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
var lookupResults = await _buildIdIndex.BatchLookupAsync(buildIds, cancellationToken).ConfigureAwait(false);
|
||||
if (lookupResults.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var mapping = lookupResults
|
||||
.GroupBy(result => result.BuildId, StringComparer.OrdinalIgnoreCase)
|
||||
.ToDictionary(group => group.Key, group => group.First(), StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
context.Analysis.Set(
|
||||
ScanAnalysisKeys.BinaryBuildIdMappings,
|
||||
new ReadOnlyDictionary<string, BuildIdLookupResult>(mapping));
|
||||
}
|
||||
|
||||
private async Task StorePatchVerificationResultAsync(
|
||||
ScanJobContext context,
|
||||
ImmutableArray<BinaryVulnerabilityFinding> findings,
|
||||
IReadOnlyList<BinaryLayerContext> layerContexts,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
using var scope = _scopeFactory.CreateScope();
|
||||
var patchVerification = scope.ServiceProvider.GetService<IPatchVerificationOrchestrator>();
|
||||
if (patchVerification is null)
|
||||
{
|
||||
_logger.LogDebug("Patch verification orchestrator not registered; skipping binary patch verification.");
|
||||
return;
|
||||
}
|
||||
|
||||
var cveIds = findings
|
||||
.Select(finding => finding.CveId)
|
||||
.Where(cveId => !string.IsNullOrWhiteSpace(cveId))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
|
||||
if (cveIds.Length == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var artifactPurl = findings
|
||||
.Select(finding => finding.VulnerablePurl)
|
||||
.FirstOrDefault(purl => !string.IsNullOrWhiteSpace(purl))
|
||||
?? "pkg:generic/unknown-binary";
|
||||
|
||||
var binaryPaths = BuildPatchBinaryPathMap(context, layerContexts);
|
||||
var patchContext = new PatchVerificationContext
|
||||
{
|
||||
ScanId = context.ScanId,
|
||||
TenantId = ResolveTenant(context),
|
||||
ImageDigest = ResolveImageDigest(context),
|
||||
ArtifactPurl = artifactPurl,
|
||||
CveIds = cveIds,
|
||||
BinaryPaths = binaryPaths,
|
||||
Options = new PatchVerificationOptions
|
||||
{
|
||||
ContinueOnError = true,
|
||||
EmitNoPatchDataEvidence = true
|
||||
}
|
||||
};
|
||||
|
||||
var patchResult = await patchVerification.VerifyAsync(patchContext, cancellationToken).ConfigureAwait(false);
|
||||
context.Analysis.Set(ScanAnalysisKeys.BinaryPatchVerificationResult, patchResult);
|
||||
}
|
||||
|
||||
private static IReadOnlyDictionary<string, string> BuildPatchBinaryPathMap(
|
||||
ScanJobContext context,
|
||||
IReadOnlyList<BinaryLayerContext> layerContexts)
|
||||
{
|
||||
context.Lease.Metadata.TryGetValue(ScanMetadataKeys.RootFilesystemPath, out var rootfsPath);
|
||||
|
||||
var paths = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var layerContext in layerContexts)
|
||||
{
|
||||
foreach (var binaryPath in layerContext.BinaryPaths)
|
||||
{
|
||||
if (paths.ContainsKey(binaryPath))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var resolved = binaryPath;
|
||||
if (!string.IsNullOrWhiteSpace(rootfsPath))
|
||||
{
|
||||
resolved = Path.Combine(rootfsPath, binaryPath.TrimStart('/', '\\'));
|
||||
}
|
||||
|
||||
paths[binaryPath] = resolved;
|
||||
}
|
||||
}
|
||||
|
||||
return new ReadOnlyDictionary<string, string>(paths);
|
||||
}
|
||||
|
||||
private static string ResolveTenant(ScanJobContext context)
|
||||
{
|
||||
if (context.Lease.Metadata.TryGetValue("scanner.tenant", out var tenant) &&
|
||||
!string.IsNullOrWhiteSpace(tenant))
|
||||
{
|
||||
return tenant.Trim();
|
||||
}
|
||||
|
||||
return "default";
|
||||
}
|
||||
|
||||
private static string ResolveImageDigest(ScanJobContext context)
|
||||
{
|
||||
if (context.Lease.Metadata.TryGetValue("scanner.image.digest", out var digest) &&
|
||||
!string.IsNullOrWhiteSpace(digest))
|
||||
{
|
||||
return digest.Trim();
|
||||
}
|
||||
|
||||
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(context.ScanId));
|
||||
return $"sha256:{Convert.ToHexString(bytes).ToLowerInvariant()}";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -5,6 +5,7 @@ using Microsoft.Extensions.Options;
|
||||
using StellaOps.Cryptography;
|
||||
using StellaOps.Scanner.Core.Contracts;
|
||||
using StellaOps.Scanner.EntryTrace;
|
||||
using StellaOps.Scanner.EntryTrace.Binary;
|
||||
using StellaOps.Scanner.EntryTrace.FileSystem;
|
||||
using StellaOps.Scanner.EntryTrace.Runtime;
|
||||
using StellaOps.Scanner.Surface.Env;
|
||||
@@ -20,8 +21,11 @@ using System.Linq;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using EntryTraceBinaryArchitecture = StellaOps.Scanner.EntryTrace.Binary.BinaryArchitecture;
|
||||
using EntryTraceBinaryFormat = StellaOps.Scanner.EntryTrace.Binary.BinaryFormat;
|
||||
|
||||
namespace StellaOps.Scanner.Worker.Processing;
|
||||
|
||||
@@ -38,6 +42,9 @@ public sealed class EntryTraceExecutionService : IEntryTraceExecutionService
|
||||
};
|
||||
|
||||
private static readonly UTF8Encoding StrictUtf8 = new(encoderShouldEmitUTF8Identifier: false, throwOnInvalidBytes: true);
|
||||
private static readonly Regex CveRegex = new(
|
||||
"CVE-\\d{4}-\\d{4,7}",
|
||||
RegexOptions.Compiled | RegexOptions.CultureInvariant | RegexOptions.IgnoreCase);
|
||||
|
||||
private sealed record FileSystemHandle(
|
||||
IRootFileSystem FileSystem,
|
||||
@@ -196,6 +203,16 @@ public sealed class EntryTraceExecutionService : IEntryTraceExecutionService
|
||||
var runtimeGraph = BuildRuntimeGraph(metadata, context.JobId);
|
||||
graph = _runtimeReconciler.Reconcile(graph, runtimeGraph);
|
||||
|
||||
var binaryIntelligence = await BuildBinaryIntelligenceAsync(
|
||||
graph,
|
||||
fileSystemHandle.RootPath,
|
||||
context.TimeProvider,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
if (binaryIntelligence is not null)
|
||||
{
|
||||
graph = graph with { BinaryIntelligence = binaryIntelligence };
|
||||
}
|
||||
|
||||
var generatedAt = context.TimeProvider.GetUtcNow();
|
||||
var ndjson = EntryTraceNdjsonWriter.Serialize(
|
||||
graph,
|
||||
@@ -727,6 +744,387 @@ public sealed class EntryTraceExecutionService : IEntryTraceExecutionService
|
||||
return builder.ToImmutable();
|
||||
}
|
||||
|
||||
private async Task<EntryTraceBinaryIntelligence?> BuildBinaryIntelligenceAsync(
|
||||
EntryTraceGraph graph,
|
||||
string rootDirectory,
|
||||
TimeProvider timeProvider,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var terminalPaths = graph.Terminals
|
||||
.Where(terminal => terminal.Type == EntryTraceTerminalType.Native && !string.IsNullOrWhiteSpace(terminal.Path))
|
||||
.Select(terminal => terminal.Path.Trim())
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
|
||||
if (terminalPaths.IsDefaultOrEmpty)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var targets = ImmutableArray.CreateBuilder<EntryTraceBinaryTarget>();
|
||||
|
||||
foreach (var terminalPath in terminalPaths)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var fullPath = ResolveTerminalPath(rootDirectory, terminalPath);
|
||||
if (fullPath is null || !File.Exists(fullPath))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
byte[] payload;
|
||||
try
|
||||
{
|
||||
payload = await File.ReadAllBytesAsync(fullPath, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
_logger.LogDebug(ex, "Unable to read terminal binary '{TerminalPath}' for entry trace binary intelligence.", terminalPath);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (payload.Length == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var functions = ExtractFunctions(payload);
|
||||
if (functions.IsDefaultOrEmpty)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var (architecture, format) = DetectBinaryShape(payload);
|
||||
var binaryHash = "sha256:" + _hash.ComputeHashHex(payload, HashAlgorithms.Sha256);
|
||||
var packagePurl = BuildTerminalPurl(terminalPath);
|
||||
var vulnerabilityIds = DetectVulnerabilityIds(functions);
|
||||
|
||||
var analyzer = new BinaryIntelligenceAnalyzer(timeProvider: timeProvider);
|
||||
await analyzer.IndexPackageAsync(
|
||||
packagePurl,
|
||||
"unknown",
|
||||
functions,
|
||||
vulnerabilityIds,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var analysis = await analyzer.AnalyzeAsync(
|
||||
terminalPath,
|
||||
binaryHash,
|
||||
functions,
|
||||
architecture,
|
||||
format,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var matches = analysis.VulnerableMatches
|
||||
.Select(match => new EntryTraceBinaryVulnerability(
|
||||
match.VulnerabilityId,
|
||||
match.FunctionName,
|
||||
match.SourcePackage,
|
||||
match.VulnerableFunctionName,
|
||||
match.MatchConfidence,
|
||||
match.Severity.ToString()))
|
||||
.ToImmutableArray();
|
||||
|
||||
targets.Add(new EntryTraceBinaryTarget(
|
||||
terminalPath,
|
||||
binaryHash,
|
||||
architecture.ToString(),
|
||||
format.ToString(),
|
||||
analysis.Functions.Length,
|
||||
analysis.RecoveredSymbolCount,
|
||||
analysis.SourceCorrelations.Length,
|
||||
analysis.VulnerableMatches.Length,
|
||||
matches));
|
||||
}
|
||||
|
||||
if (targets.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new EntryTraceBinaryIntelligence(
|
||||
targets.ToImmutable(),
|
||||
terminalPaths.Length,
|
||||
targets.Count,
|
||||
targets.Sum(target => target.VulnerableMatchCount),
|
||||
timeProvider.GetUtcNow());
|
||||
}
|
||||
|
||||
private static string BuildTerminalPurl(string terminalPath)
|
||||
{
|
||||
var fileName = IOPath.GetFileName(terminalPath);
|
||||
if (string.IsNullOrWhiteSpace(fileName))
|
||||
{
|
||||
fileName = "unknown-binary";
|
||||
}
|
||||
|
||||
var normalized = fileName.Trim().ToLowerInvariant();
|
||||
return $"pkg:generic/{normalized}";
|
||||
}
|
||||
|
||||
private static string? ResolveTerminalPath(string rootDirectory, string terminalPath)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(rootDirectory) || string.IsNullOrWhiteSpace(terminalPath))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
string candidate;
|
||||
try
|
||||
{
|
||||
if (IOPath.IsPathRooted(terminalPath))
|
||||
{
|
||||
candidate = IOPath.GetFullPath(IOPath.Combine(rootDirectory, terminalPath.TrimStart('\\', '/')));
|
||||
}
|
||||
else
|
||||
{
|
||||
candidate = IOPath.GetFullPath(IOPath.Combine(rootDirectory, terminalPath));
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var rootFullPath = IOPath.GetFullPath(rootDirectory);
|
||||
if (!candidate.StartsWith(rootFullPath, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return candidate;
|
||||
}
|
||||
|
||||
private static (EntryTraceBinaryArchitecture Architecture, EntryTraceBinaryFormat Format) DetectBinaryShape(ReadOnlySpan<byte> payload)
|
||||
{
|
||||
if (payload.Length >= 4 && payload[0] == 0x7F && payload[1] == (byte)'E' && payload[2] == (byte)'L' && payload[3] == (byte)'F')
|
||||
{
|
||||
var architecture = EntryTraceBinaryArchitecture.Unknown;
|
||||
if (payload.Length >= 20)
|
||||
{
|
||||
var machine = BitConverter.ToUInt16(payload.Slice(18, 2));
|
||||
architecture = machine switch
|
||||
{
|
||||
0x03 => EntryTraceBinaryArchitecture.X86,
|
||||
0x3E => EntryTraceBinaryArchitecture.X64,
|
||||
0x28 => EntryTraceBinaryArchitecture.ARM,
|
||||
0xB7 => EntryTraceBinaryArchitecture.ARM64,
|
||||
0xF3 => EntryTraceBinaryArchitecture.RISCV64,
|
||||
_ => EntryTraceBinaryArchitecture.Unknown
|
||||
};
|
||||
}
|
||||
|
||||
return (architecture, EntryTraceBinaryFormat.ELF);
|
||||
}
|
||||
|
||||
if (payload.Length >= 4 && payload[0] == 0x4D && payload[1] == 0x5A)
|
||||
{
|
||||
return (EntryTraceBinaryArchitecture.Unknown, EntryTraceBinaryFormat.PE);
|
||||
}
|
||||
|
||||
if (payload.Length >= 4)
|
||||
{
|
||||
var magic = BitConverter.ToUInt32(payload.Slice(0, 4));
|
||||
if (magic is 0xFEEDFACE or 0xCEFAEDFE or 0xFEEDFACF or 0xCFFAEDFE)
|
||||
{
|
||||
return (EntryTraceBinaryArchitecture.Unknown, EntryTraceBinaryFormat.MachO);
|
||||
}
|
||||
}
|
||||
|
||||
if (payload.Length >= 4 && payload[0] == 0x00 && payload[1] == (byte)'a' && payload[2] == (byte)'s' && payload[3] == (byte)'m')
|
||||
{
|
||||
return (EntryTraceBinaryArchitecture.WASM, EntryTraceBinaryFormat.WASM);
|
||||
}
|
||||
|
||||
return (EntryTraceBinaryArchitecture.Unknown, EntryTraceBinaryFormat.Raw);
|
||||
}
|
||||
|
||||
private static ImmutableArray<FunctionSignature> ExtractFunctions(byte[] payload)
|
||||
{
|
||||
const int minFunctionSize = 16;
|
||||
const int windowSize = 256;
|
||||
const int maxFunctions = 24;
|
||||
|
||||
var functions = ImmutableArray.CreateBuilder<FunctionSignature>();
|
||||
for (var offset = 0; offset < payload.Length && functions.Count < maxFunctions; offset += windowSize)
|
||||
{
|
||||
var length = Math.Min(windowSize, payload.Length - offset);
|
||||
if (length < minFunctionSize)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var window = payload.AsSpan(offset, length);
|
||||
var stringRefs = ExtractAsciiStrings(window);
|
||||
var importRefs = stringRefs
|
||||
.Where(IsLikelyImportReference)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.Take(12)
|
||||
.ToImmutableArray();
|
||||
var basicBlocks = BuildBasicBlocks(window, offset);
|
||||
|
||||
functions.Add(new FunctionSignature(
|
||||
Name: null,
|
||||
Offset: offset,
|
||||
Size: length,
|
||||
CallingConvention: CallingConvention.Unknown,
|
||||
ParameterCount: null,
|
||||
ReturnType: null,
|
||||
Fingerprint: CodeFingerprint.Empty,
|
||||
BasicBlocks: basicBlocks,
|
||||
StringReferences: stringRefs,
|
||||
ImportReferences: importRefs));
|
||||
}
|
||||
|
||||
return functions.ToImmutable();
|
||||
}
|
||||
|
||||
private static ImmutableArray<BasicBlock> BuildBasicBlocks(ReadOnlySpan<byte> window, int baseOffset)
|
||||
{
|
||||
const int blockSize = 16;
|
||||
var blocks = ImmutableArray.CreateBuilder<BasicBlock>();
|
||||
var blockCount = (int)Math.Ceiling(window.Length / (double)blockSize);
|
||||
|
||||
for (var i = 0; i < blockCount; i++)
|
||||
{
|
||||
var offset = i * blockSize;
|
||||
var size = Math.Min(blockSize, window.Length - offset);
|
||||
if (size <= 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var normalizedBytes = window.Slice(offset, size).ToArray().ToImmutableArray();
|
||||
var successors = i < blockCount - 1
|
||||
? ImmutableArray.Create(i + 1)
|
||||
: ImmutableArray<int>.Empty;
|
||||
var predecessors = i > 0
|
||||
? ImmutableArray.Create(i - 1)
|
||||
: ImmutableArray<int>.Empty;
|
||||
|
||||
blocks.Add(new BasicBlock(
|
||||
Id: i,
|
||||
Offset: baseOffset + offset,
|
||||
Size: size,
|
||||
InstructionCount: Math.Max(1, size / 4),
|
||||
Successors: successors,
|
||||
Predecessors: predecessors,
|
||||
NormalizedBytes: normalizedBytes));
|
||||
}
|
||||
|
||||
return blocks.ToImmutable();
|
||||
}
|
||||
|
||||
private static ImmutableArray<string> ExtractAsciiStrings(ReadOnlySpan<byte> payload)
|
||||
{
|
||||
const int minLength = 4;
|
||||
const int maxStrings = 24;
|
||||
|
||||
var result = new List<string>(maxStrings);
|
||||
var current = new List<char>(64);
|
||||
|
||||
static bool IsPrintable(byte value) => value >= 32 && value <= 126;
|
||||
|
||||
void Flush()
|
||||
{
|
||||
if (current.Count >= minLength && result.Count < maxStrings)
|
||||
{
|
||||
result.Add(new string(current.ToArray()));
|
||||
}
|
||||
current.Clear();
|
||||
}
|
||||
|
||||
foreach (var value in payload)
|
||||
{
|
||||
if (IsPrintable(value))
|
||||
{
|
||||
current.Add((char)value);
|
||||
}
|
||||
else
|
||||
{
|
||||
Flush();
|
||||
if (result.Count >= maxStrings)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (result.Count < maxStrings)
|
||||
{
|
||||
Flush();
|
||||
}
|
||||
|
||||
return result
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToImmutableArray();
|
||||
}
|
||||
|
||||
private static bool IsLikelyImportReference(string candidate)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(candidate) || candidate.Length > 96)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (candidate.Contains(' ') || candidate.Contains('/') || candidate.Contains('\\'))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var likelyPrefixes = new[]
|
||||
{
|
||||
"SSL_",
|
||||
"EVP_",
|
||||
"BIO_",
|
||||
"inflate",
|
||||
"deflate",
|
||||
"mem",
|
||||
"str",
|
||||
"free",
|
||||
"malloc",
|
||||
"open",
|
||||
"read",
|
||||
"write"
|
||||
};
|
||||
|
||||
return likelyPrefixes.Any(prefix => candidate.StartsWith(prefix, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
private static ImmutableArray<string> DetectVulnerabilityIds(ImmutableArray<FunctionSignature> functions)
|
||||
{
|
||||
var ids = ImmutableHashSet.CreateBuilder<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var function in functions)
|
||||
{
|
||||
foreach (var reference in function.StringReferences)
|
||||
{
|
||||
foreach (Match match in CveRegex.Matches(reference))
|
||||
{
|
||||
ids.Add(match.Value.ToUpperInvariant());
|
||||
}
|
||||
|
||||
if (reference.Contains("HEARTBLEED", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
ids.Add("CVE-2014-0160");
|
||||
}
|
||||
|
||||
if (reference.Contains("SHELLSHOCK", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
ids.Add("CVE-2014-6271");
|
||||
}
|
||||
|
||||
if (reference.Contains("LOG4J", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
ids.Add("CVE-2021-44228");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ids.ToImmutableArray();
|
||||
}
|
||||
|
||||
private static IEnumerable<string> SplitLayerString(string raw)
|
||||
=> raw.Split(new[] { '\n', '\r', ';' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
|
||||
|
||||
@@ -42,6 +42,7 @@
|
||||
<ProjectReference Include="../__Libraries/StellaOps.Scanner.CryptoAnalysis/StellaOps.Scanner.CryptoAnalysis.csproj" />
|
||||
<ProjectReference Include="../__Libraries/StellaOps.Scanner.AiMlSecurity/StellaOps.Scanner.AiMlSecurity.csproj" />
|
||||
<ProjectReference Include="../__Libraries/StellaOps.Scanner.BuildProvenance/StellaOps.Scanner.BuildProvenance.csproj" />
|
||||
<ProjectReference Include="../__Libraries/StellaOps.Scanner.PatchVerification/StellaOps.Scanner.PatchVerification.csproj" />
|
||||
<ProjectReference Include="../StellaOps.Scanner.Analyzers.Native/StellaOps.Scanner.Analyzers.Native.csproj" />
|
||||
<ProjectReference Include="../../Concelier/__Libraries/StellaOps.Concelier.SbomIntegration/StellaOps.Concelier.SbomIntegration.csproj" />
|
||||
<ProjectReference Include="../../Concelier/__Libraries/StellaOps.Concelier.Core/StellaOps.Concelier.Core.csproj" />
|
||||
|
||||
@@ -5,6 +5,8 @@ Source of truth: `docs/implplan/SPRINT_20260113_001_001_SCANNER_elf_section_hash
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| QA-SCANNER-VERIFY-009 | DONE | `SPRINT_20260212_002_Scanner_unchecked_feature_verification_batch1.md`: added worker runtime wiring for patch verification orchestration, Build-ID lookup mapping publication, and unified binary finding mapping in `BinaryLookupStageExecutor`; validated in run-002 (2026-02-12). |
|
||||
| QA-SCANNER-VERIFY-008 | DONE | `SPRINT_20260212_002_Scanner_unchecked_feature_verification_batch1.md`: wired/verified entry-trace binary intelligence enrichment and deterministic native-terminal coverage; validated in run-002 (2026-02-12). |
|
||||
| QA-SCANNER-VERIFY-004 | DONE | SPRINT_20260212_002 run-001: validated AI/ML worker stage integration path for `ai-ml-supply-chain-security-analysis-module` during Tier 0/1/2 verification. |
|
||||
| ELF-SECTION-EVIDENCE-0001 | DONE | Populate section hashes into native metadata for SBOM emission. |
|
||||
| ELF-SECTION-DI-0001 | DONE | Register section hash extractor options and services. |
|
||||
|
||||
@@ -9,7 +9,9 @@ using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.IO;
|
||||
using System.IO.Compression;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
@@ -87,8 +89,24 @@ internal sealed class DpkgPackageAnalyzer : OsPackageAnalyzerBase
|
||||
var provides = entry.Provides.ToArray();
|
||||
|
||||
var fileEvidence = BuildFileEvidence(infoDirectory, entry, evidenceFactory, cancellationToken);
|
||||
var changelogEntries = ReadChangelogEntries(context.RootPath, fileEvidence, cancellationToken);
|
||||
var changelogBugMappings = ChangelogBugReferenceExtractor.Extract(changelogEntries.ToArray());
|
||||
|
||||
var cveHints = CveHintExtractor.Extract(entry.Description, string.Join(' ', dependencies), string.Join(' ', provides));
|
||||
if (changelogBugMappings.BugReferences.Count > 0)
|
||||
{
|
||||
vendorMetadata["changelogBugRefs"] = changelogBugMappings.ToBugReferencesMetadataValue();
|
||||
}
|
||||
|
||||
if (changelogBugMappings.BugToCves.Count > 0)
|
||||
{
|
||||
vendorMetadata["changelogBugToCves"] = changelogBugMappings.ToBugToCvesMetadataValue();
|
||||
}
|
||||
|
||||
var cveHints = CveHintExtractor.Extract(
|
||||
entry.Description,
|
||||
string.Join(' ', dependencies),
|
||||
string.Join(' ', provides),
|
||||
string.Join('\n', changelogEntries));
|
||||
|
||||
var record = new OSPackageRecord(
|
||||
AnalyzerId,
|
||||
@@ -247,6 +265,83 @@ internal sealed class DpkgPackageAnalyzer : OsPackageAnalyzerBase
|
||||
return new ReadOnlyCollection<OSPackageFileEvidence>(evidence);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> ReadChangelogEntries(
|
||||
string rootPath,
|
||||
IReadOnlyList<OSPackageFileEvidence> files,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var entries = new List<string>();
|
||||
|
||||
foreach (var file in files)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
if (!LooksLikeChangelog(file.Path))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var relativePath = file.Path.TrimStart('/', '\\').Replace('/', Path.DirectorySeparatorChar);
|
||||
if (string.IsNullOrWhiteSpace(relativePath))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var fullPath = Path.Combine(rootPath, relativePath);
|
||||
if (!File.Exists(fullPath))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var changelogText = TryReadChangelogFile(fullPath);
|
||||
if (string.IsNullOrWhiteSpace(changelogText))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
entries.Add(changelogText);
|
||||
}
|
||||
|
||||
return new ReadOnlyCollection<string>(entries);
|
||||
}
|
||||
|
||||
private static bool LooksLikeChangelog(string path)
|
||||
=> path.EndsWith("changelog", StringComparison.OrdinalIgnoreCase)
|
||||
|| path.EndsWith("changelog.gz", StringComparison.OrdinalIgnoreCase)
|
||||
|| path.Contains("/changelog.", StringComparison.OrdinalIgnoreCase)
|
||||
|| path.Contains("\\changelog.", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
private static string? TryReadChangelogFile(string fullPath)
|
||||
{
|
||||
const int maxChars = 256 * 1024;
|
||||
|
||||
try
|
||||
{
|
||||
using var fileStream = File.OpenRead(fullPath);
|
||||
using Stream contentStream = fullPath.EndsWith(".gz", StringComparison.OrdinalIgnoreCase)
|
||||
? new GZipStream(fileStream, CompressionMode.Decompress)
|
||||
: fileStream;
|
||||
using var reader = new StreamReader(contentStream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true);
|
||||
|
||||
var buffer = new char[maxChars];
|
||||
var read = reader.ReadBlock(buffer, 0, buffer.Length);
|
||||
|
||||
return read <= 0 ? null : new string(buffer, 0, read);
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
catch (InvalidDataException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerable<string> GetInfoFileCandidates(string packageName, string architecture)
|
||||
{
|
||||
yield return packageName + ":" + architecture;
|
||||
|
||||
@@ -69,6 +69,18 @@ internal sealed class RpmPackageAnalyzer : OsPackageAnalyzerBase
|
||||
vendorMetadata[$"rpm:{kvp.Key}"] = kvp.Value;
|
||||
}
|
||||
|
||||
var changelogBugMappings = ChangelogBugReferenceExtractor.Extract(
|
||||
header.ChangeLogs.ToArray());
|
||||
if (changelogBugMappings.BugReferences.Count > 0)
|
||||
{
|
||||
vendorMetadata["changelogBugRefs"] = changelogBugMappings.ToBugReferencesMetadataValue();
|
||||
}
|
||||
|
||||
if (changelogBugMappings.BugToCves.Count > 0)
|
||||
{
|
||||
vendorMetadata["changelogBugToCves"] = changelogBugMappings.ToBugToCvesMetadataValue();
|
||||
}
|
||||
|
||||
var provides = ComposeRelations(header.Provides, header.ProvideVersions);
|
||||
var requires = ComposeRelations(header.Requires, header.RequireVersions);
|
||||
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.OS.Helpers;
|
||||
|
||||
public static partial class ChangelogBugReferenceExtractor
|
||||
{
|
||||
public static ChangelogBugReferenceExtractionResult Extract(params string?[] changelogInputs)
|
||||
{
|
||||
if (changelogInputs is null || changelogInputs.Length == 0)
|
||||
{
|
||||
return ChangelogBugReferenceExtractionResult.Empty;
|
||||
}
|
||||
|
||||
var bugReferences = new SortedSet<string>(StringComparer.Ordinal);
|
||||
var bugToCves = new SortedDictionary<string, SortedSet<string>>(StringComparer.Ordinal);
|
||||
|
||||
foreach (var input in changelogInputs)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(input))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var entry in SplitEntries(input))
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(entry))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var cves = ExtractCves(entry);
|
||||
var bugsInEntry = ExtractBugs(entry);
|
||||
|
||||
foreach (var bug in bugsInEntry)
|
||||
{
|
||||
bugReferences.Add(bug);
|
||||
}
|
||||
|
||||
if (cves.Count == 0 || bugsInEntry.Count == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var bug in bugsInEntry)
|
||||
{
|
||||
if (!bugToCves.TryGetValue(bug, out var mapped))
|
||||
{
|
||||
mapped = new SortedSet<string>(StringComparer.Ordinal);
|
||||
bugToCves[bug] = mapped;
|
||||
}
|
||||
|
||||
mapped.UnionWith(cves);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (bugReferences.Count == 0)
|
||||
{
|
||||
return ChangelogBugReferenceExtractionResult.Empty;
|
||||
}
|
||||
|
||||
var immutableMap = new ReadOnlyDictionary<string, IReadOnlyList<string>>(
|
||||
bugToCves.ToDictionary(
|
||||
pair => pair.Key,
|
||||
pair => (IReadOnlyList<string>)new ReadOnlyCollection<string>(pair.Value.ToArray()),
|
||||
StringComparer.Ordinal));
|
||||
|
||||
return new ChangelogBugReferenceExtractionResult(
|
||||
new ReadOnlyCollection<string>(bugReferences.ToArray()),
|
||||
immutableMap);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> SplitEntries(string input)
|
||||
{
|
||||
var entries = new List<string>();
|
||||
foreach (var paragraph in EntrySeparatorRegex().Split(input))
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(paragraph))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var line in paragraph.Split('\n'))
|
||||
{
|
||||
var trimmed = line.Trim();
|
||||
if (trimmed.Length == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
entries.Add(trimmed);
|
||||
}
|
||||
}
|
||||
|
||||
return entries.Count == 0
|
||||
? new[] { input.Trim() }
|
||||
: entries;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> ExtractCves(string entry)
|
||||
{
|
||||
var cves = new SortedSet<string>(StringComparer.Ordinal);
|
||||
foreach (Match match in CveRegex().Matches(entry))
|
||||
{
|
||||
cves.Add(match.Value.ToUpperInvariant());
|
||||
}
|
||||
|
||||
return cves.ToArray();
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> ExtractBugs(string entry)
|
||||
{
|
||||
var bugs = new SortedSet<string>(StringComparer.Ordinal);
|
||||
|
||||
foreach (Match closesMatch in DebianClosesRegex().Matches(entry))
|
||||
{
|
||||
foreach (Match idMatch in HashBugIdRegex().Matches(closesMatch.Value))
|
||||
{
|
||||
bugs.Add($"debian:#{idMatch.Groups["id"].Value}");
|
||||
}
|
||||
}
|
||||
|
||||
foreach (Match rhbz in RhbzRegex().Matches(entry))
|
||||
{
|
||||
bugs.Add($"rhbz:#{rhbz.Groups["id"].Value}");
|
||||
}
|
||||
|
||||
foreach (Match lp in LaunchpadShortRegex().Matches(entry))
|
||||
{
|
||||
bugs.Add($"launchpad:#{lp.Groups["id"].Value}");
|
||||
}
|
||||
|
||||
foreach (Match lp in LaunchpadLongRegex().Matches(entry))
|
||||
{
|
||||
bugs.Add($"launchpad:#{lp.Groups["id"].Value}");
|
||||
}
|
||||
|
||||
return bugs.ToArray();
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"(?:\r?\n){2,}", RegexOptions.Compiled)]
|
||||
private static partial Regex EntrySeparatorRegex();
|
||||
|
||||
[GeneratedRegex(@"CVE-\d{4}-\d{4,7}", RegexOptions.IgnoreCase | RegexOptions.Compiled)]
|
||||
private static partial Regex CveRegex();
|
||||
|
||||
[GeneratedRegex(@"\bCloses\s*:\s*(?:#\d+[,\s]*)+", RegexOptions.IgnoreCase | RegexOptions.Compiled)]
|
||||
private static partial Regex DebianClosesRegex();
|
||||
|
||||
[GeneratedRegex(@"#(?<id>\d+)", RegexOptions.Compiled)]
|
||||
private static partial Regex HashBugIdRegex();
|
||||
|
||||
[GeneratedRegex(@"\bRHBZ\s*#\s*(?<id>\d+)\b", RegexOptions.IgnoreCase | RegexOptions.Compiled)]
|
||||
private static partial Regex RhbzRegex();
|
||||
|
||||
[GeneratedRegex(@"\bLP\s*:\s*#\s*(?<id>\d+)\b", RegexOptions.IgnoreCase | RegexOptions.Compiled)]
|
||||
private static partial Regex LaunchpadShortRegex();
|
||||
|
||||
[GeneratedRegex(@"\bLaunchpad(?:\s+bug)?\s*#\s*(?<id>\d+)\b", RegexOptions.IgnoreCase | RegexOptions.Compiled)]
|
||||
private static partial Regex LaunchpadLongRegex();
|
||||
}
|
||||
|
||||
public sealed record ChangelogBugReferenceExtractionResult(
|
||||
IReadOnlyList<string> BugReferences,
|
||||
IReadOnlyDictionary<string, IReadOnlyList<string>> BugToCves)
|
||||
{
|
||||
public static ChangelogBugReferenceExtractionResult Empty { get; } = new(
|
||||
Array.Empty<string>(),
|
||||
new ReadOnlyDictionary<string, IReadOnlyList<string>>(
|
||||
new Dictionary<string, IReadOnlyList<string>>(0, StringComparer.Ordinal)));
|
||||
|
||||
public string ToBugReferencesMetadataValue()
|
||||
=> string.Join(",", BugReferences);
|
||||
|
||||
public string ToBugToCvesMetadataValue()
|
||||
=> string.Join(
|
||||
";",
|
||||
BugToCves.Select(static pair => $"{pair.Key}=>{string.Join('|', pair.Value)}"));
|
||||
}
|
||||
@@ -4,5 +4,6 @@ Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_sol
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| QA-SCANNER-VERIFY-010 | DONE | Implemented deterministic changelog bug-id to CVE mapping (`Closes`, `RHBZ`, `LP`) for OS analyzers with Tier 0/1/2 evidence in run-001. |
|
||||
| REMED-06-SOLID | DOING | SOLID review for OS analyzer files (Tier 0 remediation batch) in progress. |
|
||||
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
|
||||
|
||||
@@ -48,6 +48,8 @@ public static class ScanAnalysisKeys
|
||||
public const string ReplaySealedBundleMetadata = "analysis.replay.sealed.bundle";
|
||||
|
||||
public const string BinaryVulnerabilityFindings = "analysis.binary.findings";
|
||||
public const string BinaryBuildIdMappings = "analysis.binary.buildid.mappings";
|
||||
public const string BinaryPatchVerificationResult = "analysis.binary.patchverification.result";
|
||||
|
||||
// Sprint: SPRINT_3500_0001_0001 - Proof of Exposure
|
||||
public const string VulnerabilityMatches = "analysis.poe.vulnerability.matches";
|
||||
|
||||
@@ -4,5 +4,6 @@ Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_sol
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| QA-SCANNER-VERIFY-009 | DONE | `SPRINT_20260212_002_Scanner_unchecked_feature_verification_batch1.md`: extended binary analysis contracts with Build-ID mapping and patch-verification analysis keys for worker runtime wiring (2026-02-12). |
|
||||
| REMED-05 | TODO | Remediation checklist: docs/implplan/audits/csproj-standards/remediation/checklists/src/Scanner/__Libraries/StellaOps.Scanner.Core/StellaOps.Scanner.Core.md. |
|
||||
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
|
||||
|
||||
@@ -159,7 +159,8 @@ public sealed record EntryTraceGraph(
|
||||
ImmutableArray<EntryTraceEdge> Edges,
|
||||
ImmutableArray<EntryTraceDiagnostic> Diagnostics,
|
||||
ImmutableArray<EntryTracePlan> Plans,
|
||||
ImmutableArray<EntryTraceTerminal> Terminals);
|
||||
ImmutableArray<EntryTraceTerminal> Terminals,
|
||||
EntryTraceBinaryIntelligence? BinaryIntelligence = null);
|
||||
|
||||
/// <summary>
|
||||
/// Describes a classified terminal executable.
|
||||
@@ -188,6 +189,41 @@ public sealed record EntryTraceTerminal(
|
||||
string WorkingDirectory,
|
||||
ImmutableArray<string> Arguments);
|
||||
|
||||
/// <summary>
|
||||
/// Binary intelligence attached to an entry trace graph.
|
||||
/// </summary>
|
||||
public sealed record EntryTraceBinaryIntelligence(
|
||||
ImmutableArray<EntryTraceBinaryTarget> Targets,
|
||||
int TotalTargets,
|
||||
int AnalyzedTargets,
|
||||
int TotalVulnerableMatches,
|
||||
DateTimeOffset GeneratedAtUtc);
|
||||
|
||||
/// <summary>
|
||||
/// Binary analysis summary for one resolved terminal target.
|
||||
/// </summary>
|
||||
public sealed record EntryTraceBinaryTarget(
|
||||
string Path,
|
||||
string BinaryHash,
|
||||
string Architecture,
|
||||
string Format,
|
||||
int FunctionCount,
|
||||
int RecoveredSymbolCount,
|
||||
int SourceCorrelationCount,
|
||||
int VulnerableMatchCount,
|
||||
ImmutableArray<EntryTraceBinaryVulnerability> VulnerableMatches);
|
||||
|
||||
/// <summary>
|
||||
/// Vulnerability evidence from binary intelligence matching.
|
||||
/// </summary>
|
||||
public sealed record EntryTraceBinaryVulnerability(
|
||||
string VulnerabilityId,
|
||||
string? FunctionName,
|
||||
string SourcePackage,
|
||||
string VulnerableFunctionName,
|
||||
float MatchConfidence,
|
||||
string Severity);
|
||||
|
||||
/// <summary>
|
||||
/// Represents a fallback entrypoint candidate inferred from image metadata or filesystem.
|
||||
/// </summary>
|
||||
|
||||
@@ -40,6 +40,7 @@ public static class EntryTraceGraphSerializer
|
||||
public List<EntryTraceDiagnosticContract> Diagnostics { get; set; } = new();
|
||||
public List<EntryTracePlanContract> Plans { get; set; } = new();
|
||||
public List<EntryTraceTerminalContract> Terminals { get; set; } = new();
|
||||
public EntryTraceBinaryIntelligenceContract? BinaryIntelligence { get; set; }
|
||||
|
||||
public static EntryTraceGraphContract FromGraph(EntryTraceGraph graph)
|
||||
{
|
||||
@@ -50,7 +51,10 @@ public static class EntryTraceGraphSerializer
|
||||
Edges = graph.Edges.Select(EntryTraceEdgeContract.FromEdge).ToList(),
|
||||
Diagnostics = graph.Diagnostics.Select(EntryTraceDiagnosticContract.FromDiagnostic).ToList(),
|
||||
Plans = graph.Plans.Select(EntryTracePlanContract.FromPlan).ToList(),
|
||||
Terminals = graph.Terminals.Select(EntryTraceTerminalContract.FromTerminal).ToList()
|
||||
Terminals = graph.Terminals.Select(EntryTraceTerminalContract.FromTerminal).ToList(),
|
||||
BinaryIntelligence = graph.BinaryIntelligence is null
|
||||
? null
|
||||
: EntryTraceBinaryIntelligenceContract.FromBinaryIntelligence(graph.BinaryIntelligence)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -62,7 +66,116 @@ public static class EntryTraceGraphSerializer
|
||||
Edges.Select(e => e.ToEdge()).ToImmutableArray(),
|
||||
Diagnostics.Select(d => d.ToDiagnostic()).ToImmutableArray(),
|
||||
Plans.Select(p => p.ToPlan()).ToImmutableArray(),
|
||||
Terminals.Select(t => t.ToTerminal()).ToImmutableArray());
|
||||
Terminals.Select(t => t.ToTerminal()).ToImmutableArray(),
|
||||
BinaryIntelligence?.ToBinaryIntelligence());
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class EntryTraceBinaryIntelligenceContract
|
||||
{
|
||||
public List<EntryTraceBinaryTargetContract> Targets { get; set; } = new();
|
||||
public int TotalTargets { get; set; }
|
||||
public int AnalyzedTargets { get; set; }
|
||||
public int TotalVulnerableMatches { get; set; }
|
||||
public DateTimeOffset GeneratedAtUtc { get; set; }
|
||||
|
||||
public static EntryTraceBinaryIntelligenceContract FromBinaryIntelligence(EntryTraceBinaryIntelligence intelligence)
|
||||
{
|
||||
return new EntryTraceBinaryIntelligenceContract
|
||||
{
|
||||
Targets = intelligence.Targets.Select(EntryTraceBinaryTargetContract.FromBinaryTarget).ToList(),
|
||||
TotalTargets = intelligence.TotalTargets,
|
||||
AnalyzedTargets = intelligence.AnalyzedTargets,
|
||||
TotalVulnerableMatches = intelligence.TotalVulnerableMatches,
|
||||
GeneratedAtUtc = intelligence.GeneratedAtUtc
|
||||
};
|
||||
}
|
||||
|
||||
public EntryTraceBinaryIntelligence ToBinaryIntelligence()
|
||||
{
|
||||
return new EntryTraceBinaryIntelligence(
|
||||
Targets.Select(target => target.ToBinaryTarget()).ToImmutableArray(),
|
||||
TotalTargets,
|
||||
AnalyzedTargets,
|
||||
TotalVulnerableMatches,
|
||||
GeneratedAtUtc);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class EntryTraceBinaryTargetContract
|
||||
{
|
||||
public string Path { get; set; } = string.Empty;
|
||||
public string BinaryHash { get; set; } = string.Empty;
|
||||
public string Architecture { get; set; } = string.Empty;
|
||||
public string Format { get; set; } = string.Empty;
|
||||
public int FunctionCount { get; set; }
|
||||
public int RecoveredSymbolCount { get; set; }
|
||||
public int SourceCorrelationCount { get; set; }
|
||||
public int VulnerableMatchCount { get; set; }
|
||||
public List<EntryTraceBinaryVulnerabilityContract> VulnerableMatches { get; set; } = new();
|
||||
|
||||
public static EntryTraceBinaryTargetContract FromBinaryTarget(EntryTraceBinaryTarget target)
|
||||
{
|
||||
return new EntryTraceBinaryTargetContract
|
||||
{
|
||||
Path = target.Path,
|
||||
BinaryHash = target.BinaryHash,
|
||||
Architecture = target.Architecture,
|
||||
Format = target.Format,
|
||||
FunctionCount = target.FunctionCount,
|
||||
RecoveredSymbolCount = target.RecoveredSymbolCount,
|
||||
SourceCorrelationCount = target.SourceCorrelationCount,
|
||||
VulnerableMatchCount = target.VulnerableMatchCount,
|
||||
VulnerableMatches = target.VulnerableMatches.Select(EntryTraceBinaryVulnerabilityContract.FromBinaryVulnerability).ToList()
|
||||
};
|
||||
}
|
||||
|
||||
public EntryTraceBinaryTarget ToBinaryTarget()
|
||||
{
|
||||
return new EntryTraceBinaryTarget(
|
||||
Path,
|
||||
BinaryHash,
|
||||
Architecture,
|
||||
Format,
|
||||
FunctionCount,
|
||||
RecoveredSymbolCount,
|
||||
SourceCorrelationCount,
|
||||
VulnerableMatchCount,
|
||||
VulnerableMatches.Select(vulnerability => vulnerability.ToBinaryVulnerability()).ToImmutableArray());
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class EntryTraceBinaryVulnerabilityContract
|
||||
{
|
||||
public string VulnerabilityId { get; set; } = string.Empty;
|
||||
public string? FunctionName { get; set; }
|
||||
public string SourcePackage { get; set; } = string.Empty;
|
||||
public string VulnerableFunctionName { get; set; } = string.Empty;
|
||||
public float MatchConfidence { get; set; }
|
||||
public string Severity { get; set; } = string.Empty;
|
||||
|
||||
public static EntryTraceBinaryVulnerabilityContract FromBinaryVulnerability(EntryTraceBinaryVulnerability vulnerability)
|
||||
{
|
||||
return new EntryTraceBinaryVulnerabilityContract
|
||||
{
|
||||
VulnerabilityId = vulnerability.VulnerabilityId,
|
||||
FunctionName = vulnerability.FunctionName,
|
||||
SourcePackage = vulnerability.SourcePackage,
|
||||
VulnerableFunctionName = vulnerability.VulnerableFunctionName,
|
||||
MatchConfidence = vulnerability.MatchConfidence,
|
||||
Severity = vulnerability.Severity
|
||||
};
|
||||
}
|
||||
|
||||
public EntryTraceBinaryVulnerability ToBinaryVulnerability()
|
||||
{
|
||||
return new EntryTraceBinaryVulnerability(
|
||||
VulnerabilityId,
|
||||
FunctionName,
|
||||
SourcePackage,
|
||||
VulnerableFunctionName,
|
||||
MatchConfidence,
|
||||
Severity);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,5 +4,6 @@ Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_sol
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| QA-SCANNER-VERIFY-008 | DONE | `SPRINT_20260212_002_Scanner_unchecked_feature_verification_batch1.md`: binary intelligence graph contract/serializer path verified with run-002 Tier 1/Tier 2 evidence and dossier promotion (2026-02-12). |
|
||||
| REMED-05 | TODO | Remediation checklist: docs/implplan/audits/csproj-standards/remediation/checklists/src/Scanner/__Libraries/StellaOps.Scanner.EntryTrace/StellaOps.Scanner.EntryTrace.md. |
|
||||
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
|
||||
|
||||
@@ -13,7 +13,8 @@ public sealed class PostgresArtifactBomRepository : IArtifactBomRepository
|
||||
private readonly ScannerDataSource _dataSource;
|
||||
private readonly ILogger<PostgresArtifactBomRepository> _logger;
|
||||
|
||||
private string SchemaName => _dataSource.SchemaName ?? ScannerDataSource.DefaultSchema;
|
||||
// Artifact BOM projection migrations/functions are currently bound to the default scanner schema.
|
||||
private const string SchemaName = ScannerDataSource.DefaultSchema;
|
||||
private string TableName => $"{SchemaName}.artifact_boms";
|
||||
|
||||
public PostgresArtifactBomRepository(
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.IO.Compression;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Scanner.Analyzers.OS;
|
||||
using StellaOps.Scanner.Analyzers.OS.Dpkg;
|
||||
using StellaOps.Scanner.Core.Contracts;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.OS.Tests.Dpkg;
|
||||
|
||||
public sealed class DpkgChangelogBugCorrelationTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task AnalyzeAsync_ExtractsBugReferencesAndCveMappingsFromDpkgChangelog()
|
||||
{
|
||||
var rootPath = Path.Combine(Path.GetTempPath(), $"stellaops-dpkg-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(rootPath);
|
||||
|
||||
try
|
||||
{
|
||||
WriteFixture(rootPath);
|
||||
|
||||
var analyzer = new DpkgPackageAnalyzer(NullLogger<DpkgPackageAnalyzer>.Instance);
|
||||
var metadata = new Dictionary<string, string> { [ScanMetadataKeys.RootFilesystemPath] = rootPath };
|
||||
var context = new OSPackageAnalyzerContext(
|
||||
rootPath,
|
||||
workspacePath: null,
|
||||
TimeProvider.System,
|
||||
NullLoggerFactory.Instance.CreateLogger("dpkg-changelog-tests"),
|
||||
metadata);
|
||||
|
||||
var result = await analyzer.AnalyzeAsync(context, TestContext.Current.CancellationToken);
|
||||
var package = Assert.Single(result.Packages);
|
||||
|
||||
Assert.Equal("debian:#123456,launchpad:#7654321,rhbz:#424242", package.VendorMetadata["changelogBugRefs"]);
|
||||
Assert.Equal(
|
||||
"debian:#123456=>CVE-2026-1000;launchpad:#7654321=>CVE-2026-1000;rhbz:#424242=>CVE-2026-1001",
|
||||
package.VendorMetadata["changelogBugToCves"]);
|
||||
Assert.Contains("CVE-2026-1000", package.CveHints);
|
||||
Assert.Contains("CVE-2026-1001", package.CveHints);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (Directory.Exists(rootPath))
|
||||
{
|
||||
Directory.Delete(rootPath, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void WriteFixture(string rootPath)
|
||||
{
|
||||
var statusPath = Path.Combine(rootPath, "var", "lib", "dpkg", "status");
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(statusPath)!);
|
||||
File.WriteAllText(
|
||||
statusPath,
|
||||
"""
|
||||
Package: bash
|
||||
Status: install ok installed
|
||||
Priority: important
|
||||
Section: shells
|
||||
Installed-Size: 1024
|
||||
Maintainer: Debian Developers <debian-devel@lists.debian.org>
|
||||
Architecture: amd64
|
||||
Version: 5.2.21-2
|
||||
Description: GNU Bourne Again SHell
|
||||
|
||||
""");
|
||||
|
||||
var listPath = Path.Combine(rootPath, "var", "lib", "dpkg", "info", "bash.list");
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(listPath)!);
|
||||
File.WriteAllText(
|
||||
listPath,
|
||||
"""
|
||||
/usr/share/doc/bash/changelog.Debian.gz
|
||||
""");
|
||||
|
||||
var changelogPath = Path.Combine(rootPath, "usr", "share", "doc", "bash", "changelog.Debian.gz");
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(changelogPath)!);
|
||||
|
||||
using var fs = File.Create(changelogPath);
|
||||
using var gzip = new GZipStream(fs, CompressionMode.Compress);
|
||||
using var writer = new StreamWriter(gzip, Encoding.UTF8);
|
||||
writer.Write(
|
||||
"""
|
||||
bash (5.2.21-2) unstable; urgency=medium
|
||||
|
||||
* Backport parser fix (Closes: #123456; LP: #7654321; CVE-2026-1000).
|
||||
* Hardening update (RHBZ#424242; CVE-2026-1001).
|
||||
|
||||
-- Debian Maintainer <maintainer@example.org> Thu, 12 Feb 2026 09:00:00 +0000
|
||||
""");
|
||||
}
|
||||
}
|
||||
@@ -15,10 +15,11 @@
|
||||
"release": "8.el9",
|
||||
"sourcePackage": "openssl-3.2.1-8.el9.src.rpm",
|
||||
"license": "OpenSSL",
|
||||
"evidenceSource": "RpmDatabase",
|
||||
"cveHints": [
|
||||
"CVE-2025-1234"
|
||||
],
|
||||
"evidenceSource": "RpmDatabase",
|
||||
"cveHints": [
|
||||
"CVE-2025-1234",
|
||||
"CVE-2025-9999"
|
||||
],
|
||||
"provides": [
|
||||
"libcrypto.so.3()(64bit)",
|
||||
"openssl-libs"
|
||||
@@ -48,10 +49,12 @@
|
||||
}
|
||||
}
|
||||
],
|
||||
"vendorMetadata": {
|
||||
"buildTime": null,
|
||||
"description": null,
|
||||
"installTime": null,
|
||||
"vendorMetadata": {
|
||||
"buildTime": null,
|
||||
"changelogBugRefs": "debian:#102030,debian:#102031,launchpad:#456789,rhbz:#220001",
|
||||
"changelogBugToCves": "debian:#102030=\u003ECVE-2025-9999;debian:#102031=\u003ECVE-2025-9999;launchpad:#456789=\u003ECVE-2025-9999;rhbz:#220001=\u003ECVE-2025-1234",
|
||||
"description": null,
|
||||
"installTime": null,
|
||||
"rpm:summary": "TLS toolkit",
|
||||
"sourceRpm": "openssl-3.2.1-8.el9.src.rpm",
|
||||
"summary": "TLS toolkit",
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
using StellaOps.Scanner.Analyzers.OS.Helpers;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.OS.Tests.Helpers;
|
||||
|
||||
public sealed class ChangelogBugReferenceExtractorTests
|
||||
{
|
||||
[Fact]
|
||||
public void Extract_MapsDebianRhbzAndLaunchpadReferencesToCves()
|
||||
{
|
||||
var result = ChangelogBugReferenceExtractor.Extract(
|
||||
"Backport parser fix. Closes: #123456, #123457; CVE-2026-0001",
|
||||
"Fix OpenSSL issue RHBZ#998877 with CVE-2026-0002.",
|
||||
"Ubuntu patch for LP: #445566 and CVE-2026-0003.");
|
||||
|
||||
Assert.Equal(
|
||||
new[]
|
||||
{
|
||||
"debian:#123456",
|
||||
"debian:#123457",
|
||||
"launchpad:#445566",
|
||||
"rhbz:#998877"
|
||||
},
|
||||
result.BugReferences);
|
||||
|
||||
Assert.Equal(
|
||||
"debian:#123456=>CVE-2026-0001;debian:#123457=>CVE-2026-0001;launchpad:#445566=>CVE-2026-0003;rhbz:#998877=>CVE-2026-0002",
|
||||
result.ToBugToCvesMetadataValue());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Extract_DoesNotEmitMappingsWithoutCveInSameEntry()
|
||||
{
|
||||
var result = ChangelogBugReferenceExtractor.Extract(
|
||||
"Closes: #222222; housekeeping only",
|
||||
"LP: #777777 without security identifier");
|
||||
|
||||
Assert.Equal(
|
||||
new[]
|
||||
{
|
||||
"debian:#222222",
|
||||
"launchpad:#777777"
|
||||
},
|
||||
result.BugReferences);
|
||||
Assert.Empty(result.BugToCves);
|
||||
Assert.Equal(string.Empty, result.ToBugToCvesMetadataValue());
|
||||
}
|
||||
}
|
||||
@@ -65,7 +65,11 @@ public sealed class OsAnalyzerDeterminismTests
|
||||
new RpmFileEntry("/usr/lib64/libcrypto.so.3", false, new Dictionary<string, string> { ["sha256"] = "abc123" }),
|
||||
new RpmFileEntry("/etc/pki/tls/openssl.cnf", true, new Dictionary<string, string> { ["md5"] = "c0ffee" })
|
||||
},
|
||||
changeLogs: new[] { "Resolves: CVE-2025-1234" },
|
||||
changeLogs: new[]
|
||||
{
|
||||
"Resolves: CVE-2025-1234 (RHBZ#220001)",
|
||||
"Fix startup regression. Closes: #102030, #102031; LP: #456789; CVE-2025-9999"
|
||||
},
|
||||
metadata: new Dictionary<string, string?> { ["summary"] = "TLS toolkit" })
|
||||
};
|
||||
|
||||
|
||||
@@ -4,5 +4,6 @@ Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_sol
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| QA-SCANNER-VERIFY-010 | DONE | Added behavioral coverage for changelog bug-reference extraction and bug-to-CVE mapping evidence in OS analyzer outputs. |
|
||||
| REMED-05 | TODO | Remediation checklist: docs/implplan/audits/csproj-standards/remediation/checklists/src/Scanner/__Tests/StellaOps.Scanner.Analyzers.OS.Tests/StellaOps.Scanner.Analyzers.OS.Tests.md. |
|
||||
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Scanner.CallGraph.Bun;
|
||||
using StellaOps.Scanner.Contracts;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.CallGraph.Tests;
|
||||
|
||||
public sealed class BunCallGraphExtractorTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task ExtractAsync_FromSource_DetectsBunEntrypointsAndSinks()
|
||||
{
|
||||
var root = Path.Combine(Path.GetTempPath(), "stella-bun-callgraph-tests", Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(root);
|
||||
|
||||
try
|
||||
{
|
||||
var sourcePath = Path.Combine(root, "app.ts");
|
||||
await File.WriteAllTextAsync(
|
||||
sourcePath,
|
||||
"""
|
||||
import { Elysia } from "elysia";
|
||||
import { Hono } from "hono";
|
||||
|
||||
const app = new Elysia().get("/health", () => "ok");
|
||||
const hono = new Hono();
|
||||
hono.get("/v1/ping", (c) => c.text("pong"));
|
||||
|
||||
Bun.serve({
|
||||
fetch(req) {
|
||||
return new Response("ok");
|
||||
}
|
||||
});
|
||||
|
||||
const config = Bun.file("/tmp/config.json");
|
||||
Bun.spawn(["sh", "-c", "echo hello"]);
|
||||
""");
|
||||
|
||||
var extractor = new BunCallGraphExtractor(NullLogger<BunCallGraphExtractor>.Instance);
|
||||
var snapshot = await extractor.ExtractAsync(new CallGraphExtractionRequest("scan-bun-001", "bun", root));
|
||||
|
||||
Assert.Equal("bun", snapshot.Language);
|
||||
Assert.StartsWith("sha256:", snapshot.GraphDigest, StringComparison.Ordinal);
|
||||
|
||||
Assert.Contains(snapshot.Nodes, n => n.IsEntrypoint && n.Symbol == "Bun.serve");
|
||||
Assert.Contains(snapshot.Nodes, n => n.IsSink && n.SinkCategory == SinkCategory.FileWrite && n.Symbol == "Bun.file");
|
||||
Assert.Contains(snapshot.Nodes, n => n.IsSink && n.SinkCategory == SinkCategory.CmdExec && n.Symbol == "Bun.spawn");
|
||||
Assert.NotEmpty(snapshot.EntrypointIds);
|
||||
Assert.NotEmpty(snapshot.SinkIds);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (Directory.Exists(root))
|
||||
{
|
||||
Directory.Delete(root, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task ExtractAsync_WithMismatchedLanguage_ThrowsArgumentException()
|
||||
{
|
||||
var extractor = new BunCallGraphExtractor(NullLogger<BunCallGraphExtractor>.Instance);
|
||||
|
||||
await Assert.ThrowsAsync<ArgumentException>(() =>
|
||||
extractor.ExtractAsync(new CallGraphExtractionRequest("scan-bun-002", "node", ".")));
|
||||
}
|
||||
}
|
||||
@@ -4,5 +4,6 @@ Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_sol
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| QA-SCANNER-VERIFY-008 | DONE | `SPRINT_20260212_002_Scanner_unchecked_feature_verification_batch1.md`: added/verified binary namespace coverage for entry-trace binary intelligence in run-002 Tier 1/Tier 2 checks (2026-02-12). |
|
||||
| REMED-05 | TODO | Remediation checklist: docs/implplan/audits/csproj-standards/remediation/checklists/src/Scanner/__Tests/StellaOps.Scanner.EntryTrace.Tests/StellaOps.Scanner.EntryTrace.Tests.md. |
|
||||
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
|
||||
|
||||
@@ -93,7 +93,28 @@ public sealed class EntryTraceResultStoreTests
|
||||
ImmutableArray<EntryTraceEdge>.Empty,
|
||||
ImmutableArray<EntryTraceDiagnostic>.Empty,
|
||||
ImmutableArray.Create(plan),
|
||||
ImmutableArray.Create(terminal));
|
||||
ImmutableArray.Create(terminal),
|
||||
new EntryTraceBinaryIntelligence(
|
||||
ImmutableArray.Create(new EntryTraceBinaryTarget(
|
||||
"/app/main.py",
|
||||
"sha256:bin",
|
||||
"Unknown",
|
||||
"Raw",
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
ImmutableArray.Create(new EntryTraceBinaryVulnerability(
|
||||
"CVE-2024-5678",
|
||||
"main",
|
||||
"pkg:generic/demo",
|
||||
"main",
|
||||
0.91f,
|
||||
"High")))),
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
generatedAt));
|
||||
|
||||
var ndjson = EntryTraceNdjsonWriter.Serialize(
|
||||
graph,
|
||||
@@ -112,6 +133,9 @@ public sealed class EntryTraceResultStoreTests
|
||||
Assert.Equal(
|
||||
EntryTraceGraphSerializer.Serialize(result.Graph),
|
||||
EntryTraceGraphSerializer.Serialize(stored.Graph));
|
||||
Assert.NotNull(stored.Graph.BinaryIntelligence);
|
||||
Assert.Equal(1, stored.Graph.BinaryIntelligence!.TotalTargets);
|
||||
Assert.Equal(1, stored.Graph.BinaryIntelligence.TotalVulnerableMatches);
|
||||
Assert.Equal(result.Ndjson.ToArray(), stored.Ndjson.ToArray());
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_sol
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| QA-SCANNER-VERIFY-008 | DONE | `SPRINT_20260212_002_Scanner_unchecked_feature_verification_batch1.md`: added entry-trace store round-trip coverage for binary intelligence payload; validated in run-002 Tier 2 (2026-02-12). |
|
||||
| REMED-05 | TODO | Remediation checklist: docs/implplan/audits/csproj-standards/remediation/checklists/src/Scanner/__Tests/StellaOps.Scanner.Storage.Tests/StellaOps.Scanner.Storage.Tests.md. |
|
||||
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
|
||||
| HOT-002 | DONE | `SPRINT_20260210_001_DOCS_sbom_attestation_hot_lookup_contract.md`: migration coverage for `scanner.artifact_boms` partition/index profile. |
|
||||
|
||||
@@ -1,83 +1,166 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using StellaOps.Scanner.Storage.ObjectStore;
|
||||
using StellaOps.Scanner.WebService.Contracts;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.Scanner.WebService.Tests;
|
||||
|
||||
public sealed class SbomUploadEndpointsTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Upload_validates_cyclonedx_format()
|
||||
public async Task Upload_accepts_cyclonedx_inline_and_persists_record()
|
||||
{
|
||||
// This test validates that CycloneDX format detection works
|
||||
// Full integration with upload service is tested separately
|
||||
using var secrets = new TestSurfaceSecretsScope();
|
||||
await using var factory = await CreateFactoryAsync();
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
var sampleCycloneDx = """
|
||||
{
|
||||
"bomFormat": "CycloneDX",
|
||||
"specVersion": "1.6",
|
||||
"version": 1,
|
||||
"metadata": { "timestamp": "2025-01-15T10:00:00Z" },
|
||||
"components": []
|
||||
}
|
||||
""";
|
||||
var base64 = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(sampleCycloneDx));
|
||||
|
||||
{
|
||||
"bomFormat": "CycloneDX",
|
||||
"specVersion": "1.6",
|
||||
"version": 1,
|
||||
"components": [
|
||||
{
|
||||
"type": "library",
|
||||
"name": "left-pad",
|
||||
"version": "1.3.0",
|
||||
"purl": "pkg:npm/left-pad@1.3.0",
|
||||
"licenses": [{ "license": { "id": "MIT" } }]
|
||||
},
|
||||
{
|
||||
"type": "library",
|
||||
"name": "chalk",
|
||||
"version": "5.0.0",
|
||||
"purl": "pkg:npm/chalk@5.0.0"
|
||||
}
|
||||
]
|
||||
}
|
||||
""";
|
||||
|
||||
var request = new SbomUploadRequestDto
|
||||
{
|
||||
ArtifactRef = "example.com/app:1.0",
|
||||
SbomBase64 = base64,
|
||||
ArtifactRef = "example.com/app:1.0.0",
|
||||
ArtifactDigest = "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
Sbom = JsonDocument.Parse(sampleCycloneDx).RootElement.Clone(),
|
||||
Source = new SbomUploadSourceDto
|
||||
{
|
||||
Tool = "syft",
|
||||
Version = "1.0.0"
|
||||
Version = "1.0.0",
|
||||
CiContext = new SbomUploadCiContextDto
|
||||
{
|
||||
BuildId = "build-42",
|
||||
Repository = "example/repo"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Verify the request is valid and can be serialized
|
||||
Assert.NotNull(request.ArtifactRef);
|
||||
Assert.NotEmpty(request.SbomBase64);
|
||||
Assert.NotNull(request.Source);
|
||||
Assert.Equal("syft", request.Source.Tool);
|
||||
var uploadResponse = await client.PostAsJsonAsync("/api/v1/sbom/upload", request);
|
||||
Assert.Equal(HttpStatusCode.Accepted, uploadResponse.StatusCode);
|
||||
|
||||
var payload = await uploadResponse.Content.ReadFromJsonAsync<SbomUploadResponseDto>();
|
||||
Assert.NotNull(payload);
|
||||
Assert.False(string.IsNullOrWhiteSpace(payload!.SbomId));
|
||||
Assert.Equal("cyclonedx", payload.Format);
|
||||
Assert.Equal("1.6", payload.FormatVersion);
|
||||
Assert.True(payload.ValidationResult.Valid);
|
||||
Assert.Equal(2, payload.ValidationResult.ComponentCount);
|
||||
Assert.Equal(0.85d, payload.ValidationResult.QualityScore);
|
||||
Assert.StartsWith("sha256:", payload.Digest, StringComparison.Ordinal);
|
||||
Assert.False(string.IsNullOrWhiteSpace(payload.AnalysisJobId));
|
||||
|
||||
var getResponse = await client.GetAsync($"/api/v1/sbom/uploads/{payload.SbomId}");
|
||||
Assert.Equal(HttpStatusCode.OK, getResponse.StatusCode);
|
||||
|
||||
var record = await getResponse.Content.ReadFromJsonAsync<SbomUploadRecordDto>();
|
||||
Assert.NotNull(record);
|
||||
Assert.Equal(payload.SbomId, record!.SbomId);
|
||||
Assert.Equal("example.com/app:1.0.0", record.ArtifactRef);
|
||||
Assert.Equal("sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", record.ArtifactDigest);
|
||||
Assert.Equal("cyclonedx", record.Format);
|
||||
Assert.Equal("1.6", record.FormatVersion);
|
||||
Assert.Equal("build-42", record.Source?.CiContext?.BuildId);
|
||||
Assert.Equal("example/repo", record.Source?.CiContext?.Repository);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Upload_validates_spdx_format()
|
||||
public async Task Upload_accepts_spdx_base64_and_tracks_ci_context()
|
||||
{
|
||||
// This test validates that SPDX format detection works
|
||||
using var secrets = new TestSurfaceSecretsScope();
|
||||
await using var factory = await CreateFactoryAsync();
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
var sampleSpdx = """
|
||||
{
|
||||
"spdxVersion": "SPDX-2.3",
|
||||
"dataLicense": "CC0-1.0",
|
||||
"SPDXID": "SPDXRef-DOCUMENT",
|
||||
"name": "test-sbom",
|
||||
"documentNamespace": "https://example.com/test",
|
||||
"packages": []
|
||||
}
|
||||
""";
|
||||
var base64 = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(sampleSpdx));
|
||||
{
|
||||
"spdxVersion": "SPDX-2.3",
|
||||
"dataLicense": "CC0-1.0",
|
||||
"SPDXID": "SPDXRef-DOCUMENT",
|
||||
"name": "test-sbom",
|
||||
"documentNamespace": "https://example.com/test",
|
||||
"packages": [
|
||||
{
|
||||
"name": "openssl",
|
||||
"versionInfo": "3.0.0",
|
||||
"licenseDeclared": "Apache-2.0",
|
||||
"externalRefs": [
|
||||
{
|
||||
"referenceType": "purl",
|
||||
"referenceLocator": "pkg:generic/openssl@3.0.0"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
""";
|
||||
|
||||
var request = new SbomUploadRequestDto
|
||||
{
|
||||
ArtifactRef = "example.com/service:2.0",
|
||||
SbomBase64 = base64
|
||||
ArtifactRef = "example.com/service:2.0.0",
|
||||
SbomBase64 = Convert.ToBase64String(Encoding.UTF8.GetBytes(sampleSpdx)),
|
||||
Source = new SbomUploadSourceDto
|
||||
{
|
||||
Tool = "trivy",
|
||||
Version = "0.50.0",
|
||||
CiContext = new SbomUploadCiContextDto
|
||||
{
|
||||
BuildId = "build-77",
|
||||
Repository = "example/service-repo"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Verify the request is valid
|
||||
Assert.NotNull(request.ArtifactRef);
|
||||
Assert.NotEmpty(request.SbomBase64);
|
||||
var uploadResponse = await client.PostAsJsonAsync("/api/v1/sbom/upload", request);
|
||||
Assert.Equal(HttpStatusCode.Accepted, uploadResponse.StatusCode);
|
||||
|
||||
var payload = await uploadResponse.Content.ReadFromJsonAsync<SbomUploadResponseDto>();
|
||||
Assert.NotNull(payload);
|
||||
Assert.Equal("spdx", payload!.Format);
|
||||
Assert.Equal("2.3", payload.FormatVersion);
|
||||
Assert.True(payload.ValidationResult.Valid);
|
||||
Assert.Equal(1, payload.ValidationResult.ComponentCount);
|
||||
Assert.Equal(1.0d, payload.ValidationResult.QualityScore);
|
||||
Assert.False(string.IsNullOrWhiteSpace(payload.AnalysisJobId));
|
||||
Assert.StartsWith("sha256:", payload.Digest, StringComparison.Ordinal);
|
||||
|
||||
var getResponse = await client.GetAsync($"/api/v1/sbom/uploads/{payload.SbomId}");
|
||||
Assert.Equal(HttpStatusCode.OK, getResponse.StatusCode);
|
||||
|
||||
var record = await getResponse.Content.ReadFromJsonAsync<SbomUploadRecordDto>();
|
||||
Assert.NotNull(record);
|
||||
Assert.Equal("build-77", record!.Source?.CiContext?.BuildId);
|
||||
Assert.Equal("example/service-repo", record.Source?.CiContext?.Repository);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public async Task Upload_rejects_unknown_format()
|
||||
{
|
||||
using var secrets = new TestSurfaceSecretsScope();
|
||||
@@ -87,7 +170,7 @@ public sealed class SbomUploadEndpointsTests
|
||||
var invalid = new SbomUploadRequestDto
|
||||
{
|
||||
ArtifactRef = "example.com/invalid:1.0",
|
||||
SbomBase64 = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes("{\"name\":\"oops\"}"))
|
||||
SbomBase64 = Convert.ToBase64String(Encoding.UTF8.GetBytes("{\"name\":\"oops\"}"))
|
||||
};
|
||||
|
||||
var response = await client.PostAsJsonAsync("/api/v1/sbom/upload", invalid);
|
||||
@@ -109,38 +192,6 @@ public sealed class SbomUploadEndpointsTests
|
||||
return factory;
|
||||
}
|
||||
|
||||
private static string LoadFixtureBase64(string fileName)
|
||||
{
|
||||
var repoRoot = ResolveRepoRoot();
|
||||
var path = Path.Combine(
|
||||
repoRoot,
|
||||
"src",
|
||||
"AirGap",
|
||||
"__Tests",
|
||||
"StellaOps.AirGap.Importer.Tests",
|
||||
"Reconciliation",
|
||||
"Fixtures",
|
||||
fileName);
|
||||
|
||||
Assert.True(File.Exists(path), $"Fixture not found at {path}.");
|
||||
var bytes = File.ReadAllBytes(path);
|
||||
return Convert.ToBase64String(bytes);
|
||||
}
|
||||
|
||||
private static string ResolveRepoRoot()
|
||||
{
|
||||
var baseDirectory = AppContext.BaseDirectory;
|
||||
return Path.GetFullPath(Path.Combine(
|
||||
baseDirectory,
|
||||
"..",
|
||||
"..",
|
||||
"..",
|
||||
"..",
|
||||
"..",
|
||||
"..",
|
||||
".."));
|
||||
}
|
||||
|
||||
private sealed class InMemoryArtifactObjectStore : IArtifactObjectStore
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, byte[]> _objects = new(StringComparer.Ordinal);
|
||||
|
||||
@@ -153,7 +153,28 @@ public sealed partial class ScansEndpointsTests
|
||||
ImmutableArray<EntryTraceEdge>.Empty,
|
||||
ImmutableArray<EntryTraceDiagnostic>.Empty,
|
||||
ImmutableArray.Create(plan),
|
||||
ImmutableArray.Create(terminal));
|
||||
ImmutableArray.Create(terminal),
|
||||
new EntryTraceBinaryIntelligence(
|
||||
ImmutableArray.Create(new EntryTraceBinaryTarget(
|
||||
"/usr/local/bin/app",
|
||||
"sha256:abc",
|
||||
"X64",
|
||||
"ELF",
|
||||
3,
|
||||
2,
|
||||
1,
|
||||
1,
|
||||
ImmutableArray.Create(new EntryTraceBinaryVulnerability(
|
||||
"CVE-2024-1234",
|
||||
"SSL_read",
|
||||
"pkg:generic/openssl",
|
||||
"SSL_read",
|
||||
0.98f,
|
||||
"Critical")))),
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
generatedAt));
|
||||
|
||||
var ndjson = EntryTraceNdjsonWriter.Serialize(graph, new EntryTraceNdjsonMetadata(scanId, "sha256:test", generatedAt));
|
||||
var storedResult = new EntryTraceResult(scanId, "sha256:test", generatedAt, graph, ndjson);
|
||||
@@ -173,6 +194,8 @@ public sealed partial class ScansEndpointsTests
|
||||
Assert.Equal(storedResult.ScanId, payload!.ScanId);
|
||||
Assert.Equal(storedResult.ImageDigest, payload.ImageDigest);
|
||||
Assert.Equal(storedResult.Graph.Plans.Length, payload.Graph.Plans.Length);
|
||||
Assert.NotNull(payload.Graph.BinaryIntelligence);
|
||||
Assert.Equal(1, payload.Graph.BinaryIntelligence!.TotalVulnerableMatches);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
|
||||
@@ -4,6 +4,7 @@ Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_sol
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| QA-SCANNER-VERIFY-008 | DONE | `SPRINT_20260212_002_Scanner_unchecked_feature_verification_batch1.md`: extended entry-trace endpoint contract test assertions for `graph.binaryIntelligence`; verified in run-002 Tier 2 (2026-02-12). |
|
||||
| REMED-05 | TODO | Remediation checklist: docs/implplan/audits/csproj-standards/remediation/checklists/src/Scanner/__Tests/StellaOps.Scanner.WebService.Tests/StellaOps.Scanner.WebService.Tests.md. |
|
||||
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
|
||||
| SPRINT-20260208-062-VEXREACH-001 | DONE | Added deterministic unit coverage for VEX+reachability filter matrix and controller endpoint (`6` tests passed on filtered run, 2026-02-08). |
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
using System.Collections.Immutable;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using StellaOps.BinaryIndex.Core.Models;
|
||||
using StellaOps.BinaryIndex.Core.Services;
|
||||
using StellaOps.Scanner.Analyzers.Native.Index;
|
||||
using StellaOps.Scanner.Core.Contracts;
|
||||
using StellaOps.Scanner.PatchVerification;
|
||||
using StellaOps.Scanner.PatchVerification.Models;
|
||||
using StellaOps.Scanner.Worker.Extensions;
|
||||
using StellaOps.Scanner.Worker.Processing;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
using BinaryFormat = StellaOps.BinaryIndex.Core.Models.BinaryFormat;
|
||||
|
||||
namespace StellaOps.Scanner.Worker.Tests;
|
||||
|
||||
public sealed class BinaryLookupStageExecutorTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_WiresBuildIdLookupPatchVerificationAndMappedFindings()
|
||||
{
|
||||
const string scanId = "11111111-1111-1111-1111-111111111111";
|
||||
|
||||
var identity = new BinaryIdentity
|
||||
{
|
||||
BinaryKey = "gnu-build-id:abc123:sha256:deadbeef",
|
||||
BuildId = "gnu-build-id:abc123",
|
||||
BuildIdType = "gnu-build-id",
|
||||
FileSha256 = "sha256:deadbeef",
|
||||
Format = BinaryFormat.Elf,
|
||||
Architecture = "x86_64"
|
||||
};
|
||||
|
||||
var vulnMatch = new BinaryVulnMatch
|
||||
{
|
||||
CveId = "CVE-2026-1234",
|
||||
VulnerablePurl = "pkg:deb/debian/libssl@1.1.1",
|
||||
Method = MatchMethod.BuildIdCatalog,
|
||||
Confidence = 0.97m,
|
||||
Evidence = new MatchEvidence { BuildId = "gnu-build-id:abc123" }
|
||||
};
|
||||
|
||||
var vulnService = new Mock<IBinaryVulnerabilityService>(MockBehavior.Strict);
|
||||
vulnService
|
||||
.Setup(service => service.LookupBatchAsync(
|
||||
It.IsAny<IEnumerable<BinaryIdentity>>(),
|
||||
It.IsAny<LookupOptions?>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(ImmutableDictionary<string, ImmutableArray<BinaryVulnMatch>>.Empty.Add(identity.BinaryKey, [vulnMatch]));
|
||||
vulnService
|
||||
.Setup(service => service.GetFixStatusBatchAsync(
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<IEnumerable<string>>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(ImmutableDictionary<string, FixStatusResult>.Empty);
|
||||
|
||||
var extractor = new Mock<IBinaryFeatureExtractor>(MockBehavior.Strict);
|
||||
extractor
|
||||
.Setup(e => e.ExtractIdentityAsync(It.IsAny<Stream>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(identity);
|
||||
|
||||
var buildIdIndex = new Mock<IBuildIdIndex>(MockBehavior.Strict);
|
||||
buildIdIndex.SetupGet(index => index.IsLoaded).Returns(false);
|
||||
buildIdIndex.SetupGet(index => index.Count).Returns(1);
|
||||
buildIdIndex
|
||||
.Setup(index => index.LoadAsync(It.IsAny<CancellationToken>()))
|
||||
.Returns(Task.CompletedTask);
|
||||
buildIdIndex
|
||||
.Setup(index => index.BatchLookupAsync(It.IsAny<IEnumerable<string>>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(
|
||||
[
|
||||
new BuildIdLookupResult(
|
||||
"gnu-build-id:abc123",
|
||||
"pkg:deb/debian/libssl@1.1.1",
|
||||
"1.1.1",
|
||||
"debian",
|
||||
BuildIdConfidence.Exact,
|
||||
DateTimeOffset.UtcNow)
|
||||
]);
|
||||
buildIdIndex
|
||||
.Setup(index => index.LookupAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((BuildIdLookupResult?)null);
|
||||
|
||||
var patchVerification = new Mock<IPatchVerificationOrchestrator>(MockBehavior.Strict);
|
||||
patchVerification
|
||||
.Setup(orchestrator => orchestrator.VerifyAsync(
|
||||
It.IsAny<PatchVerificationContext>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new PatchVerificationResult
|
||||
{
|
||||
ScanId = scanId,
|
||||
Evidence = [],
|
||||
PatchedCves = ImmutableHashSet<string>.Empty,
|
||||
UnpatchedCves = ImmutableHashSet<string>.Empty,
|
||||
InconclusiveCves = ImmutableHashSet<string>.Empty,
|
||||
NoPatchDataCves = ImmutableHashSet<string>.Empty,
|
||||
VerifiedAt = DateTimeOffset.UtcNow,
|
||||
VerifierVersion = PatchVerificationOrchestrator.VerifierVersion
|
||||
});
|
||||
|
||||
await using var scopedProvider = new ServiceCollection()
|
||||
.AddSingleton(patchVerification.Object)
|
||||
.BuildServiceProvider();
|
||||
|
||||
var analyzer = new BinaryVulnerabilityAnalyzer(
|
||||
vulnService.Object,
|
||||
extractor.Object,
|
||||
NullLogger<BinaryVulnerabilityAnalyzer>.Instance);
|
||||
|
||||
var findingMapper = new BinaryFindingMapper(
|
||||
vulnService.Object,
|
||||
NullLogger<BinaryFindingMapper>.Instance);
|
||||
|
||||
var stage = new BinaryLookupStageExecutor(
|
||||
analyzer,
|
||||
findingMapper,
|
||||
buildIdIndex.Object,
|
||||
scopedProvider.GetRequiredService<IServiceScopeFactory>(),
|
||||
new BinaryIndexOptions { Enabled = true },
|
||||
NullLogger<BinaryLookupStageExecutor>.Instance);
|
||||
|
||||
var metadata = new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
[ScanMetadataKeys.RootFilesystemPath] = "/tmp/rootfs"
|
||||
};
|
||||
|
||||
var lease = new TestLease(metadata, "job-1", scanId);
|
||||
var context = new ScanJobContext(lease, TimeProvider.System, TimeProvider.System.GetUtcNow(), TestContext.Current.CancellationToken);
|
||||
|
||||
context.Analysis.Set("layers", new List<LayerInfo>
|
||||
{
|
||||
new() { Digest = "sha256:layer1", MediaType = "application/vnd.oci.image.layer.v1.tar", Size = 123 }
|
||||
});
|
||||
context.Analysis.Set("binary_paths_sha256:layer1", new List<string> { "/usr/lib/libssl.so" });
|
||||
context.Analysis.Set("detected_distro", "debian");
|
||||
context.Analysis.Set("detected_release", "12");
|
||||
context.Analysis.Set<Func<string, string, Stream?>>(
|
||||
"layer_file_opener",
|
||||
(_, _) => new MemoryStream([0x7F, 0x45, 0x4C, 0x46, 0x02, 0x01]));
|
||||
|
||||
await stage.ExecuteAsync(context, TestContext.Current.CancellationToken);
|
||||
|
||||
var rawFindings = context.Analysis.GetBinaryFindings();
|
||||
Assert.Single(rawFindings);
|
||||
Assert.Equal("CVE-2026-1234", rawFindings[0].CveId);
|
||||
|
||||
Assert.True(context.Analysis.TryGet<IReadOnlyList<object>>(ScanAnalysisKeys.BinaryVulnerabilityFindings, out var mapped));
|
||||
Assert.Single(mapped);
|
||||
|
||||
Assert.True(context.Analysis.TryGet<IReadOnlyDictionary<string, BuildIdLookupResult>>(ScanAnalysisKeys.BinaryBuildIdMappings, out var mappings));
|
||||
Assert.True(mappings.ContainsKey("gnu-build-id:abc123"));
|
||||
|
||||
Assert.True(context.Analysis.TryGet<PatchVerificationResult>(ScanAnalysisKeys.BinaryPatchVerificationResult, out var patchResult));
|
||||
Assert.Equal(scanId, patchResult.ScanId);
|
||||
|
||||
buildIdIndex.Verify(index => index.LoadAsync(It.IsAny<CancellationToken>()), Times.Once);
|
||||
buildIdIndex.Verify(index => index.BatchLookupAsync(
|
||||
It.Is<IEnumerable<string>>(ids => ids.Contains("gnu-build-id:abc123")),
|
||||
It.IsAny<CancellationToken>()), Times.Once);
|
||||
patchVerification.Verify(orchestrator => orchestrator.VerifyAsync(
|
||||
It.Is<PatchVerificationContext>(ctx => ctx.CveIds.Contains("CVE-2026-1234")),
|
||||
It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
private sealed class TestLease : IScanJobLease
|
||||
{
|
||||
public TestLease(IReadOnlyDictionary<string, string> metadata, string jobId, string scanId)
|
||||
{
|
||||
Metadata = metadata;
|
||||
JobId = jobId;
|
||||
ScanId = scanId;
|
||||
Attempt = 1;
|
||||
EnqueuedAtUtc = DateTimeOffset.UtcNow;
|
||||
LeasedAtUtc = DateTimeOffset.UtcNow;
|
||||
LeaseDuration = TimeSpan.FromMinutes(1);
|
||||
}
|
||||
|
||||
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 { get; }
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -92,6 +92,58 @@ public sealed class EntryTraceExecutionServiceTests : IDisposable
|
||||
Assert.Equal(ndjsonPayload, store.LastResult.Ndjson);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_AddsBinaryIntelligence_ForNativeTerminal()
|
||||
{
|
||||
var metadata = CreateMetadata("PATH=/bin:/usr/bin");
|
||||
var rootDirectory = metadata[ScanMetadataKeys.RootFilesystemPath];
|
||||
var binaryPath = Path.Combine(rootDirectory, "usr", "bin", "app");
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(binaryPath)!);
|
||||
File.WriteAllBytes(binaryPath, CreateElfPayloadWithMarker("CVE-2024-9999"));
|
||||
|
||||
var graph = new EntryTraceGraph(
|
||||
EntryTraceOutcome.Resolved,
|
||||
ImmutableArray<EntryTraceNode>.Empty,
|
||||
ImmutableArray<EntryTraceEdge>.Empty,
|
||||
ImmutableArray<EntryTraceDiagnostic>.Empty,
|
||||
ImmutableArray.Create(new EntryTracePlan(
|
||||
ImmutableArray.Create("/usr/bin/app"),
|
||||
ImmutableDictionary<string, string>.Empty,
|
||||
"/workspace",
|
||||
"scanner",
|
||||
"/usr/bin/app",
|
||||
EntryTraceTerminalType.Native,
|
||||
null,
|
||||
0.9,
|
||||
ImmutableDictionary<string, string>.Empty)),
|
||||
ImmutableArray.Create(new EntryTraceTerminal(
|
||||
"/usr/bin/app",
|
||||
EntryTraceTerminalType.Native,
|
||||
null,
|
||||
0.9,
|
||||
ImmutableDictionary<string, string>.Empty,
|
||||
"scanner",
|
||||
"/workspace",
|
||||
ImmutableArray<string>.Empty)));
|
||||
|
||||
var analyzer = new CapturingEntryTraceAnalyzer(graph);
|
||||
var store = new CapturingEntryTraceResultStore();
|
||||
var service = CreateService(analyzer, store);
|
||||
|
||||
await service.ExecuteAsync(CreateContext(metadata), TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.True(store.Stored);
|
||||
Assert.NotNull(store.LastResult);
|
||||
Assert.NotNull(store.LastResult!.Graph.BinaryIntelligence);
|
||||
Assert.Equal(1, store.LastResult.Graph.BinaryIntelligence!.TotalTargets);
|
||||
Assert.Equal(1, store.LastResult.Graph.BinaryIntelligence.AnalyzedTargets);
|
||||
Assert.Single(store.LastResult.Graph.BinaryIntelligence.Targets);
|
||||
Assert.Contains(
|
||||
store.LastResult.Graph.BinaryIntelligence.Targets[0].VulnerableMatches,
|
||||
match => string.Equals(match.VulnerabilityId, "CVE-2024-9999", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
@@ -273,19 +325,24 @@ public sealed class EntryTraceExecutionServiceTests : IDisposable
|
||||
|
||||
private sealed class CapturingEntryTraceAnalyzer : IEntryTraceAnalyzer
|
||||
{
|
||||
public CapturingEntryTraceAnalyzer(EntryTraceGraph? graph = null)
|
||||
{
|
||||
Graph = graph ?? new EntryTraceGraph(
|
||||
EntryTraceOutcome.Resolved,
|
||||
ImmutableArray<EntryTraceNode>.Empty,
|
||||
ImmutableArray<EntryTraceEdge>.Empty,
|
||||
ImmutableArray<EntryTraceDiagnostic>.Empty,
|
||||
ImmutableArray<EntryTracePlan>.Empty,
|
||||
ImmutableArray<EntryTraceTerminal>.Empty);
|
||||
}
|
||||
|
||||
public bool Invoked { get; private set; }
|
||||
|
||||
public EntrypointSpecification? LastEntrypoint { get; private set; }
|
||||
|
||||
public EntryTraceContext? LastContext { get; private set; }
|
||||
|
||||
public EntryTraceGraph Graph { get; } = new(
|
||||
EntryTraceOutcome.Resolved,
|
||||
ImmutableArray<EntryTraceNode>.Empty,
|
||||
ImmutableArray<EntryTraceEdge>.Empty,
|
||||
ImmutableArray<EntryTraceDiagnostic>.Empty,
|
||||
ImmutableArray<EntryTracePlan>.Empty,
|
||||
ImmutableArray<EntryTraceTerminal>.Empty);
|
||||
public EntryTraceGraph Graph { get; }
|
||||
|
||||
public ValueTask<EntryTraceGraph> ResolveAsync(EntrypointSpecification entrypoint, EntryTraceContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
@@ -303,6 +360,26 @@ public sealed class EntryTraceExecutionServiceTests : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] CreateElfPayloadWithMarker(string marker)
|
||||
{
|
||||
var prefix = new byte[]
|
||||
{
|
||||
0x7F, 0x45, 0x4C, 0x46, // ELF
|
||||
0x02, 0x01, 0x01, 0x00, // 64-bit, little-endian
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0x02, 0x00, // e_type
|
||||
0x3E, 0x00, // x64 machine
|
||||
0x01, 0x00, 0x00, 0x00
|
||||
};
|
||||
|
||||
var markerBytes = Encoding.ASCII.GetBytes($"SSL_read\0{marker}\0");
|
||||
var payload = new byte[512];
|
||||
Buffer.BlockCopy(prefix, 0, payload, 0, prefix.Length);
|
||||
Buffer.BlockCopy(markerBytes, 0, payload, 128, markerBytes.Length);
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
private sealed class CapturingEntryTraceResultStore : IEntryTraceResultStore
|
||||
{
|
||||
public bool Stored { get; private set; }
|
||||
|
||||
@@ -4,6 +4,8 @@ Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_sol
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| QA-SCANNER-VERIFY-009 | DONE | `SPRINT_20260212_002_Scanner_unchecked_feature_verification_batch1.md`: added deterministic `BinaryLookupStageExecutorTests` coverage for runtime patch verification, Build-ID mapping, and unified finding publication wiring (run-002, 2026-02-12). |
|
||||
| QA-SCANNER-VERIFY-008 | DONE | `SPRINT_20260212_002_Scanner_unchecked_feature_verification_batch1.md`: added worker entry-trace execution coverage for binary intelligence graph enrichment and validated run-002 pass (2026-02-12). |
|
||||
| REMED-05 | TODO | Remediation checklist: docs/implplan/audits/csproj-standards/remediation/checklists/src/Scanner/__Tests/StellaOps.Scanner.Worker.Tests/StellaOps.Scanner.Worker.Tests.md. |
|
||||
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
|
||||
| SPRINT-20260208-060-IDEMP-001 | DONE | Implement idempotent verdict attestation submission (idempotency key + dedupe + retry classification + tests). |
|
||||
|
||||
Reference in New Issue
Block a user