using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Security.Cryptography; using System.Text; using System.Text.Json; using System.Threading; using System.Threading.Tasks; using FluentAssertions; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using MongoDB.Bson; using StellaOps.Scanner.Reachability; using StellaOps.Signals.Models; using StellaOps.Signals.Options; using StellaOps.Signals.Parsing; using StellaOps.Signals.Persistence; using StellaOps.Signals.Services; using StellaOps.Signals.Storage; using StellaOps.Signals.Storage.Models; using Xunit; namespace StellaOps.ScannerSignals.IntegrationTests; public sealed class ScannerToSignalsReachabilityTests { private static readonly string RepoRoot = LocateRepoRoot(); private static readonly string FixtureRoot = Path.Combine(RepoRoot, "tests", "reachability", "fixtures", "reachbench-2025-expanded", "cases"); [Fact] public async Task ScannerBuilderFeedsSignalsScoringPipeline() { var caseId = "java-log4j-CVE-2021-44228-log4shell"; var variant = "reachable"; var variantPath = Path.Combine(FixtureRoot, caseId, "images", variant); Directory.Exists(variantPath).Should().BeTrue(); var builder = ReachabilityGraphBuilder.FromFixture(variantPath); var artifactJson = builder.BuildJson(indented: false); var parser = new SimpleJsonCallgraphParser("java"); var parserResolver = new StaticParserResolver(new Dictionary { ["java"] = parser }); var artifactStore = new InMemoryCallgraphArtifactStore(); var callgraphRepo = new InMemoryCallgraphRepository(); var ingestionService = new CallgraphIngestionService( parserResolver, artifactStore, callgraphRepo, Options.Create(new SignalsOptions()), TimeProvider.System, NullLogger.Instance); var request = new CallgraphIngestRequest( Language: "java", Component: caseId, Version: variant, ArtifactContentType: "application/json", ArtifactFileName: "callgraph.static.json", ArtifactContentBase64: Convert.ToBase64String(Encoding.UTF8.GetBytes(artifactJson)), Metadata: null); var ingestResponse = await ingestionService.IngestAsync(request, CancellationToken.None); ingestResponse.CallgraphId.Should().NotBeNullOrWhiteSpace(); var scoringService = new ReachabilityScoringService( callgraphRepo, new InMemoryReachabilityFactRepository(), TimeProvider.System, NullLogger.Instance); var truth = JsonDocument.Parse(File.ReadAllText(Path.Combine(variantPath, "reachgraph.truth.json"))).RootElement; var entryPoints = truth.GetProperty("paths").EnumerateArray() .Select(path => path[0].GetString()!) .Distinct(StringComparer.Ordinal) .ToList(); var targets = truth.GetProperty("sinks").EnumerateArray().Select(s => s.GetProperty("sid").GetString()!).ToList(); var recomputeRequest = new ReachabilityRecomputeRequest { CallgraphId = ingestResponse.CallgraphId, Subject = new ReachabilitySubject { ScanId = $"{caseId}:{variant}", Component = caseId, Version = variant }, EntryPoints = entryPoints, Targets = targets, RuntimeHits = ReadRuntimeHits(Path.Combine(variantPath, "traces.runtime.jsonl")) }; var fact = await scoringService.RecomputeAsync(recomputeRequest, CancellationToken.None); fact.States.Should().ContainSingle(state => state.Target == targets[0] && state.Reachable); } private static List ReadRuntimeHits(string tracePath) { var hits = new List(); if (!File.Exists(tracePath)) { return hits; } foreach (var line in File.ReadLines(tracePath)) { if (string.IsNullOrWhiteSpace(line)) { continue; } using var doc = JsonDocument.Parse(line); if (doc.RootElement.TryGetProperty("sid", out var sid)) { hits.Add(sid.GetString()!); } } return hits; } private sealed class StaticParserResolver : ICallgraphParserResolver { private readonly IReadOnlyDictionary parsers; public StaticParserResolver(IReadOnlyDictionary parsers) { this.parsers = parsers; } public ICallgraphParser Resolve(string language) { if (parsers.TryGetValue(language, out var parser)) { return parser; } throw new CallgraphParserNotFoundException(language); } } private sealed class InMemoryCallgraphRepository : ICallgraphRepository { private readonly Dictionary storage = new(StringComparer.Ordinal); public Task GetByIdAsync(string id, CancellationToken cancellationToken) { storage.TryGetValue(id, out var document); return Task.FromResult(document); } public Task UpsertAsync(CallgraphDocument document, CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(document.Id)) { document.Id = ObjectId.GenerateNewId().ToString(); } storage[document.Id] = document; return Task.FromResult(document); } } private sealed class InMemoryReachabilityFactRepository : IReachabilityFactRepository { private readonly Dictionary storage = new(StringComparer.Ordinal); public Task GetBySubjectAsync(string subjectKey, CancellationToken cancellationToken) { storage.TryGetValue(subjectKey, out var document); return Task.FromResult(document); } public Task UpsertAsync(ReachabilityFactDocument document, CancellationToken cancellationToken) { storage[document.SubjectKey] = document; return Task.FromResult(document); } } private sealed class InMemoryCallgraphArtifactStore : ICallgraphArtifactStore { public async Task SaveAsync(CallgraphArtifactSaveRequest request, Stream content, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(request); ArgumentNullException.ThrowIfNull(content); await using var buffer = new MemoryStream(); await content.CopyToAsync(buffer, cancellationToken).ConfigureAwait(false); var bytes = buffer.ToArray(); var computedHash = Convert.ToHexString(SHA256.HashData(bytes)); if (content.CanSeek) { content.Position = 0; } if (!computedHash.Equals(request.Hash, StringComparison.OrdinalIgnoreCase)) { throw new InvalidOperationException($"Hash mismatch for {request.FileName}: expected {request.Hash} but computed {computedHash}."); } return new StoredCallgraphArtifact( Path: $"cas://fixtures/{request.Component}/{request.Version}/{request.FileName}", Length: bytes.Length, Hash: computedHash, ContentType: request.ContentType); } } private static string LocateRepoRoot() { var current = new DirectoryInfo(AppContext.BaseDirectory); while (current != null) { if (File.Exists(Path.Combine(current.FullName, "Directory.Build.props"))) { return current.FullName; } current = current.Parent; } throw new InvalidOperationException("Cannot locate repository root (missing Directory.Build.props)."); } }