Some checks failed
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
Policy Simulation / policy-simulate (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Signals CI & Image / signals-ci (push) Has been cancelled
Signals Reachability Scoring & Events / reachability-smoke (push) Has been cancelled
Signals Reachability Scoring & Events / sign-and-upload (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Scanner Analyzers / Discover Analyzers (push) Has been cancelled
Scanner Analyzers / Build Analyzers (push) Has been cancelled
Scanner Analyzers / Test Language Analyzers (push) Has been cancelled
Scanner Analyzers / Validate Test Fixtures (push) Has been cancelled
Scanner Analyzers / Verify Deterministic Output (push) Has been cancelled
336 lines
12 KiB
C#
336 lines
12 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 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 truth = JsonDocument.Parse(File.ReadAllText(Path.Combine(variantPath, "reachgraph.truth.json"))).RootElement;
|
|
var paths = truth.GetProperty("paths")
|
|
.EnumerateArray()
|
|
.Select(path => path.EnumerateArray().Select(x => x.GetString()!).Where(x => !string.IsNullOrWhiteSpace(x)).ToList())
|
|
.Where(path => path.Count > 0)
|
|
.ToList();
|
|
|
|
var builder = new ReachabilityGraphBuilder();
|
|
foreach (var path in paths)
|
|
{
|
|
for (var i = 0; i < path.Count; i++)
|
|
{
|
|
builder.AddNode(path[i]);
|
|
if (i + 1 < path.Count)
|
|
{
|
|
builder.AddEdge(path[i], path[i + 1]);
|
|
}
|
|
}
|
|
}
|
|
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 reachabilityStore = new InMemoryReachabilityStoreRepository(TimeProvider.System);
|
|
var ingestionService = new CallgraphIngestionService(
|
|
parserResolver,
|
|
artifactStore,
|
|
callgraphRepo,
|
|
reachabilityStore,
|
|
new CallgraphNormalizationService(),
|
|
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 scoringOptions = new SignalsOptions();
|
|
var scoringService = new ReachabilityScoringService(
|
|
callgraphRepo,
|
|
new InMemoryReachabilityFactRepository(),
|
|
TimeProvider.System,
|
|
Options.Create(scoringOptions),
|
|
new InMemoryReachabilityCache(),
|
|
new InMemoryUnknownsRepository(),
|
|
new NullEventsPublisher(),
|
|
NullLogger<ReachabilityScoringService>.Instance);
|
|
|
|
var entryPoints = paths
|
|
.Select(path => path[0])
|
|
.Distinct(StringComparer.Ordinal)
|
|
.ToList();
|
|
var targets = paths
|
|
.Select(path => path[^1])
|
|
.Distinct(StringComparer.Ordinal)
|
|
.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 = $"cg-{storage.Count + 1}";
|
|
}
|
|
|
|
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 InMemoryReachabilityCache : IReachabilityCache
|
|
{
|
|
private readonly Dictionary<string, ReachabilityFactDocument> storage = new(StringComparer.Ordinal);
|
|
|
|
public Task<ReachabilityFactDocument?> GetAsync(string subjectKey, CancellationToken cancellationToken)
|
|
{
|
|
storage.TryGetValue(subjectKey, out var document);
|
|
return Task.FromResult(document);
|
|
}
|
|
|
|
public Task SetAsync(ReachabilityFactDocument document, CancellationToken cancellationToken)
|
|
{
|
|
storage[document.SubjectKey] = document;
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
public Task InvalidateAsync(string subjectKey, CancellationToken cancellationToken)
|
|
{
|
|
storage.Remove(subjectKey);
|
|
return Task.CompletedTask;
|
|
}
|
|
}
|
|
|
|
private sealed class InMemoryUnknownsRepository : IUnknownsRepository
|
|
{
|
|
public Task UpsertAsync(string subjectKey, IEnumerable<UnknownSymbolDocument> items, CancellationToken cancellationToken) =>
|
|
Task.CompletedTask;
|
|
|
|
public Task<IReadOnlyList<UnknownSymbolDocument>> GetBySubjectAsync(string subjectKey, CancellationToken cancellationToken) =>
|
|
Task.FromResult((IReadOnlyList<UnknownSymbolDocument>)Array.Empty<UnknownSymbolDocument>());
|
|
|
|
public Task<int> CountBySubjectAsync(string subjectKey, CancellationToken cancellationToken) =>
|
|
Task.FromResult(0);
|
|
}
|
|
|
|
private sealed class NullEventsPublisher : IEventsPublisher
|
|
{
|
|
public Task PublishFactUpdatedAsync(ReachabilityFactDocument fact, CancellationToken cancellationToken) => Task.CompletedTask;
|
|
}
|
|
|
|
private sealed class InMemoryCallgraphArtifactStore : ICallgraphArtifactStore
|
|
{
|
|
private readonly Dictionary<string, byte[]> artifacts = new(StringComparer.OrdinalIgnoreCase);
|
|
private readonly Dictionary<string, byte[]> manifests = new(StringComparer.OrdinalIgnoreCase);
|
|
|
|
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";
|
|
|
|
artifacts[computedHash] = bytes;
|
|
|
|
if (request.ManifestContent is not null)
|
|
{
|
|
await using var manifestBuffer = new MemoryStream();
|
|
await request.ManifestContent.CopyToAsync(manifestBuffer, cancellationToken).ConfigureAwait(false);
|
|
manifests[computedHash] = manifestBuffer.ToArray();
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
public Task<Stream?> GetAsync(string hash, string? fileName, CancellationToken cancellationToken)
|
|
{
|
|
if (!artifacts.TryGetValue(hash, out var bytes))
|
|
{
|
|
return Task.FromResult<Stream?>(null);
|
|
}
|
|
|
|
return Task.FromResult<Stream?>(new MemoryStream(bytes, writable: false));
|
|
}
|
|
|
|
public Task<Stream?> GetManifestAsync(string hash, CancellationToken cancellationToken)
|
|
{
|
|
if (!manifests.TryGetValue(hash, out var bytes))
|
|
{
|
|
return Task.FromResult<Stream?>(null);
|
|
}
|
|
|
|
return Task.FromResult<Stream?>(new MemoryStream(bytes, writable: false));
|
|
}
|
|
|
|
public Task<bool> ExistsAsync(string hash, CancellationToken cancellationToken)
|
|
=> Task.FromResult(artifacts.ContainsKey(hash));
|
|
}
|
|
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).");
|
|
}
|
|
}
|