consolidate the tests locations
This commit is contained in:
@@ -1,111 +0,0 @@
|
||||
# PoE Golden Fixtures
|
||||
|
||||
This directory contains golden test fixtures for Proof of Exposure (PoE) determinism testing.
|
||||
|
||||
## Purpose
|
||||
|
||||
Golden fixtures serve as:
|
||||
1. **Determinism Tests**: Verify that PoE generation produces identical output for identical inputs
|
||||
2. **Regression Tests**: Detect unintended changes to PoE format or content
|
||||
3. **Documentation**: Show real-world examples of PoE artifacts
|
||||
|
||||
## Fixtures
|
||||
|
||||
| Fixture | Description | Size | Paths | Nodes | Edges |
|
||||
|---------|-------------|------|-------|-------|-------|
|
||||
| `log4j-cve-2021-44228.poe.golden.json` | Log4j RCE with single path | ~2.5 KB | 1 | 4 | 3 |
|
||||
| `multi-path-java.poe.golden.json` | Java with 3 alternative paths | ~8 KB | 3 | 12 | 18 |
|
||||
| `guarded-path-dotnet.poe.golden.json` | .NET with feature flag guards | ~5 KB | 2 | 8 | 10 |
|
||||
| `stripped-binary-c.poe.golden.json` | C/C++ stripped binary (code_id) | ~6 KB | 1 | 6 | 5 |
|
||||
|
||||
## Hash Verification
|
||||
|
||||
Each fixture has a known BLAKE3-256 hash for integrity verification:
|
||||
|
||||
```
|
||||
log4j-cve-2021-44228.poe.golden.json:
|
||||
blake3: 7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b
|
||||
sha256: abc123def456789012345678901234567890123456789012345678901234567890
|
||||
```
|
||||
|
||||
## Usage in Tests
|
||||
|
||||
### Determinism Test
|
||||
|
||||
```csharp
|
||||
[Fact]
|
||||
public async Task PoEGeneration_WithSameInputs_ProducesSameHash()
|
||||
{
|
||||
var goldenPath = "Fixtures/log4j-cve-2021-44228.poe.golden.json";
|
||||
var goldenBytes = await File.ReadAllBytesAsync(goldenPath);
|
||||
var goldenHash = ComputeBlake3Hash(goldenBytes);
|
||||
|
||||
// Generate PoE from test inputs
|
||||
var generatedPoe = await GeneratePoE(testInputs);
|
||||
var generatedHash = ComputeBlake3Hash(generatedPoe);
|
||||
|
||||
Assert.Equal(goldenHash, generatedHash);
|
||||
}
|
||||
```
|
||||
|
||||
### Regression Test
|
||||
|
||||
```csharp
|
||||
[Fact]
|
||||
public async Task PoEGeneration_Schema_MatchesGolden()
|
||||
{
|
||||
var goldenPath = "Fixtures/log4j-cve-2021-44228.poe.golden.json";
|
||||
var golden = await LoadPoE(goldenPath);
|
||||
|
||||
// Generate PoE from test inputs
|
||||
var generated = await GeneratePoE(testInputs);
|
||||
|
||||
// Schema should match (structure, field types)
|
||||
Assert.Equal(golden.Schema, generated.Schema);
|
||||
Assert.Equal(golden.Subject.VulnId, generated.Subject.VulnId);
|
||||
Assert.Equal(golden.Subgraph.Nodes.Count, generated.Subgraph.Nodes.Count);
|
||||
}
|
||||
```
|
||||
|
||||
## Generating New Fixtures
|
||||
|
||||
To create a new golden fixture:
|
||||
|
||||
1. **Run scanner on test image:**
|
||||
```bash
|
||||
stella scan --image test/log4j:vulnerable --emit-poe --output ./test-output/
|
||||
```
|
||||
|
||||
2. **Extract PoE artifact:**
|
||||
```bash
|
||||
cp ./test-output/poe.json ./Fixtures/new-fixture.poe.golden.json
|
||||
```
|
||||
|
||||
3. **Verify determinism:**
|
||||
```bash
|
||||
# Run scan again
|
||||
stella scan --image test/log4j:vulnerable --emit-poe --output ./test-output2/
|
||||
|
||||
# Compare hashes
|
||||
sha256sum ./test-output/poe.json ./test-output2/poe.json
|
||||
# Hashes MUST match for determinism
|
||||
```
|
||||
|
||||
4. **Commit fixture:**
|
||||
```bash
|
||||
git add ./Fixtures/new-fixture.poe.golden.json
|
||||
git commit -m "Add golden fixture: new-fixture"
|
||||
```
|
||||
|
||||
## Maintenance
|
||||
|
||||
- **Update fixtures** when PoE schema version changes (schema field)
|
||||
- **Regenerate fixtures** when canonical JSON format changes
|
||||
- **Verify hashes** after any changes to serialization logic
|
||||
- **Document breaking changes** in fixture commit messages
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [POE_PREDICATE_SPEC.md](../../../../src/Attestor/POE_PREDICATE_SPEC.md) - PoE schema specification
|
||||
- [SUBGRAPH_EXTRACTION.md](../../../../src/Scanner/__Libraries/StellaOps.Scanner.Reachability/SUBGRAPH_EXTRACTION.md) - Extraction algorithm
|
||||
- [PoEArtifactGeneratorTests.cs](../../../../src/Attestor/__Tests/PoEArtifactGeneratorTests.cs) - Unit tests using fixtures
|
||||
@@ -1,192 +0,0 @@
|
||||
{
|
||||
"@type": "https://stellaops.dev/predicates/proof-of-exposure@v1",
|
||||
"evidence": {
|
||||
"graphHash": "blake3:f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5",
|
||||
"sbomRef": "cas://scanner-artifacts/sbom.cdx.json"
|
||||
},
|
||||
"metadata": {
|
||||
"analyzer": {
|
||||
"name": "stellaops-scanner",
|
||||
"toolchainDigest": "sha256:567890123456789012345678901234567890123456789012345678901234",
|
||||
"version": "1.2.0"
|
||||
},
|
||||
"generatedAt": "2025-12-23T12:15:00.000Z",
|
||||
"policy": {
|
||||
"evaluatedAt": "2025-12-23T12:13:00.000Z",
|
||||
"policyDigest": "sha256:890123456789012345678901234567890123456789012345678901234567",
|
||||
"policyId": "prod-release-v42"
|
||||
},
|
||||
"reproSteps": [
|
||||
"1. Build container image from Dockerfile (commit: ghi789)",
|
||||
"2. Run scanner with config: etc/scanner.yaml (includeGuards=true)",
|
||||
"3. Extract reachability graph with maxDepth=10",
|
||||
"4. Resolve CVE-2024-56789 to vulnerable symbols with guard predicates"
|
||||
]
|
||||
},
|
||||
"schema": "stellaops.dev/poe@v1",
|
||||
"subject": {
|
||||
"buildId": "gnu-build-id:9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f",
|
||||
"componentRef": "pkg:nuget/VulnerableLib@2.3.1",
|
||||
"imageDigest": "sha256:ghi789012345678901234567890123456789012345678901234567890123",
|
||||
"vulnId": "CVE-2024-56789"
|
||||
},
|
||||
"subgraph": {
|
||||
"edges": [
|
||||
{
|
||||
"confidence": 0.98,
|
||||
"from": "sym:csharp:WebApi.Controllers.PaymentController.ProcessPayment",
|
||||
"guards": [],
|
||||
"to": "sym:csharp:WebApi.Services.PaymentService.Charge"
|
||||
},
|
||||
{
|
||||
"confidence": 0.96,
|
||||
"from": "sym:csharp:WebApi.Services.PaymentService.Charge",
|
||||
"guards": [
|
||||
"FeatureFlags.EnableLegacyPayment"
|
||||
],
|
||||
"to": "sym:csharp:WebApi.Legacy.LegacyPaymentAdapter.ProcessLegacy"
|
||||
},
|
||||
{
|
||||
"confidence": 0.94,
|
||||
"from": "sym:csharp:WebApi.Legacy.LegacyPaymentAdapter.ProcessLegacy",
|
||||
"guards": [],
|
||||
"to": "sym:csharp:VulnerableLib.Crypto.InsecureHasher.ComputeHash"
|
||||
},
|
||||
{
|
||||
"confidence": 0.97,
|
||||
"from": "sym:csharp:WebApi.Controllers.PaymentController.ProcessPayment",
|
||||
"guards": [],
|
||||
"to": "sym:csharp:WebApi.Services.PaymentService.Validate"
|
||||
},
|
||||
{
|
||||
"confidence": 0.95,
|
||||
"from": "sym:csharp:WebApi.Services.PaymentService.Validate",
|
||||
"guards": [
|
||||
"RuntimeInformation.IsOSPlatform(OSPlatform.Windows)"
|
||||
],
|
||||
"to": "sym:csharp:WebApi.Validation.WindowsValidator.CheckSignature"
|
||||
},
|
||||
{
|
||||
"confidence": 0.93,
|
||||
"from": "sym:csharp:WebApi.Validation.WindowsValidator.CheckSignature",
|
||||
"guards": [],
|
||||
"to": "sym:csharp:VulnerableLib.Crypto.InsecureHasher.ComputeHash"
|
||||
},
|
||||
{
|
||||
"confidence": 0.92,
|
||||
"from": "sym:csharp:WebApi.Controllers.AdminController.MigrateData",
|
||||
"guards": [
|
||||
"Environment.GetEnvironmentVariable(\"ENABLE_MIGRATION\") == \"true\""
|
||||
],
|
||||
"to": "sym:csharp:WebApi.Migration.DataMigrator.ExecuteMigration"
|
||||
},
|
||||
{
|
||||
"confidence": 0.90,
|
||||
"from": "sym:csharp:WebApi.Migration.DataMigrator.ExecuteMigration",
|
||||
"guards": [],
|
||||
"to": "sym:csharp:WebApi.Legacy.LegacyDataConverter.ConvertFormat"
|
||||
},
|
||||
{
|
||||
"confidence": 0.88,
|
||||
"from": "sym:csharp:WebApi.Legacy.LegacyDataConverter.ConvertFormat",
|
||||
"guards": [],
|
||||
"to": "sym:csharp:VulnerableLib.Crypto.InsecureHasher.ComputeHash"
|
||||
},
|
||||
{
|
||||
"confidence": 0.87,
|
||||
"from": "sym:csharp:WebApi.Services.PaymentService.Charge",
|
||||
"guards": [],
|
||||
"to": "sym:csharp:WebApi.Logging.AuditLogger.LogTransaction"
|
||||
}
|
||||
],
|
||||
"entryRefs": [
|
||||
"sym:csharp:WebApi.Controllers.PaymentController.ProcessPayment",
|
||||
"sym:csharp:WebApi.Controllers.AdminController.MigrateData"
|
||||
],
|
||||
"nodes": [
|
||||
{
|
||||
"addr": "0x601000",
|
||||
"file": "PaymentController.cs",
|
||||
"id": "sym:csharp:WebApi.Controllers.PaymentController.ProcessPayment",
|
||||
"line": 56,
|
||||
"moduleHash": "sha256:601234567890123456789012345678901234567890123456789012345678",
|
||||
"symbol": "WebApi.Controllers.PaymentController.ProcessPayment(PaymentRequest)"
|
||||
},
|
||||
{
|
||||
"addr": "0x602000",
|
||||
"file": "AdminController.cs",
|
||||
"id": "sym:csharp:WebApi.Controllers.AdminController.MigrateData",
|
||||
"line": 89,
|
||||
"moduleHash": "sha256:601234567890123456789012345678901234567890123456789012345678",
|
||||
"symbol": "WebApi.Controllers.AdminController.MigrateData()"
|
||||
},
|
||||
{
|
||||
"addr": "0x603000",
|
||||
"file": "PaymentService.cs",
|
||||
"id": "sym:csharp:WebApi.Services.PaymentService.Charge",
|
||||
"line": 123,
|
||||
"moduleHash": "sha256:612345678901234567890123456789012345678901234567890123456789",
|
||||
"symbol": "WebApi.Services.PaymentService.Charge(decimal, string)"
|
||||
},
|
||||
{
|
||||
"addr": "0x603100",
|
||||
"file": "PaymentService.cs",
|
||||
"id": "sym:csharp:WebApi.Services.PaymentService.Validate",
|
||||
"line": 167,
|
||||
"moduleHash": "sha256:612345678901234567890123456789012345678901234567890123456789",
|
||||
"symbol": "WebApi.Services.PaymentService.Validate(PaymentRequest)"
|
||||
},
|
||||
{
|
||||
"addr": "0x604000",
|
||||
"file": "LegacyPaymentAdapter.cs",
|
||||
"id": "sym:csharp:WebApi.Legacy.LegacyPaymentAdapter.ProcessLegacy",
|
||||
"line": 78,
|
||||
"moduleHash": "sha256:623456789012345678901234567890123456789012345678901234567890",
|
||||
"symbol": "WebApi.Legacy.LegacyPaymentAdapter.ProcessLegacy(LegacyPayment)"
|
||||
},
|
||||
{
|
||||
"addr": "0x605000",
|
||||
"file": "WindowsValidator.cs",
|
||||
"id": "sym:csharp:WebApi.Validation.WindowsValidator.CheckSignature",
|
||||
"line": 45,
|
||||
"moduleHash": "sha256:634567890123456789012345678901234567890123456789012345678901",
|
||||
"symbol": "WebApi.Validation.WindowsValidator.CheckSignature(byte[])"
|
||||
},
|
||||
{
|
||||
"addr": "0x606000",
|
||||
"file": "DataMigrator.cs",
|
||||
"id": "sym:csharp:WebApi.Migration.DataMigrator.ExecuteMigration",
|
||||
"line": 234,
|
||||
"moduleHash": "sha256:645678901234567890123456789012345678901234567890123456789012",
|
||||
"symbol": "WebApi.Migration.DataMigrator.ExecuteMigration(MigrationConfig)"
|
||||
},
|
||||
{
|
||||
"addr": "0x607000",
|
||||
"file": "LegacyDataConverter.cs",
|
||||
"id": "sym:csharp:WebApi.Legacy.LegacyDataConverter.ConvertFormat",
|
||||
"line": 156,
|
||||
"moduleHash": "sha256:623456789012345678901234567890123456789012345678901234567890",
|
||||
"symbol": "WebApi.Legacy.LegacyDataConverter.ConvertFormat(byte[])"
|
||||
},
|
||||
{
|
||||
"addr": "0x608000",
|
||||
"file": "InsecureHasher.cs",
|
||||
"id": "sym:csharp:VulnerableLib.Crypto.InsecureHasher.ComputeHash",
|
||||
"line": 67,
|
||||
"moduleHash": "sha256:656789012345678901234567890123456789012345678901234567890123",
|
||||
"symbol": "VulnerableLib.Crypto.InsecureHasher.ComputeHash(byte[])"
|
||||
},
|
||||
{
|
||||
"addr": "0x609000",
|
||||
"file": "AuditLogger.cs",
|
||||
"id": "sym:csharp:WebApi.Logging.AuditLogger.LogTransaction",
|
||||
"line": 91,
|
||||
"moduleHash": "sha256:612345678901234567890123456789012345678901234567890123456789",
|
||||
"symbol": "WebApi.Logging.AuditLogger.LogTransaction(string, decimal)"
|
||||
}
|
||||
],
|
||||
"sinkRefs": [
|
||||
"sym:csharp:VulnerableLib.Crypto.InsecureHasher.ComputeHash"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
{
|
||||
"@type": "https://stellaops.dev/predicates/proof-of-exposure@v1",
|
||||
"evidence": {
|
||||
"graphHash": "blake3:a1b2c3d4e5f6789012345678901234567890123456789012345678901234",
|
||||
"sbomRef": "cas://scanner-artifacts/sbom.cdx.json"
|
||||
},
|
||||
"metadata": {
|
||||
"analyzer": {
|
||||
"name": "stellaops-scanner",
|
||||
"toolchainDigest": "sha256:def456789012345678901234567890123456789012345678901234567890",
|
||||
"version": "1.2.0"
|
||||
},
|
||||
"generatedAt": "2025-12-23T10:00:00.000Z",
|
||||
"policy": {
|
||||
"evaluatedAt": "2025-12-23T09:58:00.000Z",
|
||||
"policyDigest": "sha256:abc123456789012345678901234567890123456789012345678901234567",
|
||||
"policyId": "prod-release-v42"
|
||||
},
|
||||
"reproSteps": [
|
||||
"1. Build container image from Dockerfile (commit: abc123)",
|
||||
"2. Run scanner with config: etc/scanner.yaml",
|
||||
"3. Extract reachability graph with maxDepth=10",
|
||||
"4. Resolve CVE-2021-44228 to vulnerable symbols"
|
||||
]
|
||||
},
|
||||
"schema": "stellaops.dev/poe@v1",
|
||||
"subject": {
|
||||
"buildId": "gnu-build-id:5f0c7c3c4d5e6f7a8b9c0d1e2f3a4b5c",
|
||||
"componentRef": "pkg:maven/org.apache.logging.log4j/log4j-core@2.14.1",
|
||||
"imageDigest": "sha256:abc123def456789012345678901234567890123456789012345678901234",
|
||||
"vulnId": "CVE-2021-44228"
|
||||
},
|
||||
"subgraph": {
|
||||
"edges": [
|
||||
{
|
||||
"confidence": 0.98,
|
||||
"from": "sym:java:com.example.GreetingService.greet",
|
||||
"to": "sym:java:com.example.GreetingService.processRequest"
|
||||
},
|
||||
{
|
||||
"confidence": 0.95,
|
||||
"from": "sym:java:com.example.GreetingService.processRequest",
|
||||
"to": "sym:java:org.apache.logging.log4j.Logger.error"
|
||||
},
|
||||
{
|
||||
"confidence": 0.92,
|
||||
"from": "sym:java:org.apache.logging.log4j.Logger.error",
|
||||
"to": "sym:java:org.apache.logging.log4j.core.lookup.JndiLookup.lookup"
|
||||
}
|
||||
],
|
||||
"entryRefs": [
|
||||
"sym:java:com.example.GreetingService.greet"
|
||||
],
|
||||
"nodes": [
|
||||
{
|
||||
"addr": "0x401000",
|
||||
"file": "GreetingService.java",
|
||||
"id": "sym:java:com.example.GreetingService.greet",
|
||||
"line": 42,
|
||||
"moduleHash": "sha256:abc123456789012345678901234567890123456789012345678901234567",
|
||||
"symbol": "com.example.GreetingService.greet(String)"
|
||||
},
|
||||
{
|
||||
"addr": "0x401100",
|
||||
"file": "GreetingService.java",
|
||||
"id": "sym:java:com.example.GreetingService.processRequest",
|
||||
"line": 58,
|
||||
"moduleHash": "sha256:abc123456789012345678901234567890123456789012345678901234567",
|
||||
"symbol": "com.example.GreetingService.processRequest(String)"
|
||||
},
|
||||
{
|
||||
"addr": "0x402000",
|
||||
"file": "Logger.java",
|
||||
"id": "sym:java:org.apache.logging.log4j.Logger.error",
|
||||
"line": 128,
|
||||
"moduleHash": "sha256:def456789012345678901234567890123456789012345678901234567890",
|
||||
"symbol": "org.apache.logging.log4j.Logger.error(String)"
|
||||
},
|
||||
{
|
||||
"addr": "0x403000",
|
||||
"file": "JndiLookup.java",
|
||||
"id": "sym:java:org.apache.logging.log4j.core.lookup.JndiLookup.lookup",
|
||||
"line": 56,
|
||||
"moduleHash": "sha256:def456789012345678901234567890123456789012345678901234567890",
|
||||
"symbol": "org.apache.logging.log4j.core.lookup.JndiLookup.lookup(LogEvent, String)"
|
||||
}
|
||||
],
|
||||
"sinkRefs": [
|
||||
"sym:java:org.apache.logging.log4j.core.lookup.JndiLookup.lookup"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,257 +0,0 @@
|
||||
{
|
||||
"@type": "https://stellaops.dev/predicates/proof-of-exposure@v1",
|
||||
"evidence": {
|
||||
"graphHash": "blake3:e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3",
|
||||
"sbomRef": "cas://scanner-artifacts/sbom.cdx.json"
|
||||
},
|
||||
"metadata": {
|
||||
"analyzer": {
|
||||
"name": "stellaops-scanner",
|
||||
"toolchainDigest": "sha256:456789012345678901234567890123456789012345678901234567890123",
|
||||
"version": "1.2.0"
|
||||
},
|
||||
"generatedAt": "2025-12-23T11:30:00.000Z",
|
||||
"policy": {
|
||||
"evaluatedAt": "2025-12-23T11:28:00.000Z",
|
||||
"policyDigest": "sha256:789012345678901234567890123456789012345678901234567890123456",
|
||||
"policyId": "prod-release-v42"
|
||||
},
|
||||
"reproSteps": [
|
||||
"1. Build container image from Dockerfile (commit: def456)",
|
||||
"2. Run scanner with config: etc/scanner.yaml",
|
||||
"3. Extract reachability graph with maxDepth=10, maxPaths=3",
|
||||
"4. Resolve CVE-2023-12345 to vulnerable symbols"
|
||||
]
|
||||
},
|
||||
"schema": "stellaops.dev/poe@v1",
|
||||
"subject": {
|
||||
"buildId": "gnu-build-id:7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d",
|
||||
"componentRef": "pkg:maven/com.example/vulnerable-lib@1.5.0",
|
||||
"imageDigest": "sha256:def456789012345678901234567890123456789012345678901234567890",
|
||||
"vulnId": "CVE-2023-12345"
|
||||
},
|
||||
"subgraph": {
|
||||
"edges": [
|
||||
{
|
||||
"confidence": 0.98,
|
||||
"from": "sym:java:com.example.api.UserController.getUser",
|
||||
"to": "sym:java:com.example.service.UserService.fetchUser"
|
||||
},
|
||||
{
|
||||
"confidence": 0.95,
|
||||
"from": "sym:java:com.example.service.UserService.fetchUser",
|
||||
"to": "sym:java:com.example.util.XmlParser.parse"
|
||||
},
|
||||
{
|
||||
"confidence": 0.92,
|
||||
"from": "sym:java:com.example.util.XmlParser.parse",
|
||||
"to": "sym:java:com.vulnerable.XXEVulnerableParser.parseXml"
|
||||
},
|
||||
{
|
||||
"confidence": 0.97,
|
||||
"from": "sym:java:com.example.api.UserController.updateUser",
|
||||
"to": "sym:java:com.example.service.UserService.updateProfile"
|
||||
},
|
||||
{
|
||||
"confidence": 0.94,
|
||||
"from": "sym:java:com.example.service.UserService.updateProfile",
|
||||
"to": "sym:java:com.example.util.XmlParser.parse"
|
||||
},
|
||||
{
|
||||
"confidence": 0.96,
|
||||
"from": "sym:java:com.example.api.AdminController.importUsers",
|
||||
"to": "sym:java:com.example.service.ImportService.processXml"
|
||||
},
|
||||
{
|
||||
"confidence": 0.93,
|
||||
"from": "sym:java:com.example.service.ImportService.processXml",
|
||||
"to": "sym:java:com.example.util.XmlParser.parseStream"
|
||||
},
|
||||
{
|
||||
"confidence": 0.91,
|
||||
"from": "sym:java:com.example.util.XmlParser.parseStream",
|
||||
"to": "sym:java:com.vulnerable.XXEVulnerableParser.parseXml"
|
||||
},
|
||||
{
|
||||
"confidence": 0.89,
|
||||
"from": "sym:java:com.example.api.UserController.getUser",
|
||||
"to": "sym:java:com.example.cache.CacheService.getCachedUser"
|
||||
},
|
||||
{
|
||||
"confidence": 0.87,
|
||||
"from": "sym:java:com.example.cache.CacheService.getCachedUser",
|
||||
"to": "sym:java:com.example.serialization.Deserializer.fromXml"
|
||||
},
|
||||
{
|
||||
"confidence": 0.85,
|
||||
"from": "sym:java:com.example.serialization.Deserializer.fromXml",
|
||||
"to": "sym:java:com.example.util.XmlParser.parse"
|
||||
},
|
||||
{
|
||||
"confidence": 0.88,
|
||||
"from": "sym:java:com.example.api.AdminController.importUsers",
|
||||
"to": "sym:java:com.example.validation.XmlValidator.validate"
|
||||
},
|
||||
{
|
||||
"confidence": 0.86,
|
||||
"from": "sym:java:com.example.validation.XmlValidator.validate",
|
||||
"to": "sym:java:com.vulnerable.XXEVulnerableParser.parseXml"
|
||||
},
|
||||
{
|
||||
"confidence": 0.90,
|
||||
"from": "sym:java:com.example.service.UserService.fetchUser",
|
||||
"to": "sym:java:com.example.logging.AuditLogger.logAccess"
|
||||
},
|
||||
{
|
||||
"confidence": 0.84,
|
||||
"from": "sym:java:com.example.logging.AuditLogger.logAccess",
|
||||
"to": "sym:java:com.example.util.XmlParser.parseConfig"
|
||||
},
|
||||
{
|
||||
"confidence": 0.82,
|
||||
"from": "sym:java:com.example.util.XmlParser.parseConfig",
|
||||
"to": "sym:java:com.vulnerable.XXEVulnerableParser.parseXml"
|
||||
},
|
||||
{
|
||||
"confidence": 0.95,
|
||||
"from": "sym:java:com.example.service.ImportService.processXml",
|
||||
"to": "sym:java:com.example.transform.XsltTransformer.transform"
|
||||
},
|
||||
{
|
||||
"confidence": 0.88,
|
||||
"from": "sym:java:com.example.transform.XsltTransformer.transform",
|
||||
"to": "sym:java:com.vulnerable.XXEVulnerableParser.parseXml"
|
||||
}
|
||||
],
|
||||
"entryRefs": [
|
||||
"sym:java:com.example.api.UserController.getUser",
|
||||
"sym:java:com.example.api.UserController.updateUser",
|
||||
"sym:java:com.example.api.AdminController.importUsers"
|
||||
],
|
||||
"nodes": [
|
||||
{
|
||||
"addr": "0x501000",
|
||||
"file": "UserController.java",
|
||||
"id": "sym:java:com.example.api.UserController.getUser",
|
||||
"line": 45,
|
||||
"moduleHash": "sha256:123456789012345678901234567890123456789012345678901234567890",
|
||||
"symbol": "com.example.api.UserController.getUser(String)"
|
||||
},
|
||||
{
|
||||
"addr": "0x501100",
|
||||
"file": "UserController.java",
|
||||
"id": "sym:java:com.example.api.UserController.updateUser",
|
||||
"line": 67,
|
||||
"moduleHash": "sha256:123456789012345678901234567890123456789012345678901234567890",
|
||||
"symbol": "com.example.api.UserController.updateUser(String, UserData)"
|
||||
},
|
||||
{
|
||||
"addr": "0x502000",
|
||||
"file": "AdminController.java",
|
||||
"id": "sym:java:com.example.api.AdminController.importUsers",
|
||||
"line": 89,
|
||||
"moduleHash": "sha256:123456789012345678901234567890123456789012345678901234567890",
|
||||
"symbol": "com.example.api.AdminController.importUsers(InputStream)"
|
||||
},
|
||||
{
|
||||
"addr": "0x503000",
|
||||
"file": "UserService.java",
|
||||
"id": "sym:java:com.example.service.UserService.fetchUser",
|
||||
"line": 34,
|
||||
"moduleHash": "sha256:234567890123456789012345678901234567890123456789012345678901",
|
||||
"symbol": "com.example.service.UserService.fetchUser(String)"
|
||||
},
|
||||
{
|
||||
"addr": "0x503100",
|
||||
"file": "UserService.java",
|
||||
"id": "sym:java:com.example.service.UserService.updateProfile",
|
||||
"line": 78,
|
||||
"moduleHash": "sha256:234567890123456789012345678901234567890123456789012345678901",
|
||||
"symbol": "com.example.service.UserService.updateProfile(String, UserData)"
|
||||
},
|
||||
{
|
||||
"addr": "0x504000",
|
||||
"file": "ImportService.java",
|
||||
"id": "sym:java:com.example.service.ImportService.processXml",
|
||||
"line": 56,
|
||||
"moduleHash": "sha256:234567890123456789012345678901234567890123456789012345678901",
|
||||
"symbol": "com.example.service.ImportService.processXml(InputStream)"
|
||||
},
|
||||
{
|
||||
"addr": "0x505000",
|
||||
"file": "XmlParser.java",
|
||||
"id": "sym:java:com.example.util.XmlParser.parse",
|
||||
"line": 112,
|
||||
"moduleHash": "sha256:345678901234567890123456789012345678901234567890123456789012",
|
||||
"symbol": "com.example.util.XmlParser.parse(String)"
|
||||
},
|
||||
{
|
||||
"addr": "0x505100",
|
||||
"file": "XmlParser.java",
|
||||
"id": "sym:java:com.example.util.XmlParser.parseStream",
|
||||
"line": 145,
|
||||
"moduleHash": "sha256:345678901234567890123456789012345678901234567890123456789012",
|
||||
"symbol": "com.example.util.XmlParser.parseStream(InputStream)"
|
||||
},
|
||||
{
|
||||
"addr": "0x505200",
|
||||
"file": "XmlParser.java",
|
||||
"id": "sym:java:com.example.util.XmlParser.parseConfig",
|
||||
"line": 178,
|
||||
"moduleHash": "sha256:345678901234567890123456789012345678901234567890123456789012",
|
||||
"symbol": "com.example.util.XmlParser.parseConfig(File)"
|
||||
},
|
||||
{
|
||||
"addr": "0x506000",
|
||||
"file": "XXEVulnerableParser.java",
|
||||
"id": "sym:java:com.vulnerable.XXEVulnerableParser.parseXml",
|
||||
"line": 67,
|
||||
"moduleHash": "sha256:456789012345678901234567890123456789012345678901234567890123",
|
||||
"symbol": "com.vulnerable.XXEVulnerableParser.parseXml(InputSource)"
|
||||
},
|
||||
{
|
||||
"addr": "0x507000",
|
||||
"file": "CacheService.java",
|
||||
"id": "sym:java:com.example.cache.CacheService.getCachedUser",
|
||||
"line": 89,
|
||||
"moduleHash": "sha256:234567890123456789012345678901234567890123456789012345678901",
|
||||
"symbol": "com.example.cache.CacheService.getCachedUser(String)"
|
||||
},
|
||||
{
|
||||
"addr": "0x508000",
|
||||
"file": "Deserializer.java",
|
||||
"id": "sym:java:com.example.serialization.Deserializer.fromXml",
|
||||
"line": 123,
|
||||
"moduleHash": "sha256:345678901234567890123456789012345678901234567890123456789012",
|
||||
"symbol": "com.example.serialization.Deserializer.fromXml(String)"
|
||||
},
|
||||
{
|
||||
"addr": "0x509000",
|
||||
"file": "XmlValidator.java",
|
||||
"id": "sym:java:com.example.validation.XmlValidator.validate",
|
||||
"line": 45,
|
||||
"moduleHash": "sha256:345678901234567890123456789012345678901234567890123456789012",
|
||||
"symbol": "com.example.validation.XmlValidator.validate(InputStream)"
|
||||
},
|
||||
{
|
||||
"addr": "0x50A000",
|
||||
"file": "AuditLogger.java",
|
||||
"id": "sym:java:com.example.logging.AuditLogger.logAccess",
|
||||
"line": 78,
|
||||
"moduleHash": "sha256:234567890123456789012345678901234567890123456789012345678901",
|
||||
"symbol": "com.example.logging.AuditLogger.logAccess(String, String)"
|
||||
},
|
||||
{
|
||||
"addr": "0x50B000",
|
||||
"file": "XsltTransformer.java",
|
||||
"id": "sym:java:com.example.transform.XsltTransformer.transform",
|
||||
"line": 134,
|
||||
"moduleHash": "sha256:345678901234567890123456789012345678901234567890123456789012",
|
||||
"symbol": "com.example.transform.XsltTransformer.transform(Document)"
|
||||
}
|
||||
],
|
||||
"sinkRefs": [
|
||||
"sym:java:com.vulnerable.XXEVulnerableParser.parseXml"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
{
|
||||
"@type": "https://stellaops.dev/predicates/proof-of-exposure@v1",
|
||||
"evidence": {
|
||||
"graphHash": "blake3:a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0",
|
||||
"sbomRef": "cas://scanner-artifacts/sbom.cdx.json"
|
||||
},
|
||||
"metadata": {
|
||||
"analyzer": {
|
||||
"name": "stellaops-scanner",
|
||||
"toolchainDigest": "sha256:678901234567890123456789012345678901234567890123456789012345",
|
||||
"version": "1.2.0"
|
||||
},
|
||||
"generatedAt": "2025-12-23T13:00:00.000Z",
|
||||
"policy": {
|
||||
"evaluatedAt": "2025-12-23T12:58:00.000Z",
|
||||
"policyDigest": "sha256:901234567890123456789012345678901234567890123456789012345678",
|
||||
"policyId": "prod-release-v42"
|
||||
},
|
||||
"reproSteps": [
|
||||
"1. Build container image from Dockerfile (commit: jkl012)",
|
||||
"2. Run scanner with config: etc/scanner.yaml",
|
||||
"3. Extract reachability graph with maxDepth=10 (stripped binary)",
|
||||
"4. Resolve CVE-2024-99999 to vulnerable code addresses via binary diffing"
|
||||
]
|
||||
},
|
||||
"schema": "stellaops.dev/poe@v1",
|
||||
"subject": {
|
||||
"buildId": "gnu-build-id:c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9",
|
||||
"componentRef": "pkg:generic/libcrypto.so.1.1@1.1.1k",
|
||||
"imageDigest": "sha256:jkl012345678901234567890123456789012345678901234567890123456",
|
||||
"vulnId": "CVE-2024-99999"
|
||||
},
|
||||
"subgraph": {
|
||||
"edges": [
|
||||
{
|
||||
"confidence": 0.94,
|
||||
"from": "code_id:0x401530",
|
||||
"to": "code_id:0x402140"
|
||||
},
|
||||
{
|
||||
"confidence": 0.91,
|
||||
"from": "code_id:0x402140",
|
||||
"to": "code_id:0x403880"
|
||||
},
|
||||
{
|
||||
"confidence": 0.89,
|
||||
"from": "code_id:0x403880",
|
||||
"to": "code_id:0x405220"
|
||||
},
|
||||
{
|
||||
"confidence": 0.87,
|
||||
"from": "code_id:0x405220",
|
||||
"to": "code_id:0x407b40"
|
||||
},
|
||||
{
|
||||
"confidence": 0.85,
|
||||
"from": "code_id:0x407b40",
|
||||
"to": "code_id:0x409c60"
|
||||
}
|
||||
],
|
||||
"entryRefs": [
|
||||
"code_id:0x401530"
|
||||
],
|
||||
"nodes": [
|
||||
{
|
||||
"addr": "0x401530",
|
||||
"id": "code_id:0x401530",
|
||||
"moduleHash": "sha256:701234567890123456789012345678901234567890123456789012345678",
|
||||
"symbol": "<stripped>"
|
||||
},
|
||||
{
|
||||
"addr": "0x402140",
|
||||
"id": "code_id:0x402140",
|
||||
"moduleHash": "sha256:701234567890123456789012345678901234567890123456789012345678",
|
||||
"symbol": "<stripped>"
|
||||
},
|
||||
{
|
||||
"addr": "0x403880",
|
||||
"id": "code_id:0x403880",
|
||||
"moduleHash": "sha256:701234567890123456789012345678901234567890123456789012345678",
|
||||
"symbol": "<stripped>"
|
||||
},
|
||||
{
|
||||
"addr": "0x405220",
|
||||
"id": "code_id:0x405220",
|
||||
"moduleHash": "sha256:712345678901234567890123456789012345678901234567890123456789",
|
||||
"symbol": "<stripped>"
|
||||
},
|
||||
{
|
||||
"addr": "0x407b40",
|
||||
"id": "code_id:0x407b40",
|
||||
"moduleHash": "sha256:712345678901234567890123456789012345678901234567890123456789",
|
||||
"symbol": "<stripped>"
|
||||
},
|
||||
{
|
||||
"addr": "0x409c60",
|
||||
"id": "code_id:0x409c60",
|
||||
"moduleHash": "sha256:723456789012345678901234567890123456789012345678901234567890",
|
||||
"symbol": "<stripped>"
|
||||
}
|
||||
],
|
||||
"sinkRefs": [
|
||||
"code_id:0x409c60"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
# Reachability Fixture Harness
|
||||
|
||||
This directory carries the reachbench fixture packs used by Sprint 201 to validate reachability explainability.
|
||||
|
||||
- `fixtures/reachbench-2025-expanded/` contains 24 multi-language cases with reachable and unreachable variants, SBOMs, callgraphs, runtime traces, and DSSE envelopes. Each variant ships a manifest with SHA-256 hashes to keep runs deterministic.
|
||||
- `StellaOps.Reachability.FixtureTests` provides guard rails that ensure files exist, hashes match manifests, and ground-truth paths align with reachable/unreachable variants before the Signals/Scanner reachability pipeline consumes them.
|
||||
|
||||
## Running the fixture tests
|
||||
|
||||
```bash
|
||||
# From the repo root
|
||||
DOTNET_CLI_UI_LANGUAGE=en DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1 \
|
||||
dotnet test tests/reachability/StellaOps.Reachability.FixtureTests/StellaOps.Reachability.FixtureTests.csproj
|
||||
|
||||
# Focus only the evaluation harness checks
|
||||
DOTNET_CLI_UI_LANGUAGE=en DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1 \
|
||||
dotnet test tests/reachability/StellaOps.Reachability.FixtureTests/StellaOps.Reachability.FixtureTests.csproj \
|
||||
--filter ReachbenchEvaluationHarnessTests
|
||||
```
|
||||
|
||||
The tests validate fixture integrity (hashes, schema versions) and now enforce the evaluation harness contract: reachable variants must surface execution paths while unreachable variants must not. Keep the environment overrides above in CI to avoid localization drift during hash comparisons.
|
||||
@@ -1,100 +0,0 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using FluentAssertions;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Reachability.FixtureTests;
|
||||
|
||||
public class CorpusFixtureTests
|
||||
{
|
||||
private static readonly string RepoRoot = ReachbenchFixtureTests.LocateRepoRoot();
|
||||
private static readonly string CorpusRoot = Path.Combine(RepoRoot, "tests", "reachability", "corpus");
|
||||
|
||||
[Fact]
|
||||
public void ManifestExistsAndIsDeterministic()
|
||||
{
|
||||
var manifestPath = Path.Combine(CorpusRoot, "manifest.json");
|
||||
File.Exists(manifestPath).Should().BeTrue("corpus manifest should exist");
|
||||
|
||||
using var stream = File.OpenRead(manifestPath);
|
||||
using var doc = JsonDocument.Parse(stream);
|
||||
doc.RootElement.ValueKind.Should().Be(JsonValueKind.Array);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CorpusEntriesMatchManifestHashes()
|
||||
{
|
||||
var manifestPath = Path.Combine(CorpusRoot, "manifest.json");
|
||||
var manifest = JsonDocument.Parse(File.ReadAllBytes(manifestPath)).RootElement.EnumerateArray().ToArray();
|
||||
|
||||
manifest.Should().NotBeEmpty("corpus 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();
|
||||
|
||||
var caseDir = Path.Combine(CorpusRoot, language!, id!);
|
||||
Directory.Exists(caseDir).Should().BeTrue($"case folder missing: {caseDir}");
|
||||
|
||||
foreach (var fileProp in files.EnumerateObject())
|
||||
{
|
||||
var filename = fileProp.Name;
|
||||
var expectedHash = fileProp.Value.GetString();
|
||||
File.Exists(Path.Combine(caseDir, filename)).Should().BeTrue($"{id} missing {filename}");
|
||||
|
||||
var actualHash = BitConverter.ToString(SHA256.HashData(File.ReadAllBytes(Path.Combine(caseDir, filename)))).Replace("-", "").ToLowerInvariant();
|
||||
actualHash.Should().Be(expectedHash, $"{id} hash mismatch for {filename}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GroundTruthFilesContainRequiredFields()
|
||||
{
|
||||
var manifestPath = Path.Combine(CorpusRoot, "manifest.json");
|
||||
var manifest = JsonDocument.Parse(File.ReadAllBytes(manifestPath)).RootElement.EnumerateArray().ToArray();
|
||||
const string expectedSchemaVersion = "reachbench.reachgraph.truth/v1";
|
||||
var allowedVariants = new[] { "reachable", "unreachable" };
|
||||
|
||||
foreach (var entry in manifest)
|
||||
{
|
||||
var id = entry.GetProperty("id").GetString()!;
|
||||
var language = entry.GetProperty("language").GetString()!;
|
||||
var truthPath = Path.Combine(CorpusRoot, language, id, "ground-truth.json");
|
||||
File.Exists(truthPath).Should().BeTrue($"{id} missing ground-truth.json");
|
||||
|
||||
using var truthDoc = JsonDocument.Parse(File.ReadAllBytes(truthPath));
|
||||
truthDoc.RootElement.GetProperty("schema_version").GetString().Should().Be(expectedSchemaVersion, $"{id} ground-truth schema_version mismatch");
|
||||
truthDoc.RootElement.GetProperty("case_id").GetString().Should().Be(id, $"{id} ground-truth case_id must match manifest id");
|
||||
|
||||
var variant = truthDoc.RootElement.GetProperty("variant").GetString();
|
||||
variant.Should().NotBeNullOrWhiteSpace($"{id} ground-truth must set variant");
|
||||
allowedVariants.Should().Contain(variant!, $"{id} variant must be reachable|unreachable");
|
||||
|
||||
truthDoc.RootElement.TryGetProperty("paths", out var pathsProp).Should().BeTrue($"{id} ground-truth must include paths");
|
||||
pathsProp.ValueKind.Should().Be(JsonValueKind.Array, $"{id} paths must be an array");
|
||||
|
||||
if (string.Equals(variant, "reachable", StringComparison.Ordinal))
|
||||
{
|
||||
pathsProp.GetArrayLength().Should().BeGreaterThan(0, $"{id} reachable ground-truth should include at least one path");
|
||||
}
|
||||
|
||||
foreach (var path in pathsProp.EnumerateArray())
|
||||
{
|
||||
path.ValueKind.Should().Be(JsonValueKind.Array, $"{id} each path must be an array");
|
||||
path.GetArrayLength().Should().BeGreaterThan(0, $"{id} each path must contain at least one symbol");
|
||||
|
||||
foreach (var segment in path.EnumerateArray())
|
||||
{
|
||||
segment.ValueKind.Should().Be(JsonValueKind.String, $"{id} path segments must be strings");
|
||||
segment.GetString().Should().NotBeNullOrWhiteSpace($"{id} path segments must be non-empty strings");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
using System.Text.Json;
|
||||
using FluentAssertions;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Reachability.FixtureTests;
|
||||
|
||||
public sealed class FixtureCoverageTests
|
||||
{
|
||||
private static readonly string RepoRoot = ReachbenchFixtureTests.LocateRepoRoot();
|
||||
private static readonly string ReachabilityRoot = Path.Combine(RepoRoot, "tests", "reachability");
|
||||
private static readonly string CorpusRoot = Path.Combine(ReachabilityRoot, "corpus");
|
||||
private static readonly string SamplesPublicRoot = Path.Combine(ReachabilityRoot, "samples-public");
|
||||
|
||||
[Fact]
|
||||
public void CorpusAndPublicSamplesCoverExpectedLanguageBuckets()
|
||||
{
|
||||
var corpusLanguages = ReadManifestLanguages(Path.Combine(CorpusRoot, "manifest.json"));
|
||||
corpusLanguages.Should().Contain(new[] { "dotnet", "go", "python", "rust" });
|
||||
|
||||
var samplesLanguages = ReadManifestLanguages(Path.Combine(SamplesPublicRoot, "manifest.json"));
|
||||
samplesLanguages.Should().Contain(new[] { "csharp", "js", "php" });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CorpusManifestIsSorted()
|
||||
{
|
||||
var keys = ReadManifestKeys(Path.Combine(CorpusRoot, "manifest.json"));
|
||||
keys.Should().NotBeEmpty("corpus manifest should have entries");
|
||||
keys.Should().BeInAscendingOrder(StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
private static string[] ReadManifestLanguages(string manifestPath)
|
||||
{
|
||||
File.Exists(manifestPath).Should().BeTrue($"{manifestPath} should exist");
|
||||
|
||||
using var doc = JsonDocument.Parse(File.ReadAllBytes(manifestPath));
|
||||
return doc.RootElement.EnumerateArray()
|
||||
.Select(entry => entry.GetProperty("language").GetString())
|
||||
.Where(language => !string.IsNullOrWhiteSpace(language))
|
||||
.Select(language => language!)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.OrderBy(language => language, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static string[] ReadManifestKeys(string manifestPath)
|
||||
{
|
||||
File.Exists(manifestPath).Should().BeTrue($"{manifestPath} should exist");
|
||||
|
||||
using var doc = JsonDocument.Parse(File.ReadAllBytes(manifestPath));
|
||||
return doc.RootElement.EnumerateArray()
|
||||
.Select(entry => $"{entry.GetProperty("language").GetString()}/{entry.GetProperty("id").GetString()}")
|
||||
.ToArray();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,375 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
using StellaOps.Scanner.Reachability;
|
||||
|
||||
namespace StellaOps.Reachability.FixtureTests.PatchOracle;
|
||||
|
||||
/// <summary>
|
||||
/// Compares a RichGraph against a patch-oracle definition.
|
||||
/// Reports missing expected elements and present forbidden elements.
|
||||
/// </summary>
|
||||
public sealed class PatchOracleComparer
|
||||
{
|
||||
private readonly PatchOracleDefinition _oracle;
|
||||
private readonly double _defaultMinConfidence;
|
||||
|
||||
public PatchOracleComparer(PatchOracleDefinition oracle)
|
||||
{
|
||||
_oracle = oracle ?? throw new ArgumentNullException(nameof(oracle));
|
||||
_defaultMinConfidence = oracle.MinConfidence;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Compares the graph against the oracle and returns a result.
|
||||
/// </summary>
|
||||
public PatchOracleResult Compare(RichGraph graph)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(graph);
|
||||
|
||||
var violations = new List<PatchOracleViolation>();
|
||||
|
||||
// Check expected functions
|
||||
foreach (var expected in _oracle.ExpectedFunctions.Where(f => f.Required))
|
||||
{
|
||||
if (!HasMatchingNode(graph, expected))
|
||||
{
|
||||
violations.Add(new PatchOracleViolation(
|
||||
ViolationType.MissingFunction,
|
||||
expected.SymbolId,
|
||||
null,
|
||||
expected.Reason ?? $"Expected function '{expected.SymbolId}' not found in graph"));
|
||||
}
|
||||
}
|
||||
|
||||
// Check expected edges
|
||||
foreach (var expected in _oracle.ExpectedEdges.Where(e => e.Required))
|
||||
{
|
||||
var minConf = expected.MinConfidence ?? _defaultMinConfidence;
|
||||
if (!HasMatchingEdge(graph, expected, minConf))
|
||||
{
|
||||
violations.Add(new PatchOracleViolation(
|
||||
ViolationType.MissingEdge,
|
||||
expected.From,
|
||||
expected.To,
|
||||
expected.Reason ?? $"Expected edge '{expected.From}' -> '{expected.To}' not found in graph"));
|
||||
}
|
||||
}
|
||||
|
||||
// Check expected roots
|
||||
foreach (var expected in _oracle.ExpectedRoots.Where(r => r.Required))
|
||||
{
|
||||
if (!HasMatchingRoot(graph, expected))
|
||||
{
|
||||
violations.Add(new PatchOracleViolation(
|
||||
ViolationType.MissingRoot,
|
||||
expected.Id,
|
||||
null,
|
||||
expected.Reason ?? $"Expected root '{expected.Id}' not found in graph"));
|
||||
}
|
||||
}
|
||||
|
||||
// Check forbidden functions
|
||||
foreach (var forbidden in _oracle.ForbiddenFunctions)
|
||||
{
|
||||
if (HasMatchingNode(graph, forbidden))
|
||||
{
|
||||
violations.Add(new PatchOracleViolation(
|
||||
ViolationType.ForbiddenFunctionPresent,
|
||||
forbidden.SymbolId,
|
||||
null,
|
||||
forbidden.Reason ?? $"Forbidden function '{forbidden.SymbolId}' is present in graph"));
|
||||
}
|
||||
}
|
||||
|
||||
// Check forbidden edges
|
||||
foreach (var forbidden in _oracle.ForbiddenEdges)
|
||||
{
|
||||
if (HasMatchingEdge(graph, forbidden, 0.0))
|
||||
{
|
||||
violations.Add(new PatchOracleViolation(
|
||||
ViolationType.ForbiddenEdgePresent,
|
||||
forbidden.From,
|
||||
forbidden.To,
|
||||
forbidden.Reason ?? $"Forbidden edge '{forbidden.From}' -> '{forbidden.To}' is present in graph"));
|
||||
}
|
||||
}
|
||||
|
||||
// Strict mode: check for unexpected elements
|
||||
if (_oracle.StrictMode)
|
||||
{
|
||||
var unexpectedNodes = FindUnexpectedNodes(graph);
|
||||
foreach (var node in unexpectedNodes)
|
||||
{
|
||||
violations.Add(new PatchOracleViolation(
|
||||
ViolationType.UnexpectedFunction,
|
||||
node.Id,
|
||||
null,
|
||||
$"Strict mode: unexpected function '{node.Id}' found in graph"));
|
||||
}
|
||||
|
||||
var unexpectedEdges = FindUnexpectedEdges(graph);
|
||||
foreach (var edge in unexpectedEdges)
|
||||
{
|
||||
violations.Add(new PatchOracleViolation(
|
||||
ViolationType.UnexpectedEdge,
|
||||
edge.From,
|
||||
edge.To,
|
||||
$"Strict mode: unexpected edge '{edge.From}' -> '{edge.To}' found in graph"));
|
||||
}
|
||||
}
|
||||
|
||||
return new PatchOracleResult(
|
||||
OracleId: _oracle.Id,
|
||||
CaseRef: _oracle.CaseRef,
|
||||
Variant: _oracle.Variant,
|
||||
Success: violations.Count == 0,
|
||||
Violations: violations,
|
||||
Summary: GenerateSummary(graph, violations));
|
||||
}
|
||||
|
||||
private bool HasMatchingNode(RichGraph graph, ExpectedFunction expected)
|
||||
{
|
||||
foreach (var node in graph.Nodes)
|
||||
{
|
||||
if (!MatchesPattern(node.Id, expected.SymbolId) &&
|
||||
!MatchesPattern(node.SymbolId, expected.SymbolId))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(expected.Lang) &&
|
||||
!string.Equals(node.Lang, expected.Lang, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(expected.Kind) &&
|
||||
!string.Equals(node.Kind, expected.Kind, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(expected.PurlPattern) &&
|
||||
!MatchesPattern(node.Purl ?? string.Empty, expected.PurlPattern))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private bool HasMatchingEdge(RichGraph graph, ExpectedEdge expected, double minConfidence)
|
||||
{
|
||||
foreach (var edge in graph.Edges)
|
||||
{
|
||||
if (!MatchesPattern(edge.From, expected.From))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!MatchesPattern(edge.To, expected.To))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(expected.Kind) &&
|
||||
!string.Equals(edge.Kind, expected.Kind, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (edge.Confidence < minConfidence)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private bool HasMatchingRoot(RichGraph graph, ExpectedRoot expected)
|
||||
{
|
||||
foreach (var root in graph.Roots)
|
||||
{
|
||||
if (!MatchesPattern(root.Id, expected.Id))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(expected.Phase) &&
|
||||
!string.Equals(root.Phase, expected.Phase, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private IEnumerable<RichGraphNode> FindUnexpectedNodes(RichGraph graph)
|
||||
{
|
||||
var allExpected = _oracle.ExpectedFunctions
|
||||
.Select(f => f.SymbolId)
|
||||
.ToHashSet(StringComparer.Ordinal);
|
||||
|
||||
foreach (var node in graph.Nodes)
|
||||
{
|
||||
var isExpected = allExpected.Any(pattern => MatchesPattern(node.Id, pattern) || MatchesPattern(node.SymbolId, pattern));
|
||||
if (!isExpected)
|
||||
{
|
||||
yield return node;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private IEnumerable<RichGraphEdge> FindUnexpectedEdges(RichGraph graph)
|
||||
{
|
||||
foreach (var edge in graph.Edges)
|
||||
{
|
||||
var isExpected = _oracle.ExpectedEdges.Any(e =>
|
||||
MatchesPattern(edge.From, e.From) && MatchesPattern(edge.To, e.To));
|
||||
if (!isExpected)
|
||||
{
|
||||
yield return edge;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Matches a value against a pattern supporting '*' wildcards.
|
||||
/// </summary>
|
||||
private static bool MatchesPattern(string value, string pattern)
|
||||
{
|
||||
if (string.IsNullOrEmpty(pattern))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(value))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Exact match
|
||||
if (!pattern.Contains('*'))
|
||||
{
|
||||
return string.Equals(value, pattern, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
// Convert wildcard pattern to regex
|
||||
var regexPattern = "^" + Regex.Escape(pattern).Replace("\\*", ".*") + "$";
|
||||
return Regex.IsMatch(value, regexPattern, RegexOptions.None, TimeSpan.FromMilliseconds(100));
|
||||
}
|
||||
|
||||
private static PatchOracleSummary GenerateSummary(RichGraph graph, List<PatchOracleViolation> violations)
|
||||
{
|
||||
return new PatchOracleSummary(
|
||||
TotalNodes: graph.Nodes.Count,
|
||||
TotalEdges: graph.Edges.Count,
|
||||
TotalRoots: graph.Roots.Count,
|
||||
MissingFunctions: violations.Count(v => v.Type == ViolationType.MissingFunction),
|
||||
MissingEdges: violations.Count(v => v.Type == ViolationType.MissingEdge),
|
||||
MissingRoots: violations.Count(v => v.Type == ViolationType.MissingRoot),
|
||||
ForbiddenFunctionsPresent: violations.Count(v => v.Type == ViolationType.ForbiddenFunctionPresent),
|
||||
ForbiddenEdgesPresent: violations.Count(v => v.Type == ViolationType.ForbiddenEdgePresent),
|
||||
UnexpectedFunctions: violations.Count(v => v.Type == ViolationType.UnexpectedFunction),
|
||||
UnexpectedEdges: violations.Count(v => v.Type == ViolationType.UnexpectedEdge));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of comparing a graph against a patch-oracle.
|
||||
/// </summary>
|
||||
public sealed record PatchOracleResult(
|
||||
string OracleId,
|
||||
string CaseRef,
|
||||
string Variant,
|
||||
bool Success,
|
||||
IReadOnlyList<PatchOracleViolation> Violations,
|
||||
PatchOracleSummary Summary)
|
||||
{
|
||||
/// <summary>
|
||||
/// Generates a human-readable report.
|
||||
/// </summary>
|
||||
public string ToReport()
|
||||
{
|
||||
var lines = new List<string>
|
||||
{
|
||||
$"Patch-Oracle Validation Report",
|
||||
$"==============================",
|
||||
$"Oracle: {OracleId}",
|
||||
$"Case: {CaseRef} ({Variant})",
|
||||
$"Status: {(Success ? "PASS" : "FAIL")}",
|
||||
string.Empty,
|
||||
$"Graph Statistics:",
|
||||
$" Nodes: {Summary.TotalNodes}",
|
||||
$" Edges: {Summary.TotalEdges}",
|
||||
$" Roots: {Summary.TotalRoots}",
|
||||
string.Empty
|
||||
};
|
||||
|
||||
if (Violations.Count > 0)
|
||||
{
|
||||
lines.Add($"Violations ({Violations.Count}):");
|
||||
foreach (var v in Violations)
|
||||
{
|
||||
var target = v.To is not null ? $" -> {v.To}" : string.Empty;
|
||||
lines.Add($" [{v.Type}] {v.From}{target}");
|
||||
lines.Add($" Reason: {v.Message}");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
lines.Add("No violations found.");
|
||||
}
|
||||
|
||||
return string.Join(Environment.NewLine, lines);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A single violation found during oracle comparison.
|
||||
/// </summary>
|
||||
public sealed record PatchOracleViolation(
|
||||
ViolationType Type,
|
||||
string From,
|
||||
string? To,
|
||||
string Message);
|
||||
|
||||
/// <summary>
|
||||
/// Type of oracle violation.
|
||||
/// </summary>
|
||||
public enum ViolationType
|
||||
{
|
||||
MissingFunction,
|
||||
MissingEdge,
|
||||
MissingRoot,
|
||||
ForbiddenFunctionPresent,
|
||||
ForbiddenEdgePresent,
|
||||
UnexpectedFunction,
|
||||
UnexpectedEdge
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Summary statistics for oracle comparison.
|
||||
/// </summary>
|
||||
public sealed record PatchOracleSummary(
|
||||
int TotalNodes,
|
||||
int TotalEdges,
|
||||
int TotalRoots,
|
||||
int MissingFunctions,
|
||||
int MissingEdges,
|
||||
int MissingRoots,
|
||||
int ForbiddenFunctionsPresent,
|
||||
int ForbiddenEdgesPresent,
|
||||
int UnexpectedFunctions,
|
||||
int UnexpectedEdges);
|
||||
@@ -1,112 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.Reachability.FixtureTests.PatchOracle;
|
||||
|
||||
/// <summary>
|
||||
/// Loads patch-oracle definitions from fixture files.
|
||||
/// </summary>
|
||||
public sealed class PatchOracleLoader
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
ReadCommentHandling = JsonCommentHandling.Skip,
|
||||
AllowTrailingCommas = true
|
||||
};
|
||||
|
||||
private readonly string _fixtureRoot;
|
||||
|
||||
public PatchOracleLoader(string fixtureRoot)
|
||||
{
|
||||
_fixtureRoot = fixtureRoot ?? throw new ArgumentNullException(nameof(fixtureRoot));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Loads the oracle index from INDEX.json.
|
||||
/// </summary>
|
||||
public PatchOracleIndex LoadIndex()
|
||||
{
|
||||
var indexPath = Path.Combine(_fixtureRoot, "INDEX.json");
|
||||
if (!File.Exists(indexPath))
|
||||
{
|
||||
throw new FileNotFoundException($"Patch-oracle INDEX.json not found at {indexPath}");
|
||||
}
|
||||
|
||||
var json = File.ReadAllText(indexPath);
|
||||
return JsonSerializer.Deserialize<PatchOracleIndex>(json, JsonOptions)
|
||||
?? throw new InvalidOperationException("Failed to deserialize patch-oracle index");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Loads an oracle definition by its ID.
|
||||
/// </summary>
|
||||
public PatchOracleDefinition LoadOracle(string oracleId)
|
||||
{
|
||||
var index = LoadIndex();
|
||||
var entry = index.Oracles
|
||||
.FirstOrDefault(o => string.Equals(o.Id, oracleId, StringComparison.Ordinal))
|
||||
?? throw new KeyNotFoundException($"Oracle '{oracleId}' not found in index");
|
||||
|
||||
return LoadOracleFromPath(entry.Path);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Loads an oracle definition from a relative path.
|
||||
/// </summary>
|
||||
public PatchOracleDefinition LoadOracleFromPath(string relativePath)
|
||||
{
|
||||
var fullPath = Path.Combine(_fixtureRoot, relativePath);
|
||||
if (!File.Exists(fullPath))
|
||||
{
|
||||
throw new FileNotFoundException($"Oracle file not found at {fullPath}");
|
||||
}
|
||||
|
||||
var json = File.ReadAllText(fullPath);
|
||||
return JsonSerializer.Deserialize<PatchOracleDefinition>(json, JsonOptions)
|
||||
?? throw new InvalidOperationException($"Failed to deserialize oracle from {fullPath}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Loads all oracles for a specific case.
|
||||
/// </summary>
|
||||
public IEnumerable<PatchOracleDefinition> LoadOraclesForCase(string caseRef)
|
||||
{
|
||||
var index = LoadIndex();
|
||||
foreach (var entry in index.Oracles.Where(o => string.Equals(o.CaseRef, caseRef, StringComparison.Ordinal)))
|
||||
{
|
||||
yield return LoadOracleFromPath(entry.Path);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Loads all available oracles.
|
||||
/// </summary>
|
||||
public IEnumerable<PatchOracleDefinition> LoadAllOracles()
|
||||
{
|
||||
var index = LoadIndex();
|
||||
foreach (var entry in index.Oracles)
|
||||
{
|
||||
yield return LoadOracleFromPath(entry.Path);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Enumerates all oracle entries without loading full definitions.
|
||||
/// </summary>
|
||||
public IEnumerable<PatchOracleIndexEntry> EnumerateOracles()
|
||||
{
|
||||
var index = LoadIndex();
|
||||
return index.Oracles;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if the oracle index exists.
|
||||
/// </summary>
|
||||
public bool IndexExists()
|
||||
{
|
||||
return File.Exists(Path.Combine(_fixtureRoot, "INDEX.json"));
|
||||
}
|
||||
}
|
||||
@@ -1,158 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Reachability.FixtureTests.PatchOracle;
|
||||
|
||||
/// <summary>
|
||||
/// Root model for patch-oracle fixture files.
|
||||
/// </summary>
|
||||
public sealed record PatchOracleDefinition
|
||||
{
|
||||
[JsonPropertyName("schema_version")]
|
||||
public string SchemaVersion { get; init; } = "patch-oracle/v1";
|
||||
|
||||
[JsonPropertyName("id")]
|
||||
public required string Id { get; init; }
|
||||
|
||||
[JsonPropertyName("case_ref")]
|
||||
public required string CaseRef { get; init; }
|
||||
|
||||
[JsonPropertyName("variant")]
|
||||
public required string Variant { get; init; }
|
||||
|
||||
[JsonPropertyName("description")]
|
||||
public string? Description { get; init; }
|
||||
|
||||
[JsonPropertyName("expected_functions")]
|
||||
public IReadOnlyList<ExpectedFunction> ExpectedFunctions { get; init; } = Array.Empty<ExpectedFunction>();
|
||||
|
||||
[JsonPropertyName("expected_edges")]
|
||||
public IReadOnlyList<ExpectedEdge> ExpectedEdges { get; init; } = Array.Empty<ExpectedEdge>();
|
||||
|
||||
[JsonPropertyName("expected_roots")]
|
||||
public IReadOnlyList<ExpectedRoot> ExpectedRoots { get; init; } = Array.Empty<ExpectedRoot>();
|
||||
|
||||
[JsonPropertyName("forbidden_functions")]
|
||||
public IReadOnlyList<ExpectedFunction> ForbiddenFunctions { get; init; } = Array.Empty<ExpectedFunction>();
|
||||
|
||||
[JsonPropertyName("forbidden_edges")]
|
||||
public IReadOnlyList<ExpectedEdge> ForbiddenEdges { get; init; } = Array.Empty<ExpectedEdge>();
|
||||
|
||||
[JsonPropertyName("min_confidence")]
|
||||
public double MinConfidence { get; init; } = 0.5;
|
||||
|
||||
[JsonPropertyName("strict_mode")]
|
||||
public bool StrictMode { get; init; } = false;
|
||||
|
||||
[JsonPropertyName("created_at")]
|
||||
public DateTimeOffset? CreatedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("updated_at")]
|
||||
public DateTimeOffset? UpdatedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Expected function/node in the graph.
|
||||
/// </summary>
|
||||
public sealed record ExpectedFunction
|
||||
{
|
||||
[JsonPropertyName("symbol_id")]
|
||||
public required string SymbolId { get; init; }
|
||||
|
||||
[JsonPropertyName("lang")]
|
||||
public string? Lang { get; init; }
|
||||
|
||||
[JsonPropertyName("kind")]
|
||||
public string? Kind { get; init; }
|
||||
|
||||
[JsonPropertyName("purl_pattern")]
|
||||
public string? PurlPattern { get; init; }
|
||||
|
||||
[JsonPropertyName("required")]
|
||||
public bool Required { get; init; } = true;
|
||||
|
||||
[JsonPropertyName("reason")]
|
||||
public string? Reason { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Expected edge in the graph.
|
||||
/// </summary>
|
||||
public sealed record ExpectedEdge
|
||||
{
|
||||
[JsonPropertyName("from")]
|
||||
public required string From { get; init; }
|
||||
|
||||
[JsonPropertyName("to")]
|
||||
public required string To { get; init; }
|
||||
|
||||
[JsonPropertyName("kind")]
|
||||
public string? Kind { get; init; }
|
||||
|
||||
[JsonPropertyName("min_confidence")]
|
||||
public double? MinConfidence { get; init; }
|
||||
|
||||
[JsonPropertyName("required")]
|
||||
public bool Required { get; init; } = true;
|
||||
|
||||
[JsonPropertyName("reason")]
|
||||
public string? Reason { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Expected root node in the graph.
|
||||
/// </summary>
|
||||
public sealed record ExpectedRoot
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public required string Id { get; init; }
|
||||
|
||||
[JsonPropertyName("phase")]
|
||||
public string? Phase { get; init; }
|
||||
|
||||
[JsonPropertyName("required")]
|
||||
public bool Required { get; init; } = true;
|
||||
|
||||
[JsonPropertyName("reason")]
|
||||
public string? Reason { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Index entry for an oracle.
|
||||
/// </summary>
|
||||
public sealed record PatchOracleIndexEntry
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public required string Id { get; init; }
|
||||
|
||||
[JsonPropertyName("case_ref")]
|
||||
public required string CaseRef { get; init; }
|
||||
|
||||
[JsonPropertyName("variant")]
|
||||
public required string Variant { get; init; }
|
||||
|
||||
[JsonPropertyName("path")]
|
||||
public required string Path { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Root model for patch-oracle INDEX.json.
|
||||
/// </summary>
|
||||
public sealed record PatchOracleIndex
|
||||
{
|
||||
[JsonPropertyName("version")]
|
||||
public string Version { get; init; } = "1.0";
|
||||
|
||||
[JsonPropertyName("schema")]
|
||||
public string Schema { get; init; } = "patch-oracle/v1";
|
||||
|
||||
[JsonPropertyName("generated_at")]
|
||||
public DateTimeOffset? GeneratedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("description")]
|
||||
public string? Description { get; init; }
|
||||
|
||||
[JsonPropertyName("oracles")]
|
||||
public IReadOnlyList<PatchOracleIndexEntry> Oracles { get; init; } = Array.Empty<PatchOracleIndexEntry>();
|
||||
}
|
||||
@@ -1,494 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using FluentAssertions;
|
||||
using StellaOps.Reachability.FixtureTests.PatchOracle;
|
||||
using StellaOps.Scanner.Reachability;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Reachability.FixtureTests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for the patch-oracle harness infrastructure.
|
||||
/// Validates that the oracle comparison logic correctly identifies missing and forbidden elements.
|
||||
/// </summary>
|
||||
public class PatchOracleHarnessTests
|
||||
{
|
||||
private static readonly string RepoRoot = ReachbenchFixtureTests.LocateRepoRoot();
|
||||
private static readonly string PatchOracleRoot = Path.Combine(
|
||||
RepoRoot, "tests", "reachability", "fixtures", "patch-oracles");
|
||||
|
||||
#region Oracle Loading Tests
|
||||
|
||||
[Fact]
|
||||
public void Loader_IndexExists()
|
||||
{
|
||||
var loader = new PatchOracleLoader(PatchOracleRoot);
|
||||
loader.IndexExists().Should().BeTrue("patch-oracle INDEX.json should exist");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Loader_IndexLoadsSuccessfully()
|
||||
{
|
||||
var loader = new PatchOracleLoader(PatchOracleRoot);
|
||||
var index = loader.LoadIndex();
|
||||
|
||||
index.Should().NotBeNull();
|
||||
index.Version.Should().Be("1.0");
|
||||
index.Schema.Should().Be("patch-oracle/v1");
|
||||
index.Oracles.Should().NotBeEmpty("should have at least one oracle defined");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Loader_AllOraclesLoadSuccessfully()
|
||||
{
|
||||
var loader = new PatchOracleLoader(PatchOracleRoot);
|
||||
var oracles = loader.LoadAllOracles().ToList();
|
||||
|
||||
oracles.Should().NotBeEmpty();
|
||||
foreach (var oracle in oracles)
|
||||
{
|
||||
oracle.SchemaVersion.Should().Be("patch-oracle/v1");
|
||||
oracle.Id.Should().NotBeNullOrEmpty();
|
||||
oracle.CaseRef.Should().NotBeNullOrEmpty();
|
||||
oracle.Variant.Should().BeOneOf("reachable", "unreachable");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Loader_LoadOracleById()
|
||||
{
|
||||
var loader = new PatchOracleLoader(PatchOracleRoot);
|
||||
var oracle = loader.LoadOracle("curl-CVE-2023-38545-socks5-heap-reachable");
|
||||
|
||||
oracle.Should().NotBeNull();
|
||||
oracle.Id.Should().Be("curl-CVE-2023-38545-socks5-heap-reachable");
|
||||
oracle.CaseRef.Should().Be("curl-CVE-2023-38545-socks5-heap");
|
||||
oracle.Variant.Should().Be("reachable");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Comparer Tests - Pass Cases
|
||||
|
||||
[Fact]
|
||||
public void Comparer_PassesWhenAllExpectedElementsPresent()
|
||||
{
|
||||
var oracle = new PatchOracleDefinition
|
||||
{
|
||||
Id = "test-pass",
|
||||
CaseRef = "test-case",
|
||||
Variant = "reachable",
|
||||
ExpectedFunctions = new[]
|
||||
{
|
||||
new ExpectedFunction { SymbolId = "sym://test#func1", Required = true },
|
||||
new ExpectedFunction { SymbolId = "sym://test#func2", Required = true }
|
||||
},
|
||||
ExpectedEdges = new[]
|
||||
{
|
||||
new ExpectedEdge { From = "sym://test#func1", To = "sym://test#func2", Required = true }
|
||||
}
|
||||
};
|
||||
|
||||
var graph = new RichGraph(
|
||||
Nodes: new[]
|
||||
{
|
||||
new RichGraphNode("sym://test#func1", "sym://test#func1", null, null, "test", "function", null, null, null, null, null),
|
||||
new RichGraphNode("sym://test#func2", "sym://test#func2", null, null, "test", "function", null, null, null, null, null)
|
||||
},
|
||||
Edges: new[]
|
||||
{
|
||||
new RichGraphEdge("sym://test#func1", "sym://test#func2", "call", null, null, null, 0.9, null)
|
||||
},
|
||||
Roots: Array.Empty<RichGraphRoot>(),
|
||||
Analyzer: new RichGraphAnalyzer("test", "1.0", null)
|
||||
);
|
||||
|
||||
var comparer = new PatchOracleComparer(oracle);
|
||||
var result = comparer.Compare(graph);
|
||||
|
||||
result.Success.Should().BeTrue();
|
||||
result.Violations.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Comparer_PassesWithWildcardPatterns()
|
||||
{
|
||||
var oracle = new PatchOracleDefinition
|
||||
{
|
||||
Id = "test-wildcard",
|
||||
CaseRef = "test-case",
|
||||
Variant = "reachable",
|
||||
ExpectedFunctions = new[]
|
||||
{
|
||||
new ExpectedFunction { SymbolId = "sym://test#*", Required = true }
|
||||
}
|
||||
};
|
||||
|
||||
var graph = new RichGraph(
|
||||
Nodes: new[]
|
||||
{
|
||||
new RichGraphNode("sym://test#anything", "sym://test#anything", null, null, "test", "function", null, null, null, null, null)
|
||||
},
|
||||
Edges: Array.Empty<RichGraphEdge>(),
|
||||
Roots: Array.Empty<RichGraphRoot>(),
|
||||
Analyzer: new RichGraphAnalyzer("test", "1.0", null)
|
||||
);
|
||||
|
||||
var comparer = new PatchOracleComparer(oracle);
|
||||
var result = comparer.Compare(graph);
|
||||
|
||||
result.Success.Should().BeTrue();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Comparer Tests - Fail Cases
|
||||
|
||||
[Fact]
|
||||
public void Comparer_FailsWhenExpectedFunctionMissing()
|
||||
{
|
||||
var oracle = new PatchOracleDefinition
|
||||
{
|
||||
Id = "test-missing-func",
|
||||
CaseRef = "test-case",
|
||||
Variant = "reachable",
|
||||
ExpectedFunctions = new[]
|
||||
{
|
||||
new ExpectedFunction { SymbolId = "sym://test#missing", Required = true, Reason = "This function is critical" }
|
||||
}
|
||||
};
|
||||
|
||||
var graph = new RichGraph(
|
||||
Nodes: Array.Empty<RichGraphNode>(),
|
||||
Edges: Array.Empty<RichGraphEdge>(),
|
||||
Roots: Array.Empty<RichGraphRoot>(),
|
||||
Analyzer: new RichGraphAnalyzer("test", "1.0", null)
|
||||
);
|
||||
|
||||
var comparer = new PatchOracleComparer(oracle);
|
||||
var result = comparer.Compare(graph);
|
||||
|
||||
result.Success.Should().BeFalse();
|
||||
result.Violations.Should().HaveCount(1);
|
||||
result.Violations[0].Type.Should().Be(ViolationType.MissingFunction);
|
||||
result.Violations[0].From.Should().Be("sym://test#missing");
|
||||
result.Summary.MissingFunctions.Should().Be(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Comparer_FailsWhenExpectedEdgeMissing()
|
||||
{
|
||||
var oracle = new PatchOracleDefinition
|
||||
{
|
||||
Id = "test-missing-edge",
|
||||
CaseRef = "test-case",
|
||||
Variant = "reachable",
|
||||
ExpectedEdges = new[]
|
||||
{
|
||||
new ExpectedEdge { From = "sym://a", To = "sym://b", Required = true }
|
||||
}
|
||||
};
|
||||
|
||||
var graph = new RichGraph(
|
||||
Nodes: new[]
|
||||
{
|
||||
new RichGraphNode("sym://a", "sym://a", null, null, "test", "function", null, null, null, null, null),
|
||||
new RichGraphNode("sym://b", "sym://b", null, null, "test", "function", null, null, null, null, null)
|
||||
},
|
||||
Edges: Array.Empty<RichGraphEdge>(),
|
||||
Roots: Array.Empty<RichGraphRoot>(),
|
||||
Analyzer: new RichGraphAnalyzer("test", "1.0", null)
|
||||
);
|
||||
|
||||
var comparer = new PatchOracleComparer(oracle);
|
||||
var result = comparer.Compare(graph);
|
||||
|
||||
result.Success.Should().BeFalse();
|
||||
result.Violations.Should().HaveCount(1);
|
||||
result.Violations[0].Type.Should().Be(ViolationType.MissingEdge);
|
||||
result.Summary.MissingEdges.Should().Be(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Comparer_FailsWhenExpectedRootMissing()
|
||||
{
|
||||
var oracle = new PatchOracleDefinition
|
||||
{
|
||||
Id = "test-missing-root",
|
||||
CaseRef = "test-case",
|
||||
Variant = "reachable",
|
||||
ExpectedRoots = new[]
|
||||
{
|
||||
new ExpectedRoot { Id = "sym://root#main", Phase = "main", Required = true }
|
||||
}
|
||||
};
|
||||
|
||||
var graph = new RichGraph(
|
||||
Nodes: Array.Empty<RichGraphNode>(),
|
||||
Edges: Array.Empty<RichGraphEdge>(),
|
||||
Roots: Array.Empty<RichGraphRoot>(),
|
||||
Analyzer: new RichGraphAnalyzer("test", "1.0", null)
|
||||
);
|
||||
|
||||
var comparer = new PatchOracleComparer(oracle);
|
||||
var result = comparer.Compare(graph);
|
||||
|
||||
result.Success.Should().BeFalse();
|
||||
result.Violations.Should().HaveCount(1);
|
||||
result.Violations[0].Type.Should().Be(ViolationType.MissingRoot);
|
||||
result.Summary.MissingRoots.Should().Be(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Comparer_FailsWhenForbiddenFunctionPresent()
|
||||
{
|
||||
var oracle = new PatchOracleDefinition
|
||||
{
|
||||
Id = "test-forbidden-func",
|
||||
CaseRef = "test-case",
|
||||
Variant = "unreachable",
|
||||
ForbiddenFunctions = new[]
|
||||
{
|
||||
new ExpectedFunction { SymbolId = "sym://dangerous#sink", Reason = "Should not be reachable" }
|
||||
}
|
||||
};
|
||||
|
||||
var graph = new RichGraph(
|
||||
Nodes: new[]
|
||||
{
|
||||
new RichGraphNode("sym://dangerous#sink", "sym://dangerous#sink", null, null, "test", "function", null, null, null, null, null)
|
||||
},
|
||||
Edges: Array.Empty<RichGraphEdge>(),
|
||||
Roots: Array.Empty<RichGraphRoot>(),
|
||||
Analyzer: new RichGraphAnalyzer("test", "1.0", null)
|
||||
);
|
||||
|
||||
var comparer = new PatchOracleComparer(oracle);
|
||||
var result = comparer.Compare(graph);
|
||||
|
||||
result.Success.Should().BeFalse();
|
||||
result.Violations.Should().HaveCount(1);
|
||||
result.Violations[0].Type.Should().Be(ViolationType.ForbiddenFunctionPresent);
|
||||
result.Summary.ForbiddenFunctionsPresent.Should().Be(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Comparer_FailsWhenForbiddenEdgePresent()
|
||||
{
|
||||
var oracle = new PatchOracleDefinition
|
||||
{
|
||||
Id = "test-forbidden-edge",
|
||||
CaseRef = "test-case",
|
||||
Variant = "unreachable",
|
||||
ForbiddenEdges = new[]
|
||||
{
|
||||
new ExpectedEdge { From = "sym://entry", To = "sym://sink", Reason = "Path should be blocked" }
|
||||
}
|
||||
};
|
||||
|
||||
var graph = new RichGraph(
|
||||
Nodes: Array.Empty<RichGraphNode>(),
|
||||
Edges: new[]
|
||||
{
|
||||
new RichGraphEdge("sym://entry", "sym://sink", "call", null, null, null, 1.0, null)
|
||||
},
|
||||
Roots: Array.Empty<RichGraphRoot>(),
|
||||
Analyzer: new RichGraphAnalyzer("test", "1.0", null)
|
||||
);
|
||||
|
||||
var comparer = new PatchOracleComparer(oracle);
|
||||
var result = comparer.Compare(graph);
|
||||
|
||||
result.Success.Should().BeFalse();
|
||||
result.Violations.Should().HaveCount(1);
|
||||
result.Violations[0].Type.Should().Be(ViolationType.ForbiddenEdgePresent);
|
||||
result.Summary.ForbiddenEdgesPresent.Should().Be(1);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Confidence Threshold Tests
|
||||
|
||||
[Fact]
|
||||
public void Comparer_RespectsMinConfidenceThreshold()
|
||||
{
|
||||
var oracle = new PatchOracleDefinition
|
||||
{
|
||||
Id = "test-confidence",
|
||||
CaseRef = "test-case",
|
||||
Variant = "reachable",
|
||||
MinConfidence = 0.8,
|
||||
ExpectedEdges = new[]
|
||||
{
|
||||
new ExpectedEdge { From = "sym://a", To = "sym://b", Required = true }
|
||||
}
|
||||
};
|
||||
|
||||
var lowConfidenceGraph = new RichGraph(
|
||||
Nodes: Array.Empty<RichGraphNode>(),
|
||||
Edges: new[]
|
||||
{
|
||||
new RichGraphEdge("sym://a", "sym://b", "call", null, null, null, 0.5, null)
|
||||
},
|
||||
Roots: Array.Empty<RichGraphRoot>(),
|
||||
Analyzer: new RichGraphAnalyzer("test", "1.0", null)
|
||||
);
|
||||
|
||||
var comparer = new PatchOracleComparer(oracle);
|
||||
var result = comparer.Compare(lowConfidenceGraph);
|
||||
|
||||
result.Success.Should().BeFalse("edge confidence 0.5 is below threshold 0.8");
|
||||
result.Summary.MissingEdges.Should().Be(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Comparer_EdgeSpecificConfidenceOverridesDefault()
|
||||
{
|
||||
var oracle = new PatchOracleDefinition
|
||||
{
|
||||
Id = "test-edge-confidence",
|
||||
CaseRef = "test-case",
|
||||
Variant = "reachable",
|
||||
MinConfidence = 0.8,
|
||||
ExpectedEdges = new[]
|
||||
{
|
||||
new ExpectedEdge { From = "sym://a", To = "sym://b", MinConfidence = 0.3, Required = true }
|
||||
}
|
||||
};
|
||||
|
||||
var lowConfidenceGraph = new RichGraph(
|
||||
Nodes: Array.Empty<RichGraphNode>(),
|
||||
Edges: new[]
|
||||
{
|
||||
new RichGraphEdge("sym://a", "sym://b", "call", null, null, null, 0.5, null)
|
||||
},
|
||||
Roots: Array.Empty<RichGraphRoot>(),
|
||||
Analyzer: new RichGraphAnalyzer("test", "1.0", null)
|
||||
);
|
||||
|
||||
var comparer = new PatchOracleComparer(oracle);
|
||||
var result = comparer.Compare(lowConfidenceGraph);
|
||||
|
||||
result.Success.Should().BeTrue("edge-specific threshold 0.3 allows confidence 0.5");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Strict Mode Tests
|
||||
|
||||
[Fact]
|
||||
public void Comparer_StrictModeRejectsUnexpectedNodes()
|
||||
{
|
||||
var oracle = new PatchOracleDefinition
|
||||
{
|
||||
Id = "test-strict",
|
||||
CaseRef = "test-case",
|
||||
Variant = "reachable",
|
||||
StrictMode = true,
|
||||
ExpectedFunctions = new[]
|
||||
{
|
||||
new ExpectedFunction { SymbolId = "sym://expected", Required = true }
|
||||
}
|
||||
};
|
||||
|
||||
var graph = new RichGraph(
|
||||
Nodes: new[]
|
||||
{
|
||||
new RichGraphNode("sym://expected", "sym://expected", null, null, "test", "function", null, null, null, null, null),
|
||||
new RichGraphNode("sym://unexpected", "sym://unexpected", null, null, "test", "function", null, null, null, null, null)
|
||||
},
|
||||
Edges: Array.Empty<RichGraphEdge>(),
|
||||
Roots: Array.Empty<RichGraphRoot>(),
|
||||
Analyzer: new RichGraphAnalyzer("test", "1.0", null)
|
||||
);
|
||||
|
||||
var comparer = new PatchOracleComparer(oracle);
|
||||
var result = comparer.Compare(graph);
|
||||
|
||||
result.Success.Should().BeFalse();
|
||||
result.Violations.Should().Contain(v => v.Type == ViolationType.UnexpectedFunction);
|
||||
result.Summary.UnexpectedFunctions.Should().Be(1);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Report Generation Tests
|
||||
|
||||
[Fact]
|
||||
public void Result_GeneratesReadableReport()
|
||||
{
|
||||
var oracle = new PatchOracleDefinition
|
||||
{
|
||||
Id = "test-report",
|
||||
CaseRef = "test-case",
|
||||
Variant = "reachable",
|
||||
ExpectedFunctions = new[]
|
||||
{
|
||||
new ExpectedFunction { SymbolId = "sym://missing", Required = true, Reason = "Critical sink" }
|
||||
}
|
||||
};
|
||||
|
||||
var graph = new RichGraph(
|
||||
Nodes: new[]
|
||||
{
|
||||
new RichGraphNode("sym://other", "sym://other", null, null, "test", "function", null, null, null, null, null)
|
||||
},
|
||||
Edges: Array.Empty<RichGraphEdge>(),
|
||||
Roots: Array.Empty<RichGraphRoot>(),
|
||||
Analyzer: new RichGraphAnalyzer("test", "1.0", null)
|
||||
);
|
||||
|
||||
var comparer = new PatchOracleComparer(oracle);
|
||||
var result = comparer.Compare(graph);
|
||||
|
||||
var report = result.ToReport();
|
||||
|
||||
report.Should().Contain("FAIL");
|
||||
report.Should().Contain("test-report");
|
||||
report.Should().Contain("MissingFunction");
|
||||
report.Should().Contain("sym://missing");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Integration with Fixture Data
|
||||
|
||||
public static IEnumerable<object[]> AllOracleData()
|
||||
{
|
||||
var loader = new PatchOracleLoader(PatchOracleRoot);
|
||||
if (!loader.IndexExists())
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
foreach (var entry in loader.EnumerateOracles())
|
||||
{
|
||||
yield return new object[] { entry.Id, entry.CaseRef, entry.Variant };
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(AllOracleData))]
|
||||
public void AllOracles_HaveValidStructure(string oracleId, string caseRef, string variant)
|
||||
{
|
||||
var loader = new PatchOracleLoader(PatchOracleRoot);
|
||||
var oracle = loader.LoadOracle(oracleId);
|
||||
|
||||
oracle.Id.Should().Be(oracleId);
|
||||
oracle.CaseRef.Should().Be(caseRef);
|
||||
oracle.Variant.Should().Be(variant);
|
||||
oracle.SchemaVersion.Should().Be("patch-oracle/v1");
|
||||
|
||||
// At least one expectation should be defined
|
||||
var hasExpectations = oracle.ExpectedFunctions.Count > 0
|
||||
|| oracle.ExpectedEdges.Count > 0
|
||||
|| oracle.ExpectedRoots.Count > 0
|
||||
|| oracle.ForbiddenFunctions.Count > 0
|
||||
|| oracle.ForbiddenEdges.Count > 0;
|
||||
hasExpectations.Should().BeTrue($"Oracle '{oracleId}' should define at least one expectation");
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -1,405 +0,0 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using FluentAssertions;
|
||||
using StellaOps.Scanner.Reachability;
|
||||
using StellaOps.Scanner.Reachability.Lifters;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Reachability.FixtureTests;
|
||||
|
||||
public sealed class ReachabilityLifterTests : IDisposable
|
||||
{
|
||||
private readonly string _tempDir;
|
||||
|
||||
public ReachabilityLifterTests()
|
||||
{
|
||||
_tempDir = Path.Combine(Path.GetTempPath(), $"lifter-test-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(_tempDir);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (Directory.Exists(_tempDir))
|
||||
{
|
||||
Directory.Delete(_tempDir, true);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Best effort cleanup
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NodeLifter_ExtractsPackageInfo()
|
||||
{
|
||||
// Arrange
|
||||
var packageJson = """
|
||||
{
|
||||
"name": "my-app",
|
||||
"version": "1.0.0",
|
||||
"main": "index.js",
|
||||
"dependencies": {
|
||||
"express": "^4.18.0",
|
||||
"lodash": "^4.17.0"
|
||||
}
|
||||
}
|
||||
""";
|
||||
await File.WriteAllTextAsync(Path.Combine(_tempDir, "package.json"), packageJson);
|
||||
|
||||
var lifter = new NodeReachabilityLifter();
|
||||
var context = new ReachabilityLifterContext
|
||||
{
|
||||
RootPath = _tempDir,
|
||||
AnalysisId = "test-analysis-001"
|
||||
};
|
||||
var builder = new ReachabilityGraphBuilder();
|
||||
|
||||
// Act
|
||||
await lifter.LiftAsync(context, builder, CancellationToken.None);
|
||||
var graph = builder.ToUnionGraph("node");
|
||||
|
||||
// Assert
|
||||
graph.Nodes.Should().NotBeEmpty();
|
||||
graph.Nodes.Should().Contain(n => n.Display == "my-app");
|
||||
graph.Nodes.Should().Contain(n => n.Display == "express");
|
||||
graph.Nodes.Should().Contain(n => n.Display == "lodash");
|
||||
|
||||
graph.Edges.Should().NotBeEmpty();
|
||||
graph.Edges.Should().Contain(e => e.EdgeType == "import");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NodeLifter_ExtractsEntrypoints()
|
||||
{
|
||||
// Arrange
|
||||
var packageJson = """
|
||||
{
|
||||
"name": "my-cli",
|
||||
"version": "2.0.0",
|
||||
"main": "lib/index.js",
|
||||
"module": "lib/index.mjs",
|
||||
"bin": {
|
||||
"mycli": "bin/cli.js"
|
||||
}
|
||||
}
|
||||
""";
|
||||
await File.WriteAllTextAsync(Path.Combine(_tempDir, "package.json"), packageJson);
|
||||
|
||||
var lifter = new NodeReachabilityLifter();
|
||||
var context = new ReachabilityLifterContext
|
||||
{
|
||||
RootPath = _tempDir,
|
||||
AnalysisId = "test-analysis-002"
|
||||
};
|
||||
var builder = new ReachabilityGraphBuilder();
|
||||
|
||||
// Act
|
||||
await lifter.LiftAsync(context, builder, CancellationToken.None);
|
||||
var graph = builder.ToUnionGraph("node");
|
||||
|
||||
// Assert
|
||||
graph.Nodes.Should().Contain(n => n.Kind == "entrypoint");
|
||||
graph.Nodes.Should().Contain(n => n.Kind == "binary");
|
||||
graph.Edges.Should().Contain(e => e.EdgeType == "loads");
|
||||
graph.Edges.Should().Contain(e => e.EdgeType == "spawn");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NodeLifter_ExtractsImportsFromSource()
|
||||
{
|
||||
// Arrange
|
||||
var packageJson = """
|
||||
{
|
||||
"name": "my-app",
|
||||
"version": "1.0.0"
|
||||
}
|
||||
""";
|
||||
await File.WriteAllTextAsync(Path.Combine(_tempDir, "package.json"), packageJson);
|
||||
|
||||
var sourceCode = """
|
||||
import express from 'express';
|
||||
const lodash = require('lodash');
|
||||
import('./dynamic-module.js');
|
||||
""";
|
||||
await File.WriteAllTextAsync(Path.Combine(_tempDir, "index.js"), sourceCode);
|
||||
|
||||
var lifter = new NodeReachabilityLifter();
|
||||
var context = new ReachabilityLifterContext
|
||||
{
|
||||
RootPath = _tempDir,
|
||||
AnalysisId = "test-analysis-003"
|
||||
};
|
||||
var builder = new ReachabilityGraphBuilder();
|
||||
|
||||
// Act
|
||||
await lifter.LiftAsync(context, builder, CancellationToken.None);
|
||||
var graph = builder.ToUnionGraph("node");
|
||||
|
||||
// Assert
|
||||
graph.Nodes.Should().Contain(n => n.Display == "express");
|
||||
graph.Nodes.Should().Contain(n => n.Display == "lodash");
|
||||
graph.Edges.Count(e => e.EdgeType == "import").Should().BeGreaterOrEqualTo(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DotNetLifter_ExtractsProjectInfo()
|
||||
{
|
||||
// Arrange
|
||||
var csproj = """
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<AssemblyName>MyApp</AssemblyName>
|
||||
<RootNamespace>MyCompany.MyApp</RootNamespace>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
|
||||
<PackageReference Include="Serilog" Version="3.1.1" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
""";
|
||||
await File.WriteAllTextAsync(Path.Combine(_tempDir, "MyApp.csproj"), csproj);
|
||||
|
||||
var lifter = new DotNetReachabilityLifter();
|
||||
var context = new ReachabilityLifterContext
|
||||
{
|
||||
RootPath = _tempDir,
|
||||
AnalysisId = "test-analysis-004"
|
||||
};
|
||||
var builder = new ReachabilityGraphBuilder();
|
||||
|
||||
// Act
|
||||
await lifter.LiftAsync(context, builder, CancellationToken.None);
|
||||
var graph = builder.ToUnionGraph("dotnet");
|
||||
|
||||
// Assert
|
||||
graph.Nodes.Should().NotBeEmpty();
|
||||
graph.Nodes.Should().Contain(n => n.Display == "MyApp");
|
||||
graph.Nodes.Should().Contain(n => n.Display == "Newtonsoft.Json");
|
||||
graph.Nodes.Should().Contain(n => n.Display == "Serilog");
|
||||
graph.Nodes.Should().Contain(n => n.Kind == "namespace" && n.Display == "MyCompany.MyApp");
|
||||
|
||||
graph.Edges.Should().NotBeEmpty();
|
||||
graph.Edges.Count(e => e.EdgeType == "import").Should().BeGreaterOrEqualTo(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DotNetLifter_ExtractsProjectReferences()
|
||||
{
|
||||
// Arrange
|
||||
var libDir = Path.Combine(_tempDir, "Lib");
|
||||
Directory.CreateDirectory(libDir);
|
||||
|
||||
var libCsproj = """
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<AssemblyName>MyLib</AssemblyName>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
""";
|
||||
await File.WriteAllTextAsync(Path.Combine(libDir, "MyLib.csproj"), libCsproj);
|
||||
|
||||
var appCsproj = """
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<AssemblyName>MyApp</AssemblyName>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="Lib/MyLib.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
""";
|
||||
await File.WriteAllTextAsync(Path.Combine(_tempDir, "MyApp.csproj"), appCsproj);
|
||||
|
||||
var lifter = new DotNetReachabilityLifter();
|
||||
var context = new ReachabilityLifterContext
|
||||
{
|
||||
RootPath = _tempDir,
|
||||
AnalysisId = "test-analysis-005"
|
||||
};
|
||||
var builder = new ReachabilityGraphBuilder();
|
||||
|
||||
// Act
|
||||
await lifter.LiftAsync(context, builder, CancellationToken.None);
|
||||
var graph = builder.ToUnionGraph("dotnet");
|
||||
|
||||
// Assert
|
||||
graph.Nodes.Should().Contain(n => n.Display == "MyApp");
|
||||
graph.Nodes.Should().Contain(n => n.Display == "MyLib");
|
||||
graph.Edges.Should().Contain(e => e.EdgeType == "import");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LifterRegistry_CombinesMultipleLanguages()
|
||||
{
|
||||
// Arrange
|
||||
var packageJson = """
|
||||
{
|
||||
"name": "hybrid-app",
|
||||
"version": "1.0.0",
|
||||
"dependencies": { "express": "^4.0.0" }
|
||||
}
|
||||
""";
|
||||
await File.WriteAllTextAsync(Path.Combine(_tempDir, "package.json"), packageJson);
|
||||
|
||||
var csproj = """
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<AssemblyName>HybridBackend</AssemblyName>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="8.0.0" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
""";
|
||||
await File.WriteAllTextAsync(Path.Combine(_tempDir, "Backend.csproj"), csproj);
|
||||
|
||||
var registry = new ReachabilityLifterRegistry();
|
||||
var context = new ReachabilityLifterContext
|
||||
{
|
||||
RootPath = _tempDir,
|
||||
AnalysisId = "test-analysis-006"
|
||||
};
|
||||
|
||||
// Act
|
||||
var graph = await registry.LiftAllAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
registry.Lifters.Should().HaveCountGreaterOrEqualTo(2);
|
||||
graph.Nodes.Should().Contain(n => n.Lang == "node");
|
||||
graph.Nodes.Should().Contain(n => n.Lang == "dotnet");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LifterRegistry_SelectsSpecificLanguages()
|
||||
{
|
||||
// Arrange
|
||||
var packageJson = """
|
||||
{
|
||||
"name": "node-only",
|
||||
"version": "1.0.0"
|
||||
}
|
||||
""";
|
||||
await File.WriteAllTextAsync(Path.Combine(_tempDir, "package.json"), packageJson);
|
||||
|
||||
var registry = new ReachabilityLifterRegistry();
|
||||
var context = new ReachabilityLifterContext
|
||||
{
|
||||
RootPath = _tempDir,
|
||||
AnalysisId = "test-analysis-007"
|
||||
};
|
||||
|
||||
// Act
|
||||
var graph = await registry.LiftAsync(context, new[] { "node" }, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
graph.Nodes.Should().OnlyContain(n => n.Lang == "node");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LifterRegistry_LiftAndWrite_CreatesOutputFiles()
|
||||
{
|
||||
// Arrange
|
||||
var packageJson = """
|
||||
{
|
||||
"name": "write-test",
|
||||
"version": "1.0.0",
|
||||
"dependencies": { "lodash": "^4.0.0" }
|
||||
}
|
||||
""";
|
||||
await File.WriteAllTextAsync(Path.Combine(_tempDir, "package.json"), packageJson);
|
||||
|
||||
var outputDir = Path.Combine(_tempDir, "output");
|
||||
Directory.CreateDirectory(outputDir);
|
||||
|
||||
var registry = new ReachabilityLifterRegistry();
|
||||
var context = new ReachabilityLifterContext
|
||||
{
|
||||
RootPath = _tempDir,
|
||||
AnalysisId = "test-write-001"
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await registry.LiftAndWriteAsync(context, outputDir, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
File.Exists(result.MetaPath).Should().BeTrue();
|
||||
File.Exists(result.Nodes.Path).Should().BeTrue();
|
||||
File.Exists(result.Edges.Path).Should().BeTrue();
|
||||
result.Nodes.RecordCount.Should().BeGreaterThan(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GraphBuilder_AddsRichNodes()
|
||||
{
|
||||
// Arrange
|
||||
var builder = new ReachabilityGraphBuilder();
|
||||
|
||||
// Act
|
||||
builder.AddNode(
|
||||
symbolId: "sym:test:abc123",
|
||||
lang: "test",
|
||||
kind: "function",
|
||||
display: "myFunction",
|
||||
sourceFile: "src/main.ts",
|
||||
sourceLine: 42,
|
||||
attributes: new System.Collections.Generic.Dictionary<string, string>
|
||||
{
|
||||
["visibility"] = "public",
|
||||
["async"] = "true"
|
||||
});
|
||||
|
||||
var graph = builder.ToUnionGraph("test");
|
||||
|
||||
// Assert
|
||||
graph.Nodes.Should().HaveCount(1);
|
||||
var node = graph.Nodes.First();
|
||||
node.SymbolId.Should().Be("sym:test:abc123");
|
||||
node.Lang.Should().Be("test");
|
||||
node.Kind.Should().Be("function");
|
||||
node.Display.Should().Be("myFunction");
|
||||
node.Attributes.Should().ContainKey("visibility");
|
||||
node.Source.Should().NotBeNull();
|
||||
node.Source!.Evidence.Should().Contain("src/main.ts:42");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GraphBuilder_AddsRichEdges()
|
||||
{
|
||||
// Arrange
|
||||
var builder = new ReachabilityGraphBuilder();
|
||||
|
||||
// Act
|
||||
builder.AddEdge(
|
||||
from: "sym:test:from",
|
||||
to: "sym:test:to",
|
||||
edgeType: EdgeTypes.Call,
|
||||
confidence: EdgeConfidence.High,
|
||||
origin: "static",
|
||||
provenance: Provenance.Il,
|
||||
evidence: "file:src/main.cs:100");
|
||||
|
||||
var graph = builder.ToUnionGraph("test");
|
||||
|
||||
// Assert
|
||||
graph.Edges.Should().HaveCount(1);
|
||||
var edge = graph.Edges.First();
|
||||
edge.From.Should().Be("sym:test:from");
|
||||
edge.To.Should().Be("sym:test:to");
|
||||
edge.EdgeType.Should().Be("call");
|
||||
edge.Confidence.Should().Be("high");
|
||||
edge.Source.Should().NotBeNull();
|
||||
edge.Source!.Origin.Should().Be("static");
|
||||
edge.Source.Provenance.Should().Be("il");
|
||||
}
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using FluentAssertions;
|
||||
using StellaOps.Replay.Core;
|
||||
using StellaOps.Scanner.Reachability;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Reachability.FixtureTests;
|
||||
|
||||
public sealed class ReachabilityReplayWriterTests
|
||||
{
|
||||
[Fact]
|
||||
public void AttachEvidence_AppendsGraphsAndTracesDeterministically()
|
||||
{
|
||||
var manifest = new ReplayManifest
|
||||
{
|
||||
Scan = new ReplayScanMetadata { Id = "scan-123", Time = DateTimeOffset.Parse("2025-10-15T10:00:00Z", CultureInfo.InvariantCulture) }
|
||||
};
|
||||
|
||||
var graphs = new List<ReachabilityReplayGraph>
|
||||
{
|
||||
new("static", "cas://graph/B", "ABCDEF", "scanner-jvm", "1.0.0"),
|
||||
new("framework", "cas://graph/A", "abcdef", "scanner-jvm", "1.0.0"),
|
||||
new("static", "cas://graph/B", "ABCDEF", "scanner-jvm", "1.0.0") // duplicate
|
||||
};
|
||||
|
||||
var traces = new List<ReachabilityReplayTrace>
|
||||
{
|
||||
new("zastava", "cas://trace/1", "FFEE", DateTimeOffset.Parse("2025-10-15T09:00:00+02:00", CultureInfo.InvariantCulture)),
|
||||
new("zastava", "cas://trace/2", "ffee", DateTimeOffset.Parse("2025-10-15T09:05:00Z", CultureInfo.InvariantCulture)),
|
||||
new("zastava", "cas://trace/1", "FFEE", DateTimeOffset.Parse("2025-10-15T09:00:00Z", CultureInfo.InvariantCulture)) // duplicate once normalized
|
||||
};
|
||||
|
||||
var writer = new StellaOps.Scanner.Reachability.ReachabilityReplayWriter();
|
||||
writer.AttachEvidence(manifest, graphs, traces);
|
||||
|
||||
manifest.Reachability.Should().NotBeNull();
|
||||
manifest.Reachability!.Graphs.Should().HaveCount(2);
|
||||
manifest.Reachability.Graphs[0].CasUri.Should().Be("cas://graph/A");
|
||||
manifest.Reachability.Graphs[0].Sha256.Should().Be("abcdef");
|
||||
manifest.Reachability.Graphs[1].CasUri.Should().Be("cas://graph/B");
|
||||
manifest.Reachability.Graphs[1].Kind.Should().Be("static");
|
||||
|
||||
manifest.Reachability.RuntimeTraces.Should().HaveCount(2);
|
||||
manifest.Reachability.RuntimeTraces[0].RecordedAt.Should().Be(DateTimeOffset.Parse("2025-10-15T07:00:00Z"));
|
||||
manifest.Reachability.RuntimeTraces[0].Sha256.Should().Be("ffee");
|
||||
manifest.Reachability.RuntimeTraces[1].CasUri.Should().Be("cas://trace/2");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AttachEvidence_DoesNotCreateSectionWhenEmpty()
|
||||
{
|
||||
var manifest = new ReplayManifest();
|
||||
var writer = new StellaOps.Scanner.Reachability.ReachabilityReplayWriter();
|
||||
|
||||
writer.AttachEvidence(manifest, Array.Empty<ReachabilityReplayGraph>(), Array.Empty<ReachabilityReplayTrace>());
|
||||
|
||||
manifest.Reachability.AnalysisId.Should().BeNull();
|
||||
manifest.Reachability.Graphs.Should().BeEmpty();
|
||||
manifest.Reachability.RuntimeTraces.Should().BeEmpty();
|
||||
}
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
using System.Text.Json;
|
||||
using FluentAssertions;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Reachability.FixtureTests;
|
||||
|
||||
public class ReachbenchEvaluationHarnessTests
|
||||
{
|
||||
private static readonly string RepoRoot = LocateRepoRoot();
|
||||
private static readonly string CasesRoot = Path.Combine(
|
||||
RepoRoot,
|
||||
"tests",
|
||||
"reachability",
|
||||
"fixtures",
|
||||
"reachbench-2025-expanded",
|
||||
"cases");
|
||||
|
||||
public static IEnumerable<object[]> CaseIds()
|
||||
{
|
||||
return Directory.EnumerateDirectories(CasesRoot)
|
||||
.OrderBy(path => path, StringComparer.Ordinal)
|
||||
.Select(path => new object[] { Path.GetFileName(path)! });
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(CaseIds))]
|
||||
public void GroundTruthStatusesMatchVariantIntent(string caseId)
|
||||
{
|
||||
var caseJsonPath = Path.Combine(CasesRoot, caseId, "case.json");
|
||||
File.Exists(caseJsonPath).Should().BeTrue();
|
||||
|
||||
using var caseDoc = JsonDocument.Parse(File.ReadAllBytes(caseJsonPath));
|
||||
var groundTruth = caseDoc.RootElement.GetProperty("ground_truth");
|
||||
|
||||
groundTruth.GetProperty("reachable_variant")
|
||||
.GetProperty("status")
|
||||
.GetString()
|
||||
.Should()
|
||||
.Be("affected", $"{caseId} reachable variant should be marked affected for evaluation harness");
|
||||
|
||||
groundTruth.GetProperty("unreachable_variant")
|
||||
.GetProperty("status")
|
||||
.GetString()
|
||||
.Should()
|
||||
.Be("not_affected", $"{caseId} unreachable variant should be marked not_affected for evaluation harness");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(CaseIds))]
|
||||
public void TruthGraphsAlignWithExpectedReachability(string caseId)
|
||||
{
|
||||
var reachablePaths = CountTruthPaths(caseId, "reachable");
|
||||
reachablePaths.Should().BeGreaterThan(0, $"{caseId} reachable variant should expose at least one execution path");
|
||||
|
||||
var unreachablePaths = CountTruthPaths(caseId, "unreachable");
|
||||
unreachablePaths.Should().Be(0, $"{caseId} unreachable variant should have no execution paths");
|
||||
}
|
||||
|
||||
private static int CountTruthPaths(string caseId, string variant)
|
||||
{
|
||||
var truthPath = Path.Combine(CasesRoot, caseId, "images", variant, "reachgraph.truth.json");
|
||||
File.Exists(truthPath).Should().BeTrue();
|
||||
|
||||
using var truthDoc = JsonDocument.Parse(File.ReadAllBytes(truthPath));
|
||||
var paths = truthDoc.RootElement.GetProperty("paths");
|
||||
paths.ValueKind.Should().Be(JsonValueKind.Array, $"{caseId}:{variant} should list truth paths as an array");
|
||||
return paths.GetArrayLength();
|
||||
}
|
||||
|
||||
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).");
|
||||
}
|
||||
}
|
||||
@@ -1,159 +0,0 @@
|
||||
using System.Text.Json;
|
||||
using FluentAssertions;
|
||||
using Xunit;
|
||||
using System.Security.Cryptography;
|
||||
using System.Linq;
|
||||
|
||||
namespace StellaOps.Reachability.FixtureTests;
|
||||
|
||||
public class ReachbenchFixtureTests
|
||||
{
|
||||
private static readonly string RepoRoot = LocateRepoRoot();
|
||||
private static readonly string FixtureRoot = Path.Combine(
|
||||
RepoRoot, "tests", "reachability", "fixtures", "reachbench-2025-expanded");
|
||||
private static readonly string CasesRoot = Path.Combine(FixtureRoot, "cases");
|
||||
|
||||
[Fact]
|
||||
public void IndexListsAllCases()
|
||||
{
|
||||
Directory.Exists(FixtureRoot).Should().BeTrue("reachbench fixtures should exist under tests/reachability/fixtures");
|
||||
File.Exists(Path.Combine(FixtureRoot, "INDEX.json")).Should().BeTrue("the reachbench index must be present");
|
||||
|
||||
using var indexStream = File.OpenRead(Path.Combine(FixtureRoot, "INDEX.json"));
|
||||
using var document = JsonDocument.Parse(indexStream);
|
||||
var names = new List<string>();
|
||||
var found = false;
|
||||
JsonElement casesElement = default;
|
||||
foreach (var property in document.RootElement.EnumerateObject())
|
||||
{
|
||||
names.Add(property.Name);
|
||||
if (property.NameEquals("cases"))
|
||||
{
|
||||
casesElement = property.Value;
|
||||
found = true;
|
||||
}
|
||||
}
|
||||
|
||||
found.Should().BeTrue($"INDEX.json should contain 'cases'. Properties present: {string.Join(",", names)}");
|
||||
casesElement.ValueKind.Should().Be(JsonValueKind.Array);
|
||||
casesElement.GetArrayLength().Should().BeGreaterOrEqualTo(20, "expanded pack should carry broad coverage");
|
||||
|
||||
foreach (var entry in casesElement.EnumerateArray())
|
||||
{
|
||||
var id = entry.GetProperty("id").GetString();
|
||||
id.Should().NotBeNullOrEmpty();
|
||||
var rel = entry.TryGetProperty("path", out var relProp)
|
||||
? relProp.GetString()
|
||||
: Path.Combine("cases", id!);
|
||||
rel.Should().NotBeNullOrEmpty();
|
||||
var path = Path.Combine(FixtureRoot, rel!);
|
||||
Directory.Exists(path).Should().BeTrue($"case '{id}' folder '{rel}' should exist");
|
||||
}
|
||||
}
|
||||
|
||||
public static IEnumerable<object[]> CaseVariantData()
|
||||
{
|
||||
foreach (var caseDir in Directory.EnumerateDirectories(CasesRoot))
|
||||
{
|
||||
var caseId = Path.GetFileName(caseDir);
|
||||
yield return new object[] { caseId!, Path.Combine(caseDir, "images", "reachable") };
|
||||
yield return new object[] { caseId!, Path.Combine(caseDir, "images", "unreachable") };
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(CaseVariantData))]
|
||||
public void CaseVariantContainsExpectedArtifacts(string caseId, string variantPath)
|
||||
{
|
||||
Directory.Exists(variantPath).Should().BeTrue();
|
||||
|
||||
var requiredFiles = new[]
|
||||
{
|
||||
"manifest.json",
|
||||
"sbom.cdx.json",
|
||||
"sbom.spdx.json",
|
||||
"symbols.json",
|
||||
"callgraph.static.json",
|
||||
"callgraph.framework.json",
|
||||
"reachgraph.truth.json",
|
||||
"vex.openvex.json",
|
||||
"attestation.dsse.json"
|
||||
};
|
||||
|
||||
foreach (var file in requiredFiles)
|
||||
{
|
||||
File.Exists(Path.Combine(variantPath, file)).Should().BeTrue($"{caseId}:{Path.GetFileName(variantPath)} missing {file}");
|
||||
}
|
||||
|
||||
var truthPath = Path.Combine(variantPath, "reachgraph.truth.json");
|
||||
using var truthStream = File.OpenRead(truthPath);
|
||||
using var truthDoc = JsonDocument.Parse(truthStream);
|
||||
truthDoc.RootElement.GetProperty("schema_version").GetString().Should().NotBeNullOrEmpty();
|
||||
truthDoc.RootElement.GetProperty("paths").ValueKind.Should().Be(JsonValueKind.Array);
|
||||
|
||||
VerifyManifestHashes(caseId, variantPath, requiredFiles);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(CaseVariantData))]
|
||||
public void CaseGroundTruthMatchesVariants(string caseId, string variantPath)
|
||||
{
|
||||
var caseJsonPath = Path.Combine(Path.GetDirectoryName(Path.GetDirectoryName(variantPath))!, "case.json");
|
||||
File.Exists(caseJsonPath).Should().BeTrue();
|
||||
|
||||
using var caseStream = File.OpenRead(caseJsonPath);
|
||||
using var caseDoc = JsonDocument.Parse(caseStream);
|
||||
var groundTruth = caseDoc.RootElement.GetProperty("ground_truth");
|
||||
var variantKey = variantPath.EndsWith("reachable", StringComparison.OrdinalIgnoreCase)
|
||||
? "reachable_variant"
|
||||
: "unreachable_variant";
|
||||
|
||||
var variant = groundTruth.GetProperty(variantKey);
|
||||
variant.GetProperty("status").GetString().Should().NotBeNullOrEmpty($"{caseId}:{variantKey} should set status");
|
||||
variant.TryGetProperty("evidence", out var evidence).Should().BeTrue($"{caseId}:{variantKey} should define evidence");
|
||||
evidence.TryGetProperty("paths", out var pathsProp).Should().BeTrue();
|
||||
pathsProp.ValueKind.Should().Be(JsonValueKind.Array);
|
||||
|
||||
var truthPath = Path.Combine(variantPath, "reachgraph.truth.json");
|
||||
using var truthStream = File.OpenRead(truthPath);
|
||||
using var truthDoc = JsonDocument.Parse(truthStream);
|
||||
var paths = truthDoc.RootElement.GetProperty("paths");
|
||||
|
||||
paths.ValueKind.Should().Be(JsonValueKind.Array);
|
||||
}
|
||||
|
||||
internal 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).");
|
||||
}
|
||||
|
||||
private static void VerifyManifestHashes(string caseId, string variantPath, IEnumerable<string> requiredFiles)
|
||||
{
|
||||
var manifestPath = Path.Combine(variantPath, "manifest.json");
|
||||
using var manifestStream = File.OpenRead(manifestPath);
|
||||
using var manifestDoc = JsonDocument.Parse(manifestStream);
|
||||
var files = manifestDoc.RootElement.GetProperty("files");
|
||||
|
||||
foreach (var file in requiredFiles.Where(f => f != "manifest.json"))
|
||||
{
|
||||
files.TryGetProperty(file, out var hashProp).Should().BeTrue($"{caseId}:{variantPath} manifest missing hash for {file}");
|
||||
var expectedHash = hashProp.GetString();
|
||||
expectedHash.Should().NotBeNullOrEmpty($"{caseId}:{variantPath} hash missing for {file}");
|
||||
|
||||
var path = Path.Combine(variantPath, file);
|
||||
var actualHash = BitConverter.ToString(SHA256.HashData(File.ReadAllBytes(path))).Replace("-", "").ToLowerInvariant();
|
||||
actualHash.Should().Be(expectedHash, $"{caseId}:{variantPath} hash mismatch for {file}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.10.0" />
|
||||
<PackageReference Include="xunit" Version="2.7.0" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.8">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="FluentAssertions" Version="6.12.0" />
|
||||
<PackageReference Include="System.Text.Json" Version="10.0.0-preview.7.24405.7" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Content Include="..\\..\\fixtures\\**\\*">
|
||||
<Link>fixtures\\%(RecursiveDir)%(Filename)%(Extension)</Link>
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</Content>
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\src\Scanner\__Libraries\StellaOps.Scanner.Reachability\StellaOps.Scanner.Reachability.csproj" />
|
||||
<ProjectReference Include="..\..\..\src\__Libraries\StellaOps.Replay.Core\StellaOps.Replay.Core.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -1,186 +0,0 @@
|
||||
using FluentAssertions;
|
||||
using StellaOps.Scanner.Reachability;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Reachability.FixtureTests;
|
||||
|
||||
public sealed class SymbolIdTests
|
||||
{
|
||||
[Fact]
|
||||
public void ForJava_CreatesCanonicalSymbolId()
|
||||
{
|
||||
var id = SymbolId.ForJava("com.example", "MyClass", "doSomething", "(Ljava/lang/String;)V");
|
||||
|
||||
id.Should().StartWith("sym:java:");
|
||||
id.Should().HaveLength("sym:java:".Length + 43); // Base64url SHA-256 without padding = 43 chars
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ForJava_IsDeterministic()
|
||||
{
|
||||
var id1 = SymbolId.ForJava("com.example", "MyClass", "doSomething", "(Ljava/lang/String;)V");
|
||||
var id2 = SymbolId.ForJava("com.example", "MyClass", "doSomething", "(Ljava/lang/String;)V");
|
||||
|
||||
id1.Should().Be(id2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ForJava_IsCaseInsensitive()
|
||||
{
|
||||
var id1 = SymbolId.ForJava("com.example", "MyClass", "doSomething", "()V");
|
||||
var id2 = SymbolId.ForJava("COM.EXAMPLE", "MYCLASS", "DOSOMETHING", "()V");
|
||||
|
||||
id1.Should().Be(id2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ForDotNet_CreatesCanonicalSymbolId()
|
||||
{
|
||||
var id = SymbolId.ForDotNet("MyAssembly", "MyNamespace", "MyClass", "MyMethod(System.String)");
|
||||
|
||||
id.Should().StartWith("sym:dotnet:");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ForDotNet_DifferentSignaturesProduceDifferentIds()
|
||||
{
|
||||
var id1 = SymbolId.ForDotNet("MyAssembly", "MyNamespace", "MyClass", "Method(String)");
|
||||
var id2 = SymbolId.ForDotNet("MyAssembly", "MyNamespace", "MyClass", "Method(Int32)");
|
||||
|
||||
id1.Should().NotBe(id2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ForNode_CreatesCanonicalSymbolId()
|
||||
{
|
||||
var id = SymbolId.ForNode("express", "lib/router", "function");
|
||||
|
||||
id.Should().StartWith("sym:node:");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ForNode_HandlesScopedPackages()
|
||||
{
|
||||
var id1 = SymbolId.ForNode("@angular/core", "src/render", "function");
|
||||
var id2 = SymbolId.ForNode("@angular/core", "src/render", "function");
|
||||
|
||||
id1.Should().Be(id2);
|
||||
id1.Should().StartWith("sym:node:");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ForGo_CreatesCanonicalSymbolId()
|
||||
{
|
||||
var id = SymbolId.ForGo("github.com/example/repo", "pkg/http", "Server", "HandleRequest");
|
||||
|
||||
id.Should().StartWith("sym:go:");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ForGo_FunctionWithoutReceiver()
|
||||
{
|
||||
var id = SymbolId.ForGo("github.com/example/repo", "pkg/main", "", "main");
|
||||
|
||||
id.Should().StartWith("sym:go:");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ForRust_CreatesCanonicalSymbolId()
|
||||
{
|
||||
var id = SymbolId.ForRust("my_crate", "foo::bar", "my_function", "_ZN8my_crate3foo3bar11my_functionE");
|
||||
|
||||
id.Should().StartWith("sym:rust:");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ForSwift_CreatesCanonicalSymbolId()
|
||||
{
|
||||
var id = SymbolId.ForSwift("MyModule", "MyClass", "myMethod", null);
|
||||
|
||||
id.Should().StartWith("sym:swift:");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ForShell_CreatesCanonicalSymbolId()
|
||||
{
|
||||
var id = SymbolId.ForShell("scripts/deploy.sh", "run_migration");
|
||||
|
||||
id.Should().StartWith("sym:shell:");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ForBinary_CreatesCanonicalSymbolId()
|
||||
{
|
||||
var id = SymbolId.ForBinary("7f6e5d4c3b2a1908", ".text", "_start");
|
||||
|
||||
id.Should().StartWith("sym:binary:");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ForPython_CreatesCanonicalSymbolId()
|
||||
{
|
||||
var id = SymbolId.ForPython("requests", "requests.api", "get");
|
||||
|
||||
id.Should().StartWith("sym:python:");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ForRuby_CreatesCanonicalSymbolId()
|
||||
{
|
||||
var id = SymbolId.ForRuby("rails", "ActiveRecord::Base", "#save");
|
||||
|
||||
id.Should().StartWith("sym:ruby:");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ForPhp_CreatesCanonicalSymbolId()
|
||||
{
|
||||
var id = SymbolId.ForPhp("laravel/framework", "Illuminate\\Http", "Request::input");
|
||||
|
||||
id.Should().StartWith("sym:php:");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_ValidSymbolId_ReturnsComponents()
|
||||
{
|
||||
var id = SymbolId.ForJava("com.example", "MyClass", "method", "()V");
|
||||
|
||||
var result = SymbolId.Parse(id);
|
||||
|
||||
result.Should().NotBeNull();
|
||||
result!.Value.Lang.Should().Be("java");
|
||||
result.Value.Fragment.Should().HaveLength(43);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_InvalidSymbolId_ReturnsNull()
|
||||
{
|
||||
SymbolId.Parse("invalid").Should().BeNull();
|
||||
SymbolId.Parse("sym:").Should().BeNull();
|
||||
SymbolId.Parse("sym:java").Should().BeNull();
|
||||
SymbolId.Parse("").Should().BeNull();
|
||||
SymbolId.Parse(null!).Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromTuple_CreatesSymbolIdFromRawTuple()
|
||||
{
|
||||
var tuple = "my\0canonical\0tuple";
|
||||
var id = SymbolId.FromTuple("custom", tuple);
|
||||
|
||||
id.Should().StartWith("sym:custom:");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AllLanguagesAreDifferent()
|
||||
{
|
||||
// Same tuple data should produce different IDs for different languages
|
||||
var java = SymbolId.ForJava("pkg", "cls", "meth", "()V");
|
||||
var dotnet = SymbolId.ForDotNet("pkg", "cls", "meth", "()V");
|
||||
var node = SymbolId.ForNode("pkg", "cls", "meth");
|
||||
|
||||
java.Should().NotBe(dotnet);
|
||||
dotnet.Should().NotBe(node);
|
||||
java.Should().NotBe(node);
|
||||
}
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
using System.Text.Json;
|
||||
using FluentAssertions;
|
||||
using StellaOps.Replay.Core;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Replay.Core.Tests;
|
||||
|
||||
public sealed class CanonicalJsonTests
|
||||
{
|
||||
[Fact]
|
||||
public void CanonicalJson_OrdersPropertiesLexicographically()
|
||||
{
|
||||
var payload = new
|
||||
{
|
||||
zeta = 1,
|
||||
alpha = new { z = 9, m = 7 },
|
||||
list = new[] { new { y = 2, x = 1 } }
|
||||
};
|
||||
|
||||
var canonical = CanonicalJson.Serialize(payload);
|
||||
|
||||
canonical.Should().Be("{\"alpha\":{\"m\":7,\"z\":9},\"list\":[{\"x\":1,\"y\":2}],\"zeta\":1}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanonicalJson_PreservesNumbersAndBooleans()
|
||||
{
|
||||
var payload = JsonSerializer.Deserialize<JsonElement>("{\"b\":true,\"a\":1.25}");
|
||||
|
||||
var canonical = CanonicalJson.Serialize(payload);
|
||||
|
||||
canonical.Should().Be("{\"a\":1.25,\"b\":true}");
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
using System.Text;
|
||||
using FluentAssertions;
|
||||
using StellaOps.Replay.Core;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Replay.Core.Tests;
|
||||
|
||||
public sealed class DeterministicHashTests
|
||||
{
|
||||
[Fact]
|
||||
public void Sha256Hex_ComputesLowercaseDigest()
|
||||
{
|
||||
var digest = DeterministicHash.Sha256Hex("replay-core");
|
||||
|
||||
digest.Should().Be("a914f5ac6a57aab0189bb55bcb0ef6bcdbd86f77198c8669eab5ae38a325e41d");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MerkleRootHex_IsDeterministic()
|
||||
{
|
||||
var leaves = new[] { "alpha", "beta", "gamma" }
|
||||
.Select(Encoding.UTF8.GetBytes)
|
||||
.ToList();
|
||||
|
||||
var root = DeterministicHash.MerkleRootHex(leaves);
|
||||
|
||||
root.Should().Be("50298939464ed02cbf2b587250a55746b3422e133ac4f09b7e2b07869023bc9e");
|
||||
DeterministicHash.MerkleRootHex(leaves).Should().Be(root);
|
||||
}
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
using System;
|
||||
using System.Text;
|
||||
using FluentAssertions;
|
||||
using StellaOps.Replay.Core;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Replay.Core.Tests;
|
||||
|
||||
public sealed class DsseEnvelopeTests
|
||||
{
|
||||
[Fact]
|
||||
public void BuildUnsigned_ProducesCanonicalPayload()
|
||||
{
|
||||
var manifest = new ReplayManifest
|
||||
{
|
||||
Scan = new ReplayScanMetadata
|
||||
{
|
||||
Id = "scan-123",
|
||||
Time = DateTimeOffset.UnixEpoch
|
||||
}
|
||||
};
|
||||
|
||||
var envelope = DssePayloadBuilder.BuildUnsigned(manifest);
|
||||
|
||||
envelope.PayloadType.Should().Be(DssePayloadBuilder.ReplayPayloadType);
|
||||
envelope.Signatures.Should().BeEmpty();
|
||||
|
||||
var payload = Convert.FromBase64String(envelope.Payload);
|
||||
var json = Encoding.UTF8.GetString(payload);
|
||||
|
||||
json.Should().Be("{\"reachability\":{\"graphs\":[],\"runtimeTraces\":[]},\"scan\":{\"id\":\"scan-123\",\"time\":\"1970-01-01T00:00:00+00:00\"},\"schemaVersion\":\"1.0\"}");
|
||||
envelope.DigestSha256.Should().Be(DeterministicHash.Sha256Hex(payload));
|
||||
}
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Formats.Tar;
|
||||
using System.IO;
|
||||
using FluentAssertions;
|
||||
using StellaOps.Replay.Core;
|
||||
using ZstdSharp;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Replay.Core.Tests;
|
||||
|
||||
public sealed class ReplayBundleWriterTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task WriteTarZstAsync_IsDeterministicAndSorted()
|
||||
{
|
||||
var entries = new[]
|
||||
{
|
||||
new ReplayBundleEntry("b.txt", "beta"u8.ToArray()),
|
||||
new ReplayBundleEntry("a.txt", "alpha"u8.ToArray())
|
||||
};
|
||||
|
||||
await using var buffer = new MemoryStream();
|
||||
var first = await ReplayBundleWriter.WriteTarZstAsync(entries, buffer, compressionLevel: 3);
|
||||
|
||||
var firstBytes = buffer.ToArray();
|
||||
|
||||
await using var buffer2 = new MemoryStream();
|
||||
var second = await ReplayBundleWriter.WriteTarZstAsync(entries.Reverse(), buffer2, compressionLevel: 3);
|
||||
|
||||
first.ZstSha256.Should().Be(second.ZstSha256);
|
||||
first.TarSha256.Should().Be(second.TarSha256);
|
||||
firstBytes.Should().Equal(buffer2.ToArray());
|
||||
|
||||
// Decompress and verify ordering/content
|
||||
buffer.Position = 0;
|
||||
await using var decompressed = new MemoryStream();
|
||||
await using (var decompress = new DecompressionStream(buffer, 16 * 1024, leaveOpen: true, enableMultiThreaded: false))
|
||||
{
|
||||
await decompress.CopyToAsync(decompressed);
|
||||
}
|
||||
|
||||
decompressed.Position = 0;
|
||||
var reader = new TarReader(decompressed, leaveOpen: true);
|
||||
var names = new List<string>();
|
||||
TarEntry? entry;
|
||||
while ((entry = reader.GetNextEntry()) != null)
|
||||
{
|
||||
names.Add(entry.Name);
|
||||
using var ms = new MemoryStream();
|
||||
entry.DataStream!.CopyTo(ms);
|
||||
var text = System.Text.Encoding.UTF8.GetString(ms.ToArray());
|
||||
text.Should().Be(entry.Name.StartsWith("a") ? "alpha" : "beta");
|
||||
}
|
||||
|
||||
names.Should().BeEquivalentTo(new[] { "a.txt", "b.txt" }, opts => opts.WithStrictOrdering());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildCasUri_UsesPrefixAndShard()
|
||||
{
|
||||
ReplayBundleWriter.BuildCasUri("abcdef", null).Should().Be("cas://replay/ab/abcdef.tar.zst");
|
||||
ReplayBundleWriter.BuildCasUri("1234", "custom").Should().Be("cas://custom/12/1234.tar.zst");
|
||||
}
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
using System.Text.Json;
|
||||
using FluentAssertions;
|
||||
using StellaOps.Replay.Core;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Replay.Core.Tests;
|
||||
|
||||
public sealed class ReplayManifestExtensionsTests
|
||||
{
|
||||
[Fact]
|
||||
public void AddsReachabilityEvidence()
|
||||
{
|
||||
var manifest = new ReplayManifest
|
||||
{
|
||||
Scan = new ReplayScanMetadata { Id = "scan-1" }
|
||||
};
|
||||
|
||||
manifest.AddReachabilityGraph(new ReplayReachabilityGraphReference
|
||||
{
|
||||
Kind = "static",
|
||||
Analyzer = "scanner/java",
|
||||
CasUri = "cas://replay/graph",
|
||||
Sha256 = "abc",
|
||||
Version = "1.0"
|
||||
});
|
||||
|
||||
manifest.AddReachabilityTrace(new ReplayReachabilityTraceReference
|
||||
{
|
||||
Source = "zastava",
|
||||
CasUri = "cas://replay/trace",
|
||||
Sha256 = "def"
|
||||
});
|
||||
|
||||
manifest.Reachability.Should().NotBeNull();
|
||||
manifest.Reachability!.Graphs.Should().HaveCount(1);
|
||||
manifest.Reachability.RuntimeTraces.Should().HaveCount(1);
|
||||
|
||||
var json = JsonSerializer.Serialize(manifest);
|
||||
json.Should().Contain("\"reachability\"");
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.10.0" />
|
||||
<PackageReference Include="xunit" Version="2.7.0" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.8">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="FluentAssertions" Version="6.12.0" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../../src/__Libraries/StellaOps.Replay.Core/StellaOps.Replay.Core.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -1,613 +0,0 @@
|
||||
using System.Collections.Immutable;
|
||||
using FluentAssertions;
|
||||
using StellaOps.Scanner.CallGraph;
|
||||
using StellaOps.Scanner.Reachability;
|
||||
using StellaOps.Scanner.ReachabilityDrift;
|
||||
using StellaOps.Scanner.ReachabilityDrift.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.ScannerSignals.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests for the Reachability Drift Detection pipeline.
|
||||
/// Tests the end-to-end flow from call graph extraction through drift detection.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Task: RDRIFT-MASTER-0002
|
||||
/// Sprint: SPRINT_3600_0001_0001_reachability_drift_master
|
||||
/// </remarks>
|
||||
public sealed class ReachabilityDriftIntegrationTests
|
||||
{
|
||||
private readonly TimeProvider _fixedTime = new FakeTimeProvider(
|
||||
new DateTimeOffset(2025, 12, 19, 12, 0, 0, TimeSpan.Zero));
|
||||
|
||||
#region Drift Detection Tests
|
||||
|
||||
[Fact]
|
||||
public void DetectDrift_WhenPathBecomesReachable_ReportsNewlyReachableSink()
|
||||
{
|
||||
// Arrange: unreachable -> reachable (guard removed)
|
||||
var baseGraph = CreateUnreachableGraph("scan-v1");
|
||||
var headGraph = CreateReachableGraph("scan-v2");
|
||||
|
||||
var extractor = new CodeChangeFactExtractor(_fixedTime);
|
||||
var codeChanges = extractor.Extract(baseGraph, headGraph);
|
||||
var detector = new ReachabilityDriftDetector(_fixedTime);
|
||||
|
||||
// Act
|
||||
var drift = detector.Detect(baseGraph, headGraph, codeChanges, includeFullPath: true);
|
||||
|
||||
// Assert
|
||||
drift.Should().NotBeNull();
|
||||
drift.BaseScanId.Should().Be("scan-v1");
|
||||
drift.HeadScanId.Should().Be("scan-v2");
|
||||
drift.Language.Should().Be("java");
|
||||
drift.HasMaterialDrift.Should().BeTrue();
|
||||
|
||||
drift.NewlyReachable.Should().HaveCount(1);
|
||||
drift.NewlyUnreachable.Should().BeEmpty();
|
||||
|
||||
var sink = drift.NewlyReachable[0];
|
||||
sink.Direction.Should().Be(DriftDirection.BecameReachable);
|
||||
sink.SinkNodeId.Should().Be("jndi-lookup-sink");
|
||||
sink.SinkCategory.Should().Be(SinkCategory.CmdExec);
|
||||
sink.Cause.Kind.Should().Be(DriftCauseKind.GuardRemoved);
|
||||
sink.Path.Entrypoint.NodeId.Should().Be("http-handler-entry");
|
||||
sink.Path.Sink.NodeId.Should().Be("jndi-lookup-sink");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DetectDrift_WhenPathBecomesUnreachable_ReportsNewlyUnreachableSink()
|
||||
{
|
||||
// Arrange: reachable -> unreachable (guard added)
|
||||
var baseGraph = CreateReachableGraph("scan-v1");
|
||||
var headGraph = CreateUnreachableGraph("scan-v2");
|
||||
|
||||
var extractor = new CodeChangeFactExtractor(_fixedTime);
|
||||
var codeChanges = extractor.Extract(baseGraph, headGraph);
|
||||
var detector = new ReachabilityDriftDetector(_fixedTime);
|
||||
|
||||
// Act
|
||||
var drift = detector.Detect(baseGraph, headGraph, codeChanges, includeFullPath: true);
|
||||
|
||||
// Assert
|
||||
drift.Should().NotBeNull();
|
||||
drift.BaseScanId.Should().Be("scan-v1");
|
||||
drift.HeadScanId.Should().Be("scan-v2");
|
||||
drift.HasMaterialDrift.Should().BeFalse();
|
||||
|
||||
drift.NewlyReachable.Should().BeEmpty();
|
||||
drift.NewlyUnreachable.Should().HaveCount(1);
|
||||
|
||||
var sink = drift.NewlyUnreachable[0];
|
||||
sink.Direction.Should().Be(DriftDirection.BecameUnreachable);
|
||||
sink.SinkNodeId.Should().Be("jndi-lookup-sink");
|
||||
sink.Cause.Kind.Should().Be(DriftCauseKind.GuardAdded);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DetectDrift_WhenNoChange_ReportsNoDrift()
|
||||
{
|
||||
// Arrange: same graph, no changes
|
||||
var baseGraph = CreateReachableGraph("scan-v1");
|
||||
var headGraph = CreateReachableGraph("scan-v2");
|
||||
|
||||
var extractor = new CodeChangeFactExtractor(_fixedTime);
|
||||
var codeChanges = extractor.Extract(baseGraph, headGraph);
|
||||
var detector = new ReachabilityDriftDetector(_fixedTime);
|
||||
|
||||
// Act
|
||||
var drift = detector.Detect(baseGraph, headGraph, codeChanges, includeFullPath: false);
|
||||
|
||||
// Assert
|
||||
drift.Should().NotBeNull();
|
||||
drift.HasMaterialDrift.Should().BeFalse();
|
||||
drift.NewlyReachable.Should().BeEmpty();
|
||||
drift.NewlyUnreachable.Should().BeEmpty();
|
||||
drift.TotalDriftCount.Should().Be(0);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Determinism Tests
|
||||
|
||||
[Fact]
|
||||
public void DetectDrift_IsDeterministic_SameInputsProduceSameOutputs()
|
||||
{
|
||||
// Arrange
|
||||
var baseGraph = CreateUnreachableGraph("scan-v1");
|
||||
var headGraph = CreateReachableGraph("scan-v2");
|
||||
|
||||
var extractor = new CodeChangeFactExtractor(_fixedTime);
|
||||
var codeChanges = extractor.Extract(baseGraph, headGraph);
|
||||
var detector = new ReachabilityDriftDetector(_fixedTime);
|
||||
|
||||
// Act
|
||||
var drift1 = detector.Detect(baseGraph, headGraph, codeChanges, includeFullPath: true);
|
||||
var drift2 = detector.Detect(baseGraph, headGraph, codeChanges, includeFullPath: true);
|
||||
|
||||
// Assert
|
||||
drift1.Id.Should().Be(drift2.Id);
|
||||
drift1.ResultDigest.Should().Be(drift2.ResultDigest);
|
||||
drift1.DetectedAt.Should().Be(drift2.DetectedAt);
|
||||
drift1.NewlyReachable.Length.Should().Be(drift2.NewlyReachable.Length);
|
||||
|
||||
for (var i = 0; i < drift1.NewlyReachable.Length; i++)
|
||||
{
|
||||
drift1.NewlyReachable[i].Id.Should().Be(drift2.NewlyReachable[i].Id);
|
||||
drift1.NewlyReachable[i].SinkNodeId.Should().Be(drift2.NewlyReachable[i].SinkNodeId);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DetectDrift_ResultDigest_IsStableAcrossRuns()
|
||||
{
|
||||
// Arrange
|
||||
var baseGraph = CreateUnreachableGraph("scan-v1");
|
||||
var headGraph = CreateReachableGraph("scan-v2");
|
||||
|
||||
var extractor = new CodeChangeFactExtractor(_fixedTime);
|
||||
var codeChanges = extractor.Extract(baseGraph, headGraph);
|
||||
|
||||
// Act: Create multiple detectors and run independently
|
||||
var detector1 = new ReachabilityDriftDetector(_fixedTime);
|
||||
var detector2 = new ReachabilityDriftDetector(_fixedTime);
|
||||
|
||||
var drift1 = detector1.Detect(baseGraph, headGraph, codeChanges, includeFullPath: false);
|
||||
var drift2 = detector2.Detect(baseGraph, headGraph, codeChanges, includeFullPath: false);
|
||||
|
||||
// Assert
|
||||
drift1.ResultDigest.Should().NotBeNullOrWhiteSpace();
|
||||
drift1.ResultDigest.Should().Be(drift2.ResultDigest);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region CodeChangeFact Extraction Tests
|
||||
|
||||
[Fact]
|
||||
public void CodeChangeFactExtractor_DetectsAddedEdge()
|
||||
{
|
||||
// Arrange
|
||||
var baseGraph = CreateUnreachableGraph("scan-v1");
|
||||
var headGraph = CreateReachableGraph("scan-v2");
|
||||
|
||||
var extractor = new CodeChangeFactExtractor(_fixedTime);
|
||||
|
||||
// Act
|
||||
var codeChanges = extractor.Extract(baseGraph, headGraph);
|
||||
|
||||
// Assert - The extractor reports edge changes as GuardChanged with details
|
||||
codeChanges.Should().NotBeEmpty();
|
||||
codeChanges.Should().Contain(c =>
|
||||
c.Kind == CodeChangeKind.GuardChanged &&
|
||||
c.Details.HasValue &&
|
||||
c.Details.Value.GetRawText().Contains("edge_added", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CodeChangeFactExtractor_DetectsRemovedEdge()
|
||||
{
|
||||
// Arrange
|
||||
var baseGraph = CreateReachableGraph("scan-v1");
|
||||
var headGraph = CreateUnreachableGraph("scan-v2");
|
||||
|
||||
var extractor = new CodeChangeFactExtractor(_fixedTime);
|
||||
|
||||
// Act
|
||||
var codeChanges = extractor.Extract(baseGraph, headGraph);
|
||||
|
||||
// Assert - The extractor reports edge changes as GuardChanged with details
|
||||
codeChanges.Should().NotBeEmpty();
|
||||
codeChanges.Should().Contain(c =>
|
||||
c.Kind == CodeChangeKind.GuardChanged &&
|
||||
c.Details.HasValue &&
|
||||
c.Details.Value.GetRawText().Contains("edge_removed", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Multi-Sink Tests
|
||||
|
||||
[Fact]
|
||||
public void DetectDrift_WithMultipleSinks_ReportsAllDriftedSinks()
|
||||
{
|
||||
// Arrange: Multiple sinks become reachable
|
||||
var baseGraph = CreateMultiSinkUnreachableGraph("scan-v1");
|
||||
var headGraph = CreateMultiSinkReachableGraph("scan-v2");
|
||||
|
||||
var extractor = new CodeChangeFactExtractor(_fixedTime);
|
||||
var codeChanges = extractor.Extract(baseGraph, headGraph);
|
||||
var detector = new ReachabilityDriftDetector(_fixedTime);
|
||||
|
||||
// Act
|
||||
var drift = detector.Detect(baseGraph, headGraph, codeChanges, includeFullPath: true);
|
||||
|
||||
// Assert
|
||||
drift.Should().NotBeNull();
|
||||
drift.HasMaterialDrift.Should().BeTrue();
|
||||
drift.NewlyReachable.Should().HaveCount(2);
|
||||
drift.NewlyUnreachable.Should().BeEmpty();
|
||||
|
||||
var sinkIds = drift.NewlyReachable.Select(s => s.SinkNodeId).OrderBy(s => s).ToList();
|
||||
sinkIds.Should().Contain("jndi-lookup-sink");
|
||||
sinkIds.Should().Contain("file-write-sink");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DetectDrift_OrderingSinks_IsStableAndDeterministic()
|
||||
{
|
||||
// Arrange
|
||||
var baseGraph = CreateMultiSinkUnreachableGraph("scan-v1");
|
||||
var headGraph = CreateMultiSinkReachableGraph("scan-v2");
|
||||
|
||||
var extractor = new CodeChangeFactExtractor(_fixedTime);
|
||||
var codeChanges = extractor.Extract(baseGraph, headGraph);
|
||||
var detector = new ReachabilityDriftDetector(_fixedTime);
|
||||
|
||||
// Act: Run multiple times
|
||||
var results = Enumerable.Range(0, 5)
|
||||
.Select(_ => detector.Detect(baseGraph, headGraph, codeChanges, includeFullPath: false))
|
||||
.ToList();
|
||||
|
||||
// Assert: All results should have same ordering
|
||||
var expectedOrder = results[0].NewlyReachable.Select(s => s.SinkNodeId).ToList();
|
||||
foreach (var result in results.Skip(1))
|
||||
{
|
||||
var actualOrder = result.NewlyReachable.Select(s => s.SinkNodeId).ToList();
|
||||
actualOrder.Should().Equal(expectedOrder);
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Path Compression Tests
|
||||
|
||||
[Fact]
|
||||
public void DetectDrift_WithFullPath_IncludesIntermediateNodes()
|
||||
{
|
||||
// Arrange
|
||||
var baseGraph = CreateUnreachableGraph("scan-v1");
|
||||
var headGraph = CreateReachableGraphWithIntermediates("scan-v2");
|
||||
|
||||
var extractor = new CodeChangeFactExtractor(_fixedTime);
|
||||
var codeChanges = extractor.Extract(baseGraph, headGraph);
|
||||
var detector = new ReachabilityDriftDetector(_fixedTime);
|
||||
|
||||
// Act
|
||||
var drift = detector.Detect(baseGraph, headGraph, codeChanges, includeFullPath: true);
|
||||
|
||||
// Assert
|
||||
drift.NewlyReachable.Should().HaveCount(1);
|
||||
var sink = drift.NewlyReachable[0];
|
||||
|
||||
sink.Path.Should().NotBeNull();
|
||||
sink.Path.Entrypoint.NodeId.Should().Be("http-handler-entry");
|
||||
sink.Path.Sink.NodeId.Should().Be("jndi-lookup-sink");
|
||||
sink.Path.FullPath.Should().NotBeNullOrEmpty();
|
||||
sink.Path.FullPath!.Value.Length.Should().BeGreaterThan(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DetectDrift_WithoutFullPath_OmitsIntermediateNodes()
|
||||
{
|
||||
// Arrange
|
||||
var baseGraph = CreateUnreachableGraph("scan-v1");
|
||||
var headGraph = CreateReachableGraphWithIntermediates("scan-v2");
|
||||
|
||||
var extractor = new CodeChangeFactExtractor(_fixedTime);
|
||||
var codeChanges = extractor.Extract(baseGraph, headGraph);
|
||||
var detector = new ReachabilityDriftDetector(_fixedTime);
|
||||
|
||||
// Act
|
||||
var drift = detector.Detect(baseGraph, headGraph, codeChanges, includeFullPath: false);
|
||||
|
||||
// Assert
|
||||
drift.NewlyReachable.Should().HaveCount(1);
|
||||
var sink = drift.NewlyReachable[0];
|
||||
|
||||
sink.Path.Should().NotBeNull();
|
||||
sink.Path.Entrypoint.NodeId.Should().Be("http-handler-entry");
|
||||
sink.Path.Sink.NodeId.Should().Be("jndi-lookup-sink");
|
||||
sink.Path.FullPath.Should().BeNullOrEmpty();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Error Handling Tests
|
||||
|
||||
[Fact]
|
||||
public void DetectDrift_WithLanguageMismatch_ThrowsArgumentException()
|
||||
{
|
||||
// Arrange
|
||||
var baseGraph = CreateGraph("scan-v1", "java", ImmutableArray<CallGraphEdge>.Empty);
|
||||
var headGraph = CreateGraph("scan-v2", "dotnet", ImmutableArray<CallGraphEdge>.Empty);
|
||||
|
||||
var detector = new ReachabilityDriftDetector(_fixedTime);
|
||||
|
||||
// Act & Assert
|
||||
var act = () => detector.Detect(baseGraph, headGraph, ImmutableArray<CodeChangeFact>.Empty.ToList(), includeFullPath: false);
|
||||
act.Should().Throw<ArgumentException>().WithMessage("*Language mismatch*");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DetectDrift_WithNullBaseGraph_ThrowsArgumentNullException()
|
||||
{
|
||||
// Arrange
|
||||
var headGraph = CreateReachableGraph("scan-v2");
|
||||
var detector = new ReachabilityDriftDetector(_fixedTime);
|
||||
|
||||
// Act & Assert
|
||||
var act = () => detector.Detect(null!, headGraph, Array.Empty<CodeChangeFact>(), includeFullPath: false);
|
||||
act.Should().Throw<ArgumentNullException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DetectDrift_WithNullHeadGraph_ThrowsArgumentNullException()
|
||||
{
|
||||
// Arrange
|
||||
var baseGraph = CreateUnreachableGraph("scan-v1");
|
||||
var detector = new ReachabilityDriftDetector(_fixedTime);
|
||||
|
||||
// Act & Assert
|
||||
var act = () => detector.Detect(baseGraph, null!, Array.Empty<CodeChangeFact>(), includeFullPath: false);
|
||||
act.Should().Throw<ArgumentNullException>();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
private static CallGraphSnapshot CreateUnreachableGraph(string scanId)
|
||||
{
|
||||
// Graph with no edges - sink is unreachable
|
||||
return CreateGraph(scanId, "java", ImmutableArray<CallGraphEdge>.Empty);
|
||||
}
|
||||
|
||||
private static CallGraphSnapshot CreateReachableGraph(string scanId)
|
||||
{
|
||||
// Graph with edge from entry to sink - sink is reachable
|
||||
var edges = ImmutableArray.Create(
|
||||
new CallGraphEdge("http-handler-entry", "jndi-lookup-sink", CallKind.Direct, "Logger.java:42"));
|
||||
return CreateGraph(scanId, "java", edges);
|
||||
}
|
||||
|
||||
private static CallGraphSnapshot CreateReachableGraphWithIntermediates(string scanId)
|
||||
{
|
||||
// Graph with intermediate nodes: entry -> logger -> substitutor -> sink
|
||||
var edges = ImmutableArray.Create(
|
||||
new CallGraphEdge("http-handler-entry", "logger-method", CallKind.Direct, "App.java:10"),
|
||||
new CallGraphEdge("logger-method", "pattern-converter", CallKind.Direct, "Logger.java:15"),
|
||||
new CallGraphEdge("pattern-converter", "str-substitutor", CallKind.Direct, "PatternConverter.java:20"),
|
||||
new CallGraphEdge("str-substitutor", "jndi-lookup-sink", CallKind.Direct, "StrSubstitutor.java:25"));
|
||||
|
||||
var nodes = ImmutableArray.Create(
|
||||
new CallGraphNode(
|
||||
NodeId: "http-handler-entry",
|
||||
Symbol: "com.example.App.handleRequest",
|
||||
File: "App.java",
|
||||
Line: 10,
|
||||
Package: "pkg:maven/com.example/app@1.0.0",
|
||||
Visibility: Visibility.Public,
|
||||
IsEntrypoint: true,
|
||||
EntrypointType: EntrypointType.HttpHandler,
|
||||
IsSink: false,
|
||||
SinkCategory: null),
|
||||
new CallGraphNode(
|
||||
NodeId: "logger-method",
|
||||
Symbol: "org.apache.logging.log4j.Logger.info",
|
||||
File: "Logger.java",
|
||||
Line: 15,
|
||||
Package: "pkg:maven/org.apache.logging.log4j/log4j-core@2.14.0",
|
||||
Visibility: Visibility.Public,
|
||||
IsEntrypoint: false,
|
||||
EntrypointType: null,
|
||||
IsSink: false,
|
||||
SinkCategory: null),
|
||||
new CallGraphNode(
|
||||
NodeId: "pattern-converter",
|
||||
Symbol: "org.apache.logging.log4j.core.pattern.MessagePatternConverter.format",
|
||||
File: "PatternConverter.java",
|
||||
Line: 20,
|
||||
Package: "pkg:maven/org.apache.logging.log4j/log4j-core@2.14.0",
|
||||
Visibility: Visibility.Internal,
|
||||
IsEntrypoint: false,
|
||||
EntrypointType: null,
|
||||
IsSink: false,
|
||||
SinkCategory: null),
|
||||
new CallGraphNode(
|
||||
NodeId: "str-substitutor",
|
||||
Symbol: "org.apache.logging.log4j.core.lookup.StrSubstitutor.replace",
|
||||
File: "StrSubstitutor.java",
|
||||
Line: 25,
|
||||
Package: "pkg:maven/org.apache.logging.log4j/log4j-core@2.14.0",
|
||||
Visibility: Visibility.Internal,
|
||||
IsEntrypoint: false,
|
||||
EntrypointType: null,
|
||||
IsSink: false,
|
||||
SinkCategory: null),
|
||||
new CallGraphNode(
|
||||
NodeId: "jndi-lookup-sink",
|
||||
Symbol: "org.apache.logging.log4j.core.lookup.JndiLookup.lookup",
|
||||
File: "JndiLookup.java",
|
||||
Line: 30,
|
||||
Package: "pkg:maven/org.apache.logging.log4j/log4j-core@2.14.0",
|
||||
Visibility: Visibility.Public,
|
||||
IsEntrypoint: false,
|
||||
EntrypointType: null,
|
||||
IsSink: true,
|
||||
SinkCategory: SinkCategory.CmdExec));
|
||||
|
||||
var provisional = new CallGraphSnapshot(
|
||||
ScanId: scanId,
|
||||
GraphDigest: string.Empty,
|
||||
Language: "java",
|
||||
ExtractedAt: DateTimeOffset.UnixEpoch,
|
||||
Nodes: nodes,
|
||||
Edges: edges,
|
||||
EntrypointIds: ImmutableArray.Create("http-handler-entry"),
|
||||
SinkIds: ImmutableArray.Create("jndi-lookup-sink"));
|
||||
|
||||
return provisional with { GraphDigest = CallGraphDigests.ComputeGraphDigest(provisional) };
|
||||
}
|
||||
|
||||
private static CallGraphSnapshot CreateMultiSinkUnreachableGraph(string scanId)
|
||||
{
|
||||
// Graph with multiple sinks, none reachable
|
||||
var nodes = ImmutableArray.Create(
|
||||
new CallGraphNode(
|
||||
NodeId: "http-handler-entry",
|
||||
Symbol: "com.example.App.handleRequest",
|
||||
File: "App.java",
|
||||
Line: 10,
|
||||
Package: "pkg:maven/com.example/app@1.0.0",
|
||||
Visibility: Visibility.Public,
|
||||
IsEntrypoint: true,
|
||||
EntrypointType: EntrypointType.HttpHandler,
|
||||
IsSink: false,
|
||||
SinkCategory: null),
|
||||
new CallGraphNode(
|
||||
NodeId: "jndi-lookup-sink",
|
||||
Symbol: "org.apache.logging.log4j.core.lookup.JndiLookup.lookup",
|
||||
File: "JndiLookup.java",
|
||||
Line: 30,
|
||||
Package: "pkg:maven/org.apache.logging.log4j/log4j-core@2.14.0",
|
||||
Visibility: Visibility.Public,
|
||||
IsEntrypoint: false,
|
||||
EntrypointType: null,
|
||||
IsSink: true,
|
||||
SinkCategory: SinkCategory.CmdExec),
|
||||
new CallGraphNode(
|
||||
NodeId: "file-write-sink",
|
||||
Symbol: "java.io.FileOutputStream.write",
|
||||
File: "FileOutputStream.java",
|
||||
Line: 100,
|
||||
Package: "pkg:maven/java/jdk@17",
|
||||
Visibility: Visibility.Public,
|
||||
IsEntrypoint: false,
|
||||
EntrypointType: null,
|
||||
IsSink: true,
|
||||
SinkCategory: SinkCategory.FileWrite));
|
||||
|
||||
var provisional = new CallGraphSnapshot(
|
||||
ScanId: scanId,
|
||||
GraphDigest: string.Empty,
|
||||
Language: "java",
|
||||
ExtractedAt: DateTimeOffset.UnixEpoch,
|
||||
Nodes: nodes,
|
||||
Edges: ImmutableArray<CallGraphEdge>.Empty,
|
||||
EntrypointIds: ImmutableArray.Create("http-handler-entry"),
|
||||
SinkIds: ImmutableArray.Create("jndi-lookup-sink", "file-write-sink"));
|
||||
|
||||
return provisional with { GraphDigest = CallGraphDigests.ComputeGraphDigest(provisional) };
|
||||
}
|
||||
|
||||
private static CallGraphSnapshot CreateMultiSinkReachableGraph(string scanId)
|
||||
{
|
||||
// Graph with multiple sinks, all reachable
|
||||
var nodes = ImmutableArray.Create(
|
||||
new CallGraphNode(
|
||||
NodeId: "http-handler-entry",
|
||||
Symbol: "com.example.App.handleRequest",
|
||||
File: "App.java",
|
||||
Line: 10,
|
||||
Package: "pkg:maven/com.example/app@1.0.0",
|
||||
Visibility: Visibility.Public,
|
||||
IsEntrypoint: true,
|
||||
EntrypointType: EntrypointType.HttpHandler,
|
||||
IsSink: false,
|
||||
SinkCategory: null),
|
||||
new CallGraphNode(
|
||||
NodeId: "jndi-lookup-sink",
|
||||
Symbol: "org.apache.logging.log4j.core.lookup.JndiLookup.lookup",
|
||||
File: "JndiLookup.java",
|
||||
Line: 30,
|
||||
Package: "pkg:maven/org.apache.logging.log4j/log4j-core@2.14.0",
|
||||
Visibility: Visibility.Public,
|
||||
IsEntrypoint: false,
|
||||
EntrypointType: null,
|
||||
IsSink: true,
|
||||
SinkCategory: SinkCategory.CmdExec),
|
||||
new CallGraphNode(
|
||||
NodeId: "file-write-sink",
|
||||
Symbol: "java.io.FileOutputStream.write",
|
||||
File: "FileOutputStream.java",
|
||||
Line: 100,
|
||||
Package: "pkg:maven/java/jdk@17",
|
||||
Visibility: Visibility.Public,
|
||||
IsEntrypoint: false,
|
||||
EntrypointType: null,
|
||||
IsSink: true,
|
||||
SinkCategory: SinkCategory.FileWrite));
|
||||
|
||||
var edges = ImmutableArray.Create(
|
||||
new CallGraphEdge("http-handler-entry", "jndi-lookup-sink", CallKind.Direct, "App.java:15"),
|
||||
new CallGraphEdge("http-handler-entry", "file-write-sink", CallKind.Direct, "App.java:20"));
|
||||
|
||||
var provisional = new CallGraphSnapshot(
|
||||
ScanId: scanId,
|
||||
GraphDigest: string.Empty,
|
||||
Language: "java",
|
||||
ExtractedAt: DateTimeOffset.UnixEpoch,
|
||||
Nodes: nodes,
|
||||
Edges: edges,
|
||||
EntrypointIds: ImmutableArray.Create("http-handler-entry"),
|
||||
SinkIds: ImmutableArray.Create("jndi-lookup-sink", "file-write-sink"));
|
||||
|
||||
return provisional with { GraphDigest = CallGraphDigests.ComputeGraphDigest(provisional) };
|
||||
}
|
||||
|
||||
private static CallGraphSnapshot CreateGraph(string scanId, string language, ImmutableArray<CallGraphEdge> edges)
|
||||
{
|
||||
var nodes = ImmutableArray.Create(
|
||||
new CallGraphNode(
|
||||
NodeId: "http-handler-entry",
|
||||
Symbol: "com.example.App.handleRequest",
|
||||
File: "App.java",
|
||||
Line: 10,
|
||||
Package: "pkg:maven/com.example/app@1.0.0",
|
||||
Visibility: Visibility.Public,
|
||||
IsEntrypoint: true,
|
||||
EntrypointType: EntrypointType.HttpHandler,
|
||||
IsSink: false,
|
||||
SinkCategory: null),
|
||||
new CallGraphNode(
|
||||
NodeId: "jndi-lookup-sink",
|
||||
Symbol: "org.apache.logging.log4j.core.lookup.JndiLookup.lookup",
|
||||
File: "JndiLookup.java",
|
||||
Line: 30,
|
||||
Package: "pkg:maven/org.apache.logging.log4j/log4j-core@2.14.0",
|
||||
Visibility: Visibility.Public,
|
||||
IsEntrypoint: false,
|
||||
EntrypointType: null,
|
||||
IsSink: true,
|
||||
SinkCategory: SinkCategory.CmdExec));
|
||||
|
||||
var provisional = new CallGraphSnapshot(
|
||||
ScanId: scanId,
|
||||
GraphDigest: string.Empty,
|
||||
Language: language,
|
||||
ExtractedAt: DateTimeOffset.UnixEpoch,
|
||||
Nodes: nodes,
|
||||
Edges: edges,
|
||||
EntrypointIds: ImmutableArray.Create("http-handler-entry"),
|
||||
SinkIds: ImmutableArray.Create("jndi-lookup-sink"));
|
||||
|
||||
return provisional with { GraphDigest = CallGraphDigests.ComputeGraphDigest(provisional) };
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region FakeTimeProvider
|
||||
|
||||
private sealed class FakeTimeProvider : TimeProvider
|
||||
{
|
||||
private readonly DateTimeOffset _fixedTime;
|
||||
|
||||
public FakeTimeProvider(DateTimeOffset fixedTime) => _fixedTime = fixedTime;
|
||||
|
||||
public override DateTimeOffset GetUtcNow() => _fixedTime;
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -1,382 +0,0 @@
|
||||
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(),
|
||||
new NullCallGraphSyncService(),
|
||||
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);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<ReachabilityFactDocument>> GetExpiredAsync(DateTimeOffset cutoff, int limit, CancellationToken cancellationToken)
|
||||
=> Task.FromResult((IReadOnlyList<ReachabilityFactDocument>)Array.Empty<ReachabilityFactDocument>());
|
||||
|
||||
public Task<bool> DeleteAsync(string subjectKey, CancellationToken cancellationToken)
|
||||
{
|
||||
var removed = storage.Remove(subjectKey);
|
||||
return Task.FromResult(removed);
|
||||
}
|
||||
|
||||
public Task<int> GetRuntimeFactsCountAsync(string subjectKey, CancellationToken cancellationToken)
|
||||
=> Task.FromResult(0);
|
||||
|
||||
public Task TrimRuntimeFactsAsync(string subjectKey, int maxCount, CancellationToken cancellationToken)
|
||||
=> Task.CompletedTask;
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
public Task BulkUpdateAsync(IEnumerable<UnknownSymbolDocument> items, CancellationToken cancellationToken) =>
|
||||
Task.CompletedTask;
|
||||
|
||||
public Task<IReadOnlyList<string>> GetAllSubjectKeysAsync(CancellationToken cancellationToken) =>
|
||||
Task.FromResult((IReadOnlyList<string>)Array.Empty<string>());
|
||||
|
||||
public Task<IReadOnlyList<UnknownSymbolDocument>> GetDueForRescanAsync(
|
||||
UnknownsBand band,
|
||||
int limit,
|
||||
CancellationToken cancellationToken) =>
|
||||
Task.FromResult((IReadOnlyList<UnknownSymbolDocument>)Array.Empty<UnknownSymbolDocument>());
|
||||
|
||||
public Task<IReadOnlyList<UnknownSymbolDocument>> QueryAsync(
|
||||
UnknownsBand? band,
|
||||
int limit,
|
||||
int offset,
|
||||
CancellationToken cancellationToken) =>
|
||||
Task.FromResult((IReadOnlyList<UnknownSymbolDocument>)Array.Empty<UnknownSymbolDocument>());
|
||||
|
||||
public Task<UnknownSymbolDocument?> GetByIdAsync(string id, CancellationToken cancellationToken) =>
|
||||
Task.FromResult<UnknownSymbolDocument?>(null);
|
||||
}
|
||||
|
||||
private sealed class NullEventsPublisher : IEventsPublisher
|
||||
{
|
||||
public Task PublishFactUpdatedAsync(ReachabilityFactDocument fact, CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
}
|
||||
|
||||
private sealed class NullCallGraphSyncService : ICallGraphSyncService
|
||||
{
|
||||
public Task<CallGraphSyncResult> SyncAsync(Guid scanId, string artifactDigest, CallgraphDocument document, CancellationToken cancellationToken)
|
||||
=> Task.FromResult(new CallGraphSyncResult(scanId, 0, 0, 0, false, 0L));
|
||||
|
||||
public Task DeleteByScanAsync(Guid scanId, 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).");
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.10.0" />
|
||||
<PackageReference Include="xunit" Version="2.7.0" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.8">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="FluentAssertions" Version="6.12.0" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../../src/Signals/StellaOps.Signals/StellaOps.Signals.csproj" />
|
||||
<ProjectReference Include="../../../src/Scanner/__Libraries/StellaOps.Scanner.Reachability/StellaOps.Scanner.Reachability.csproj" />
|
||||
<ProjectReference Include="../../../src/Scanner/__Libraries/StellaOps.Scanner.ReachabilityDrift/StellaOps.Scanner.ReachabilityDrift.csproj" />
|
||||
<ProjectReference Include="../../../src/Scanner/__Libraries/StellaOps.Scanner.CallGraph/StellaOps.Scanner.CallGraph.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Content Include="..\..\fixtures\**\*">
|
||||
<Link>fixtures\%(RecursiveDir)%(Filename)%(Extension)</Link>
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</Content>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -1,732 +0,0 @@
|
||||
using FluentAssertions;
|
||||
using StellaOps.Signals.Models;
|
||||
using StellaOps.Signals.Parsing;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Signals.Reachability.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for CallgraphSchemaMigrator.
|
||||
/// Verifies schema migration from legacy format to stella.callgraph.v1.
|
||||
/// </summary>
|
||||
public class CallgraphSchemaMigratorTests
|
||||
{
|
||||
#region EnsureV1 - Schema Version Tests
|
||||
|
||||
[Fact]
|
||||
public void EnsureV1_SetsSchemaToV1_WhenNotSet()
|
||||
{
|
||||
// Arrange
|
||||
var document = new CallgraphDocument
|
||||
{
|
||||
Schema = string.Empty
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = CallgraphSchemaMigrator.EnsureV1(document);
|
||||
|
||||
// Assert
|
||||
result.Schema.Should().Be(CallgraphSchemaVersions.V1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnsureV1_PreservesV1Schema_WhenAlreadySet()
|
||||
{
|
||||
// Arrange
|
||||
var document = new CallgraphDocument
|
||||
{
|
||||
Schema = CallgraphSchemaVersions.V1
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = CallgraphSchemaMigrator.EnsureV1(document);
|
||||
|
||||
// Assert
|
||||
result.Schema.Should().Be(CallgraphSchemaVersions.V1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnsureV1_UpdatesLegacySchema_ToV1()
|
||||
{
|
||||
// Arrange
|
||||
var document = new CallgraphDocument
|
||||
{
|
||||
Schema = "legacy-schema-1.0"
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = CallgraphSchemaMigrator.EnsureV1(document);
|
||||
|
||||
// Assert
|
||||
result.Schema.Should().Be(CallgraphSchemaVersions.V1);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region EnsureV1 - Language Parsing Tests
|
||||
|
||||
[Theory]
|
||||
[InlineData("dotnet", CallgraphLanguage.DotNet)]
|
||||
[InlineData(".net", CallgraphLanguage.DotNet)]
|
||||
[InlineData("csharp", CallgraphLanguage.DotNet)]
|
||||
[InlineData("c#", CallgraphLanguage.DotNet)]
|
||||
[InlineData("java", CallgraphLanguage.Java)]
|
||||
[InlineData("node", CallgraphLanguage.Node)]
|
||||
[InlineData("nodejs", CallgraphLanguage.Node)]
|
||||
[InlineData("javascript", CallgraphLanguage.Node)]
|
||||
[InlineData("typescript", CallgraphLanguage.Node)]
|
||||
[InlineData("python", CallgraphLanguage.Python)]
|
||||
[InlineData("go", CallgraphLanguage.Go)]
|
||||
[InlineData("golang", CallgraphLanguage.Go)]
|
||||
[InlineData("rust", CallgraphLanguage.Rust)]
|
||||
[InlineData("ruby", CallgraphLanguage.Ruby)]
|
||||
[InlineData("php", CallgraphLanguage.Php)]
|
||||
[InlineData("binary", CallgraphLanguage.Binary)]
|
||||
[InlineData("native", CallgraphLanguage.Binary)]
|
||||
[InlineData("elf", CallgraphLanguage.Binary)]
|
||||
[InlineData("swift", CallgraphLanguage.Swift)]
|
||||
[InlineData("kotlin", CallgraphLanguage.Kotlin)]
|
||||
public void EnsureV1_ParsesLanguageString_ToEnum(string languageString, CallgraphLanguage expected)
|
||||
{
|
||||
// Arrange
|
||||
var document = new CallgraphDocument
|
||||
{
|
||||
Language = languageString,
|
||||
LanguageType = CallgraphLanguage.Unknown
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = CallgraphSchemaMigrator.EnsureV1(document);
|
||||
|
||||
// Assert
|
||||
result.LanguageType.Should().Be(expected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnsureV1_PreservesLanguageType_WhenAlreadySet()
|
||||
{
|
||||
// Arrange
|
||||
var document = new CallgraphDocument
|
||||
{
|
||||
Language = "java",
|
||||
LanguageType = CallgraphLanguage.DotNet // Already set to something different
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = CallgraphSchemaMigrator.EnsureV1(document);
|
||||
|
||||
// Assert
|
||||
result.LanguageType.Should().Be(CallgraphLanguage.DotNet);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region EnsureV1 - Node Visibility Inference Tests
|
||||
|
||||
[Fact]
|
||||
public void EnsureV1_InfersPublicVisibility_ForStandardNames()
|
||||
{
|
||||
// Arrange
|
||||
var document = new CallgraphDocument
|
||||
{
|
||||
Nodes = new List<CallgraphNode>
|
||||
{
|
||||
new() { Id = "node1", Name = "ProcessOrder", Visibility = SymbolVisibility.Unknown }
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = CallgraphSchemaMigrator.EnsureV1(document);
|
||||
|
||||
// Assert
|
||||
result.Nodes.Should().ContainSingle()
|
||||
.Which.Visibility.Should().Be(SymbolVisibility.Public);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnsureV1_InfersPrivateVisibility_ForUnderscorePrefixed()
|
||||
{
|
||||
// Arrange
|
||||
var document = new CallgraphDocument
|
||||
{
|
||||
Nodes = new List<CallgraphNode>
|
||||
{
|
||||
new() { Id = "node1", Name = "_privateMethod", Visibility = SymbolVisibility.Unknown }
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = CallgraphSchemaMigrator.EnsureV1(document);
|
||||
|
||||
// Assert
|
||||
result.Nodes.Should().ContainSingle()
|
||||
.Which.Visibility.Should().Be(SymbolVisibility.Private);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnsureV1_InfersPrivateVisibility_ForAngleBracketNames()
|
||||
{
|
||||
// Arrange
|
||||
var document = new CallgraphDocument
|
||||
{
|
||||
Nodes = new List<CallgraphNode>
|
||||
{
|
||||
new() { Id = "node1", Name = "<Main>$", Visibility = SymbolVisibility.Unknown }
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = CallgraphSchemaMigrator.EnsureV1(document);
|
||||
|
||||
// Assert
|
||||
result.Nodes.Should().ContainSingle()
|
||||
.Which.Visibility.Should().Be(SymbolVisibility.Private);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnsureV1_InfersInternalVisibility_ForInternalNamespace()
|
||||
{
|
||||
// Arrange
|
||||
var document = new CallgraphDocument
|
||||
{
|
||||
Nodes = new List<CallgraphNode>
|
||||
{
|
||||
new() { Id = "node1", Name = "Helper", Namespace = "MyApp.Internal.Utils", Visibility = SymbolVisibility.Unknown }
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = CallgraphSchemaMigrator.EnsureV1(document);
|
||||
|
||||
// Assert
|
||||
result.Nodes.Should().ContainSingle()
|
||||
.Which.Visibility.Should().Be(SymbolVisibility.Internal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnsureV1_PreservesVisibility_WhenAlreadySet()
|
||||
{
|
||||
// Arrange
|
||||
var document = new CallgraphDocument
|
||||
{
|
||||
Nodes = new List<CallgraphNode>
|
||||
{
|
||||
new() { Id = "node1", Name = "_privateMethod", Visibility = SymbolVisibility.Protected }
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = CallgraphSchemaMigrator.EnsureV1(document);
|
||||
|
||||
// Assert
|
||||
result.Nodes.Should().ContainSingle()
|
||||
.Which.Visibility.Should().Be(SymbolVisibility.Protected);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region EnsureV1 - Symbol Key Building Tests
|
||||
|
||||
[Fact]
|
||||
public void EnsureV1_BuildsSymbolKey_FromNamespaceAndName()
|
||||
{
|
||||
// Arrange
|
||||
var document = new CallgraphDocument
|
||||
{
|
||||
Nodes = new List<CallgraphNode>
|
||||
{
|
||||
new() { Id = "node1", Name = "ProcessOrder", Namespace = "MyApp.Services" }
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = CallgraphSchemaMigrator.EnsureV1(document);
|
||||
|
||||
// Assert
|
||||
result.Nodes.Should().ContainSingle()
|
||||
.Which.SymbolKey.Should().Be("MyApp.Services.ProcessOrder");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnsureV1_BuildsSymbolKey_FromNameOnly_WhenNoNamespace()
|
||||
{
|
||||
// Arrange
|
||||
var document = new CallgraphDocument
|
||||
{
|
||||
Nodes = new List<CallgraphNode>
|
||||
{
|
||||
new() { Id = "node1", Name = "GlobalMethod" }
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = CallgraphSchemaMigrator.EnsureV1(document);
|
||||
|
||||
// Assert
|
||||
result.Nodes.Should().ContainSingle()
|
||||
.Which.SymbolKey.Should().Be("GlobalMethod");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnsureV1_PreservesSymbolKey_WhenAlreadySet()
|
||||
{
|
||||
// Arrange
|
||||
var document = new CallgraphDocument
|
||||
{
|
||||
Nodes = new List<CallgraphNode>
|
||||
{
|
||||
new() { Id = "node1", Name = "Method", Namespace = "Ns", SymbolKey = "Custom.Key" }
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = CallgraphSchemaMigrator.EnsureV1(document);
|
||||
|
||||
// Assert
|
||||
result.Nodes.Should().ContainSingle()
|
||||
.Which.SymbolKey.Should().Be("Custom.Key");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region EnsureV1 - Entrypoint Candidate Detection Tests
|
||||
|
||||
[Theory]
|
||||
[InlineData("Main")]
|
||||
[InlineData("main")]
|
||||
[InlineData("MAIN")]
|
||||
public void EnsureV1_DetectsEntrypointCandidate_ForMainMethod(string methodName)
|
||||
{
|
||||
// Arrange
|
||||
var document = new CallgraphDocument
|
||||
{
|
||||
Nodes = new List<CallgraphNode>
|
||||
{
|
||||
new() { Id = "node1", Name = methodName }
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = CallgraphSchemaMigrator.EnsureV1(document);
|
||||
|
||||
// Assert
|
||||
result.Nodes.Should().ContainSingle()
|
||||
.Which.IsEntrypointCandidate.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("OrdersController")]
|
||||
[InlineData("UserController")]
|
||||
public void EnsureV1_DetectsEntrypointCandidate_ForControllerNames(string name)
|
||||
{
|
||||
// Arrange
|
||||
var document = new CallgraphDocument
|
||||
{
|
||||
Nodes = new List<CallgraphNode>
|
||||
{
|
||||
new() { Id = "node1", Name = name }
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = CallgraphSchemaMigrator.EnsureV1(document);
|
||||
|
||||
// Assert
|
||||
result.Nodes.Should().ContainSingle()
|
||||
.Which.IsEntrypointCandidate.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("RequestHandler")]
|
||||
[InlineData("EventHandler")]
|
||||
public void EnsureV1_DetectsEntrypointCandidate_ForHandlerNames(string name)
|
||||
{
|
||||
// Arrange
|
||||
var document = new CallgraphDocument
|
||||
{
|
||||
Nodes = new List<CallgraphNode>
|
||||
{
|
||||
new() { Id = "node1", Name = name }
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = CallgraphSchemaMigrator.EnsureV1(document);
|
||||
|
||||
// Assert
|
||||
result.Nodes.Should().ContainSingle()
|
||||
.Which.IsEntrypointCandidate.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(".cctor")]
|
||||
[InlineData("ModuleInitializer")]
|
||||
public void EnsureV1_DetectsEntrypointCandidate_ForModuleInitializers(string name)
|
||||
{
|
||||
// Arrange
|
||||
var document = new CallgraphDocument
|
||||
{
|
||||
Nodes = new List<CallgraphNode>
|
||||
{
|
||||
new() { Id = "node1", Name = name }
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = CallgraphSchemaMigrator.EnsureV1(document);
|
||||
|
||||
// Assert
|
||||
result.Nodes.Should().ContainSingle()
|
||||
.Which.IsEntrypointCandidate.Should().BeTrue();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region EnsureV1 - Edge Reason Inference Tests
|
||||
|
||||
[Theory]
|
||||
[InlineData("call", EdgeReason.DirectCall)]
|
||||
[InlineData("direct", EdgeReason.DirectCall)]
|
||||
[InlineData("virtual", EdgeReason.VirtualCall)]
|
||||
[InlineData("callvirt", EdgeReason.VirtualCall)]
|
||||
[InlineData("newobj", EdgeReason.NewObj)]
|
||||
[InlineData("new", EdgeReason.NewObj)]
|
||||
[InlineData("ldftn", EdgeReason.DelegateCreate)]
|
||||
[InlineData("delegate", EdgeReason.DelegateCreate)]
|
||||
[InlineData("reflection", EdgeReason.ReflectionString)]
|
||||
[InlineData("di", EdgeReason.DiBinding)]
|
||||
[InlineData("injection", EdgeReason.DiBinding)]
|
||||
[InlineData("async", EdgeReason.AsyncContinuation)]
|
||||
[InlineData("continuation", EdgeReason.AsyncContinuation)]
|
||||
[InlineData("event", EdgeReason.EventHandler)]
|
||||
[InlineData("generic", EdgeReason.GenericInstantiation)]
|
||||
[InlineData("native", EdgeReason.NativeInterop)]
|
||||
[InlineData("pinvoke", EdgeReason.NativeInterop)]
|
||||
[InlineData("ffi", EdgeReason.NativeInterop)]
|
||||
public void EnsureV1_InfersEdgeReason_FromLegacyType(string legacyType, EdgeReason expected)
|
||||
{
|
||||
// Arrange
|
||||
var document = new CallgraphDocument
|
||||
{
|
||||
Edges = new List<CallgraphEdge>
|
||||
{
|
||||
new() { SourceId = "n1", TargetId = "n2", Type = legacyType, Reason = EdgeReason.Unknown }
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = CallgraphSchemaMigrator.EnsureV1(document);
|
||||
|
||||
// Assert
|
||||
result.Edges.Should().ContainSingle()
|
||||
.Which.Reason.Should().Be(expected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnsureV1_InfersRuntimeMinted_ForRuntimeKind()
|
||||
{
|
||||
// Arrange
|
||||
var document = new CallgraphDocument
|
||||
{
|
||||
Edges = new List<CallgraphEdge>
|
||||
{
|
||||
new() { SourceId = "n1", TargetId = "n2", Type = "unknown", Kind = EdgeKind.Runtime, Reason = EdgeReason.Unknown }
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = CallgraphSchemaMigrator.EnsureV1(document);
|
||||
|
||||
// Assert
|
||||
result.Edges.Should().ContainSingle()
|
||||
.Which.Reason.Should().Be(EdgeReason.RuntimeMinted);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnsureV1_InfersDynamicImport_ForHeuristicKind()
|
||||
{
|
||||
// Arrange
|
||||
var document = new CallgraphDocument
|
||||
{
|
||||
Edges = new List<CallgraphEdge>
|
||||
{
|
||||
new() { SourceId = "n1", TargetId = "n2", Type = "unknown", Kind = EdgeKind.Heuristic, Reason = EdgeReason.Unknown }
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = CallgraphSchemaMigrator.EnsureV1(document);
|
||||
|
||||
// Assert
|
||||
result.Edges.Should().ContainSingle()
|
||||
.Which.Reason.Should().Be(EdgeReason.DynamicImport);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnsureV1_PreservesEdgeReason_WhenAlreadySet()
|
||||
{
|
||||
// Arrange
|
||||
var document = new CallgraphDocument
|
||||
{
|
||||
Edges = new List<CallgraphEdge>
|
||||
{
|
||||
new() { SourceId = "n1", TargetId = "n2", Type = "call", Reason = EdgeReason.VirtualCall }
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = CallgraphSchemaMigrator.EnsureV1(document);
|
||||
|
||||
// Assert
|
||||
result.Edges.Should().ContainSingle()
|
||||
.Which.Reason.Should().Be(EdgeReason.VirtualCall);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region EnsureV1 - Entrypoint Inference Tests
|
||||
|
||||
[Fact]
|
||||
public void EnsureV1_InfersEntrypoints_FromEntrypointCandidateNodes()
|
||||
{
|
||||
// Arrange
|
||||
var document = new CallgraphDocument
|
||||
{
|
||||
LanguageType = CallgraphLanguage.DotNet,
|
||||
Nodes = new List<CallgraphNode>
|
||||
{
|
||||
new() { Id = "main", Name = "Main", IsEntrypointCandidate = true },
|
||||
new() { Id = "helper", Name = "Helper", IsEntrypointCandidate = false }
|
||||
},
|
||||
Entrypoints = new List<CallgraphEntrypoint>()
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = CallgraphSchemaMigrator.EnsureV1(document);
|
||||
|
||||
// Assert
|
||||
result.Entrypoints.Should().ContainSingle()
|
||||
.Which.NodeId.Should().Be("main");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnsureV1_InfersEntrypoints_FromExplicitRoots()
|
||||
{
|
||||
// Arrange
|
||||
var document = new CallgraphDocument
|
||||
{
|
||||
LanguageType = CallgraphLanguage.DotNet,
|
||||
Nodes = new List<CallgraphNode>
|
||||
{
|
||||
new() { Id = "init", Name = "Initialize" }
|
||||
},
|
||||
Roots = new List<CallgraphRoot>
|
||||
{
|
||||
new("init", "init", "module_init")
|
||||
},
|
||||
Entrypoints = new List<CallgraphEntrypoint>()
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = CallgraphSchemaMigrator.EnsureV1(document);
|
||||
|
||||
// Assert
|
||||
result.Entrypoints.Should().ContainSingle()
|
||||
.Which.NodeId.Should().Be("init");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnsureV1_PreservesEntrypoints_WhenAlreadyPresent()
|
||||
{
|
||||
// Arrange
|
||||
var existingEntrypoint = new CallgraphEntrypoint
|
||||
{
|
||||
NodeId = "existing",
|
||||
Kind = EntrypointKind.Http,
|
||||
Route = "/api/test"
|
||||
};
|
||||
|
||||
var document = new CallgraphDocument
|
||||
{
|
||||
Nodes = new List<CallgraphNode>
|
||||
{
|
||||
new() { Id = "main", Name = "Main", IsEntrypointCandidate = true }
|
||||
},
|
||||
Entrypoints = new List<CallgraphEntrypoint> { existingEntrypoint }
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = CallgraphSchemaMigrator.EnsureV1(document);
|
||||
|
||||
// Assert
|
||||
result.Entrypoints.Should().ContainSingle()
|
||||
.Which.NodeId.Should().Be("existing");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region EnsureV1 - Ordering Tests
|
||||
|
||||
[Fact]
|
||||
public void EnsureV1_SortsNodes_ByIdAlphabetically()
|
||||
{
|
||||
// Arrange
|
||||
var document = new CallgraphDocument
|
||||
{
|
||||
Nodes = new List<CallgraphNode>
|
||||
{
|
||||
new() { Id = "z-node", Name = "Z" },
|
||||
new() { Id = "a-node", Name = "A" },
|
||||
new() { Id = "m-node", Name = "M" }
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = CallgraphSchemaMigrator.EnsureV1(document);
|
||||
|
||||
// Assert
|
||||
result.Nodes.Select(n => n.Id).Should().BeInAscendingOrder();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnsureV1_SortsEdges_BySourceThenTargetThenTypeThenOffset()
|
||||
{
|
||||
// Arrange
|
||||
var document = new CallgraphDocument
|
||||
{
|
||||
Edges = new List<CallgraphEdge>
|
||||
{
|
||||
new() { SourceId = "b", TargetId = "x", Type = "call", Offset = 10 },
|
||||
new() { SourceId = "a", TargetId = "y", Type = "call", Offset = 5 },
|
||||
new() { SourceId = "a", TargetId = "x", Type = "virtual", Offset = 0 },
|
||||
new() { SourceId = "a", TargetId = "x", Type = "call", Offset = 20 }
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = CallgraphSchemaMigrator.EnsureV1(document);
|
||||
|
||||
// Assert
|
||||
var sortedEdges = result.Edges.ToList();
|
||||
sortedEdges[0].SourceId.Should().Be("a");
|
||||
sortedEdges[0].TargetId.Should().Be("x");
|
||||
sortedEdges[0].Type.Should().Be("call");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnsureV1_SortsEntrypoints_ByPhaseThenOrder()
|
||||
{
|
||||
// Arrange
|
||||
var document = new CallgraphDocument
|
||||
{
|
||||
LanguageType = CallgraphLanguage.DotNet,
|
||||
Nodes = new List<CallgraphNode>
|
||||
{
|
||||
new() { Id = "main", Name = "Main", IsEntrypointCandidate = true },
|
||||
new() { Id = "init", Name = ".cctor", IsEntrypointCandidate = true }
|
||||
},
|
||||
Entrypoints = new List<CallgraphEntrypoint>()
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = CallgraphSchemaMigrator.EnsureV1(document);
|
||||
|
||||
// Assert
|
||||
result.Entrypoints.Should().HaveCount(2);
|
||||
// ModuleInit phase should come before Runtime
|
||||
result.Entrypoints.First().NodeId.Should().Be("init");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region EnsureV1 - Null Handling Tests
|
||||
|
||||
[Fact]
|
||||
public void EnsureV1_ThrowsArgumentNullException_ForNullDocument()
|
||||
{
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentNullException>(() => CallgraphSchemaMigrator.EnsureV1(null!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnsureV1_HandlesEmptyNodes_Gracefully()
|
||||
{
|
||||
// Arrange
|
||||
var document = new CallgraphDocument
|
||||
{
|
||||
Nodes = new List<CallgraphNode>()
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = CallgraphSchemaMigrator.EnsureV1(document);
|
||||
|
||||
// Assert
|
||||
result.Nodes.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnsureV1_HandlesEmptyEdges_Gracefully()
|
||||
{
|
||||
// Arrange
|
||||
var document = new CallgraphDocument
|
||||
{
|
||||
Edges = new List<CallgraphEdge>()
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = CallgraphSchemaMigrator.EnsureV1(document);
|
||||
|
||||
// Assert
|
||||
result.Edges.Should().BeEmpty();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Framework Inference Tests
|
||||
|
||||
[Fact]
|
||||
public void EnsureV1_InfersAspNetCoreFramework_ForDotNetController()
|
||||
{
|
||||
// Arrange
|
||||
var document = new CallgraphDocument
|
||||
{
|
||||
LanguageType = CallgraphLanguage.DotNet,
|
||||
Nodes = new List<CallgraphNode>
|
||||
{
|
||||
new() { Id = "ctrl", Name = "OrdersController", IsEntrypointCandidate = true }
|
||||
},
|
||||
Entrypoints = new List<CallgraphEntrypoint>()
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = CallgraphSchemaMigrator.EnsureV1(document);
|
||||
|
||||
// Assert
|
||||
result.Entrypoints.Should().ContainSingle()
|
||||
.Which.Framework.Should().Be(EntrypointFramework.AspNetCore);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnsureV1_InfersSpringFramework_ForJavaController()
|
||||
{
|
||||
// Arrange
|
||||
var document = new CallgraphDocument
|
||||
{
|
||||
LanguageType = CallgraphLanguage.Java,
|
||||
Nodes = new List<CallgraphNode>
|
||||
{
|
||||
new() { Id = "ctrl", Name = "OrderController", IsEntrypointCandidate = true }
|
||||
},
|
||||
Entrypoints = new List<CallgraphEntrypoint>()
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = CallgraphSchemaMigrator.EnsureV1(document);
|
||||
|
||||
// Assert
|
||||
result.Entrypoints.Should().ContainSingle()
|
||||
.Which.Framework.Should().Be(EntrypointFramework.Spring);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -1,396 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using FluentAssertions;
|
||||
using StellaOps.Signals.Models;
|
||||
using StellaOps.Signals.Parsing;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Signals.Reachability.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Determinism tests for the stella.callgraph.v1 schema.
|
||||
/// These tests validate:
|
||||
/// - Round-trip serialization produces identical output
|
||||
/// - Schema migration from legacy formats
|
||||
/// - Enum values serialize as expected strings
|
||||
/// - Arrays maintain stable ordering
|
||||
/// </summary>
|
||||
public sealed class CallgraphSchemaV1DeterminismTests
|
||||
{
|
||||
private static readonly string RepoRoot = LocateRepoRoot();
|
||||
private static readonly string FixtureRoot = Path.Combine(RepoRoot, "tests", "reachability", "fixtures", "callgraph-schema-v1");
|
||||
|
||||
private static readonly JsonSerializerOptions DeterministicOptions = new()
|
||||
{
|
||||
WriteIndented = true,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) }
|
||||
};
|
||||
|
||||
public static IEnumerable<object[]> GoldenFixtures()
|
||||
{
|
||||
if (!Directory.Exists(FixtureRoot))
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
foreach (var file in Directory.GetFiles(FixtureRoot, "*.json").OrderBy(f => f, StringComparer.Ordinal))
|
||||
{
|
||||
yield return new object[] { Path.GetFileNameWithoutExtension(file) };
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(GoldenFixtures))]
|
||||
public void GoldenFixture_DeserializesWithoutError(string fixtureName)
|
||||
{
|
||||
var json = File.ReadAllText(Path.Combine(FixtureRoot, $"{fixtureName}.json"));
|
||||
|
||||
var document = JsonSerializer.Deserialize<CallgraphDocument>(json);
|
||||
|
||||
document.Should().NotBeNull();
|
||||
document!.Id.Should().NotBeNullOrEmpty();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(GoldenFixtures))]
|
||||
public void GoldenFixture_NodesHaveRequiredFields(string fixtureName)
|
||||
{
|
||||
var json = File.ReadAllText(Path.Combine(FixtureRoot, $"{fixtureName}.json"));
|
||||
var document = JsonSerializer.Deserialize<CallgraphDocument>(json)!;
|
||||
|
||||
foreach (var node in document.Nodes)
|
||||
{
|
||||
node.Id.Should().NotBeNullOrEmpty($"Node in {fixtureName} must have Id");
|
||||
node.Name.Should().NotBeNullOrEmpty($"Node {node.Id} in {fixtureName} must have Name");
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(GoldenFixtures))]
|
||||
public void GoldenFixture_EdgesReferenceValidNodes(string fixtureName)
|
||||
{
|
||||
var json = File.ReadAllText(Path.Combine(FixtureRoot, $"{fixtureName}.json"));
|
||||
var document = JsonSerializer.Deserialize<CallgraphDocument>(json)!;
|
||||
|
||||
var nodeIds = document.Nodes.Select(n => n.Id).ToHashSet(StringComparer.Ordinal);
|
||||
|
||||
foreach (var edge in document.Edges)
|
||||
{
|
||||
nodeIds.Should().Contain(edge.SourceId, $"Edge source {edge.SourceId} in {fixtureName} must reference existing node");
|
||||
nodeIds.Should().Contain(edge.TargetId, $"Edge target {edge.TargetId} in {fixtureName} must reference existing node");
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(GoldenFixtures))]
|
||||
public void GoldenFixture_EntrypointsReferenceValidNodes(string fixtureName)
|
||||
{
|
||||
var json = File.ReadAllText(Path.Combine(FixtureRoot, $"{fixtureName}.json"));
|
||||
var document = JsonSerializer.Deserialize<CallgraphDocument>(json)!;
|
||||
|
||||
var nodeIds = document.Nodes.Select(n => n.Id).ToHashSet(StringComparer.Ordinal);
|
||||
|
||||
foreach (var entrypoint in document.Entrypoints)
|
||||
{
|
||||
nodeIds.Should().Contain(entrypoint.NodeId, $"Entrypoint {entrypoint.NodeId} in {fixtureName} must reference existing node");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DotNetFixture_HasCorrectLanguageEnum()
|
||||
{
|
||||
var json = File.ReadAllText(Path.Combine(FixtureRoot, "dotnet-aspnetcore-minimal.json"));
|
||||
var document = JsonSerializer.Deserialize<CallgraphDocument>(json)!;
|
||||
|
||||
document.LanguageType.Should().Be(CallgraphLanguage.DotNet);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void JavaFixture_HasCorrectLanguageEnum()
|
||||
{
|
||||
var json = File.ReadAllText(Path.Combine(FixtureRoot, "java-spring-boot.json"));
|
||||
var document = JsonSerializer.Deserialize<CallgraphDocument>(json)!;
|
||||
|
||||
document.LanguageType.Should().Be(CallgraphLanguage.Java);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NodeFixture_HasCorrectLanguageEnum()
|
||||
{
|
||||
var json = File.ReadAllText(Path.Combine(FixtureRoot, "node-express-api.json"));
|
||||
var document = JsonSerializer.Deserialize<CallgraphDocument>(json)!;
|
||||
|
||||
document.LanguageType.Should().Be(CallgraphLanguage.Node);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GoFixture_HasCorrectLanguageEnum()
|
||||
{
|
||||
var json = File.ReadAllText(Path.Combine(FixtureRoot, "go-gin-api.json"));
|
||||
var document = JsonSerializer.Deserialize<CallgraphDocument>(json)!;
|
||||
|
||||
document.LanguageType.Should().Be(CallgraphLanguage.Go);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AllEdgeReasonsFixture_ContainsAllEdgeReasons()
|
||||
{
|
||||
var json = File.ReadAllText(Path.Combine(FixtureRoot, "all-edge-reasons.json"));
|
||||
var document = JsonSerializer.Deserialize<CallgraphDocument>(json)!;
|
||||
|
||||
var expectedReasons = Enum.GetValues<EdgeReason>();
|
||||
var actualReasons = document.Edges.Select(e => e.Reason).Distinct().ToHashSet();
|
||||
|
||||
foreach (var expected in expectedReasons)
|
||||
{
|
||||
actualReasons.Should().Contain(expected, $"EdgeReason.{expected} should be covered by fixture");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AllEdgeReasonsFixture_ContainsAllEdgeKinds()
|
||||
{
|
||||
var json = File.ReadAllText(Path.Combine(FixtureRoot, "all-edge-reasons.json"));
|
||||
var document = JsonSerializer.Deserialize<CallgraphDocument>(json)!;
|
||||
|
||||
var expectedKinds = Enum.GetValues<EdgeKind>();
|
||||
var actualKinds = document.Edges.Select(e => e.Kind).Distinct().ToHashSet();
|
||||
|
||||
foreach (var expected in expectedKinds)
|
||||
{
|
||||
actualKinds.Should().Contain(expected, $"EdgeKind.{expected} should be covered by fixture");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AllVisibilityFixture_ContainsAllVisibilityLevels()
|
||||
{
|
||||
var json = File.ReadAllText(Path.Combine(FixtureRoot, "all-visibility-levels.json"));
|
||||
var document = JsonSerializer.Deserialize<CallgraphDocument>(json)!;
|
||||
|
||||
var expectedVisibilities = Enum.GetValues<SymbolVisibility>();
|
||||
var actualVisibilities = document.Nodes.Select(n => n.Visibility).Distinct().ToHashSet();
|
||||
|
||||
foreach (var expected in expectedVisibilities)
|
||||
{
|
||||
actualVisibilities.Should().Contain(expected, $"SymbolVisibility.{expected} should be covered by fixture");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LegacyFixture_HasNoSchemaField()
|
||||
{
|
||||
var json = File.ReadAllText(Path.Combine(FixtureRoot, "legacy-no-schema.json"));
|
||||
var document = JsonSerializer.Deserialize<CallgraphDocument>(json)!;
|
||||
|
||||
// Legacy fixture should deserialize but have default schema (v1) due to property default
|
||||
document.Schema.Should().Be(CallgraphSchemaVersions.V1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LegacyFixture_MigratesToV1Schema()
|
||||
{
|
||||
var json = File.ReadAllText(Path.Combine(FixtureRoot, "legacy-no-schema.json"));
|
||||
var document = JsonSerializer.Deserialize<CallgraphDocument>(json)!;
|
||||
|
||||
var migrated = CallgraphSchemaMigrator.EnsureV1(document);
|
||||
|
||||
migrated.Schema.Should().Be(CallgraphSchemaVersions.V1);
|
||||
// Verify that nodes have visibility inferred (may be Unknown for some cases)
|
||||
migrated.Nodes.Should().AllSatisfy(n => Enum.IsDefined(n.Visibility).Should().BeTrue());
|
||||
// Verify that edges have reason inferred (defaults to DirectCall for legacy 'call' type)
|
||||
migrated.Edges.Should().AllSatisfy(e => Enum.IsDefined(e.Reason).Should().BeTrue());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("dotnet-aspnetcore-minimal")]
|
||||
[InlineData("java-spring-boot")]
|
||||
[InlineData("node-express-api")]
|
||||
[InlineData("go-gin-api")]
|
||||
public void V1Fixture_MigrationIsIdempotent(string fixtureName)
|
||||
{
|
||||
var json = File.ReadAllText(Path.Combine(FixtureRoot, $"{fixtureName}.json"));
|
||||
var document = JsonSerializer.Deserialize<CallgraphDocument>(json)!;
|
||||
|
||||
var migrated1 = CallgraphSchemaMigrator.EnsureV1(document);
|
||||
var migrated2 = CallgraphSchemaMigrator.EnsureV1(migrated1);
|
||||
|
||||
migrated2.Schema.Should().Be(migrated1.Schema);
|
||||
migrated2.Nodes.Should().HaveCount(migrated1.Nodes.Count);
|
||||
migrated2.Edges.Should().HaveCount(migrated1.Edges.Count);
|
||||
migrated2.Entrypoints.Should().HaveCount(migrated1.Entrypoints.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EdgeReason_SerializesAsCamelCaseString()
|
||||
{
|
||||
var edge = new CallgraphEdge
|
||||
{
|
||||
SourceId = "s1",
|
||||
TargetId = "t1",
|
||||
Type = "call",
|
||||
Reason = EdgeReason.DirectCall
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(edge, DeterministicOptions);
|
||||
|
||||
json.Should().Contain("\"reason\": \"directCall\"");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SymbolVisibility_SerializesAsCamelCaseString()
|
||||
{
|
||||
var node = new CallgraphNode
|
||||
{
|
||||
Id = "n1",
|
||||
Name = "Test",
|
||||
Kind = "method",
|
||||
Visibility = SymbolVisibility.Public
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(node, DeterministicOptions);
|
||||
|
||||
json.Should().Contain("\"visibility\": \"public\"");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EntrypointKind_SerializesAsCamelCaseString()
|
||||
{
|
||||
var entrypoint = new CallgraphEntrypoint
|
||||
{
|
||||
NodeId = "n1",
|
||||
Kind = EntrypointKind.Http,
|
||||
Framework = EntrypointFramework.AspNetCore
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(entrypoint, DeterministicOptions);
|
||||
|
||||
json.Should().Contain("\"kind\": \"http\"");
|
||||
json.Should().Contain("\"framework\": \"aspNetCore\"");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(GoldenFixtures))]
|
||||
public void GoldenFixture_NodesSortedById(string fixtureName)
|
||||
{
|
||||
var json = File.ReadAllText(Path.Combine(FixtureRoot, $"{fixtureName}.json"));
|
||||
var document = JsonSerializer.Deserialize<CallgraphDocument>(json)!;
|
||||
|
||||
var nodeIds = document.Nodes.Select(n => n.Id).ToList();
|
||||
var sortedIds = nodeIds.OrderBy(id => id, StringComparer.Ordinal).ToList();
|
||||
|
||||
nodeIds.Should().Equal(sortedIds, $"Nodes in {fixtureName} should be sorted by Id for determinism");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(GoldenFixtures))]
|
||||
public void GoldenFixture_EntrypointsSortedByOrder(string fixtureName)
|
||||
{
|
||||
var json = File.ReadAllText(Path.Combine(FixtureRoot, $"{fixtureName}.json"));
|
||||
var document = JsonSerializer.Deserialize<CallgraphDocument>(json)!;
|
||||
|
||||
var orders = document.Entrypoints.Select(e => e.Order).ToList();
|
||||
var sortedOrders = orders.OrderBy(o => o).ToList();
|
||||
|
||||
orders.Should().Equal(sortedOrders, $"Entrypoints in {fixtureName} should be sorted by Order for determinism");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DotNetFixture_HasCorrectAspNetCoreEntrypoints()
|
||||
{
|
||||
var json = File.ReadAllText(Path.Combine(FixtureRoot, "dotnet-aspnetcore-minimal.json"));
|
||||
var document = JsonSerializer.Deserialize<CallgraphDocument>(json)!;
|
||||
|
||||
document.Entrypoints.Should().Contain(e => e.Kind == EntrypointKind.Main && e.Framework == EntrypointFramework.AspNetCore);
|
||||
document.Entrypoints.Should().Contain(e => e.Kind == EntrypointKind.Http && e.Route == "/weatherforecast");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void JavaFixture_HasCorrectSpringEntrypoints()
|
||||
{
|
||||
var json = File.ReadAllText(Path.Combine(FixtureRoot, "java-spring-boot.json"));
|
||||
var document = JsonSerializer.Deserialize<CallgraphDocument>(json)!;
|
||||
|
||||
document.Entrypoints.Should().Contain(e => e.Kind == EntrypointKind.Main && e.Framework == EntrypointFramework.SpringBoot);
|
||||
document.Entrypoints.Should().Contain(e => e.Kind == EntrypointKind.Http && e.Route == "/owners/{ownerId}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GoFixture_HasModuleInitEntrypoint()
|
||||
{
|
||||
var json = File.ReadAllText(Path.Combine(FixtureRoot, "go-gin-api.json"));
|
||||
var document = JsonSerializer.Deserialize<CallgraphDocument>(json)!;
|
||||
|
||||
document.Entrypoints.Should().Contain(e => e.Kind == EntrypointKind.ModuleInit && e.Phase == EntrypointPhase.ModuleInit);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AllEdgeReasonsFixture_ReflectionEdgeIsUnresolved()
|
||||
{
|
||||
var json = File.ReadAllText(Path.Combine(FixtureRoot, "all-edge-reasons.json"));
|
||||
var document = JsonSerializer.Deserialize<CallgraphDocument>(json)!;
|
||||
|
||||
var reflectionEdge = document.Edges.Single(e => e.Reason == EdgeReason.ReflectionString);
|
||||
reflectionEdge.IsResolved.Should().BeFalse("Reflection edges are typically unresolved");
|
||||
reflectionEdge.Weight.Should().BeLessThan(1.0, "Reflection edges should have lower confidence");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AllEdgeReasonsFixture_DiBindingHasProvenance()
|
||||
{
|
||||
var json = File.ReadAllText(Path.Combine(FixtureRoot, "all-edge-reasons.json"));
|
||||
var document = JsonSerializer.Deserialize<CallgraphDocument>(json)!;
|
||||
|
||||
var diEdge = document.Edges.Single(e => e.Reason == EdgeReason.DiBinding);
|
||||
diEdge.Provenance.Should().NotBeNullOrEmpty("DI binding edges should include provenance");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Artifacts_HaveRequiredFields()
|
||||
{
|
||||
var json = File.ReadAllText(Path.Combine(FixtureRoot, "dotnet-aspnetcore-minimal.json"));
|
||||
var document = JsonSerializer.Deserialize<CallgraphDocument>(json)!;
|
||||
|
||||
document.Artifacts.Should().NotBeEmpty();
|
||||
foreach (var artifact in document.Artifacts)
|
||||
{
|
||||
artifact.ArtifactKey.Should().NotBeNullOrEmpty();
|
||||
artifact.Kind.Should().NotBeNullOrEmpty();
|
||||
artifact.Sha256.Should().NotBeNullOrEmpty().And.HaveLength(64);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Metadata_HasRequiredToolInfo()
|
||||
{
|
||||
var json = File.ReadAllText(Path.Combine(FixtureRoot, "dotnet-aspnetcore-minimal.json"));
|
||||
var document = JsonSerializer.Deserialize<CallgraphDocument>(json)!;
|
||||
|
||||
document.GraphMetadata.Should().NotBeNull();
|
||||
document.GraphMetadata!.ToolId.Should().NotBeNullOrEmpty();
|
||||
document.GraphMetadata!.ToolVersion.Should().NotBeNullOrEmpty();
|
||||
document.GraphMetadata!.AnalysisTimestamp.Should().NotBe(default);
|
||||
}
|
||||
|
||||
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).");
|
||||
}
|
||||
}
|
||||
@@ -1,341 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Signals.Models;
|
||||
using StellaOps.Signals.Options;
|
||||
using StellaOps.Signals.Parsing;
|
||||
using StellaOps.Signals.Persistence;
|
||||
using StellaOps.Signals.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Signals.Reachability.Tests;
|
||||
|
||||
public sealed class ReachabilityScoringTests
|
||||
{
|
||||
private static readonly string RepoRoot = LocateRepoRoot();
|
||||
private static readonly string FixtureRoot = Path.Combine(RepoRoot, "tests", "reachability", "fixtures", "reachbench-2025-expanded", "cases");
|
||||
|
||||
private static readonly (string CaseId, string Variant)[] SampleCases =
|
||||
{
|
||||
("java-log4j-CVE-2021-44228-log4shell", "reachable"),
|
||||
("java-log4j-CVE-2021-44228-log4shell", "unreachable"),
|
||||
("redis-CVE-2022-0543-lua-sandbox-escape", "reachable")
|
||||
};
|
||||
|
||||
public static IEnumerable<object[]> CaseVariants()
|
||||
{
|
||||
foreach (var (caseId, variant) in SampleCases)
|
||||
{
|
||||
var path = Path.Combine(FixtureRoot, caseId, "images", variant);
|
||||
if (Directory.Exists(path))
|
||||
{
|
||||
yield return new object[] { caseId, variant };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(CaseVariants))]
|
||||
public async Task RecomputedFactsMatchTruthFixtures(string caseId, string variant)
|
||||
{
|
||||
var casePath = Path.Combine(FixtureRoot, caseId);
|
||||
var caseJson = JsonDocument.Parse(File.ReadAllText(Path.Combine(casePath, "case.json"))).RootElement;
|
||||
var reachablePathsNode = caseJson
|
||||
.GetProperty("ground_truth")
|
||||
.GetProperty("reachable_variant")
|
||||
.GetProperty("evidence")
|
||||
.GetProperty("paths");
|
||||
|
||||
var paths = reachablePathsNode.EnumerateArray()
|
||||
.Select(path => path.EnumerateArray().Select(x => x.GetString()!).Where(x => !string.IsNullOrWhiteSpace(x)).ToList())
|
||||
.Where(path => path.Count > 0)
|
||||
.ToList();
|
||||
|
||||
var entryPoints = paths
|
||||
.Select(path => path[0])
|
||||
.Where(p => !string.IsNullOrWhiteSpace(p))
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
var sinks = paths
|
||||
.Select(path => path[^1])
|
||||
.Where(p => !string.IsNullOrWhiteSpace(p))
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
var callgraph = BuildCallgraphFromPaths(caseId, paths);
|
||||
var callgraphRepo = new InMemoryCallgraphRepository(callgraph);
|
||||
var factRepo = new InMemoryReachabilityFactRepository();
|
||||
var options = new SignalsOptions();
|
||||
var cache = new InMemoryReachabilityCache();
|
||||
var eventsPublisher = new NullEventsPublisher();
|
||||
var unknowns = new InMemoryUnknownsRepository();
|
||||
var scoringService = new ReachabilityScoringService(
|
||||
callgraphRepo,
|
||||
factRepo,
|
||||
TimeProvider.System,
|
||||
Microsoft.Extensions.Options.Options.Create(options),
|
||||
cache,
|
||||
unknowns,
|
||||
eventsPublisher,
|
||||
NullLogger<ReachabilityScoringService>.Instance);
|
||||
|
||||
var request = BuildRequest(casePath, variant, sinks, entryPoints);
|
||||
request.CallgraphId = callgraph.Id;
|
||||
|
||||
var fact = await scoringService.RecomputeAsync(request, CancellationToken.None);
|
||||
fact.States.Should().HaveCount(sinks.Count);
|
||||
|
||||
var expectedReachable = variant == "reachable";
|
||||
foreach (var sink in sinks)
|
||||
{
|
||||
var state = fact.States.Single(s => s.Target == sink);
|
||||
state.Reachable.Should().Be(expectedReachable, $"{caseId}:{variant} expected reachable={expectedReachable}");
|
||||
if (expectedReachable)
|
||||
{
|
||||
state.Path.Should().NotBeEmpty();
|
||||
state.Evidence.RuntimeHits.Should().NotBeEmpty();
|
||||
}
|
||||
else
|
||||
{
|
||||
state.Path.Should().BeEmpty();
|
||||
state.Evidence.BlockedEdges.Should().NotBeNull();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static ReachabilityRecomputeRequest BuildRequest(string casePath, string variant, List<string> targets, List<string> entryPoints)
|
||||
{
|
||||
var caseJson = JsonDocument.Parse(File.ReadAllText(Path.Combine(casePath, "case.json"))).RootElement;
|
||||
var variantKey = variant == "reachable" ? "reachable_variant" : "unreachable_variant";
|
||||
var variantNode = caseJson.GetProperty("ground_truth").GetProperty(variantKey);
|
||||
|
||||
var blockedEdges = new List<ReachabilityBlockedEdge>();
|
||||
if (variantNode.TryGetProperty("evidence", out var evidence) && evidence.TryGetProperty("blocked_edges", out var blockedArray))
|
||||
{
|
||||
foreach (var item in blockedArray.EnumerateArray())
|
||||
{
|
||||
var parts = item.GetString()?.Split("->", StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries);
|
||||
if (parts is { Length: 2 })
|
||||
{
|
||||
blockedEdges.Add(new ReachabilityBlockedEdge { From = parts[0], To = parts[1] });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var runtimeHits = new List<string>();
|
||||
var tracePath = Path.Combine(casePath, "images", variant, "traces.runtime.jsonl");
|
||||
if (File.Exists(tracePath))
|
||||
{
|
||||
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 sidProp))
|
||||
{
|
||||
runtimeHits.Add(sidProp.GetString()!);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return new ReachabilityRecomputeRequest
|
||||
{
|
||||
Subject = new ReachabilitySubject
|
||||
{
|
||||
ScanId = $"{Path.GetFileName(casePath)}:{variant}",
|
||||
Component = Path.GetFileName(casePath),
|
||||
Version = variant
|
||||
},
|
||||
EntryPoints = entryPoints,
|
||||
Targets = targets,
|
||||
RuntimeHits = runtimeHits,
|
||||
BlockedEdges = blockedEdges
|
||||
};
|
||||
}
|
||||
|
||||
private static CallgraphDocument BuildCallgraphFromPaths(string caseId, IReadOnlyList<IReadOnlyList<string>> paths)
|
||||
{
|
||||
var nodes = new Dictionary<string, CallgraphNode>(StringComparer.Ordinal);
|
||||
var edges = new List<CallgraphEdge>();
|
||||
|
||||
foreach (var path in paths)
|
||||
{
|
||||
if (path.Count == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var nodeId in path)
|
||||
{
|
||||
if (!nodes.ContainsKey(nodeId))
|
||||
{
|
||||
nodes[nodeId] = new CallgraphNode(nodeId, nodeId, "function", null, null, null);
|
||||
}
|
||||
}
|
||||
|
||||
for (var i = 0; i < path.Count - 1; i++)
|
||||
{
|
||||
edges.Add(new CallgraphEdge(path[i], path[i + 1], "call"));
|
||||
}
|
||||
}
|
||||
|
||||
return new CallgraphDocument
|
||||
{
|
||||
Id = caseId,
|
||||
Language = "fixture",
|
||||
Component = caseId,
|
||||
Version = "truth",
|
||||
Nodes = nodes.Values.OrderBy(n => n.Id, StringComparer.Ordinal).ToList(),
|
||||
Edges = edges
|
||||
.OrderBy(e => e.SourceId, StringComparer.Ordinal)
|
||||
.ThenBy(e => e.TargetId, StringComparer.Ordinal)
|
||||
.ToList(),
|
||||
Artifact = new CallgraphArtifactMetadata
|
||||
{
|
||||
Path = $"cas://fixtures/{caseId}",
|
||||
Hash = "stub",
|
||||
ContentType = "application/json",
|
||||
Length = 0
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private sealed class InMemoryCallgraphRepository : ICallgraphRepository
|
||||
{
|
||||
private readonly Dictionary<string, CallgraphDocument> storage;
|
||||
|
||||
public InMemoryCallgraphRepository(CallgraphDocument document)
|
||||
{
|
||||
storage = new Dictionary<string, CallgraphDocument>(StringComparer.Ordinal)
|
||||
{
|
||||
[document.Id] = document
|
||||
};
|
||||
}
|
||||
|
||||
public Task<CallgraphDocument> UpsertAsync(CallgraphDocument document, CancellationToken cancellationToken)
|
||||
{
|
||||
storage[document.Id] = document;
|
||||
return Task.FromResult(document);
|
||||
}
|
||||
|
||||
public Task<CallgraphDocument?> GetByIdAsync(string id, CancellationToken cancellationToken)
|
||||
{
|
||||
storage.TryGetValue(id, out var 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);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<ReachabilityFactDocument>> GetExpiredAsync(DateTimeOffset olderThan, int limit, CancellationToken cancellationToken) =>
|
||||
Task.FromResult<IReadOnlyList<ReachabilityFactDocument>>(Array.Empty<ReachabilityFactDocument>());
|
||||
|
||||
public Task<bool> DeleteAsync(string subjectKey, CancellationToken cancellationToken)
|
||||
{
|
||||
var removed = storage.Remove(subjectKey);
|
||||
return Task.FromResult(removed);
|
||||
}
|
||||
|
||||
public Task<int> GetRuntimeFactsCountAsync(string subjectKey, CancellationToken cancellationToken) =>
|
||||
Task.FromResult(0);
|
||||
|
||||
public Task TrimRuntimeFactsAsync(string subjectKey, int maxCount, CancellationToken cancellationToken) =>
|
||||
Task.CompletedTask;
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
public Task BulkUpdateAsync(IEnumerable<UnknownSymbolDocument> unknowns, CancellationToken cancellationToken) =>
|
||||
Task.CompletedTask;
|
||||
|
||||
public Task<IReadOnlyList<string>> GetAllSubjectKeysAsync(CancellationToken cancellationToken) =>
|
||||
Task.FromResult((IReadOnlyList<string>)Array.Empty<string>());
|
||||
|
||||
public Task<IReadOnlyList<UnknownSymbolDocument>> GetDueForRescanAsync(UnknownsBand band, int limit, CancellationToken cancellationToken) =>
|
||||
Task.FromResult((IReadOnlyList<UnknownSymbolDocument>)Array.Empty<UnknownSymbolDocument>());
|
||||
|
||||
public Task<IReadOnlyList<UnknownSymbolDocument>> QueryAsync(UnknownsBand? band, int limit, int offset, CancellationToken cancellationToken) =>
|
||||
Task.FromResult((IReadOnlyList<UnknownSymbolDocument>)Array.Empty<UnknownSymbolDocument>());
|
||||
|
||||
public Task<UnknownSymbolDocument?> GetByIdAsync(string id, CancellationToken cancellationToken) =>
|
||||
Task.FromResult<UnknownSymbolDocument?>(null);
|
||||
}
|
||||
|
||||
private sealed class NullEventsPublisher : IEventsPublisher
|
||||
{
|
||||
public Task PublishFactUpdatedAsync(ReachabilityFactDocument fact, CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
}
|
||||
|
||||
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).");
|
||||
}
|
||||
}
|
||||
@@ -1,250 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using StellaOps.Signals.Models;
|
||||
using StellaOps.Signals.Persistence;
|
||||
using StellaOps.Signals.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Signals.Reachability.Tests;
|
||||
|
||||
public sealed class RuntimeFactsIngestionServiceTests
|
||||
{
|
||||
private readonly FakeReachabilityFactRepository repository = new();
|
||||
private readonly FakeReachabilityCache cache = new();
|
||||
private readonly FakeEventsPublisher eventsPublisher = new();
|
||||
private readonly FakeScoringService scoringService = new();
|
||||
private readonly FakeProvenanceNormalizer provenanceNormalizer = new();
|
||||
private readonly FakeTimeProvider timeProvider = new(DateTimeOffset.Parse("2025-11-09T10:15:00Z", null, System.Globalization.DateTimeStyles.AssumeUniversal));
|
||||
private readonly RuntimeFactsIngestionService sut;
|
||||
|
||||
public RuntimeFactsIngestionServiceTests()
|
||||
{
|
||||
sut = new RuntimeFactsIngestionService(
|
||||
repository,
|
||||
timeProvider,
|
||||
cache,
|
||||
eventsPublisher,
|
||||
scoringService,
|
||||
provenanceNormalizer,
|
||||
NullLogger<RuntimeFactsIngestionService>.Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IngestAsync_InsertsAggregatedFacts()
|
||||
{
|
||||
var request = new RuntimeFactsIngestRequest
|
||||
{
|
||||
CallgraphId = "cg-123",
|
||||
Subject = new ReachabilitySubject { ScanId = "scan-1" },
|
||||
Events = new List<RuntimeFactEvent>
|
||||
{
|
||||
new()
|
||||
{
|
||||
SymbolId = "symbol::foo",
|
||||
HitCount = 3,
|
||||
ProcessId = 100,
|
||||
ProcessName = "worker",
|
||||
ContainerId = "ctr-1",
|
||||
SocketAddress = "10.0.0.5:443",
|
||||
Metadata = new Dictionary<string, string?> { ["thread"] = "main" }
|
||||
},
|
||||
new()
|
||||
{
|
||||
SymbolId = "symbol::foo",
|
||||
HitCount = 2
|
||||
},
|
||||
new()
|
||||
{
|
||||
SymbolId = "symbol::bar",
|
||||
CodeId = "elf:abcd",
|
||||
LoaderBase = "0x4000",
|
||||
HitCount = 1
|
||||
}
|
||||
},
|
||||
Metadata = new Dictionary<string, string?> { ["source"] = "zastava" }
|
||||
};
|
||||
|
||||
var response = await sut.IngestAsync(request, CancellationToken.None);
|
||||
|
||||
response.SubjectKey.Should().Be("scan-1");
|
||||
response.CallgraphId.Should().Be("cg-123");
|
||||
response.RuntimeFactCount.Should().Be(2);
|
||||
response.TotalHitCount.Should().Be(6);
|
||||
response.StoredAt.Should().Be(timeProvider.GetUtcNow());
|
||||
|
||||
repository.LastUpsert.Should().NotBeNull();
|
||||
repository.LastUpsert!.RuntimeFacts.Should().NotBeNull();
|
||||
repository.LastUpsert!.RuntimeFacts.Should().HaveCount(2);
|
||||
repository.LastUpsert!.RuntimeFacts![0].SymbolId.Should().Be("symbol::bar");
|
||||
repository.LastUpsert!.RuntimeFacts![0].HitCount.Should().Be(1);
|
||||
repository.LastUpsert!.RuntimeFacts![1].SymbolId.Should().Be("symbol::foo");
|
||||
repository.LastUpsert!.RuntimeFacts![1].ProcessId.Should().Be(100);
|
||||
repository.LastUpsert!.RuntimeFacts![1].ContainerId.Should().Be("ctr-1");
|
||||
repository.LastUpsert!.RuntimeFacts![1].HitCount.Should().Be(5);
|
||||
repository.LastUpsert!.Metadata.Should().ContainKey("source");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IngestAsync_MergesExistingDocument()
|
||||
{
|
||||
var existing = new ReachabilityFactDocument
|
||||
{
|
||||
Id = "507f1f77bcf86cd799439011",
|
||||
Subject = new ReachabilitySubject { ImageDigest = "sha256:abc" },
|
||||
SubjectKey = "sha256:abc",
|
||||
CallgraphId = "cg-old",
|
||||
RuntimeFacts = new List<RuntimeFactDocument>
|
||||
{
|
||||
new() { SymbolId = "old::symbol", HitCount = 1, Metadata = new Dictionary<string, string?> { ["thread"] = "bg" } }
|
||||
}
|
||||
};
|
||||
|
||||
repository.LastUpsert = existing;
|
||||
|
||||
var request = new RuntimeFactsIngestRequest
|
||||
{
|
||||
Subject = new ReachabilitySubject { ImageDigest = "sha256:abc" },
|
||||
CallgraphId = "cg-new",
|
||||
Events = new List<RuntimeFactEvent>
|
||||
{
|
||||
new() { SymbolId = "new::symbol", HitCount = 2, ProcessName = "svc" },
|
||||
new() { SymbolId = "old::symbol", HitCount = 3, ProcessId = 200, Metadata = new Dictionary<string, string?> { ["thread"] = "main" } }
|
||||
}
|
||||
};
|
||||
|
||||
var response = await sut.IngestAsync(request, CancellationToken.None);
|
||||
|
||||
response.FactId.Should().Be(existing.Id);
|
||||
repository.LastUpsert!.RuntimeFacts.Should().HaveCount(2);
|
||||
repository.LastUpsert!.RuntimeFacts![0].SymbolId.Should().Be("new::symbol");
|
||||
repository.LastUpsert!.RuntimeFacts![1].SymbolId.Should().Be("old::symbol");
|
||||
repository.LastUpsert!.RuntimeFacts![1].HitCount.Should().Be(4);
|
||||
repository.LastUpsert!.RuntimeFacts![1].ProcessId.Should().Be(200);
|
||||
repository.LastUpsert!.RuntimeFacts![1].Metadata.Should().ContainKey("thread").WhoseValue.Should().Be("main");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
public async Task IngestAsync_ValidatesCallgraphId(string? callgraphId)
|
||||
{
|
||||
var request = new RuntimeFactsIngestRequest
|
||||
{
|
||||
Subject = new ReachabilitySubject { ScanId = "scan" },
|
||||
CallgraphId = callgraphId ?? string.Empty,
|
||||
Events = new List<RuntimeFactEvent> { new() { SymbolId = "foo" } }
|
||||
};
|
||||
|
||||
await Assert.ThrowsAsync<RuntimeFactsValidationException>(() => sut.IngestAsync(request, CancellationToken.None));
|
||||
}
|
||||
|
||||
private sealed class FakeReachabilityFactRepository : IReachabilityFactRepository
|
||||
{
|
||||
public ReachabilityFactDocument? LastUpsert { get; set; }
|
||||
|
||||
public Task<ReachabilityFactDocument> UpsertAsync(ReachabilityFactDocument document, CancellationToken cancellationToken)
|
||||
{
|
||||
LastUpsert = document;
|
||||
return Task.FromResult(document);
|
||||
}
|
||||
|
||||
public Task<ReachabilityFactDocument?> GetBySubjectAsync(string subjectKey, CancellationToken cancellationToken) =>
|
||||
Task.FromResult(LastUpsert is { SubjectKey: not null } doc && doc.SubjectKey == subjectKey ? doc : null);
|
||||
|
||||
public Task<IReadOnlyList<ReachabilityFactDocument>> GetExpiredAsync(DateTimeOffset olderThan, int limit, CancellationToken cancellationToken) =>
|
||||
Task.FromResult<IReadOnlyList<ReachabilityFactDocument>>(Array.Empty<ReachabilityFactDocument>());
|
||||
|
||||
public Task<bool> DeleteAsync(string subjectKey, CancellationToken cancellationToken) =>
|
||||
Task.FromResult(true);
|
||||
|
||||
public Task<int> GetRuntimeFactsCountAsync(string subjectKey, CancellationToken cancellationToken) =>
|
||||
Task.FromResult(0);
|
||||
|
||||
public Task TrimRuntimeFactsAsync(string subjectKey, int maxCount, CancellationToken cancellationToken) =>
|
||||
Task.CompletedTask;
|
||||
}
|
||||
|
||||
private sealed class FakeReachabilityCache : 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 FakeEventsPublisher : IEventsPublisher
|
||||
{
|
||||
public List<ReachabilityFactDocument> Published { get; } = new();
|
||||
|
||||
public Task PublishFactUpdatedAsync(ReachabilityFactDocument fact, CancellationToken cancellationToken)
|
||||
{
|
||||
Published.Add(fact);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FakeScoringService : IReachabilityScoringService
|
||||
{
|
||||
public List<ReachabilityRecomputeRequest> Requests { get; } = new();
|
||||
|
||||
public Task<ReachabilityFactDocument> RecomputeAsync(ReachabilityRecomputeRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
Requests.Add(request);
|
||||
return Task.FromResult(new ReachabilityFactDocument
|
||||
{
|
||||
Subject = request.Subject,
|
||||
SubjectKey = request.Subject.ToSubjectKey(),
|
||||
CallgraphId = request.CallgraphId,
|
||||
ComputedAt = TimeProvider.System.GetUtcNow()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FakeProvenanceNormalizer : IRuntimeFactsProvenanceNormalizer
|
||||
{
|
||||
public ProvenanceFeed NormalizeToFeed(
|
||||
IEnumerable<RuntimeFactEvent> events,
|
||||
ReachabilitySubject subject,
|
||||
string callgraphId,
|
||||
Dictionary<string, string?>? metadata,
|
||||
DateTimeOffset generatedAt) => new()
|
||||
{
|
||||
FeedId = "fixture",
|
||||
GeneratedAt = generatedAt,
|
||||
CorrelationId = callgraphId,
|
||||
Records = new List<ProvenanceRecord>()
|
||||
};
|
||||
|
||||
public ContextFacts CreateContextFacts(
|
||||
IEnumerable<RuntimeFactEvent> events,
|
||||
ReachabilitySubject subject,
|
||||
string callgraphId,
|
||||
Dictionary<string, string?>? metadata,
|
||||
DateTimeOffset timestamp) => new()
|
||||
{
|
||||
Provenance = NormalizeToFeed(events, subject, callgraphId, metadata, timestamp),
|
||||
LastUpdatedAt = timestamp,
|
||||
RecordCount = events is ICollection<RuntimeFactEvent> collection ? collection.Count : 0
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
using System.IO;
|
||||
using System.IO.Compression;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using FluentAssertions;
|
||||
using StellaOps.Signals.Parsing;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Signals.Reachability.Tests;
|
||||
|
||||
public sealed class RuntimeFactsNdjsonReaderTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ReadAsync_ParsesLines()
|
||||
{
|
||||
var ndjson = """
|
||||
{"symbolId":"sym::foo","hitCount":2,"processId":10,"processName":"api"}
|
||||
{"symbolId":"sym::bar","codeId":"elf:abcd","loaderBase":"0x1000","metadata":{"thread":"bg"}}
|
||||
""";
|
||||
|
||||
await using var stream = new MemoryStream(Encoding.UTF8.GetBytes(ndjson));
|
||||
var events = await RuntimeFactsNdjsonReader.ReadAsync(stream, gzipEncoded: false, CancellationToken.None);
|
||||
|
||||
events.Should().HaveCount(2);
|
||||
events[0].SymbolId.Should().Be("sym::foo");
|
||||
events[0].ProcessId.Should().Be(10);
|
||||
events[0].ProcessName.Should().Be("api");
|
||||
events[1].LoaderBase.Should().Be("0x1000");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadAsync_HandlesGzip()
|
||||
{
|
||||
var ndjson = """
|
||||
{"symbolId":"sym::foo"}
|
||||
""";
|
||||
await using var compressed = new MemoryStream();
|
||||
await using (var gzip = new GZipStream(compressed, CompressionLevel.Optimal, leaveOpen: true))
|
||||
await using (var writer = new StreamWriter(gzip, Encoding.UTF8, leaveOpen: true))
|
||||
{
|
||||
await writer.WriteAsync(ndjson);
|
||||
}
|
||||
|
||||
compressed.Position = 0;
|
||||
|
||||
var events = await RuntimeFactsNdjsonReader.ReadAsync(compressed, gzipEncoded: true, CancellationToken.None);
|
||||
events.Should().HaveCount(1);
|
||||
}
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using StellaOps.Signals.Hosting;
|
||||
using StellaOps.Signals.Options;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Signals.Reachability.Tests;
|
||||
|
||||
public sealed class SignalsSealedModeMonitorTests : IDisposable
|
||||
{
|
||||
private readonly string tempDir = Path.Combine(Path.GetTempPath(), $"signals-sealed-tests-{Guid.NewGuid():N}");
|
||||
|
||||
[Fact]
|
||||
public void IsCompliant_WhenEnforcementDisabled_ReturnsTrue()
|
||||
{
|
||||
var options = new SignalsOptions();
|
||||
options.AirGap.SealedMode.EnforcementEnabled = false;
|
||||
|
||||
var monitor = CreateMonitor(options);
|
||||
|
||||
monitor.IsCompliant(out _).Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsCompliant_WhenEvidenceMissing_ReturnsFalse()
|
||||
{
|
||||
var options = CreateEnforcedOptions();
|
||||
options.AirGap.SealedMode.EvidencePath = Path.Combine(tempDir, "missing.json");
|
||||
|
||||
var monitor = CreateMonitor(options);
|
||||
|
||||
monitor.IsCompliant(out var reason).Should().BeFalse();
|
||||
reason.Should().Contain("not found");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsCompliant_WhenEvidenceFresh_ReturnsTrue()
|
||||
{
|
||||
var evidencePath = CreateEvidenceFile(TimeSpan.Zero);
|
||||
var options = CreateEnforcedOptions();
|
||||
options.AirGap.SealedMode.EvidencePath = evidencePath;
|
||||
|
||||
var monitor = CreateMonitor(options);
|
||||
|
||||
monitor.IsCompliant(out _).Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsCompliant_WhenEvidenceStale_ReturnsFalse()
|
||||
{
|
||||
var evidencePath = CreateEvidenceFile(TimeSpan.FromHours(7));
|
||||
var options = CreateEnforcedOptions();
|
||||
options.AirGap.SealedMode.EvidencePath = evidencePath;
|
||||
|
||||
var monitor = CreateMonitor(options);
|
||||
|
||||
monitor.IsCompliant(out _).Should().BeFalse();
|
||||
}
|
||||
|
||||
private SignalsOptions CreateEnforcedOptions()
|
||||
{
|
||||
var options = new SignalsOptions();
|
||||
options.AirGap.SealedMode.EnforcementEnabled = true;
|
||||
options.AirGap.SealedMode.MaxEvidenceAge = TimeSpan.FromHours(6);
|
||||
options.AirGap.SealedMode.CacheLifetime = TimeSpan.FromSeconds(1);
|
||||
return options;
|
||||
}
|
||||
|
||||
private string CreateEvidenceFile(TimeSpan age)
|
||||
{
|
||||
Directory.CreateDirectory(tempDir);
|
||||
var path = Path.Combine(tempDir, $"{Guid.NewGuid():N}.json");
|
||||
File.WriteAllText(path, "{}");
|
||||
if (age > TimeSpan.Zero)
|
||||
{
|
||||
File.SetLastWriteTimeUtc(path, DateTime.UtcNow - age);
|
||||
}
|
||||
|
||||
return path;
|
||||
}
|
||||
|
||||
private SignalsSealedModeMonitor CreateMonitor(SignalsOptions options)
|
||||
{
|
||||
return new SignalsSealedModeMonitor(
|
||||
options,
|
||||
new FakeTimeProvider(DateTimeOffset.UtcNow),
|
||||
NullLogger<SignalsSealedModeMonitor>.Instance);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(tempDir))
|
||||
{
|
||||
Directory.Delete(tempDir, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.10.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.TimeProvider.Testing" Version="8.6.0" />
|
||||
<PackageReference Include="xunit" Version="2.7.0" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.8">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="FluentAssertions" Version="6.12.0" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../../src/Signals/StellaOps.Signals/StellaOps.Signals.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Content Include="..\..\fixtures\**\*">
|
||||
<Link>fixtures\%(RecursiveDir)%(Filename)%(Extension)</Link>
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</Content>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -1,22 +0,0 @@
|
||||
# Reachability Corpus (QA-CORPUS-401-031)
|
||||
|
||||
Layout
|
||||
- `manifest.json` — deterministic SHA-256 hashes for each case file.
|
||||
- `<language>/<case>/ground-truth.json` — expected reachability outcome (`reachable|unreachable`) and example path(s) (Reachbench truth schema v1).
|
||||
- `<language>/<case>/callgraph.static.json` — static call graph sample (stub for MVP).
|
||||
- `<language>/<case>/vex.openvex.json` — expected VEX slice for the case.
|
||||
- Legacy `expect.yaml` has been retired; its state/score are preserved under `legacy_expect` in `ground-truth.json`.
|
||||
|
||||
Determinism
|
||||
- JSON files have sorted keys; hashes recorded in `manifest.json`.
|
||||
- Scores rounded to 2dp; timestamps (if added later) must be UTC ISO-8601.
|
||||
- No network access required to consume the corpus.
|
||||
|
||||
MVP cases (stubs, to be replaced with real artifacts)
|
||||
- Go: `go-ssh-CVE-2020-9283-keyexchange`
|
||||
- .NET: `dotnet-kestrel-CVE-2023-44487-http2-rapid-reset`
|
||||
- Python: `python-django-CVE-2019-19844-sqli-like`
|
||||
- Rust: `rust-axum-header-parsing-TBD`
|
||||
|
||||
CI intent
|
||||
- `CorpusFixtureTests` validates presence and hashes from the manifest; hook this into CI once repo build stabilises.
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"schema_version": "reach-corpus.callgraph/v1",
|
||||
"nodes": [],
|
||||
"edges": []
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
{
|
||||
"case_id": "dotnet-kestrel-CVE-2023-44487-http2-rapid-reset",
|
||||
"legacy_expect": {
|
||||
"schema_version": "reach-corpus.expect/v1",
|
||||
"score": 0.85,
|
||||
"state": "reachable"
|
||||
},
|
||||
"paths": [
|
||||
[
|
||||
"sym://dotnet:entry",
|
||||
"sym://dotnet:sink"
|
||||
]
|
||||
],
|
||||
"schema_version": "reachbench.reachgraph.truth/v1",
|
||||
"variant": "reachable"
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"author": "StellaOps",
|
||||
"role": "reachability-corpus",
|
||||
"timestamp": "2025-11-18T00:00:00Z",
|
||||
"statements": [
|
||||
{
|
||||
"vulnerability": "TBD",
|
||||
"products": ["pkg:demo/demo"],
|
||||
"status": "affected"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"schema_version": "reach-corpus.callgraph/v1",
|
||||
"nodes": [],
|
||||
"edges": []
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
{
|
||||
"case_id": "go-ssh-CVE-2020-9283-keyexchange",
|
||||
"legacy_expect": {
|
||||
"schema_version": "reach-corpus.expect/v1",
|
||||
"score": 0.8,
|
||||
"state": "reachable"
|
||||
},
|
||||
"paths": [
|
||||
[
|
||||
"sym://go:entry",
|
||||
"sym://go:sink"
|
||||
]
|
||||
],
|
||||
"schema_version": "reachbench.reachgraph.truth/v1",
|
||||
"variant": "reachable"
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"author": "StellaOps",
|
||||
"role": "reachability-corpus",
|
||||
"timestamp": "2025-11-18T00:00:00Z",
|
||||
"statements": [
|
||||
{
|
||||
"vulnerability": "TBD",
|
||||
"products": ["pkg:demo/demo"],
|
||||
"status": "affected"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
[
|
||||
{
|
||||
"files": {
|
||||
"callgraph.static.json": "7359d8c26f16151a4b05cf0e6675e5c66b5ffb6396b906e74c0d5bb2f290e972",
|
||||
"ground-truth.json": "5e9fe73eabe607c9912c64d7b3d31b456a2b74631b935ce81f769d4520303c59",
|
||||
"vex.openvex.json": "c3593790f769974b1b66aa5331f1d3ad4d699f77f198b2e77e78659ee79d3c15"
|
||||
},
|
||||
"id": "dotnet-kestrel-CVE-2023-44487-http2-rapid-reset",
|
||||
"language": "dotnet"
|
||||
},
|
||||
{
|
||||
"files": {
|
||||
"callgraph.static.json": "7359d8c26f16151a4b05cf0e6675e5c66b5ffb6396b906e74c0d5bb2f290e972",
|
||||
"ground-truth.json": "430adb2d001b526cff666336689006bad00e27c9f82582795a2d9dd106e1797d",
|
||||
"vex.openvex.json": "c3593790f769974b1b66aa5331f1d3ad4d699f77f198b2e77e78659ee79d3c15"
|
||||
},
|
||||
"id": "go-ssh-CVE-2020-9283-keyexchange",
|
||||
"language": "go"
|
||||
},
|
||||
{
|
||||
"files": {
|
||||
"callgraph.static.json": "7359d8c26f16151a4b05cf0e6675e5c66b5ffb6396b906e74c0d5bb2f290e972",
|
||||
"ground-truth.json": "50538def2e0a8b28134051b52a848eb4b53d43cf7a6eb6d041e8fc9f1d9210f1",
|
||||
"vex.openvex.json": "c3593790f769974b1b66aa5331f1d3ad4d699f77f198b2e77e78659ee79d3c15"
|
||||
},
|
||||
"id": "python-django-CVE-2019-19844-sqli-like",
|
||||
"language": "python"
|
||||
},
|
||||
{
|
||||
"files": {
|
||||
"callgraph.static.json": "7359d8c26f16151a4b05cf0e6675e5c66b5ffb6396b906e74c0d5bb2f290e972",
|
||||
"ground-truth.json": "36312fc03b7f46c8655c21448c9fb7acd6495344896b79010fbd9644a182a865",
|
||||
"vex.openvex.json": "c3593790f769974b1b66aa5331f1d3ad4d699f77f198b2e77e78659ee79d3c15"
|
||||
},
|
||||
"id": "rust-axum-header-parsing-TBD",
|
||||
"language": "rust"
|
||||
}
|
||||
]
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"schema_version": "reach-corpus.callgraph/v1",
|
||||
"nodes": [],
|
||||
"edges": []
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
{
|
||||
"case_id": "python-django-CVE-2019-19844-sqli-like",
|
||||
"legacy_expect": {
|
||||
"schema_version": "reach-corpus.expect/v1",
|
||||
"score": 0.8,
|
||||
"state": "reachable"
|
||||
},
|
||||
"paths": [
|
||||
[
|
||||
"sym://python:entry",
|
||||
"sym://python:sink"
|
||||
]
|
||||
],
|
||||
"schema_version": "reachbench.reachgraph.truth/v1",
|
||||
"variant": "reachable"
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"author": "StellaOps",
|
||||
"role": "reachability-corpus",
|
||||
"timestamp": "2025-11-18T00:00:00Z",
|
||||
"statements": [
|
||||
{
|
||||
"vulnerability": "TBD",
|
||||
"products": ["pkg:demo/demo"],
|
||||
"status": "affected"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"schema_version": "reach-corpus.callgraph/v1",
|
||||
"nodes": [],
|
||||
"edges": []
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"case_id": "rust-axum-header-parsing-TBD",
|
||||
"legacy_expect": {
|
||||
"schema_version": "reach-corpus.expect/v1",
|
||||
"score": 0.6,
|
||||
"state": "conditional"
|
||||
},
|
||||
"paths": [],
|
||||
"schema_version": "reachbench.reachgraph.truth/v1",
|
||||
"variant": "unreachable"
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"author": "StellaOps",
|
||||
"role": "reachability-corpus",
|
||||
"timestamp": "2025-11-18T00:00:00Z",
|
||||
"statements": [
|
||||
{
|
||||
"vulnerability": "TBD",
|
||||
"products": ["pkg:demo/demo"],
|
||||
"status": "affected"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,171 +0,0 @@
|
||||
{
|
||||
"schema": "stella.callgraph.v1",
|
||||
"scanKey": "scan:edge-reasons-test:1.0.0",
|
||||
"language": "DotNet",
|
||||
"artifacts": [
|
||||
{
|
||||
"artifactKey": "TestAssembly.dll",
|
||||
"kind": "assembly",
|
||||
"sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||
}
|
||||
],
|
||||
"nodes": [
|
||||
{ "id": "async", "name": "AsyncTarget", "kind": "method", "visibility": "Public" },
|
||||
{ "id": "caller", "name": "Caller", "kind": "method", "visibility": "Public" },
|
||||
{ "id": "delegate", "name": "DelegateTarget", "kind": "method", "visibility": "Public" },
|
||||
{ "id": "di", "name": "DiTarget", "kind": "method", "visibility": "Public" },
|
||||
{ "id": "direct", "name": "DirectTarget", "kind": "method", "visibility": "Public" },
|
||||
{ "id": "dynamic", "name": "DynamicTarget", "kind": "method", "visibility": "Public" },
|
||||
{ "id": "event", "name": "EventTarget", "kind": "method", "visibility": "Public" },
|
||||
{ "id": "generic", "name": "GenericTarget", "kind": "method", "visibility": "Public" },
|
||||
{ "id": "native", "name": "NativeTarget", "kind": "method", "visibility": "Public" },
|
||||
{ "id": "newobj", "name": "NewObjTarget", "kind": "method", "visibility": "Public" },
|
||||
{ "id": "reflection", "name": "ReflectionTarget", "kind": "method", "visibility": "Public" },
|
||||
{ "id": "runtime", "name": "RuntimeTarget", "kind": "method", "visibility": "Public" },
|
||||
{ "id": "unknown", "name": "UnknownTarget", "kind": "method", "visibility": "Public" },
|
||||
{ "id": "virtual", "name": "VirtualTarget", "kind": "method", "visibility": "Public" }
|
||||
],
|
||||
"edges": [
|
||||
{
|
||||
"sourceId": "caller",
|
||||
"targetId": "direct",
|
||||
"type": "call",
|
||||
"kind": "Static",
|
||||
"reason": "DirectCall",
|
||||
"weight": 1.0,
|
||||
"isResolved": true
|
||||
},
|
||||
{
|
||||
"sourceId": "caller",
|
||||
"targetId": "virtual",
|
||||
"type": "callvirt",
|
||||
"kind": "Static",
|
||||
"reason": "VirtualCall",
|
||||
"weight": 1.0,
|
||||
"isResolved": true,
|
||||
"candidates": ["impl1", "impl2"]
|
||||
},
|
||||
{
|
||||
"sourceId": "caller",
|
||||
"targetId": "reflection",
|
||||
"type": "reflection",
|
||||
"kind": "Heuristic",
|
||||
"reason": "ReflectionString",
|
||||
"weight": 0.5,
|
||||
"isResolved": false,
|
||||
"provenance": "Type.GetMethod"
|
||||
},
|
||||
{
|
||||
"sourceId": "caller",
|
||||
"targetId": "di",
|
||||
"type": "di-binding",
|
||||
"kind": "Heuristic",
|
||||
"reason": "DiBinding",
|
||||
"weight": 0.9,
|
||||
"isResolved": true,
|
||||
"provenance": "Microsoft.Extensions.DependencyInjection"
|
||||
},
|
||||
{
|
||||
"sourceId": "caller",
|
||||
"targetId": "dynamic",
|
||||
"type": "dynamic-import",
|
||||
"kind": "Heuristic",
|
||||
"reason": "DynamicImport",
|
||||
"weight": 0.7,
|
||||
"isResolved": false
|
||||
},
|
||||
{
|
||||
"sourceId": "caller",
|
||||
"targetId": "newobj",
|
||||
"type": "newobj",
|
||||
"kind": "Static",
|
||||
"reason": "NewObj",
|
||||
"weight": 1.0,
|
||||
"isResolved": true
|
||||
},
|
||||
{
|
||||
"sourceId": "caller",
|
||||
"targetId": "delegate",
|
||||
"type": "ldftn",
|
||||
"kind": "Static",
|
||||
"reason": "DelegateCreate",
|
||||
"weight": 1.0,
|
||||
"isResolved": true
|
||||
},
|
||||
{
|
||||
"sourceId": "caller",
|
||||
"targetId": "async",
|
||||
"type": "async",
|
||||
"kind": "Static",
|
||||
"reason": "AsyncContinuation",
|
||||
"weight": 1.0,
|
||||
"isResolved": true
|
||||
},
|
||||
{
|
||||
"sourceId": "caller",
|
||||
"targetId": "event",
|
||||
"type": "event",
|
||||
"kind": "Heuristic",
|
||||
"reason": "EventHandler",
|
||||
"weight": 0.85,
|
||||
"isResolved": true
|
||||
},
|
||||
{
|
||||
"sourceId": "caller",
|
||||
"targetId": "generic",
|
||||
"type": "generic",
|
||||
"kind": "Static",
|
||||
"reason": "GenericInstantiation",
|
||||
"weight": 1.0,
|
||||
"isResolved": true
|
||||
},
|
||||
{
|
||||
"sourceId": "caller",
|
||||
"targetId": "native",
|
||||
"type": "pinvoke",
|
||||
"kind": "Static",
|
||||
"reason": "NativeInterop",
|
||||
"weight": 1.0,
|
||||
"isResolved": false,
|
||||
"provenance": "kernel32.dll"
|
||||
},
|
||||
{
|
||||
"sourceId": "caller",
|
||||
"targetId": "runtime",
|
||||
"type": "runtime",
|
||||
"kind": "Runtime",
|
||||
"reason": "RuntimeMinted",
|
||||
"weight": 1.0,
|
||||
"isResolved": true
|
||||
},
|
||||
{
|
||||
"sourceId": "caller",
|
||||
"targetId": "unknown",
|
||||
"type": "unknown",
|
||||
"kind": "Heuristic",
|
||||
"reason": "Unknown",
|
||||
"weight": 0.3,
|
||||
"isResolved": false
|
||||
}
|
||||
],
|
||||
"entrypoints": [
|
||||
{
|
||||
"nodeId": "caller",
|
||||
"kind": "Test",
|
||||
"framework": "Unknown",
|
||||
"source": "test-runner",
|
||||
"phase": "Runtime",
|
||||
"order": 0
|
||||
}
|
||||
],
|
||||
"metadata": {
|
||||
"toolId": "stellaops.scanner.test",
|
||||
"toolVersion": "1.0.0",
|
||||
"analysisTimestamp": "2025-01-15T14:00:00Z"
|
||||
},
|
||||
"id": "cg-edge-reasons-001",
|
||||
"component": "EdgeReasonsTest",
|
||||
"version": "1.0.0",
|
||||
"ingestedAt": "2025-01-15T14:00:00Z",
|
||||
"graphHash": "sha256:edge-reasons"
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
{
|
||||
"schema": "stella.callgraph.v1",
|
||||
"scanKey": "scan:visibility-test:1.0.0",
|
||||
"language": "DotNet",
|
||||
"artifacts": [
|
||||
{
|
||||
"artifactKey": "VisibilityTest.dll",
|
||||
"kind": "assembly",
|
||||
"sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
|
||||
}
|
||||
],
|
||||
"nodes": [
|
||||
{
|
||||
"id": "v001",
|
||||
"name": "PublicMethod",
|
||||
"kind": "method",
|
||||
"namespace": "VisibilityTest",
|
||||
"symbolKey": "VisibilityTest::PublicMethod()",
|
||||
"visibility": "Public",
|
||||
"isEntrypointCandidate": true
|
||||
},
|
||||
{
|
||||
"id": "v002",
|
||||
"name": "InternalMethod",
|
||||
"kind": "method",
|
||||
"namespace": "VisibilityTest.Internal",
|
||||
"symbolKey": "VisibilityTest.Internal::InternalMethod()",
|
||||
"visibility": "Internal",
|
||||
"isEntrypointCandidate": false
|
||||
},
|
||||
{
|
||||
"id": "v003",
|
||||
"name": "ProtectedMethod",
|
||||
"kind": "method",
|
||||
"namespace": "VisibilityTest",
|
||||
"symbolKey": "VisibilityTest.BaseClass::ProtectedMethod()",
|
||||
"visibility": "Protected",
|
||||
"isEntrypointCandidate": false
|
||||
},
|
||||
{
|
||||
"id": "v004",
|
||||
"name": "PrivateMethod",
|
||||
"kind": "method",
|
||||
"namespace": "VisibilityTest",
|
||||
"symbolKey": "VisibilityTest.SomeClass::PrivateMethod()",
|
||||
"visibility": "Private",
|
||||
"isEntrypointCandidate": false
|
||||
},
|
||||
{
|
||||
"id": "v005",
|
||||
"name": "UnknownMethod",
|
||||
"kind": "method",
|
||||
"namespace": "External",
|
||||
"symbolKey": "External::UnknownMethod()",
|
||||
"visibility": "Unknown",
|
||||
"isEntrypointCandidate": false
|
||||
}
|
||||
],
|
||||
"edges": [
|
||||
{
|
||||
"sourceId": "v001",
|
||||
"targetId": "v002",
|
||||
"type": "call",
|
||||
"kind": "Static",
|
||||
"reason": "DirectCall",
|
||||
"weight": 1.0,
|
||||
"isResolved": true
|
||||
},
|
||||
{
|
||||
"sourceId": "v002",
|
||||
"targetId": "v003",
|
||||
"type": "call",
|
||||
"kind": "Static",
|
||||
"reason": "DirectCall",
|
||||
"weight": 1.0,
|
||||
"isResolved": true
|
||||
},
|
||||
{
|
||||
"sourceId": "v003",
|
||||
"targetId": "v004",
|
||||
"type": "call",
|
||||
"kind": "Static",
|
||||
"reason": "DirectCall",
|
||||
"weight": 1.0,
|
||||
"isResolved": true
|
||||
},
|
||||
{
|
||||
"sourceId": "v004",
|
||||
"targetId": "v005",
|
||||
"type": "external",
|
||||
"kind": "Static",
|
||||
"reason": "DirectCall",
|
||||
"weight": 1.0,
|
||||
"isResolved": false
|
||||
}
|
||||
],
|
||||
"entrypoints": [
|
||||
{
|
||||
"nodeId": "v001",
|
||||
"kind": "Http",
|
||||
"route": "/api/visibility",
|
||||
"httpMethod": "GET",
|
||||
"framework": "AspNetCore",
|
||||
"source": "attribute",
|
||||
"phase": "Runtime",
|
||||
"order": 0
|
||||
}
|
||||
],
|
||||
"metadata": {
|
||||
"toolId": "stellaops.scanner.test",
|
||||
"toolVersion": "1.0.0",
|
||||
"analysisTimestamp": "2025-01-15T15:00:00Z"
|
||||
},
|
||||
"id": "cg-visibility-001",
|
||||
"component": "VisibilityTest",
|
||||
"version": "1.0.0",
|
||||
"ingestedAt": "2025-01-15T15:00:00Z",
|
||||
"graphHash": "sha256:visibility"
|
||||
}
|
||||
@@ -1,155 +0,0 @@
|
||||
{
|
||||
"schema": "stella.callgraph.v1",
|
||||
"scanKey": "scan:dotnet-aspnetcore-minimal:v1.0.0",
|
||||
"language": "DotNet",
|
||||
"artifacts": [
|
||||
{
|
||||
"artifactKey": "SampleApi.dll",
|
||||
"kind": "assembly",
|
||||
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
||||
"purl": "pkg:nuget/SampleApi@1.0.0",
|
||||
"buildId": "build-001",
|
||||
"filePath": "/app/SampleApi.dll",
|
||||
"sizeBytes": 12345
|
||||
}
|
||||
],
|
||||
"nodes": [
|
||||
{
|
||||
"id": "n001",
|
||||
"nodeId": "n001",
|
||||
"name": "Main",
|
||||
"kind": "method",
|
||||
"namespace": "SampleApi",
|
||||
"file": "Program.cs",
|
||||
"line": 1,
|
||||
"symbolKey": "SampleApi::Main(string[])",
|
||||
"artifactKey": "SampleApi.dll",
|
||||
"visibility": "Public",
|
||||
"isEntrypointCandidate": true,
|
||||
"attributes": {
|
||||
"returnType": "void"
|
||||
},
|
||||
"flags": 1
|
||||
},
|
||||
{
|
||||
"id": "n002",
|
||||
"nodeId": "n002",
|
||||
"name": "GetWeatherForecast",
|
||||
"kind": "method",
|
||||
"namespace": "SampleApi.Controllers",
|
||||
"file": "WeatherForecastController.cs",
|
||||
"line": 15,
|
||||
"symbolKey": "SampleApi.Controllers.WeatherForecastController::GetWeatherForecast()",
|
||||
"artifactKey": "SampleApi.dll",
|
||||
"visibility": "Public",
|
||||
"isEntrypointCandidate": true,
|
||||
"attributes": {
|
||||
"returnType": "IEnumerable<WeatherForecast>",
|
||||
"httpMethod": "GET",
|
||||
"route": "/weatherforecast"
|
||||
},
|
||||
"flags": 3
|
||||
},
|
||||
{
|
||||
"id": "n003",
|
||||
"nodeId": "n003",
|
||||
"name": "GetRandomSummary",
|
||||
"kind": "method",
|
||||
"namespace": "SampleApi.Services",
|
||||
"file": "WeatherService.cs",
|
||||
"line": 20,
|
||||
"symbolKey": "SampleApi.Services.WeatherService::GetRandomSummary()",
|
||||
"artifactKey": "SampleApi.dll",
|
||||
"visibility": "Internal",
|
||||
"isEntrypointCandidate": false,
|
||||
"attributes": {
|
||||
"returnType": "string"
|
||||
},
|
||||
"flags": 0
|
||||
},
|
||||
{
|
||||
"id": "n004",
|
||||
"nodeId": "n004",
|
||||
"name": "CreateLogger",
|
||||
"kind": "method",
|
||||
"namespace": "SampleApi.Internal",
|
||||
"file": "LoggingHelper.cs",
|
||||
"line": 8,
|
||||
"symbolKey": "SampleApi.Internal.LoggingHelper::CreateLogger()",
|
||||
"artifactKey": "SampleApi.dll",
|
||||
"visibility": "Private",
|
||||
"isEntrypointCandidate": false,
|
||||
"flags": 0
|
||||
}
|
||||
],
|
||||
"edges": [
|
||||
{
|
||||
"sourceId": "n001",
|
||||
"targetId": "n002",
|
||||
"from": "n001",
|
||||
"to": "n002",
|
||||
"type": "call",
|
||||
"kind": "Static",
|
||||
"reason": "DirectCall",
|
||||
"weight": 1.0,
|
||||
"isResolved": true
|
||||
},
|
||||
{
|
||||
"sourceId": "n002",
|
||||
"targetId": "n003",
|
||||
"from": "n002",
|
||||
"to": "n003",
|
||||
"type": "di",
|
||||
"kind": "Heuristic",
|
||||
"reason": "DiBinding",
|
||||
"weight": 0.9,
|
||||
"isResolved": true,
|
||||
"provenance": "Microsoft.Extensions.DependencyInjection"
|
||||
},
|
||||
{
|
||||
"sourceId": "n003",
|
||||
"targetId": "n004",
|
||||
"from": "n003",
|
||||
"to": "n004",
|
||||
"type": "call",
|
||||
"kind": "Static",
|
||||
"reason": "DirectCall",
|
||||
"weight": 1.0,
|
||||
"offset": 42,
|
||||
"isResolved": true
|
||||
}
|
||||
],
|
||||
"entrypoints": [
|
||||
{
|
||||
"nodeId": "n001",
|
||||
"kind": "Main",
|
||||
"framework": "AspNetCore",
|
||||
"source": "attribute",
|
||||
"phase": "AppStart",
|
||||
"order": 0
|
||||
},
|
||||
{
|
||||
"nodeId": "n002",
|
||||
"kind": "Http",
|
||||
"route": "/weatherforecast",
|
||||
"httpMethod": "GET",
|
||||
"framework": "AspNetCore",
|
||||
"source": "attribute",
|
||||
"phase": "Runtime",
|
||||
"order": 1
|
||||
}
|
||||
],
|
||||
"metadata": {
|
||||
"toolId": "stellaops.scanner.dotnet",
|
||||
"toolVersion": "1.0.0",
|
||||
"analysisTimestamp": "2025-01-15T10:00:00Z",
|
||||
"sourceCommit": "abc123def456",
|
||||
"buildId": "build-001"
|
||||
},
|
||||
"id": "cg-dotnet-aspnetcore-minimal-001",
|
||||
"languageString": "dotnet",
|
||||
"component": "SampleApi",
|
||||
"version": "1.0.0",
|
||||
"ingestedAt": "2025-01-15T10:00:00Z",
|
||||
"graphHash": "sha256:a1b2c3d4e5f6"
|
||||
}
|
||||
@@ -1,155 +0,0 @@
|
||||
{
|
||||
"schema": "stella.callgraph.v1",
|
||||
"scanKey": "scan:gin-api:1.5.0",
|
||||
"language": "Go",
|
||||
"artifacts": [
|
||||
{
|
||||
"artifactKey": "gin-api",
|
||||
"kind": "go-binary",
|
||||
"sha256": "d5e6f78901234567890abcdef0123456789abcdef0123456789abcdef0123456",
|
||||
"purl": "pkg:golang/github.com/example/gin-api@1.5.0",
|
||||
"filePath": "/app/gin-api",
|
||||
"sizeBytes": 15000000
|
||||
}
|
||||
],
|
||||
"nodes": [
|
||||
{
|
||||
"id": "g001",
|
||||
"nodeId": "g001",
|
||||
"name": "main",
|
||||
"kind": "function",
|
||||
"namespace": "main",
|
||||
"file": "main.go",
|
||||
"line": 12,
|
||||
"symbolKey": "main.main",
|
||||
"artifactKey": "gin-api",
|
||||
"visibility": "Public",
|
||||
"isEntrypointCandidate": true,
|
||||
"flags": 1
|
||||
},
|
||||
{
|
||||
"id": "g002",
|
||||
"nodeId": "g002",
|
||||
"name": "GetProduct",
|
||||
"kind": "function",
|
||||
"namespace": "handlers",
|
||||
"file": "product_handler.go",
|
||||
"line": 28,
|
||||
"symbolKey": "github.com/example/gin-api/handlers.GetProduct",
|
||||
"artifactKey": "gin-api",
|
||||
"visibility": "Public",
|
||||
"isEntrypointCandidate": true,
|
||||
"attributes": {
|
||||
"httpMethod": "GET",
|
||||
"route": "/api/products/:id"
|
||||
},
|
||||
"flags": 3
|
||||
},
|
||||
{
|
||||
"id": "g003",
|
||||
"nodeId": "g003",
|
||||
"name": "FindByID",
|
||||
"kind": "function",
|
||||
"namespace": "repository",
|
||||
"file": "product_repo.go",
|
||||
"line": 45,
|
||||
"symbolKey": "github.com/example/gin-api/repository.(*ProductRepo).FindByID",
|
||||
"artifactKey": "gin-api",
|
||||
"visibility": "Public",
|
||||
"isEntrypointCandidate": false,
|
||||
"flags": 0
|
||||
},
|
||||
{
|
||||
"id": "g004",
|
||||
"nodeId": "g004",
|
||||
"name": "init",
|
||||
"kind": "function",
|
||||
"namespace": "config",
|
||||
"file": "config.go",
|
||||
"line": 8,
|
||||
"symbolKey": "github.com/example/gin-api/config.init",
|
||||
"artifactKey": "gin-api",
|
||||
"visibility": "Unknown",
|
||||
"isEntrypointCandidate": true,
|
||||
"flags": 2
|
||||
}
|
||||
],
|
||||
"edges": [
|
||||
{
|
||||
"sourceId": "g004",
|
||||
"targetId": "g001",
|
||||
"from": "g004",
|
||||
"to": "g001",
|
||||
"type": "init",
|
||||
"kind": "Static",
|
||||
"reason": "DirectCall",
|
||||
"weight": 1.0,
|
||||
"isResolved": true,
|
||||
"provenance": "go-init-order"
|
||||
},
|
||||
{
|
||||
"sourceId": "g001",
|
||||
"targetId": "g002",
|
||||
"from": "g001",
|
||||
"to": "g002",
|
||||
"type": "router-bind",
|
||||
"kind": "Heuristic",
|
||||
"reason": "DelegateCreate",
|
||||
"weight": 0.9,
|
||||
"isResolved": true,
|
||||
"provenance": "gin-router"
|
||||
},
|
||||
{
|
||||
"sourceId": "g002",
|
||||
"targetId": "g003",
|
||||
"from": "g002",
|
||||
"to": "g003",
|
||||
"type": "interface",
|
||||
"kind": "Static",
|
||||
"reason": "VirtualCall",
|
||||
"weight": 1.0,
|
||||
"isResolved": true
|
||||
}
|
||||
],
|
||||
"entrypoints": [
|
||||
{
|
||||
"nodeId": "g004",
|
||||
"kind": "ModuleInit",
|
||||
"framework": "Unknown",
|
||||
"source": "convention",
|
||||
"phase": "ModuleInit",
|
||||
"order": 0
|
||||
},
|
||||
{
|
||||
"nodeId": "g001",
|
||||
"kind": "Main",
|
||||
"framework": "Gin",
|
||||
"source": "convention",
|
||||
"phase": "AppStart",
|
||||
"order": 1
|
||||
},
|
||||
{
|
||||
"nodeId": "g002",
|
||||
"kind": "Http",
|
||||
"route": "/api/products/:id",
|
||||
"httpMethod": "GET",
|
||||
"framework": "Gin",
|
||||
"source": "code-analysis",
|
||||
"phase": "Runtime",
|
||||
"order": 2
|
||||
}
|
||||
],
|
||||
"metadata": {
|
||||
"toolId": "stellaops.scanner.go",
|
||||
"toolVersion": "1.0.0",
|
||||
"analysisTimestamp": "2025-01-15T13:00:00Z",
|
||||
"sourceCommit": "012def345abc",
|
||||
"buildId": "build-004"
|
||||
},
|
||||
"id": "cg-go-gin-api-001",
|
||||
"languageString": "go",
|
||||
"component": "gin-api",
|
||||
"version": "1.5.0",
|
||||
"ingestedAt": "2025-01-15T13:00:00Z",
|
||||
"graphHash": "sha256:d4e5f6a7b8c9"
|
||||
}
|
||||
@@ -1,155 +0,0 @@
|
||||
{
|
||||
"schema": "stella.callgraph.v1",
|
||||
"scanKey": "scan:spring-petclinic:3.2.0",
|
||||
"language": "Java",
|
||||
"artifacts": [
|
||||
{
|
||||
"artifactKey": "spring-petclinic-3.2.0.jar",
|
||||
"kind": "jar",
|
||||
"sha256": "f4d3c2b1a0987654321fedcba0987654321fedcba0987654321fedcba098765",
|
||||
"purl": "pkg:maven/org.springframework.samples/spring-petclinic@3.2.0",
|
||||
"filePath": "/app/spring-petclinic-3.2.0.jar",
|
||||
"sizeBytes": 54321000
|
||||
}
|
||||
],
|
||||
"nodes": [
|
||||
{
|
||||
"id": "j001",
|
||||
"nodeId": "j001",
|
||||
"name": "main",
|
||||
"kind": "method",
|
||||
"namespace": "org.springframework.samples.petclinic",
|
||||
"file": "PetClinicApplication.java",
|
||||
"line": 25,
|
||||
"symbolKey": "org.springframework.samples.petclinic.PetClinicApplication::main(String[])",
|
||||
"artifactKey": "spring-petclinic-3.2.0.jar",
|
||||
"visibility": "Public",
|
||||
"isEntrypointCandidate": true,
|
||||
"attributes": {
|
||||
"returnType": "void",
|
||||
"modifiers": "public static"
|
||||
},
|
||||
"flags": 1
|
||||
},
|
||||
{
|
||||
"id": "j002",
|
||||
"nodeId": "j002",
|
||||
"name": "showOwner",
|
||||
"kind": "method",
|
||||
"namespace": "org.springframework.samples.petclinic.owner",
|
||||
"file": "OwnerController.java",
|
||||
"line": 87,
|
||||
"symbolKey": "org.springframework.samples.petclinic.owner.OwnerController::showOwner(int)",
|
||||
"artifactKey": "spring-petclinic-3.2.0.jar",
|
||||
"visibility": "Public",
|
||||
"isEntrypointCandidate": true,
|
||||
"attributes": {
|
||||
"returnType": "ModelAndView",
|
||||
"httpMethod": "GET",
|
||||
"route": "/owners/{ownerId}"
|
||||
},
|
||||
"flags": 3
|
||||
},
|
||||
{
|
||||
"id": "j003",
|
||||
"nodeId": "j003",
|
||||
"name": "findById",
|
||||
"kind": "method",
|
||||
"namespace": "org.springframework.samples.petclinic.owner",
|
||||
"file": "OwnerRepository.java",
|
||||
"line": 42,
|
||||
"symbolKey": "org.springframework.samples.petclinic.owner.OwnerRepository::findById(Integer)",
|
||||
"artifactKey": "spring-petclinic-3.2.0.jar",
|
||||
"visibility": "Public",
|
||||
"isEntrypointCandidate": false,
|
||||
"attributes": {
|
||||
"returnType": "Owner"
|
||||
},
|
||||
"flags": 0
|
||||
},
|
||||
{
|
||||
"id": "j004",
|
||||
"nodeId": "j004",
|
||||
"name": "validateOwner",
|
||||
"kind": "method",
|
||||
"namespace": "org.springframework.samples.petclinic.owner",
|
||||
"file": "OwnerValidator.java",
|
||||
"line": 30,
|
||||
"symbolKey": "org.springframework.samples.petclinic.owner.OwnerValidator::validateOwner(Owner)",
|
||||
"artifactKey": "spring-petclinic-3.2.0.jar",
|
||||
"visibility": "Protected",
|
||||
"isEntrypointCandidate": false,
|
||||
"flags": 0
|
||||
}
|
||||
],
|
||||
"edges": [
|
||||
{
|
||||
"sourceId": "j001",
|
||||
"targetId": "j002",
|
||||
"from": "j001",
|
||||
"to": "j002",
|
||||
"type": "spring-bean",
|
||||
"kind": "Heuristic",
|
||||
"reason": "DiBinding",
|
||||
"weight": 0.85,
|
||||
"isResolved": true,
|
||||
"provenance": "SpringBoot"
|
||||
},
|
||||
{
|
||||
"sourceId": "j002",
|
||||
"targetId": "j003",
|
||||
"from": "j002",
|
||||
"to": "j003",
|
||||
"type": "virtual",
|
||||
"kind": "Static",
|
||||
"reason": "VirtualCall",
|
||||
"weight": 1.0,
|
||||
"isResolved": true
|
||||
},
|
||||
{
|
||||
"sourceId": "j002",
|
||||
"targetId": "j004",
|
||||
"from": "j002",
|
||||
"to": "j004",
|
||||
"type": "call",
|
||||
"kind": "Static",
|
||||
"reason": "DirectCall",
|
||||
"weight": 1.0,
|
||||
"offset": 156,
|
||||
"isResolved": true
|
||||
}
|
||||
],
|
||||
"entrypoints": [
|
||||
{
|
||||
"nodeId": "j001",
|
||||
"kind": "Main",
|
||||
"framework": "SpringBoot",
|
||||
"source": "annotation",
|
||||
"phase": "AppStart",
|
||||
"order": 0
|
||||
},
|
||||
{
|
||||
"nodeId": "j002",
|
||||
"kind": "Http",
|
||||
"route": "/owners/{ownerId}",
|
||||
"httpMethod": "GET",
|
||||
"framework": "Spring",
|
||||
"source": "annotation",
|
||||
"phase": "Runtime",
|
||||
"order": 1
|
||||
}
|
||||
],
|
||||
"metadata": {
|
||||
"toolId": "stellaops.scanner.java",
|
||||
"toolVersion": "1.0.0",
|
||||
"analysisTimestamp": "2025-01-15T11:00:00Z",
|
||||
"sourceCommit": "def789abc012",
|
||||
"buildId": "build-002"
|
||||
},
|
||||
"id": "cg-java-spring-petclinic-001",
|
||||
"languageString": "java",
|
||||
"component": "spring-petclinic",
|
||||
"version": "3.2.0",
|
||||
"ingestedAt": "2025-01-15T11:00:00Z",
|
||||
"graphHash": "sha256:b2c3d4e5f6a7"
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
{
|
||||
"id": "cg-legacy-001",
|
||||
"languageString": "csharp",
|
||||
"component": "LegacyApp",
|
||||
"version": "0.9.0",
|
||||
"ingestedAt": "2024-06-15T08:00:00Z",
|
||||
"graphHash": "sha256:legacy123",
|
||||
"nodes": [
|
||||
{
|
||||
"id": "l001",
|
||||
"name": "Main",
|
||||
"kind": "method",
|
||||
"namespace": "LegacyApp"
|
||||
},
|
||||
{
|
||||
"id": "l002",
|
||||
"name": "ProcessData",
|
||||
"kind": "method",
|
||||
"namespace": "LegacyApp.Controllers"
|
||||
},
|
||||
{
|
||||
"id": "l003",
|
||||
"name": "ValidateInput",
|
||||
"kind": "method",
|
||||
"namespace": "LegacyApp.Internal"
|
||||
}
|
||||
],
|
||||
"edges": [
|
||||
{
|
||||
"sourceId": "l001",
|
||||
"targetId": "l002",
|
||||
"type": "call"
|
||||
},
|
||||
{
|
||||
"sourceId": "l002",
|
||||
"targetId": "l003",
|
||||
"type": "call"
|
||||
}
|
||||
],
|
||||
"roots": [
|
||||
{
|
||||
"id": "l001",
|
||||
"phase": "startup",
|
||||
"source": "convention"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,146 +0,0 @@
|
||||
{
|
||||
"schema": "stella.callgraph.v1",
|
||||
"scanKey": "scan:express-api:2.1.0",
|
||||
"language": "Node",
|
||||
"artifacts": [
|
||||
{
|
||||
"artifactKey": "express-api",
|
||||
"kind": "npm-package",
|
||||
"sha256": "c4d5e6f7890123456789abcdef0123456789abcdef0123456789abcdef012345",
|
||||
"purl": "pkg:npm/express-api@2.1.0",
|
||||
"filePath": "/app",
|
||||
"sizeBytes": 2500000
|
||||
}
|
||||
],
|
||||
"nodes": [
|
||||
{
|
||||
"id": "e001",
|
||||
"nodeId": "e001",
|
||||
"name": "startServer",
|
||||
"kind": "function",
|
||||
"namespace": "src",
|
||||
"file": "index.js",
|
||||
"line": 15,
|
||||
"symbolKey": "src/index.js::startServer",
|
||||
"artifactKey": "express-api",
|
||||
"visibility": "Public",
|
||||
"isEntrypointCandidate": true,
|
||||
"flags": 1
|
||||
},
|
||||
{
|
||||
"id": "e002",
|
||||
"nodeId": "e002",
|
||||
"name": "getUserById",
|
||||
"kind": "function",
|
||||
"namespace": "src/routes",
|
||||
"file": "users.js",
|
||||
"line": 22,
|
||||
"symbolKey": "src/routes/users.js::getUserById",
|
||||
"artifactKey": "express-api",
|
||||
"visibility": "Public",
|
||||
"isEntrypointCandidate": true,
|
||||
"attributes": {
|
||||
"httpMethod": "GET",
|
||||
"route": "/api/users/:id"
|
||||
},
|
||||
"flags": 3
|
||||
},
|
||||
{
|
||||
"id": "e003",
|
||||
"nodeId": "e003",
|
||||
"name": "findUser",
|
||||
"kind": "function",
|
||||
"namespace": "src/services",
|
||||
"file": "userService.js",
|
||||
"line": 45,
|
||||
"symbolKey": "src/services/userService.js::findUser",
|
||||
"artifactKey": "express-api",
|
||||
"visibility": "Public",
|
||||
"isEntrypointCandidate": false,
|
||||
"flags": 0
|
||||
},
|
||||
{
|
||||
"id": "e004",
|
||||
"nodeId": "e004",
|
||||
"name": "query",
|
||||
"kind": "function",
|
||||
"namespace": "src/db",
|
||||
"file": "connection.js",
|
||||
"line": 30,
|
||||
"symbolKey": "src/db/connection.js::query",
|
||||
"artifactKey": "express-api",
|
||||
"visibility": "Public",
|
||||
"isEntrypointCandidate": false,
|
||||
"flags": 0
|
||||
}
|
||||
],
|
||||
"edges": [
|
||||
{
|
||||
"sourceId": "e001",
|
||||
"targetId": "e002",
|
||||
"from": "e001",
|
||||
"to": "e002",
|
||||
"type": "require",
|
||||
"kind": "Static",
|
||||
"reason": "DynamicImport",
|
||||
"weight": 0.95,
|
||||
"isResolved": true,
|
||||
"provenance": "express-router"
|
||||
},
|
||||
{
|
||||
"sourceId": "e002",
|
||||
"targetId": "e003",
|
||||
"from": "e002",
|
||||
"to": "e003",
|
||||
"type": "call",
|
||||
"kind": "Static",
|
||||
"reason": "DirectCall",
|
||||
"weight": 1.0,
|
||||
"isResolved": true
|
||||
},
|
||||
{
|
||||
"sourceId": "e003",
|
||||
"targetId": "e004",
|
||||
"from": "e003",
|
||||
"to": "e004",
|
||||
"type": "async-call",
|
||||
"kind": "Static",
|
||||
"reason": "AsyncContinuation",
|
||||
"weight": 1.0,
|
||||
"isResolved": true
|
||||
}
|
||||
],
|
||||
"entrypoints": [
|
||||
{
|
||||
"nodeId": "e001",
|
||||
"kind": "Main",
|
||||
"framework": "Express",
|
||||
"source": "convention",
|
||||
"phase": "AppStart",
|
||||
"order": 0
|
||||
},
|
||||
{
|
||||
"nodeId": "e002",
|
||||
"kind": "Http",
|
||||
"route": "/api/users/:id",
|
||||
"httpMethod": "GET",
|
||||
"framework": "Express",
|
||||
"source": "code-analysis",
|
||||
"phase": "Runtime",
|
||||
"order": 1
|
||||
}
|
||||
],
|
||||
"metadata": {
|
||||
"toolId": "stellaops.scanner.node",
|
||||
"toolVersion": "1.0.0",
|
||||
"analysisTimestamp": "2025-01-15T12:00:00Z",
|
||||
"sourceCommit": "789abc012def",
|
||||
"buildId": "build-003"
|
||||
},
|
||||
"id": "cg-node-express-api-001",
|
||||
"languageString": "javascript",
|
||||
"component": "express-api",
|
||||
"version": "2.1.0",
|
||||
"ingestedAt": "2025-01-15T12:00:00Z",
|
||||
"graphHash": "sha256:c3d4e5f6a7b8"
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
{
|
||||
"version": "1.0",
|
||||
"schema": "patch-oracle/v1",
|
||||
"generated_at": "2025-12-13T00:00:00Z",
|
||||
"description": "Patch-oracle fixtures for CI graph validation. Each oracle defines expected functions/edges that must be present (or absent) in generated reachability graphs.",
|
||||
"oracles": [
|
||||
{
|
||||
"id": "curl-CVE-2023-38545-socks5-heap-reachable",
|
||||
"case_ref": "curl-CVE-2023-38545-socks5-heap",
|
||||
"variant": "reachable",
|
||||
"path": "cases/curl-CVE-2023-38545-socks5-heap/reachable.oracle.json"
|
||||
},
|
||||
{
|
||||
"id": "curl-CVE-2023-38545-socks5-heap-unreachable",
|
||||
"case_ref": "curl-CVE-2023-38545-socks5-heap",
|
||||
"variant": "unreachable",
|
||||
"path": "cases/curl-CVE-2023-38545-socks5-heap/unreachable.oracle.json"
|
||||
},
|
||||
{
|
||||
"id": "java-log4j-CVE-2021-44228-log4shell-reachable",
|
||||
"case_ref": "java-log4j-CVE-2021-44228-log4shell",
|
||||
"variant": "reachable",
|
||||
"path": "cases/java-log4j-CVE-2021-44228-log4shell/reachable.oracle.json"
|
||||
},
|
||||
{
|
||||
"id": "dotnet-kestrel-CVE-2023-44487-http2-rapid-reset-reachable",
|
||||
"case_ref": "dotnet-kestrel-CVE-2023-44487-http2-rapid-reset",
|
||||
"variant": "reachable",
|
||||
"path": "cases/dotnet-kestrel-CVE-2023-44487-http2-rapid-reset/reachable.oracle.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
{
|
||||
"schema_version": "patch-oracle/v1",
|
||||
"id": "curl-CVE-2023-38545-socks5-heap-reachable",
|
||||
"case_ref": "curl-CVE-2023-38545-socks5-heap",
|
||||
"variant": "reachable",
|
||||
"description": "Validates that the SOCKS5 heap overflow vulnerability path is reachable from network handler to vulnerable sink",
|
||||
"expected_functions": [
|
||||
{
|
||||
"symbol_id": "sym://net:handler#read",
|
||||
"kind": "entrypoint",
|
||||
"required": true,
|
||||
"reason": "Network read handler is the entry point for external data"
|
||||
},
|
||||
{
|
||||
"symbol_id": "sym://curl:curl.c#entry",
|
||||
"kind": "function",
|
||||
"required": true,
|
||||
"reason": "SOCKS5 protocol handling entry point"
|
||||
},
|
||||
{
|
||||
"symbol_id": "sym://curl:curl.c#sink",
|
||||
"kind": "function",
|
||||
"required": true,
|
||||
"reason": "Vulnerable buffer handling function"
|
||||
}
|
||||
],
|
||||
"expected_edges": [
|
||||
{
|
||||
"from": "sym://net:handler#read",
|
||||
"to": "sym://curl:curl.c#entry",
|
||||
"kind": "call",
|
||||
"min_confidence": 0.8,
|
||||
"required": true,
|
||||
"reason": "Data flows from network handler to SOCKS5 handler"
|
||||
},
|
||||
{
|
||||
"from": "sym://curl:curl.c#entry",
|
||||
"to": "sym://curl:curl.c#sink",
|
||||
"kind": "call",
|
||||
"min_confidence": 0.8,
|
||||
"required": true,
|
||||
"reason": "SOCKS5 handler invokes vulnerable buffer function"
|
||||
}
|
||||
],
|
||||
"expected_roots": [
|
||||
{
|
||||
"id": "sym://net:handler#read",
|
||||
"phase": "runtime",
|
||||
"required": true,
|
||||
"reason": "Network handler is the runtime entry point"
|
||||
}
|
||||
],
|
||||
"min_confidence": 0.5,
|
||||
"strict_mode": false,
|
||||
"created_at": "2025-12-13T00:00:00Z"
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
{
|
||||
"schema_version": "patch-oracle/v1",
|
||||
"id": "curl-CVE-2023-38545-socks5-heap-unreachable",
|
||||
"case_ref": "curl-CVE-2023-38545-socks5-heap",
|
||||
"variant": "unreachable",
|
||||
"description": "Validates that the SOCKS5 heap overflow vulnerability path is NOT reachable when SOCKS5 is disabled",
|
||||
"expected_functions": [
|
||||
{
|
||||
"symbol_id": "sym://net:handler#read",
|
||||
"kind": "entrypoint",
|
||||
"required": true,
|
||||
"reason": "Network read handler still exists but cannot reach vulnerable code"
|
||||
}
|
||||
],
|
||||
"expected_edges": [],
|
||||
"forbidden_functions": [
|
||||
{
|
||||
"symbol_id": "sym://curl:curl.c#sink",
|
||||
"reason": "Vulnerable sink should not be in call graph when SOCKS5 disabled"
|
||||
}
|
||||
],
|
||||
"forbidden_edges": [
|
||||
{
|
||||
"from": "sym://curl:curl.c#entry",
|
||||
"to": "sym://curl:curl.c#sink",
|
||||
"reason": "This edge should not exist when SOCKS5 is disabled"
|
||||
}
|
||||
],
|
||||
"min_confidence": 0.5,
|
||||
"strict_mode": false,
|
||||
"created_at": "2025-12-13T00:00:00Z"
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
{
|
||||
"schema_version": "patch-oracle/v1",
|
||||
"id": "dotnet-kestrel-CVE-2023-44487-http2-rapid-reset-reachable",
|
||||
"case_ref": "dotnet-kestrel-CVE-2023-44487-http2-rapid-reset",
|
||||
"variant": "reachable",
|
||||
"description": "Validates that the HTTP/2 Rapid Reset DoS vulnerability path is reachable",
|
||||
"expected_functions": [
|
||||
{
|
||||
"symbol_id": "sym://dotnet:Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http2.Http2Connection#ProcessRequestsAsync",
|
||||
"lang": "dotnet",
|
||||
"kind": "method",
|
||||
"required": true,
|
||||
"reason": "HTTP/2 connection handler entry point"
|
||||
},
|
||||
{
|
||||
"symbol_id": "sym://dotnet:Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http2.Http2Stream#*",
|
||||
"lang": "dotnet",
|
||||
"kind": "method",
|
||||
"required": true,
|
||||
"reason": "HTTP/2 stream management affected by rapid reset"
|
||||
}
|
||||
],
|
||||
"expected_edges": [
|
||||
{
|
||||
"from": "sym://dotnet:Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http2.Http2Connection#ProcessRequestsAsync",
|
||||
"to": "sym://dotnet:Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http2.Http2Stream#*",
|
||||
"kind": "call",
|
||||
"min_confidence": 0.7,
|
||||
"required": true,
|
||||
"reason": "Connection handler creates/manages streams"
|
||||
}
|
||||
],
|
||||
"expected_roots": [
|
||||
{
|
||||
"id": "sym://dotnet:Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http2.Http2Connection#ProcessRequestsAsync",
|
||||
"phase": "runtime",
|
||||
"required": true,
|
||||
"reason": "HTTP/2 processing is a runtime entry point"
|
||||
}
|
||||
],
|
||||
"min_confidence": 0.5,
|
||||
"strict_mode": false,
|
||||
"created_at": "2025-12-13T00:00:00Z"
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
{
|
||||
"schema_version": "patch-oracle/v1",
|
||||
"id": "java-log4j-CVE-2021-44228-log4shell-reachable",
|
||||
"case_ref": "java-log4j-CVE-2021-44228-log4shell",
|
||||
"variant": "reachable",
|
||||
"description": "Validates that the Log4Shell JNDI injection path is reachable from logger to JNDI lookup",
|
||||
"expected_functions": [
|
||||
{
|
||||
"symbol_id": "sym://java:org.apache.logging.log4j.core.Logger#logMessage",
|
||||
"lang": "java",
|
||||
"kind": "method",
|
||||
"required": true,
|
||||
"reason": "Logger entry point that processes user-controlled format strings"
|
||||
},
|
||||
{
|
||||
"symbol_id": "sym://java:org.apache.logging.log4j.core.pattern.MessagePatternConverter#format",
|
||||
"lang": "java",
|
||||
"kind": "method",
|
||||
"required": true,
|
||||
"reason": "Pattern converter that triggers lookup substitution"
|
||||
},
|
||||
{
|
||||
"symbol_id": "sym://java:org.apache.logging.log4j.core.lookup.StrSubstitutor#replace",
|
||||
"lang": "java",
|
||||
"kind": "method",
|
||||
"required": true,
|
||||
"reason": "String substitution that invokes lookups"
|
||||
},
|
||||
{
|
||||
"symbol_id": "sym://java:org.apache.logging.log4j.core.lookup.JndiLookup#lookup",
|
||||
"lang": "java",
|
||||
"kind": "method",
|
||||
"required": true,
|
||||
"reason": "Vulnerable JNDI lookup method"
|
||||
}
|
||||
],
|
||||
"expected_edges": [
|
||||
{
|
||||
"from": "sym://java:org.apache.logging.log4j.core.Logger#logMessage",
|
||||
"to": "sym://java:org.apache.logging.log4j.core.pattern.MessagePatternConverter#format",
|
||||
"kind": "call",
|
||||
"required": true,
|
||||
"reason": "Logger delegates to pattern converter"
|
||||
},
|
||||
{
|
||||
"from": "sym://java:org.apache.logging.log4j.core.lookup.StrSubstitutor#replace",
|
||||
"to": "sym://java:org.apache.logging.log4j.core.lookup.JndiLookup#lookup",
|
||||
"kind": "call",
|
||||
"required": true,
|
||||
"reason": "String substitution invokes JNDI lookup"
|
||||
}
|
||||
],
|
||||
"expected_roots": [
|
||||
{
|
||||
"id": "sym://java:org.apache.logging.log4j.core.Logger#*",
|
||||
"phase": "runtime",
|
||||
"required": true,
|
||||
"reason": "Logger methods are runtime entry points"
|
||||
}
|
||||
],
|
||||
"min_confidence": 0.6,
|
||||
"strict_mode": false,
|
||||
"created_at": "2025-12-13T00:00:00Z"
|
||||
}
|
||||
@@ -1,179 +0,0 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"$id": "stellaops:patch-oracle/v1",
|
||||
"title": "Patch Oracle Schema v1",
|
||||
"description": "Defines expected functions/edges for reachability graph validation. CI fails when expected elements are missing.",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"schema_version": {
|
||||
"type": "string",
|
||||
"const": "patch-oracle/v1",
|
||||
"description": "Schema version identifier"
|
||||
},
|
||||
"id": {
|
||||
"type": "string",
|
||||
"description": "Unique oracle identifier (e.g., 'curl-CVE-2023-38545-socks5-heap-reachable')"
|
||||
},
|
||||
"case_ref": {
|
||||
"type": "string",
|
||||
"description": "Reference to parent reachbench case (e.g., 'curl-CVE-2023-38545-socks5-heap')"
|
||||
},
|
||||
"variant": {
|
||||
"type": "string",
|
||||
"enum": ["reachable", "unreachable"],
|
||||
"description": "Which variant this oracle applies to"
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"description": "Human-readable description of what this oracle validates"
|
||||
},
|
||||
"expected_functions": {
|
||||
"type": "array",
|
||||
"description": "Functions that MUST be present in the generated graph",
|
||||
"items": {
|
||||
"$ref": "#/definitions/expected_function"
|
||||
}
|
||||
},
|
||||
"expected_edges": {
|
||||
"type": "array",
|
||||
"description": "Edges that MUST be present in the generated graph",
|
||||
"items": {
|
||||
"$ref": "#/definitions/expected_edge"
|
||||
}
|
||||
},
|
||||
"expected_roots": {
|
||||
"type": "array",
|
||||
"description": "Root nodes that MUST be present in the generated graph",
|
||||
"items": {
|
||||
"$ref": "#/definitions/expected_root"
|
||||
}
|
||||
},
|
||||
"forbidden_functions": {
|
||||
"type": "array",
|
||||
"description": "Functions that MUST NOT be present (for unreachable variants)",
|
||||
"items": {
|
||||
"$ref": "#/definitions/expected_function"
|
||||
}
|
||||
},
|
||||
"forbidden_edges": {
|
||||
"type": "array",
|
||||
"description": "Edges that MUST NOT be present (for unreachable variants)",
|
||||
"items": {
|
||||
"$ref": "#/definitions/expected_edge"
|
||||
}
|
||||
},
|
||||
"min_confidence": {
|
||||
"type": "number",
|
||||
"minimum": 0.0,
|
||||
"maximum": 1.0,
|
||||
"default": 0.5,
|
||||
"description": "Minimum confidence threshold for edge matching"
|
||||
},
|
||||
"strict_mode": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"description": "If true, extra functions/edges not in oracle cause failure"
|
||||
},
|
||||
"created_at": {
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"description": "When this oracle was created"
|
||||
},
|
||||
"updated_at": {
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"description": "When this oracle was last updated"
|
||||
}
|
||||
},
|
||||
"required": ["schema_version", "id", "case_ref", "variant"],
|
||||
"definitions": {
|
||||
"expected_function": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"symbol_id": {
|
||||
"type": "string",
|
||||
"description": "Expected symbol ID (exact match or pattern with '*' wildcards)"
|
||||
},
|
||||
"lang": {
|
||||
"type": "string",
|
||||
"description": "Expected language (optional, for filtering)"
|
||||
},
|
||||
"kind": {
|
||||
"type": "string",
|
||||
"description": "Expected node kind (e.g., 'function', 'method', 'entrypoint')"
|
||||
},
|
||||
"purl_pattern": {
|
||||
"type": "string",
|
||||
"description": "Expected purl pattern (optional, supports wildcards)"
|
||||
},
|
||||
"required": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "If true, missing this function fails CI"
|
||||
},
|
||||
"reason": {
|
||||
"type": "string",
|
||||
"description": "Why this function is expected (for documentation)"
|
||||
}
|
||||
},
|
||||
"required": ["symbol_id"]
|
||||
},
|
||||
"expected_edge": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"from": {
|
||||
"type": "string",
|
||||
"description": "Source node symbol ID (exact match or pattern)"
|
||||
},
|
||||
"to": {
|
||||
"type": "string",
|
||||
"description": "Target node symbol ID (exact match or pattern)"
|
||||
},
|
||||
"kind": {
|
||||
"type": "string",
|
||||
"description": "Expected edge kind (e.g., 'call', 'plt', 'indirect')"
|
||||
},
|
||||
"min_confidence": {
|
||||
"type": "number",
|
||||
"minimum": 0.0,
|
||||
"maximum": 1.0,
|
||||
"description": "Minimum confidence for this specific edge"
|
||||
},
|
||||
"required": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "If true, missing this edge fails CI"
|
||||
},
|
||||
"reason": {
|
||||
"type": "string",
|
||||
"description": "Why this edge is expected (for documentation)"
|
||||
}
|
||||
},
|
||||
"required": ["from", "to"]
|
||||
},
|
||||
"expected_root": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"description": "Root node ID (exact match or pattern)"
|
||||
},
|
||||
"phase": {
|
||||
"type": "string",
|
||||
"enum": ["load", "init", "main", "runtime", "fini"],
|
||||
"description": "Expected execution phase"
|
||||
},
|
||||
"required": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "If true, missing this root fails CI"
|
||||
},
|
||||
"reason": {
|
||||
"type": "string",
|
||||
"description": "Why this root is expected"
|
||||
}
|
||||
},
|
||||
"required": ["id"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,444 +0,0 @@
|
||||
{
|
||||
"version": "0.1",
|
||||
"generated_at": "2025-11-07T22:40:04Z",
|
||||
"cases": [
|
||||
{
|
||||
"id": "runc-CVE-2024-21626-symlink-breakout",
|
||||
"primary_axis": "container-escape",
|
||||
"tags": [
|
||||
"symlink",
|
||||
"filesystem",
|
||||
"userns"
|
||||
],
|
||||
"languages": [
|
||||
"binary"
|
||||
],
|
||||
"variants": [
|
||||
"reachable",
|
||||
"unreachable"
|
||||
],
|
||||
"severity_cvss": 9.0,
|
||||
"references": [
|
||||
"cve:CVE-2024-21626"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "linux-cgroups-CVE-2022-0492-release_agent",
|
||||
"primary_axis": "container-escape",
|
||||
"tags": [
|
||||
"cgroups",
|
||||
"kernel",
|
||||
"priv-esc"
|
||||
],
|
||||
"languages": [
|
||||
"binary"
|
||||
],
|
||||
"variants": [
|
||||
"reachable",
|
||||
"unreachable"
|
||||
],
|
||||
"severity_cvss": 9.0,
|
||||
"references": [
|
||||
"cve:CVE-2022-0492"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "glibc-CVE-2023-4911-looney-tunables",
|
||||
"primary_axis": "binary-hybrid",
|
||||
"tags": [
|
||||
"env-vars",
|
||||
"libc",
|
||||
"ldso"
|
||||
],
|
||||
"languages": [
|
||||
"c"
|
||||
],
|
||||
"variants": [
|
||||
"reachable",
|
||||
"unreachable"
|
||||
],
|
||||
"severity_cvss": 7.5,
|
||||
"references": [
|
||||
"cve:CVE-2023-4911"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "curl-CVE-2023-38545-socks5-heap",
|
||||
"primary_axis": "binary-hybrid",
|
||||
"tags": [
|
||||
"networking",
|
||||
"proxy",
|
||||
"heap"
|
||||
],
|
||||
"languages": [
|
||||
"c"
|
||||
],
|
||||
"variants": [
|
||||
"reachable",
|
||||
"unreachable"
|
||||
],
|
||||
"severity_cvss": 7.5,
|
||||
"references": [
|
||||
"cve:CVE-2023-38545"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "openssl-CVE-2022-3602-x509-name-constraints",
|
||||
"primary_axis": "binary-hybrid",
|
||||
"tags": [
|
||||
"x509",
|
||||
"parser",
|
||||
"stack-overflow"
|
||||
],
|
||||
"languages": [
|
||||
"c"
|
||||
],
|
||||
"variants": [
|
||||
"reachable",
|
||||
"unreachable"
|
||||
],
|
||||
"severity_cvss": 7.5,
|
||||
"references": [
|
||||
"cve:CVE-2022-3602"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "openssh-CVE-2024-6387-regreSSHion",
|
||||
"primary_axis": "binary-hybrid",
|
||||
"tags": [
|
||||
"signal-handler",
|
||||
"daemon"
|
||||
],
|
||||
"languages": [
|
||||
"c"
|
||||
],
|
||||
"variants": [
|
||||
"reachable",
|
||||
"unreachable"
|
||||
],
|
||||
"severity_cvss": 7.5,
|
||||
"references": [
|
||||
"cve:CVE-2024-6387"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "redis-CVE-2022-0543-lua-sandbox-escape",
|
||||
"primary_axis": "binary-hybrid",
|
||||
"tags": [
|
||||
"lua",
|
||||
"sandbox",
|
||||
"rce"
|
||||
],
|
||||
"languages": [
|
||||
"c",
|
||||
"lua"
|
||||
],
|
||||
"variants": [
|
||||
"reachable",
|
||||
"unreachable"
|
||||
],
|
||||
"severity_cvss": 7.5,
|
||||
"references": [
|
||||
"cve:CVE-2022-0543"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "java-log4j-CVE-2021-44228-log4shell",
|
||||
"primary_axis": "lang-jvm",
|
||||
"tags": [
|
||||
"jndi",
|
||||
"deserialization",
|
||||
"rce"
|
||||
],
|
||||
"languages": [
|
||||
"java"
|
||||
],
|
||||
"variants": [
|
||||
"reachable",
|
||||
"unreachable"
|
||||
],
|
||||
"severity_cvss": 9.8,
|
||||
"references": [
|
||||
"cve:CVE-2021-44228"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "java-spring-CVE-2022-22965-spring4shell",
|
||||
"primary_axis": "lang-jvm",
|
||||
"tags": [
|
||||
"binding",
|
||||
"reflection",
|
||||
"rce"
|
||||
],
|
||||
"languages": [
|
||||
"java"
|
||||
],
|
||||
"variants": [
|
||||
"reachable",
|
||||
"unreachable"
|
||||
],
|
||||
"severity_cvss": 9.8,
|
||||
"references": [
|
||||
"cve:CVE-2022-22965"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "java-jackson-CVE-2019-12384-polymorphic-deser",
|
||||
"primary_axis": "lang-jvm",
|
||||
"tags": [
|
||||
"deserialization",
|
||||
"polymorphism"
|
||||
],
|
||||
"languages": [
|
||||
"java"
|
||||
],
|
||||
"variants": [
|
||||
"reachable",
|
||||
"unreachable"
|
||||
],
|
||||
"severity_cvss": 7.5,
|
||||
"references": [
|
||||
"cve:CVE-2019-12384"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "dotnet-kestrel-CVE-2023-44487-http2-rapid-reset",
|
||||
"primary_axis": "lang-dotnet",
|
||||
"tags": [
|
||||
"protocol",
|
||||
"http2",
|
||||
"dos"
|
||||
],
|
||||
"languages": [
|
||||
"dotnet"
|
||||
],
|
||||
"variants": [
|
||||
"reachable",
|
||||
"unreachable"
|
||||
],
|
||||
"severity_cvss": 7.5,
|
||||
"references": [
|
||||
"cve:CVE-2023-44487"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "dotnet-newtonsoft-deser-TBD",
|
||||
"primary_axis": "lang-dotnet",
|
||||
"tags": [
|
||||
"deserialization",
|
||||
"json",
|
||||
"polymorphic"
|
||||
],
|
||||
"languages": [
|
||||
"dotnet"
|
||||
],
|
||||
"variants": [
|
||||
"reachable",
|
||||
"unreachable"
|
||||
],
|
||||
"severity_cvss": 7.5,
|
||||
"references": []
|
||||
},
|
||||
{
|
||||
"id": "go-ssh-CVE-2020-9283-keyexchange",
|
||||
"primary_axis": "lang-go",
|
||||
"tags": [
|
||||
"crypto",
|
||||
"handshake"
|
||||
],
|
||||
"languages": [
|
||||
"go"
|
||||
],
|
||||
"variants": [
|
||||
"reachable",
|
||||
"unreachable"
|
||||
],
|
||||
"severity_cvss": 7.5,
|
||||
"references": [
|
||||
"cve:CVE-2020-9283"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "go-gateway-reflection-auth-bypass",
|
||||
"primary_axis": "lang-go",
|
||||
"tags": [
|
||||
"grpc",
|
||||
"reflection",
|
||||
"authz-gap"
|
||||
],
|
||||
"languages": [
|
||||
"go"
|
||||
],
|
||||
"variants": [
|
||||
"reachable",
|
||||
"unreachable"
|
||||
],
|
||||
"severity_cvss": 7.5,
|
||||
"references": []
|
||||
},
|
||||
{
|
||||
"id": "node-tar-CVE-2021-37713-path-traversal",
|
||||
"primary_axis": "lang-node",
|
||||
"tags": [
|
||||
"path-traversal",
|
||||
"archive-extract"
|
||||
],
|
||||
"languages": [
|
||||
"node"
|
||||
],
|
||||
"variants": [
|
||||
"reachable",
|
||||
"unreachable"
|
||||
],
|
||||
"severity_cvss": 7.5,
|
||||
"references": [
|
||||
"cve:CVE-2021-37713"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "node-express-middleware-order-auth-bypass",
|
||||
"primary_axis": "lang-node",
|
||||
"tags": [
|
||||
"middleware-order",
|
||||
"authz"
|
||||
],
|
||||
"languages": [
|
||||
"node"
|
||||
],
|
||||
"variants": [
|
||||
"reachable",
|
||||
"unreachable"
|
||||
],
|
||||
"severity_cvss": 7.5,
|
||||
"references": []
|
||||
},
|
||||
{
|
||||
"id": "python-jinja2-CVE-2019-10906-template-injection",
|
||||
"primary_axis": "lang-python",
|
||||
"tags": [
|
||||
"template-injection"
|
||||
],
|
||||
"languages": [
|
||||
"python"
|
||||
],
|
||||
"variants": [
|
||||
"reachable",
|
||||
"unreachable"
|
||||
],
|
||||
"severity_cvss": 7.5,
|
||||
"references": [
|
||||
"cve:CVE-2019-10906"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "python-django-CVE-2019-19844-sqli-like",
|
||||
"primary_axis": "lang-python",
|
||||
"tags": [
|
||||
"sqli",
|
||||
"orm"
|
||||
],
|
||||
"languages": [
|
||||
"python"
|
||||
],
|
||||
"variants": [
|
||||
"reachable",
|
||||
"unreachable"
|
||||
],
|
||||
"severity_cvss": 7.5,
|
||||
"references": [
|
||||
"cve:CVE-2019-19844"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "python-urllib3-dos-regex-TBD",
|
||||
"primary_axis": "lang-python",
|
||||
"tags": [
|
||||
"regex-dos",
|
||||
"parser"
|
||||
],
|
||||
"languages": [
|
||||
"python"
|
||||
],
|
||||
"variants": [
|
||||
"reachable",
|
||||
"unreachable"
|
||||
],
|
||||
"severity_cvss": 7.5,
|
||||
"references": []
|
||||
},
|
||||
{
|
||||
"id": "php-phpmailer-CVE-2016-10033-rce",
|
||||
"primary_axis": "lang-php",
|
||||
"tags": [
|
||||
"rce",
|
||||
"email"
|
||||
],
|
||||
"languages": [
|
||||
"php"
|
||||
],
|
||||
"variants": [
|
||||
"reachable",
|
||||
"unreachable"
|
||||
],
|
||||
"severity_cvss": 7.5,
|
||||
"references": [
|
||||
"cve:CVE-2016-10033"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "wordpress-core-CVE-2022-21661-sqli",
|
||||
"primary_axis": "lang-php",
|
||||
"tags": [
|
||||
"sqli",
|
||||
"core"
|
||||
],
|
||||
"languages": [
|
||||
"php"
|
||||
],
|
||||
"variants": [
|
||||
"reachable",
|
||||
"unreachable"
|
||||
],
|
||||
"severity_cvss": 7.5,
|
||||
"references": [
|
||||
"cve:CVE-2022-21661"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "rails-CVE-2019-5418-file-content-disclosure",
|
||||
"primary_axis": "lang-ruby",
|
||||
"tags": [
|
||||
"path-traversal",
|
||||
"mime"
|
||||
],
|
||||
"languages": [
|
||||
"ruby"
|
||||
],
|
||||
"variants": [
|
||||
"reachable",
|
||||
"unreachable"
|
||||
],
|
||||
"severity_cvss": 7.5,
|
||||
"references": [
|
||||
"cve:CVE-2019-5418"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "rust-axum-header-parsing-TBD",
|
||||
"primary_axis": "lang-rust",
|
||||
"tags": [
|
||||
"parser",
|
||||
"config-sensitive"
|
||||
],
|
||||
"languages": [
|
||||
"rust"
|
||||
],
|
||||
"variants": [
|
||||
"reachable",
|
||||
"unreachable"
|
||||
],
|
||||
"severity_cvss": 7.5,
|
||||
"references": []
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
# ReachBench-2025 Expanded Kit (Skeleton)
|
||||
This is a scaffold containing diverse cases across languages and reach paths. Replace STUBs with real build configs, symbols, and call graphs.
|
||||
@@ -1,46 +0,0 @@
|
||||
{
|
||||
"id": "curl-CVE-2023-38545-socks5-heap",
|
||||
"cve": "CVE-2023-38545",
|
||||
"description": "STUB: Replace with accurate description and threat model for the specific CVE/case.",
|
||||
"threat_model": {
|
||||
"entry_points": [
|
||||
"STUB: define concrete inputs"
|
||||
],
|
||||
"preconditions": [
|
||||
"STUB: feature flags / modules / protocols enabled"
|
||||
],
|
||||
"privilege_boundary": [
|
||||
"STUB: describe boundary (if any)"
|
||||
]
|
||||
},
|
||||
"ground_truth": {
|
||||
"reachable_variant": {
|
||||
"status": "affected",
|
||||
"evidence": {
|
||||
"symbols": [
|
||||
"sym://curl:curl.c#sink"
|
||||
],
|
||||
"paths": [
|
||||
[
|
||||
"sym://net:handler#read",
|
||||
"sym://curl:curl.c#entry",
|
||||
"sym://curl:curl.c#sink"
|
||||
]
|
||||
],
|
||||
"runtime_proof": "traces.runtime.jsonl: lines 1-5"
|
||||
}
|
||||
},
|
||||
"unreachable_variant": {
|
||||
"status": "not_affected",
|
||||
"justification": "vulnerable_code_not_in_execute_path",
|
||||
"evidence": {
|
||||
"pruning_reason": [
|
||||
"STUB: feature disabled, module absent, or policy denies"
|
||||
],
|
||||
"blocked_edges": [
|
||||
"sym://curl:curl.c#entry -> sym://curl:curl.c#sink"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
# curl-CVE-2023-38545-socks5-heap
|
||||
Primary axis: binary-hybrid
|
||||
Tags: networking, proxy, heap
|
||||
Languages: c
|
||||
|
||||
## Variants
|
||||
- reachable: vulnerable function/path is on an executable route.
|
||||
- unreachable: same base image/config with control toggles that prune the path.
|
||||
|
||||
## Entrypoint & Controls (fill in)
|
||||
- entrypoints: e.g., http:/route, grpc method, tcp port, OCI hook
|
||||
- flags: e.g., feature_on=true, middleware_order=bad|good, module_loaded=true|false, LSM=enforcing|permissive
|
||||
|
||||
## Expected ground-truth path(s)
|
||||
See `images/*/reachgraph.truth.json`.
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"payload": "",
|
||||
"payloadType": "application/vnd.in-toto+json",
|
||||
"signatures": []
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"edges": [],
|
||||
"nodes": [],
|
||||
"schema_version": "reachbench.callgraph.framework/v1"
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"edges": [],
|
||||
"nodes": [],
|
||||
"schema_version": "reachbench.callgraph.static/v1"
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"case_id": "curl-CVE-2023-38545-socks5-heap",
|
||||
"files": {
|
||||
"attestation.dsse.json": "12ced21ccc633b0f458df44e276c954ccdbb14c5acd0d234fdf7934eec48696f",
|
||||
"callgraph.framework.json": "86ebf343e4b684a3bf2b3200e0bd1849397ea69f280330b1095aceefdff799ce",
|
||||
"callgraph.static.json": "99c850cccba6641635d1c668f831c80667930ddcd1f7acb2fe9c4c7771c63e7e",
|
||||
"reachgraph.truth.json": "9545261d413f4f85d120ebe8432c32ba97ba3feb2d34075fd689fcb5794f3ab0",
|
||||
"sbom.cdx.json": "ce41fd9b9edadf94a8cc84a3cce4e175b0602fd2e0d8dcb067273b9584479980",
|
||||
"sbom.spdx.json": "10d7417961d3cac0f3a5c4b083917fba3dc4f9bd9140d80aad0a873435158482",
|
||||
"symbols.json": "c5f473aff5b428df5a3f9c3393b7fbceb94214e3c2fd4f547d4f258ca25a3080",
|
||||
"vex.openvex.json": "0518d09c2ae692b96553feb821ff8138fc0ea6c840d75c1f80149add21127ddd"
|
||||
},
|
||||
"schema_version": "reachbench.manifest/v1",
|
||||
"variant": "reachable"
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"case_id": "curl-CVE-2023-38545-socks5-heap",
|
||||
"paths": [
|
||||
[
|
||||
"sym://net:handler#read",
|
||||
"sym://curl:curl.c#entry",
|
||||
"sym://curl:curl.c#sink"
|
||||
]
|
||||
],
|
||||
"schema_version": "reachbench.reachgraph.truth/v1",
|
||||
"variant": "reachable"
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"bomFormat": "CycloneDX",
|
||||
"components": [],
|
||||
"metadata": {
|
||||
"component": {
|
||||
"name": "curl-CVE-2023-38545-socks5-heap",
|
||||
"version": "0.0.0"
|
||||
}
|
||||
},
|
||||
"specVersion": "1.5"
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"SPDXID": "SPDXRef-DOCUMENT",
|
||||
"name": "curl-CVE-2023-38545-socks5-heap",
|
||||
"packages": [],
|
||||
"spdxVersion": "SPDX-2.3"
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"case_id": "curl-CVE-2023-38545-socks5-heap",
|
||||
"schema_version": "reachbench.symbols/v1",
|
||||
"symbols": [
|
||||
"sym://curl:curl.c#sink"
|
||||
],
|
||||
"variant": "reachable"
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
{"ts": 1.001, "event": "call", "sid": "sym://curl:curl.c#entry", "pid": 100}
|
||||
{"ts": 1.005, "event": "call", "sid": "sym://curl:curl.c#sink", "pid": 100}
|
||||
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"author": "StellaOps",
|
||||
"role": "reachbench",
|
||||
"statements": [
|
||||
{
|
||||
"products": [
|
||||
"pkg:curl-CVE-2023-38545-socks5-heap"
|
||||
],
|
||||
"status": "affected",
|
||||
"statusJustification": "component_present",
|
||||
"vulnerability": "cve:CVE-2023-38545"
|
||||
}
|
||||
],
|
||||
"timestamp": "2025-11-18T00:00:00Z"
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"payload": "",
|
||||
"payloadType": "application/vnd.in-toto+json",
|
||||
"signatures": []
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"edges": [],
|
||||
"nodes": [],
|
||||
"schema_version": "reachbench.callgraph.framework/v1"
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"edges": [],
|
||||
"nodes": [],
|
||||
"schema_version": "reachbench.callgraph.static/v1"
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"case_id": "curl-CVE-2023-38545-socks5-heap",
|
||||
"files": {
|
||||
"attestation.dsse.json": "12ced21ccc633b0f458df44e276c954ccdbb14c5acd0d234fdf7934eec48696f",
|
||||
"callgraph.framework.json": "86ebf343e4b684a3bf2b3200e0bd1849397ea69f280330b1095aceefdff799ce",
|
||||
"callgraph.static.json": "99c850cccba6641635d1c668f831c80667930ddcd1f7acb2fe9c4c7771c63e7e",
|
||||
"reachgraph.truth.json": "490c4175eb06e0c623e60263d2ce029ffa8b236aea5780c448b8180f38a1bf6f",
|
||||
"sbom.cdx.json": "ce41fd9b9edadf94a8cc84a3cce4e175b0602fd2e0d8dcb067273b9584479980",
|
||||
"sbom.spdx.json": "10d7417961d3cac0f3a5c4b083917fba3dc4f9bd9140d80aad0a873435158482",
|
||||
"symbols.json": "1b6a9e5598d2521e0ca55ed0f3f287ef19dc11cb1fb24fe961370c2fa7036214",
|
||||
"vex.openvex.json": "a9fa7e917601538e17750fb1c25b24e18333c779ec0d5d98d4fbccf84e2f544e"
|
||||
},
|
||||
"schema_version": "reachbench.manifest/v1",
|
||||
"variant": "unreachable"
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"case_id": "curl-CVE-2023-38545-socks5-heap",
|
||||
"paths": [],
|
||||
"schema_version": "reachbench.reachgraph.truth/v1",
|
||||
"variant": "unreachable"
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"bomFormat": "CycloneDX",
|
||||
"components": [],
|
||||
"metadata": {
|
||||
"component": {
|
||||
"name": "curl-CVE-2023-38545-socks5-heap",
|
||||
"version": "0.0.0"
|
||||
}
|
||||
},
|
||||
"specVersion": "1.5"
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"SPDXID": "SPDXRef-DOCUMENT",
|
||||
"name": "curl-CVE-2023-38545-socks5-heap",
|
||||
"packages": [],
|
||||
"spdxVersion": "SPDX-2.3"
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"case_id": "curl-CVE-2023-38545-socks5-heap",
|
||||
"schema_version": "reachbench.symbols/v1",
|
||||
"symbols": [],
|
||||
"variant": "unreachable"
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
{"ts": 1.001, "event": "call", "sid": "sym://curl:curl.c#entry", "pid": 100}
|
||||
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"author": "StellaOps",
|
||||
"role": "reachbench",
|
||||
"statements": [
|
||||
{
|
||||
"products": [
|
||||
"pkg:curl-CVE-2023-38545-socks5-heap"
|
||||
],
|
||||
"status": "not_affected",
|
||||
"statusJustification": "component_not_present",
|
||||
"vulnerability": "cve:CVE-2023-38545"
|
||||
}
|
||||
],
|
||||
"timestamp": "2025-11-18T00:00:00Z"
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
{
|
||||
"id": "dotnet-kestrel-CVE-2023-44487-http2-rapid-reset",
|
||||
"cve": "CVE-2023-44487",
|
||||
"description": "STUB: Replace with accurate description and threat model for the specific CVE/case.",
|
||||
"threat_model": {
|
||||
"entry_points": [
|
||||
"STUB: define concrete inputs"
|
||||
],
|
||||
"preconditions": [
|
||||
"STUB: feature flags / modules / protocols enabled"
|
||||
],
|
||||
"privilege_boundary": [
|
||||
"STUB: describe boundary (if any)"
|
||||
]
|
||||
},
|
||||
"ground_truth": {
|
||||
"reachable_variant": {
|
||||
"status": "affected",
|
||||
"evidence": {
|
||||
"symbols": [
|
||||
"sym://dotnet:dotnet.c#sink"
|
||||
],
|
||||
"paths": [
|
||||
[
|
||||
"sym://net:handler#read",
|
||||
"sym://dotnet:dotnet.c#entry",
|
||||
"sym://dotnet:dotnet.c#sink"
|
||||
]
|
||||
],
|
||||
"runtime_proof": "traces.runtime.jsonl: lines 1-5"
|
||||
}
|
||||
},
|
||||
"unreachable_variant": {
|
||||
"status": "not_affected",
|
||||
"justification": "vulnerable_code_not_in_execute_path",
|
||||
"evidence": {
|
||||
"pruning_reason": [
|
||||
"STUB: feature disabled, module absent, or policy denies"
|
||||
],
|
||||
"blocked_edges": [
|
||||
"sym://dotnet:dotnet.c#entry -> sym://dotnet:dotnet.c#sink"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
# dotnet-kestrel-CVE-2023-44487-http2-rapid-reset
|
||||
Primary axis: lang-dotnet
|
||||
Tags: protocol, http2, dos
|
||||
Languages: dotnet
|
||||
|
||||
## Variants
|
||||
- reachable: vulnerable function/path is on an executable route.
|
||||
- unreachable: same base image/config with control toggles that prune the path.
|
||||
|
||||
## Entrypoint & Controls (fill in)
|
||||
- entrypoints: e.g., http:/route, grpc method, tcp port, OCI hook
|
||||
- flags: e.g., feature_on=true, middleware_order=bad|good, module_loaded=true|false, LSM=enforcing|permissive
|
||||
|
||||
## Expected ground-truth path(s)
|
||||
See `images/*/reachgraph.truth.json`.
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"payload": "",
|
||||
"payloadType": "application/vnd.in-toto+json",
|
||||
"signatures": []
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"edges": [],
|
||||
"nodes": [],
|
||||
"schema_version": "reachbench.callgraph.framework/v1"
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"edges": [],
|
||||
"nodes": [],
|
||||
"schema_version": "reachbench.callgraph.static/v1"
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"case_id": "dotnet-kestrel-CVE-2023-44487-http2-rapid-reset",
|
||||
"files": {
|
||||
"attestation.dsse.json": "12ced21ccc633b0f458df44e276c954ccdbb14c5acd0d234fdf7934eec48696f",
|
||||
"callgraph.framework.json": "86ebf343e4b684a3bf2b3200e0bd1849397ea69f280330b1095aceefdff799ce",
|
||||
"callgraph.static.json": "99c850cccba6641635d1c668f831c80667930ddcd1f7acb2fe9c4c7771c63e7e",
|
||||
"reachgraph.truth.json": "5396e1c97612e0963bdaf9d5d3f570f095feaccfd46ed6e96af52a6dc4608608",
|
||||
"sbom.cdx.json": "8747790b2c9638b08aedca818367852889ee9bb50f1be1212b9c46b27296b8b9",
|
||||
"sbom.spdx.json": "fd5b8befa1a59f06c315406213426ee516276ad806f4acb1f53472149d97c402",
|
||||
"symbols.json": "c2bc2c131db1565b272900b2d86733086d601fc05a9072a43b9cd8b89a2e6f95",
|
||||
"vex.openvex.json": "2bc0466a7b733a0915b6a799e91ec731c0700d5bea8645c0bf983b6da180bc48"
|
||||
},
|
||||
"schema_version": "reachbench.manifest/v1",
|
||||
"variant": "reachable"
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"case_id": "dotnet-kestrel-CVE-2023-44487-http2-rapid-reset",
|
||||
"paths": [
|
||||
[
|
||||
"sym://net:handler#read",
|
||||
"sym://dotnet:dotnet.c#entry",
|
||||
"sym://dotnet:dotnet.c#sink"
|
||||
]
|
||||
],
|
||||
"schema_version": "reachbench.reachgraph.truth/v1",
|
||||
"variant": "reachable"
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"bomFormat": "CycloneDX",
|
||||
"components": [],
|
||||
"metadata": {
|
||||
"component": {
|
||||
"name": "dotnet-kestrel-CVE-2023-44487-http2-rapid-reset",
|
||||
"version": "0.0.0"
|
||||
}
|
||||
},
|
||||
"specVersion": "1.5"
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"SPDXID": "SPDXRef-DOCUMENT",
|
||||
"name": "dotnet-kestrel-CVE-2023-44487-http2-rapid-reset",
|
||||
"packages": [],
|
||||
"spdxVersion": "SPDX-2.3"
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"case_id": "dotnet-kestrel-CVE-2023-44487-http2-rapid-reset",
|
||||
"schema_version": "reachbench.symbols/v1",
|
||||
"symbols": [
|
||||
"sym://dotnet:dotnet.c#sink"
|
||||
],
|
||||
"variant": "reachable"
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
{"ts": 1.001, "event": "call", "sid": "sym://dotnet:dotnet.c#entry", "pid": 100}
|
||||
{"ts": 1.005, "event": "call", "sid": "sym://dotnet:dotnet.c#sink", "pid": 100}
|
||||
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"author": "StellaOps",
|
||||
"role": "reachbench",
|
||||
"statements": [
|
||||
{
|
||||
"products": [
|
||||
"pkg:dotnet-kestrel-CVE-2023-44487-http2-rapid-reset"
|
||||
],
|
||||
"status": "affected",
|
||||
"statusJustification": "component_present",
|
||||
"vulnerability": "cve:CVE-2023-44487"
|
||||
}
|
||||
],
|
||||
"timestamp": "2025-11-18T00:00:00Z"
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"payload": "",
|
||||
"payloadType": "application/vnd.in-toto+json",
|
||||
"signatures": []
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"edges": [],
|
||||
"nodes": [],
|
||||
"schema_version": "reachbench.callgraph.framework/v1"
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user