Files
git.stella-ops.org/tests/reachability/StellaOps.ScannerSignals.IntegrationTests/ScannerToSignalsReachabilityTests.cs
master 56c687253f
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
feat(ruby): Implement RubyManifestParser for parsing gem groups and dependencies
feat(ruby): Add RubyVendorArtifactCollector to collect vendor artifacts

test(deno): Add golden tests for Deno analyzer with various fixtures

test(deno): Create Deno module and package files for testing

test(deno): Implement Deno lock and import map for dependency management

test(deno): Add FFI and worker scripts for Deno testing

feat(ruby): Set up Ruby workspace with Gemfile and dependencies

feat(ruby): Add expected output for Ruby workspace tests

feat(signals): Introduce CallgraphManifest model for signal processing
2025-11-10 09:27:03 +02:00

234 lines
8.5 KiB
C#

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<string, ICallgraphParser>
{
["java"] = parser
});
var artifactStore = new InMemoryCallgraphArtifactStore();
var callgraphRepo = new InMemoryCallgraphRepository();
var ingestionService = new CallgraphIngestionService(
parserResolver,
artifactStore,
callgraphRepo,
Options.Create(new SignalsOptions()),
TimeProvider.System,
NullLogger<CallgraphIngestionService>.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<ReachabilityScoringService>.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<string> ReadRuntimeHits(string tracePath)
{
var hits = new List<string>();
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<string, ICallgraphParser> parsers;
public StaticParserResolver(IReadOnlyDictionary<string, ICallgraphParser> 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<string, CallgraphDocument> storage = new(StringComparer.Ordinal);
public Task<CallgraphDocument?> GetByIdAsync(string id, CancellationToken cancellationToken)
{
storage.TryGetValue(id, out var document);
return Task.FromResult(document);
}
public Task<CallgraphDocument> 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<string, ReachabilityFactDocument> storage = new(StringComparer.Ordinal);
public Task<ReachabilityFactDocument?> GetBySubjectAsync(string subjectKey, CancellationToken cancellationToken)
{
storage.TryGetValue(subjectKey, out var document);
return Task.FromResult(document);
}
public Task<ReachabilityFactDocument> UpsertAsync(ReachabilityFactDocument document, CancellationToken cancellationToken)
{
storage[document.SubjectKey] = document;
return Task.FromResult(document);
}
}
private sealed class InMemoryCallgraphArtifactStore : ICallgraphArtifactStore
{
public async Task<StoredCallgraphArtifact> 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}.");
}
var casUri = $"cas://fixtures/{request.Component}/{request.Version}/{computedHash}";
var manifestPath = $"cas://fixtures/{request.Component}/{request.Version}/{computedHash}/manifest";
return new StoredCallgraphArtifact(
Path: $"fixtures/{request.Component}/{request.Version}/{request.FileName}",
Length: bytes.Length,
Hash: computedHash,
ContentType: request.ContentType,
CasUri: casUri,
ManifestPath: manifestPath,
ManifestCasUri: manifestPath);
}
}
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).");
}
}