save checkpoint

This commit is contained in:
master
2026-02-12 21:02:43 +02:00
parent 5bca406787
commit 9911b7d73c
593 changed files with 174390 additions and 1376 deletions

View File

@@ -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`. |

View File

@@ -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)
{

View File

@@ -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>

View File

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

View File

@@ -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" />

View File

@@ -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. |

View File

@@ -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;

View File

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

View File

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

View File

@@ -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. |

View File

@@ -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";

View File

@@ -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. |

View File

@@ -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>

View File

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

View File

@@ -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. |

View File

@@ -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(

View File

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

View File

@@ -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",

View File

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

View File

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

View File

@@ -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. |

View File

@@ -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", ".")));
}
}

View File

@@ -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. |

View File

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

View File

@@ -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. |

View File

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

View File

@@ -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)]

View File

@@ -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). |

View File

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

View File

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

View File

@@ -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). |