This commit is contained in:
StellaOps Bot
2025-12-13 02:22:15 +02:00
parent 564df71bfb
commit 999e26a48e
395 changed files with 25045 additions and 2224 deletions

View File

@@ -0,0 +1,76 @@
using System.Security.Cryptography;
using System.Text.Json;
using FluentAssertions;
using Xunit;
namespace StellaOps.Reachability.FixtureTests;
public class SamplesPublicFixtureTests
{
private static readonly string RepoRoot = ReachbenchFixtureTests.LocateRepoRoot();
private static readonly string SamplesPublicRoot = Path.Combine(RepoRoot, "tests", "reachability", "samples-public");
private static readonly string SamplesRoot = Path.Combine(SamplesPublicRoot, "samples");
private static readonly string[] RequiredFiles =
[
"callgraph.static.json",
"ground-truth.json",
"sbom.cdx.json",
"vex.openvex.json",
"repro.sh"
];
[Fact]
public void ManifestExistsAndIsSorted()
{
var manifestPath = Path.Combine(SamplesPublicRoot, "manifest.json");
File.Exists(manifestPath).Should().BeTrue("samples-public manifest should exist");
using var stream = File.OpenRead(manifestPath);
using var doc = JsonDocument.Parse(stream);
doc.RootElement.ValueKind.Should().Be(JsonValueKind.Array);
var keys = doc.RootElement.EnumerateArray()
.Select(entry => $"{entry.GetProperty("language").GetString()}/{entry.GetProperty("id").GetString()}")
.ToArray();
keys.Should().NotBeEmpty("samples-public manifest should have entries");
keys.Should().BeInAscendingOrder(StringComparer.Ordinal);
}
[Fact]
public void SamplesPublicEntriesMatchManifestHashes()
{
var manifestPath = Path.Combine(SamplesPublicRoot, "manifest.json");
var manifest = JsonDocument.Parse(File.ReadAllBytes(manifestPath)).RootElement.EnumerateArray().ToArray();
manifest.Should().NotBeEmpty("samples-public manifest must have entries");
foreach (var entry in manifest)
{
var id = entry.GetProperty("id").GetString();
var language = entry.GetProperty("language").GetString();
var files = entry.GetProperty("files");
id.Should().NotBeNullOrEmpty();
language.Should().NotBeNullOrEmpty();
files.ValueKind.Should().Be(JsonValueKind.Object);
var caseDir = Path.Combine(SamplesRoot, language!, id!);
Directory.Exists(caseDir).Should().BeTrue($"case folder missing: {caseDir}");
foreach (var filename in RequiredFiles)
{
files.TryGetProperty(filename, out var expectedHashProp).Should().BeTrue($"{id} manifest missing {filename}");
var expectedHash = expectedHashProp.GetString();
expectedHash.Should().NotBeNullOrEmpty($"{id} expected hash missing for {filename}");
var filePath = Path.Combine(caseDir, filename);
File.Exists(filePath).Should().BeTrue($"{id} missing {filename}");
var actualHash = BitConverter.ToString(SHA256.HashData(File.ReadAllBytes(filePath))).Replace("-", "").ToLowerInvariant();
actualHash.Should().Be(expectedHash, $"{id} hash mismatch for {filename}");
}
}
}
}

View File

@@ -49,6 +49,7 @@ public sealed class ScannerToSignalsReachabilityTests
parserResolver,
artifactStore,
callgraphRepo,
new CallgraphNormalizationService(),
Options.Create(new SignalsOptions()),
TimeProvider.System,
NullLogger<CallgraphIngestionService>.Instance);
@@ -65,10 +66,15 @@ public sealed class ScannerToSignalsReachabilityTests
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 truth = JsonDocument.Parse(File.ReadAllText(Path.Combine(variantPath, "reachgraph.truth.json"))).RootElement;
@@ -180,6 +186,46 @@ public sealed class ScannerToSignalsReachabilityTests
}
}
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
{
public async Task<StoredCallgraphArtifact> SaveAsync(CallgraphArtifactSaveRequest request, Stream content, CancellationToken cancellationToken)

View File

@@ -7,8 +7,10 @@ using System.Threading;
using System.Threading.Tasks;
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using MongoDB.Bson;
using StellaOps.Signals.Models;
using StellaOps.Signals.Options;
using StellaOps.Signals.Parsing;
using StellaOps.Signals.Persistence;
using StellaOps.Signals.Services;
@@ -56,7 +58,19 @@ public sealed class ReachabilityScoringTests
var callgraph = await LoadCallgraphAsync(caseId, variant, variantPath);
var callgraphRepo = new InMemoryCallgraphRepository(callgraph);
var factRepo = new InMemoryReachabilityFactRepository();
var scoringService = new ReachabilityScoringService(callgraphRepo, factRepo, TimeProvider.System, NullLogger<ReachabilityScoringService>.Instance);
var options = new SignalsOptions();
var cache = new InMemoryReachabilityCache();
var eventsPublisher = new NullEventsPublisher();
var unknowns = new InMemoryUnknownsRepository();
var scoringService = new ReachabilityScoringService(
callgraphRepo,
factRepo,
TimeProvider.System,
Options.Create(options),
cache,
unknowns,
eventsPublisher,
NullLogger<ReachabilityScoringService>.Instance);
var request = BuildRequest(casePath, variant, sinks, entryPoints);
request.CallgraphId = callgraph.Id;
@@ -218,6 +232,47 @@ public sealed class ReachabilityScoringTests
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 doc);
return Task.FromResult(doc);
}
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 static string LocateRepoRoot()
{
var current = new DirectoryInfo(AppContext.BaseDirectory);

View File

@@ -0,0 +1,15 @@
# Public Reachability Samples (offline fixtures)
This folder contains a small, public-friendly reachability mini-dataset intended for:
- deterministic fixture validation in CI (hash manifests),
- offline demos and ingestion tests (Signals callgraph/runtime facts),
- documentation examples without pulling external repos.
Layout (mirrors `docs/reachability/corpus-plan.md`):
- `schema/ground-truth.schema.json` — JSON schema for `ground-truth.json`.
- `scripts/update_manifest.py` — deterministic manifest generator.
- `manifest.json` — hashes for required files in each sample directory.
- `samples/<lang>/<case-id>/` — per-sample code + callgraph + SBOM + VEX + ground truth.

View File

@@ -0,0 +1,35 @@
[
{
"files": {
"callgraph.static.json": "1492f2cd51647c6c483f4d8a169f4b0c2ef5b3cc73f280aacd46035793fb07e2",
"ground-truth.json": "34b5500cfb9f14e4d423a3021372286520826e79a29f5405e3b5b4fd7473686f",
"repro.sh": "6be2324a06a4873c80ada305b60602e3f1ca134a39d021f663694974e8e7b20b",
"sbom.cdx.json": "999333d7e6b1c2f96833d532ae9a247cec6589ae936a16e8e8db21bf6885267a",
"vex.openvex.json": "447fa7bf849d61fc59d8892c37918714c7cb878658db27d709001560a173fe0b"
},
"id": "cs-001-binaryformatter-deserialize",
"language": "csharp"
},
{
"files": {
"callgraph.static.json": "32de821ce640554c4cdeac9251c3314e30c934c17783a11b999c54af03f9321c",
"ground-truth.json": "d9493688ae781f32628476b1bf06a45501e08a260f65ae68fd4d3722e01acc46",
"repro.sh": "6be2324a06a4873c80ada305b60602e3f1ca134a39d021f663694974e8e7b20b",
"sbom.cdx.json": "035a9c241e287ff4a52d2e702735649b96ddfec1ffb1ce8de980634b44906694",
"vex.openvex.json": "c1a62ab9bde5a1e60ac48f1fb8e275fa75caeadb29177972808a9abaee2f17f5"
},
"id": "js-002-yaml-unsafe-load",
"language": "js"
},
{
"files": {
"callgraph.static.json": "e55c78a1ea4c3615477bdabe2b069e3d756be2b21c4b359186612818a7213470",
"ground-truth.json": "de5b129c315abb4eae3dabe76c0961fe5f97dff3024805c6705ebb45c809f22c",
"repro.sh": "6be2324a06a4873c80ada305b60602e3f1ca134a39d021f663694974e8e7b20b",
"sbom.cdx.json": "d4b18c13d2d7e7cda0ec7a8f6355e4dc52f0c30eaa8b1eddaea2c0da21620455",
"vex.openvex.json": "df1c795978893c01d64831ff2232aebd66efe9cfe418f461ac125aece1906778"
},
"id": "php-001-phar-deserialize",
"language": "php"
}
]

View File

@@ -0,0 +1,5 @@
$ErrorActionPreference = "Stop"
python (Join-Path $PSScriptRoot "..\\scripts\\update_manifest.py") | Out-Null
Write-Host "samples-public: manifest regenerated"

View File

@@ -0,0 +1,6 @@
#!/usr/bin/env bash
set -euo pipefail
python3 "$(dirname "$0")/../scripts/update_manifest.py" >/dev/null
echo "samples-public: manifest regenerated"

View File

@@ -0,0 +1,13 @@
using System;
using System.IO;
using System.Runtime.Serialization.Formatters.Binary;
// Fixture-only sample: demonstrates a BinaryFormatter deserialize-style sink.
// Do not deploy.
var payload = Environment.GetEnvironmentVariable("PAYLOAD") ?? string.Empty;
var bytes = Convert.FromBase64String(payload);
using var ms = new MemoryStream(bytes);
var formatter = new BinaryFormatter();
_ = formatter.Deserialize(ms);

View File

@@ -0,0 +1,4 @@
# cs-001-binaryformatter-deserialize
Minimal C# sample used as a public reachability fixture.

View File

@@ -0,0 +1,14 @@
{
"schema_version": "1.0",
"roots": [
{ "id": "sym://dotnet:Program#Main", "phase": "runtime", "source": "static" }
],
"nodes": [
{ "id": "sym://dotnet:Program#Main", "name": "Main", "kind": "function", "language": "dotnet" },
{ "id": "sym://dotnet:System.Runtime.Serialization.Formatters.Binary.BinaryFormatter#Deserialize", "name": "Deserialize", "kind": "function", "language": "dotnet" }
],
"edges": [
{ "from": "sym://dotnet:Program#Main", "to": "sym://dotnet:System.Runtime.Serialization.Formatters.Binary.BinaryFormatter#Deserialize", "kind": "call" }
]
}

View File

@@ -0,0 +1,12 @@
{
"case_id": "cs-001-binaryformatter-deserialize",
"paths": [
[
"sym://dotnet:Program#Main",
"sym://dotnet:System.Runtime.Serialization.Formatters.Binary.BinaryFormatter#Deserialize"
]
],
"schema_version": "reachbench.reachgraph.truth/v1",
"variant": "reachable"
}

View File

@@ -0,0 +1,5 @@
#!/usr/bin/env bash
set -euo pipefail
echo "Fixture-only sample: no live repro; use callgraph.static.json + ground-truth.json for ingestion/tests."

View File

@@ -0,0 +1,22 @@
{
"bomFormat": "CycloneDX",
"specVersion": "1.6",
"version": 1,
"metadata": {
"component": {
"type": "application",
"name": "cs-001-binaryformatter-deserialize",
"version": "0.0.0",
"purl": "pkg:nuget/cs-001-binaryformatter-deserialize@0.0.0"
}
},
"components": [
{
"type": "library",
"name": "System.Runtime.Serialization.Formatters",
"version": "4.3.0",
"purl": "pkg:nuget/System.Runtime.Serialization.Formatters@4.3.0"
}
]
}

View File

@@ -0,0 +1,21 @@
{
"@context": "https://openvex.dev/ns/v0.2.0",
"@id": "urn:stellaops:vex:cs-001-binaryformatter-deserialize",
"author": "StellaOps",
"timestamp": "2025-12-12T00:00:00Z",
"version": 1,
"statements": [
{
"vulnerability": {
"name": "CVE-TEST-0003"
},
"products": [
{
"@id": "pkg:nuget/System.Runtime.Serialization.Formatters@4.3.0"
}
],
"status": "under_investigation"
}
]
}

View File

@@ -0,0 +1,4 @@
# js-002-yaml-unsafe-load
Minimal JavaScript sample used as a public reachability fixture.

View File

@@ -0,0 +1,14 @@
{
"schema_version": "1.0",
"roots": [
{ "id": "sym://js:src/index.js#main", "phase": "runtime", "source": "static" }
],
"nodes": [
{ "id": "sym://js:src/index.js#main", "name": "main", "kind": "function", "file": "src/index.js", "line": 1, "language": "nodejs" },
{ "id": "sym://js:node_modules/js-yaml#load", "name": "load", "kind": "function", "namespace": "js-yaml", "language": "nodejs" }
],
"edges": [
{ "from": "sym://js:src/index.js#main", "to": "sym://js:node_modules/js-yaml#load", "kind": "call" }
]
}

View File

@@ -0,0 +1,12 @@
{
"case_id": "js-002-yaml-unsafe-load",
"paths": [
[
"sym://js:src/index.js#main",
"sym://js:node_modules/js-yaml#load"
]
],
"schema_version": "reachbench.reachgraph.truth/v1",
"variant": "reachable"
}

View File

@@ -0,0 +1,6 @@
// Fixture-only sample: demonstrates an unsafe YAML load-style sink.
// Do not deploy.
const yaml = require("js-yaml");
yaml.load(process.env.PAYLOAD || "");

View File

@@ -0,0 +1,5 @@
#!/usr/bin/env bash
set -euo pipefail
echo "Fixture-only sample: no live repro; use callgraph.static.json + ground-truth.json for ingestion/tests."

View File

@@ -0,0 +1,22 @@
{
"bomFormat": "CycloneDX",
"specVersion": "1.6",
"version": 1,
"metadata": {
"component": {
"type": "application",
"name": "js-002-yaml-unsafe-load",
"version": "0.0.0",
"purl": "pkg:npm/js-002-yaml-unsafe-load@0.0.0"
}
},
"components": [
{
"type": "library",
"name": "js-yaml",
"version": "4.1.0",
"purl": "pkg:npm/js-yaml@4.1.0"
}
]
}

View File

@@ -0,0 +1,21 @@
{
"@context": "https://openvex.dev/ns/v0.2.0",
"@id": "urn:stellaops:vex:js-002-yaml-unsafe-load",
"author": "StellaOps",
"timestamp": "2025-12-12T00:00:00Z",
"version": 1,
"statements": [
{
"vulnerability": {
"name": "CVE-TEST-0002"
},
"products": [
{
"@id": "pkg:npm/js-yaml@4.1.0"
}
],
"status": "under_investigation"
}
]
}

View File

@@ -0,0 +1,6 @@
# php-001-phar-deserialize
Minimal PHP sample used as a public reachability fixture.
This is a fixture only: it is not intended to be deployed.

View File

@@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
// Fixture-only sample: demonstrates a deserialize-style sink.
// Do not deploy.
$payload = $_GET["payload"] ?? "";
unserialize($payload);

View File

@@ -0,0 +1,16 @@
{
"schema_version": "1.0",
"roots": [
{ "id": "sym://php:public/index.php#main", "phase": "runtime", "source": "static" }
],
"nodes": [
{ "id": "sym://php:public/index.php#main", "name": "main", "kind": "function", "file": "public/index.php", "line": 1, "language": "php" },
{ "id": "sym://php:app/UploadController.php#handle", "name": "handle", "kind": "function", "file": "app/UploadController.php", "line": 1, "language": "php" },
{ "id": "sym://php:php.net#unserialize", "name": "unserialize", "kind": "function", "namespace": "php", "language": "php" }
],
"edges": [
{ "from": "sym://php:public/index.php#main", "to": "sym://php:app/UploadController.php#handle", "kind": "call" },
{ "from": "sym://php:app/UploadController.php#handle", "to": "sym://php:php.net#unserialize", "kind": "call" }
]
}

View File

@@ -0,0 +1,13 @@
{
"case_id": "php-001-phar-deserialize",
"paths": [
[
"sym://php:public/index.php#main",
"sym://php:app/UploadController.php#handle",
"sym://php:php.net#unserialize"
]
],
"schema_version": "reachbench.reachgraph.truth/v1",
"variant": "reachable"
}

View File

@@ -0,0 +1,5 @@
#!/usr/bin/env bash
set -euo pipefail
echo "Fixture-only sample: no live repro; use callgraph.static.json + ground-truth.json for ingestion/tests."

View File

@@ -0,0 +1,21 @@
{
"bomFormat": "CycloneDX",
"specVersion": "1.6",
"version": 1,
"metadata": {
"component": {
"type": "application",
"name": "php-001-phar-deserialize",
"version": "0.0.0"
}
},
"components": [
{
"type": "library",
"name": "php",
"version": "8.x",
"purl": "pkg:generic/php@8"
}
]
}

View File

@@ -0,0 +1,21 @@
{
"@context": "https://openvex.dev/ns/v0.2.0",
"@id": "urn:stellaops:vex:php-001-phar-deserialize",
"author": "StellaOps",
"timestamp": "2025-12-12T00:00:00Z",
"version": 1,
"statements": [
{
"vulnerability": {
"name": "CVE-TEST-0001"
},
"products": [
{
"@id": "pkg:generic/php@8"
}
],
"status": "under_investigation"
}
]
}

View File

@@ -0,0 +1,34 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "stellaops.reachability.ground-truth.schema.json",
"title": "StellaOps Reachability Ground Truth (public samples)",
"type": "object",
"required": ["schema_version", "case_id", "variant", "paths"],
"properties": {
"schema_version": {
"type": "string",
"const": "reachbench.reachgraph.truth/v1"
},
"case_id": {
"type": "string",
"minLength": 1
},
"variant": {
"type": "string",
"enum": ["reachable", "unreachable"]
},
"paths": {
"type": "array",
"items": {
"type": "array",
"minItems": 1,
"items": {
"type": "string",
"minLength": 1
}
}
}
},
"additionalProperties": true
}

View File

@@ -0,0 +1,53 @@
#!/usr/bin/env python3
"""
Regenerate the public samples manifest deterministically.
Usage: python tests/reachability/samples-public/scripts/update_manifest.py
"""
from __future__ import annotations
import hashlib
import json
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
SAMPLES_ROOT = ROOT / "samples"
FILE_LIST = [
"callgraph.static.json",
"ground-truth.json",
"sbom.cdx.json",
"vex.openvex.json",
"repro.sh",
]
def sha256(path: Path) -> str:
return hashlib.sha256(path.read_bytes()).hexdigest()
def main() -> int:
entries: list[dict] = []
for lang_dir in sorted(p for p in SAMPLES_ROOT.iterdir() if p.is_dir()):
for case_dir in sorted(p for p in lang_dir.iterdir() if p.is_dir()):
files: dict[str, str] = {}
for name in FILE_LIST:
path = case_dir / name
if not path.exists():
raise SystemExit(f"missing {path}")
files[name] = sha256(path)
entries.append(
{
"id": case_dir.name,
"language": lang_dir.name,
"files": files,
}
)
manifest_path = ROOT / "manifest.json"
manifest_path.write_text(json.dumps(entries, indent=2, sort_keys=True) + "\n")
print(f"wrote {manifest_path} ({len(entries)} entries)")
return 0
if __name__ == "__main__":
raise SystemExit(main())