save progress
This commit is contained in:
@@ -16,6 +16,14 @@ public interface IEventSigner
|
||||
/// <returns>The DSSE signature string.</returns>
|
||||
string Sign(TimelineEvent timelineEvent);
|
||||
|
||||
/// <summary>
|
||||
/// Signs binary content asynchronously and returns the signature.
|
||||
/// </summary>
|
||||
/// <param name="content">The content to sign.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The signature string.</returns>
|
||||
Task<string> SignAsync(byte[] content, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Verifies a timeline event signature.
|
||||
/// </summary>
|
||||
|
||||
@@ -38,7 +38,7 @@ public class InMemoryEvidenceStoreTests
|
||||
var result = await _store.StoreAsync(evidence);
|
||||
|
||||
Assert.Equal(evidence.EvidenceId, result);
|
||||
Assert.Single(_store);
|
||||
Assert.Equal(1, _store.Count);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
@@ -50,7 +50,7 @@ public class InMemoryEvidenceStoreTests
|
||||
await _store.StoreAsync(evidence);
|
||||
await _store.StoreAsync(evidence);
|
||||
|
||||
Assert.Single(_store);
|
||||
Assert.Equal(1, _store.Count);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
@@ -99,7 +99,7 @@ public class InMemoryEvidenceStoreTests
|
||||
var count = await _store.StoreBatchAsync([]);
|
||||
|
||||
Assert.Equal(0, count);
|
||||
Assert.Empty(_store);
|
||||
Assert.Equal(0, _store.Count);
|
||||
}
|
||||
|
||||
#endregion
|
||||
@@ -283,7 +283,7 @@ public class InMemoryEvidenceStoreTests
|
||||
var deleted = await _store.DeleteAsync(evidence.EvidenceId);
|
||||
|
||||
Assert.True(deleted);
|
||||
Assert.Empty(_store);
|
||||
Assert.Equal(0, _store.Count);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
@@ -348,7 +348,7 @@ public class InMemoryEvidenceStoreTests
|
||||
|
||||
_store.Clear();
|
||||
|
||||
Assert.Empty(_store);
|
||||
Assert.Equal(0, _store.Count);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
10
src/__Libraries/StellaOps.ReachGraph/TASKS.md
Normal file
10
src/__Libraries/StellaOps.ReachGraph/TASKS.md
Normal file
@@ -0,0 +1,10 @@
|
||||
# ReachGraph Core Task Board
|
||||
|
||||
This board mirrors active sprint tasks for this module.
|
||||
Source of truth: `docs/implplan/permament/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| AUDIT-0105-M | DONE | Revalidated 2026-01-08; maintainability audit for ReachGraph core. |
|
||||
| AUDIT-0105-T | DONE | Revalidated 2026-01-08; test coverage audit for ReachGraph core. |
|
||||
| AUDIT-0105-A | TODO | Pending approval (revalidated 2026-01-08). |
|
||||
@@ -0,0 +1,227 @@
|
||||
// <copyright file="ConfidenceCalculatorTests.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using FluentAssertions;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Reachability.Core.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="ConfidenceCalculator"/>.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class ConfidenceCalculatorTests
|
||||
{
|
||||
private readonly ConfidenceCalculator _calculator = new();
|
||||
|
||||
[Theory]
|
||||
[InlineData(LatticeState.Unknown, 0.00, 0.29)]
|
||||
[InlineData(LatticeState.StaticReachable, 0.30, 0.49)]
|
||||
[InlineData(LatticeState.StaticUnreachable, 0.50, 0.69)]
|
||||
[InlineData(LatticeState.RuntimeObserved, 0.70, 0.89)]
|
||||
[InlineData(LatticeState.RuntimeUnobserved, 0.70, 0.89)]
|
||||
[InlineData(LatticeState.ConfirmedReachable, 0.90, 1.00)]
|
||||
[InlineData(LatticeState.ConfirmedUnreachable, 0.90, 1.00)]
|
||||
[InlineData(LatticeState.Contested, 0.00, 0.00)]
|
||||
public void GetConfidenceRange_Returns_Correct_Range(LatticeState state, double expectedMin, double expectedMax)
|
||||
{
|
||||
// Act
|
||||
var (min, max) = ConfidenceCalculator.GetConfidenceRange(state);
|
||||
|
||||
// Assert
|
||||
min.Should().Be(expectedMin);
|
||||
max.Should().Be(expectedMax);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Calculate_Unknown_Returns_Value_In_Range()
|
||||
{
|
||||
// Act
|
||||
var confidence = _calculator.Calculate(LatticeState.Unknown, 0.5);
|
||||
|
||||
// Assert
|
||||
confidence.Should().BeInRange(0.00, 0.29);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Calculate_ConfirmedReachable_Returns_High_Confidence()
|
||||
{
|
||||
// Act
|
||||
var confidence = _calculator.Calculate(LatticeState.ConfirmedReachable, 0.5);
|
||||
|
||||
// Assert
|
||||
confidence.Should().BeInRange(0.90, 1.00);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Calculate_WithStaticResult_PathCount_Increases_Confidence()
|
||||
{
|
||||
// Arrange
|
||||
var baseResult = CreateStaticResult(isReachable: true, pathCount: 1);
|
||||
var highPathResult = CreateStaticResult(isReachable: true, pathCount: 10);
|
||||
|
||||
// Act
|
||||
var baseConfidence = _calculator.Calculate(
|
||||
LatticeState.StaticReachable, baseResult, null);
|
||||
var highPathConfidence = _calculator.Calculate(
|
||||
LatticeState.StaticReachable, highPathResult, null);
|
||||
|
||||
// Assert
|
||||
highPathConfidence.Should().BeGreaterThan(baseConfidence);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Calculate_WithGuards_Decreases_Confidence()
|
||||
{
|
||||
// Arrange
|
||||
var noGuards = CreateStaticResult(isReachable: true, pathCount: 5, guardCount: 0);
|
||||
var withGuards = CreateStaticResult(isReachable: true, pathCount: 5, guardCount: 2);
|
||||
|
||||
// Act
|
||||
var noGuardConfidence = _calculator.Calculate(
|
||||
LatticeState.StaticReachable, noGuards, null);
|
||||
var withGuardConfidence = _calculator.Calculate(
|
||||
LatticeState.StaticReachable, withGuards, null);
|
||||
|
||||
// Assert
|
||||
withGuardConfidence.Should().BeLessThan(noGuardConfidence);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Calculate_WithRuntimeResult_HitCount_Increases_Confidence()
|
||||
{
|
||||
// Arrange
|
||||
var lowHits = CreateRuntimeResult(wasObserved: true, hitCount: 1);
|
||||
var highHits = CreateRuntimeResult(wasObserved: true, hitCount: 10000);
|
||||
|
||||
// Act
|
||||
var lowHitConfidence = _calculator.Calculate(
|
||||
LatticeState.RuntimeObserved, null, lowHits);
|
||||
var highHitConfidence = _calculator.Calculate(
|
||||
LatticeState.RuntimeObserved, null, highHits);
|
||||
|
||||
// Assert
|
||||
highHitConfidence.Should().BeGreaterThan(lowHitConfidence);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Calculate_MultipleEnvironments_Increases_Confidence()
|
||||
{
|
||||
// Arrange
|
||||
var singleEnv = CreateRuntimeResult(wasObserved: true, hitCount: 100, environmentCount: 1);
|
||||
var multiEnv = CreateRuntimeResult(wasObserved: true, hitCount: 100, environmentCount: 3);
|
||||
|
||||
// Act
|
||||
var singleConfidence = _calculator.Calculate(
|
||||
LatticeState.RuntimeObserved, null, singleEnv);
|
||||
var multiConfidence = _calculator.Calculate(
|
||||
LatticeState.RuntimeObserved, null, multiEnv);
|
||||
|
||||
// Assert
|
||||
multiConfidence.Should().BeGreaterThan(singleConfidence);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Calculate_Confidence_Always_Within_State_Range()
|
||||
{
|
||||
// Arrange
|
||||
var staticResult = CreateStaticResult(isReachable: true, pathCount: 100);
|
||||
var runtimeResult = CreateRuntimeResult(wasObserved: true, hitCount: 1000000, environmentCount: 10);
|
||||
|
||||
// Act
|
||||
var confidence = _calculator.Calculate(
|
||||
LatticeState.RuntimeObserved, staticResult, runtimeResult);
|
||||
|
||||
// Assert
|
||||
var (min, max) = ConfidenceCalculator.GetConfidenceRange(LatticeState.RuntimeObserved);
|
||||
confidence.Should().BeInRange(min, max);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Calculate_Contested_Returns_Zero()
|
||||
{
|
||||
// Act
|
||||
var confidence = _calculator.Calculate(LatticeState.Contested, 0.5);
|
||||
|
||||
// Assert
|
||||
confidence.Should().Be(0.0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CustomWeights_Affect_Calculation()
|
||||
{
|
||||
// Arrange - default pathCountBonus is 0.02, custom is 0.05
|
||||
// With pathCount=5, default adds 5*0.02=0.1 bonus, custom adds 5*0.05=0.25 (capped)
|
||||
var customWeights = new ConfidenceWeights
|
||||
{
|
||||
PathCountBonus = 0.05,
|
||||
MaxPathBonus = 0.20
|
||||
};
|
||||
var customCalculator = new ConfidenceCalculator(customWeights);
|
||||
var defaultCalculator = new ConfidenceCalculator();
|
||||
|
||||
var staticResult = CreateStaticResult(isReachable: true, pathCount: 5);
|
||||
|
||||
// Act
|
||||
var customConfidence = customCalculator.Calculate(
|
||||
LatticeState.StaticReachable, staticResult, null);
|
||||
var defaultConfidence = defaultCalculator.Calculate(
|
||||
LatticeState.StaticReachable, staticResult, null);
|
||||
|
||||
// Assert - both should be in StaticReachable range (0.30-0.49) but custom should be higher
|
||||
// due to larger path count bonus
|
||||
customConfidence.Should().BeGreaterThanOrEqualTo(defaultConfidence);
|
||||
}
|
||||
|
||||
private static StaticReachabilityResult CreateStaticResult(
|
||||
bool isReachable,
|
||||
int pathCount,
|
||||
int guardCount = 0)
|
||||
{
|
||||
var guards = Enumerable.Range(0, guardCount)
|
||||
.Select(i => new GuardCondition { Type = "FeatureFlag", Key = $"flag_{i}" })
|
||||
.ToImmutableArray();
|
||||
|
||||
return new StaticReachabilityResult
|
||||
{
|
||||
Symbol = CreateTestSymbol(),
|
||||
ArtifactDigest = "sha256:abc123",
|
||||
IsReachable = isReachable,
|
||||
PathCount = pathCount,
|
||||
Guards = guards,
|
||||
AnalyzedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
}
|
||||
|
||||
private static RuntimeReachabilityResult CreateRuntimeResult(
|
||||
bool wasObserved,
|
||||
long hitCount,
|
||||
int environmentCount = 1)
|
||||
{
|
||||
var contexts = Enumerable.Range(0, environmentCount)
|
||||
.Select(i => new ExecutionContext { Environment = $"env_{i}" })
|
||||
.ToImmutableArray();
|
||||
|
||||
return new RuntimeReachabilityResult
|
||||
{
|
||||
Symbol = CreateTestSymbol(),
|
||||
ArtifactDigest = "sha256:abc123",
|
||||
WasObserved = wasObserved,
|
||||
HitCount = hitCount,
|
||||
Contexts = contexts,
|
||||
ObservationWindow = TimeSpan.FromDays(7),
|
||||
WindowStart = DateTimeOffset.UtcNow.AddDays(-7),
|
||||
WindowEnd = DateTimeOffset.UtcNow
|
||||
};
|
||||
}
|
||||
|
||||
private static SymbolRef CreateTestSymbol() => new()
|
||||
{
|
||||
Purl = "pkg:npm/lodash@4.17.21",
|
||||
Namespace = "lodash",
|
||||
Type = "_",
|
||||
Method = "get"
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
// <copyright file="EvidenceUriBuilderTests.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using FluentAssertions;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Reachability.Core.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="EvidenceUriBuilder"/> and <see cref="EvidenceUri"/>.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class EvidenceUriBuilderTests
|
||||
{
|
||||
private readonly EvidenceUriBuilder _builder = new();
|
||||
|
||||
[Fact]
|
||||
public void BuildReachGraphUri_Returns_Valid_Uri()
|
||||
{
|
||||
// Act
|
||||
var uri = _builder.BuildReachGraphUri("sha256:abc123");
|
||||
|
||||
// Assert
|
||||
uri.Should().Be("stella://reachgraph/sha256:abc123");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildReachGraphUri_Adds_Sha256_Prefix_If_Missing()
|
||||
{
|
||||
// Act
|
||||
var uri = _builder.BuildReachGraphUri("abc123");
|
||||
|
||||
// Assert
|
||||
uri.Should().Be("stella://reachgraph/sha256:abc123");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildReachGraphSliceUri_Returns_Uri_With_Symbol()
|
||||
{
|
||||
// Act
|
||||
var uri = _builder.BuildReachGraphSliceUri("sha256:abc123", "symbol_id_123");
|
||||
|
||||
// Assert
|
||||
uri.Should().StartWith("stella://reachgraph/sha256:abc123/slice?symbol=");
|
||||
uri.Should().Contain("symbol_id_123");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildReachGraphSliceUri_Escapes_SymbolId()
|
||||
{
|
||||
// Arrange
|
||||
var symbolWithSpecialChars = "System.IO/File.Read()";
|
||||
|
||||
// Act
|
||||
var uri = _builder.BuildReachGraphSliceUri("sha256:abc123", symbolWithSpecialChars);
|
||||
|
||||
// Assert
|
||||
uri.Should().NotContain("/File"); // Should be escaped
|
||||
uri.Should().Contain(Uri.EscapeDataString(symbolWithSpecialChars));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildRuntimeFactsUri_Returns_Uri_With_Tenant()
|
||||
{
|
||||
// Act
|
||||
var uri = _builder.BuildRuntimeFactsUri("tenant1", "sha256:abc123");
|
||||
|
||||
// Assert
|
||||
uri.Should().Be("stella://signals/runtime/tenant1/sha256:abc123");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildRuntimeFactsUri_WithSymbol_Returns_Full_Uri()
|
||||
{
|
||||
// Act
|
||||
var uri = _builder.BuildRuntimeFactsUri("tenant1", "sha256:abc123", "symbol_id");
|
||||
|
||||
// Assert
|
||||
uri.Should().Be("stella://signals/runtime/tenant1/sha256:abc123?symbol=symbol_id");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildRuntimeFactsUri_Escapes_TenantId()
|
||||
{
|
||||
// Arrange
|
||||
var tenantWithSpace = "tenant with space";
|
||||
|
||||
// Act
|
||||
var uri = _builder.BuildRuntimeFactsUri(tenantWithSpace, "sha256:abc123");
|
||||
|
||||
// Assert
|
||||
uri.Should().Contain(Uri.EscapeDataString(tenantWithSpace));
|
||||
uri.Should().NotContain(" ");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildCveMappingUri_Returns_Valid_Uri()
|
||||
{
|
||||
// Act
|
||||
var uri = _builder.BuildCveMappingUri("CVE-2024-1234");
|
||||
|
||||
// Assert
|
||||
uri.Should().Be("stella://cvemap/CVE-2024-1234");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildAttestationUri_Returns_Valid_Uri()
|
||||
{
|
||||
// Act
|
||||
var uri = _builder.BuildAttestationUri("sha256:attestation_digest");
|
||||
|
||||
// Assert
|
||||
uri.Should().Be("stella://attestation/sha256:attestation_digest");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildHybridResultUri_Returns_Valid_Uri()
|
||||
{
|
||||
// Act
|
||||
var uri = _builder.BuildHybridResultUri("sha256:result_digest");
|
||||
|
||||
// Assert
|
||||
uri.Should().Be("stella://hybrid/sha256:result_digest");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
public void BuildReachGraphUri_Throws_On_Invalid_Digest(string? digest)
|
||||
{
|
||||
// Act & Assert
|
||||
var act = () => _builder.BuildReachGraphUri(digest!);
|
||||
act.Should().Throw<ArgumentException>();
|
||||
}
|
||||
|
||||
// EvidenceUri parsing tests
|
||||
|
||||
[Fact]
|
||||
public void Parse_Valid_ReachGraph_Uri()
|
||||
{
|
||||
// Act
|
||||
var parsed = EvidenceUri.Parse("stella://reachgraph/sha256:abc123");
|
||||
|
||||
// Assert
|
||||
parsed.Should().NotBeNull();
|
||||
parsed!.Scheme.Should().Be("stella");
|
||||
parsed.Type.Should().Be("reachgraph");
|
||||
parsed.Segments.Should().HaveCount(1);
|
||||
parsed.Segments[0].Should().Be("sha256:abc123");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_Uri_With_Query_Parameters()
|
||||
{
|
||||
// Act
|
||||
var parsed = EvidenceUri.Parse("stella://reachgraph/sha256:abc123/slice?symbol=test_symbol");
|
||||
|
||||
// Assert
|
||||
parsed.Should().NotBeNull();
|
||||
parsed!.Type.Should().Be("reachgraph");
|
||||
parsed.Segments.Should().Contain("slice");
|
||||
parsed.Query.Should().ContainKey("symbol");
|
||||
parsed.Query["symbol"].Should().Be("test_symbol");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_Uri_With_Multiple_Query_Parameters()
|
||||
{
|
||||
// Act
|
||||
var parsed = EvidenceUri.Parse("stella://signals/runtime/tenant?symbol=sym&version=1");
|
||||
|
||||
// Assert
|
||||
parsed.Should().NotBeNull();
|
||||
parsed!.Query.Should().HaveCount(2);
|
||||
parsed.Query["symbol"].Should().Be("sym");
|
||||
parsed.Query["version"].Should().Be("1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_Decodes_Escaped_Values()
|
||||
{
|
||||
// Act
|
||||
var parsed = EvidenceUri.Parse("stella://cvemap/CVE%2D2024%2D1234");
|
||||
|
||||
// Assert
|
||||
parsed.Should().NotBeNull();
|
||||
// Note: path segments aren't automatically decoded in this implementation
|
||||
// but query params are
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData("http://example.com")]
|
||||
[InlineData("stella://")]
|
||||
public void Parse_Returns_Null_For_Invalid_Uris(string? uri)
|
||||
{
|
||||
// Act
|
||||
var parsed = EvidenceUri.Parse(uri!);
|
||||
|
||||
// Assert
|
||||
parsed.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_Is_Case_Insensitive_For_Scheme()
|
||||
{
|
||||
// Act
|
||||
var parsed = EvidenceUri.Parse("STELLA://reachgraph/sha256:abc");
|
||||
|
||||
// Assert
|
||||
parsed.Should().NotBeNull();
|
||||
parsed!.Type.Should().Be("reachgraph");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
// <copyright file="HybridReachabilityResultTests.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using FluentAssertions;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Reachability.Core.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="HybridReachabilityResult"/> and related types.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class HybridReachabilityResultTests
|
||||
{
|
||||
[Fact]
|
||||
public void ContentDigest_Is_Deterministic()
|
||||
{
|
||||
// Arrange
|
||||
var now = DateTimeOffset.Parse("2026-01-09T12:00:00Z");
|
||||
var result1 = CreateResult(now);
|
||||
var result2 = CreateResult(now);
|
||||
|
||||
// Act & Assert
|
||||
result1.ContentDigest.Should().Be(result2.ContentDigest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ContentDigest_Has_Sha256_Prefix()
|
||||
{
|
||||
// Arrange
|
||||
var result = CreateResult(DateTimeOffset.UtcNow);
|
||||
|
||||
// Act & Assert
|
||||
result.ContentDigest.Should().StartWith("sha256:");
|
||||
result.ContentDigest.Should().HaveLength(7 + 64); // sha256: + 64 hex chars
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ContentDigest_Changes_With_LatticeState()
|
||||
{
|
||||
// Arrange
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var result1 = CreateResult(now, LatticeState.ConfirmedReachable);
|
||||
var result2 = CreateResult(now, LatticeState.ConfirmedUnreachable);
|
||||
|
||||
// Act & Assert
|
||||
result1.ContentDigest.Should().NotBe(result2.ContentDigest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ContentDigest_Changes_With_Confidence()
|
||||
{
|
||||
// Arrange
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var result1 = CreateResult(now, confidence: 0.5);
|
||||
var result2 = CreateResult(now, confidence: 0.9);
|
||||
|
||||
// Act & Assert
|
||||
result1.ContentDigest.Should().NotBe(result2.ContentDigest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VerdictRecommendation_Affected_Has_Correct_Status()
|
||||
{
|
||||
// Act
|
||||
var verdict = VerdictRecommendation.Affected("Test notes");
|
||||
|
||||
// Assert
|
||||
verdict.VexStatus.Should().Be("affected");
|
||||
verdict.StatusNotes.Should().Be("Test notes");
|
||||
verdict.Justification.Should().BeNull();
|
||||
verdict.RequiresManualReview.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VerdictRecommendation_NotAffectedUnreachable_Has_Correct_Justification()
|
||||
{
|
||||
// Act
|
||||
var verdict = VerdictRecommendation.NotAffectedUnreachable("Test notes");
|
||||
|
||||
// Assert
|
||||
verdict.VexStatus.Should().Be("not_affected");
|
||||
verdict.Justification.Should().Be("vulnerable_code_not_in_execute_path");
|
||||
verdict.StatusNotes.Should().Be("Test notes");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VerdictRecommendation_UnderInvestigation_Has_Correct_Status()
|
||||
{
|
||||
// Act
|
||||
var verdict = VerdictRecommendation.UnderInvestigation("Analyzing...");
|
||||
|
||||
// Assert
|
||||
verdict.VexStatus.Should().Be("under_investigation");
|
||||
verdict.StatusNotes.Should().Be("Analyzing...");
|
||||
verdict.RequiresManualReview.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VerdictRecommendation_NeedsReview_Sets_ManualReviewFlag()
|
||||
{
|
||||
// Act
|
||||
var verdict = VerdictRecommendation.NeedsReview("Conflict detected");
|
||||
|
||||
// Assert
|
||||
verdict.VexStatus.Should().Be("under_investigation");
|
||||
verdict.RequiresManualReview.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EvidenceBundle_Empty_Has_No_Uris()
|
||||
{
|
||||
// Act
|
||||
var bundle = EvidenceBundle.Empty(DateTimeOffset.UtcNow);
|
||||
|
||||
// Assert
|
||||
bundle.Uris.Should().BeEmpty();
|
||||
bundle.Static.Should().BeNull();
|
||||
bundle.Runtime.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StaticEvidence_Contains_Required_Fields()
|
||||
{
|
||||
// Arrange
|
||||
var evidence = new StaticEvidence
|
||||
{
|
||||
ReachGraphDigest = "sha256:abc123",
|
||||
SliceUri = "stella://reachgraph/sha256:abc123/slice?symbol=test"
|
||||
};
|
||||
|
||||
// Assert
|
||||
evidence.ReachGraphDigest.Should().Be("sha256:abc123");
|
||||
evidence.SliceUri.Should().Contain("stella://");
|
||||
evidence.CallPath.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RuntimeEvidence_Contains_Required_Fields()
|
||||
{
|
||||
// Arrange
|
||||
var evidence = new RuntimeEvidence
|
||||
{
|
||||
FactsUri = "stella://signals/runtime/tenant/sha256:abc123",
|
||||
ObservationWindow = TimeSpan.FromDays(7)
|
||||
};
|
||||
|
||||
// Assert
|
||||
evidence.FactsUri.Should().Contain("stella://");
|
||||
evidence.ObservationWindow.Should().Be(TimeSpan.FromDays(7));
|
||||
evidence.SampleTraceIds.Should().BeEmpty();
|
||||
}
|
||||
|
||||
private static HybridReachabilityResult CreateResult(
|
||||
DateTimeOffset computedAt,
|
||||
LatticeState state = LatticeState.ConfirmedReachable,
|
||||
double confidence = 0.95)
|
||||
{
|
||||
var symbol = new SymbolRef
|
||||
{
|
||||
Purl = "pkg:npm/lodash@4.17.21",
|
||||
Namespace = "lodash",
|
||||
Type = "_",
|
||||
Method = "get"
|
||||
};
|
||||
|
||||
return new HybridReachabilityResult
|
||||
{
|
||||
Symbol = symbol,
|
||||
ArtifactDigest = "sha256:abc123",
|
||||
LatticeState = state,
|
||||
Confidence = confidence,
|
||||
Verdict = VerdictRecommendation.Affected(),
|
||||
Evidence = new EvidenceBundle
|
||||
{
|
||||
Uris = ImmutableArray.Create("stella://reachgraph/sha256:abc123"),
|
||||
CollectedAt = computedAt
|
||||
},
|
||||
ComputedAt = computedAt
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
// <copyright file="ReachabilityLatticeTests.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using FluentAssertions;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Reachability.Core.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="ReachabilityLattice"/>.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class ReachabilityLatticeTests
|
||||
{
|
||||
[Fact]
|
||||
public void Initial_State_Is_Unknown()
|
||||
{
|
||||
// Arrange & Act
|
||||
var lattice = new ReachabilityLattice();
|
||||
|
||||
// Assert
|
||||
lattice.CurrentState.Should().Be(LatticeState.Unknown);
|
||||
lattice.Confidence.Should().Be(0.0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ApplyEvidence_StaticReachable_TransitionsToStaticReachable()
|
||||
{
|
||||
// Arrange
|
||||
var lattice = new ReachabilityLattice();
|
||||
|
||||
// Act
|
||||
var transition = lattice.ApplyEvidence(EvidenceType.StaticReachable);
|
||||
|
||||
// Assert
|
||||
transition.Should().NotBeNull();
|
||||
transition!.ToState.Should().Be(LatticeState.StaticReachable);
|
||||
lattice.CurrentState.Should().Be(LatticeState.StaticReachable);
|
||||
lattice.Confidence.Should().BeGreaterThan(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ApplyEvidence_StaticUnreachable_TransitionsToStaticUnreachable()
|
||||
{
|
||||
// Arrange
|
||||
var lattice = new ReachabilityLattice();
|
||||
|
||||
// Act
|
||||
var transition = lattice.ApplyEvidence(EvidenceType.StaticUnreachable);
|
||||
|
||||
// Assert
|
||||
transition.Should().NotBeNull();
|
||||
transition!.ToState.Should().Be(LatticeState.StaticUnreachable);
|
||||
lattice.CurrentState.Should().Be(LatticeState.StaticUnreachable);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ApplyEvidence_RuntimeObserved_TransitionsToRuntimeObserved()
|
||||
{
|
||||
// Arrange
|
||||
var lattice = new ReachabilityLattice();
|
||||
|
||||
// Act
|
||||
var transition = lattice.ApplyEvidence(EvidenceType.RuntimeObserved);
|
||||
|
||||
// Assert
|
||||
transition.Should().NotBeNull();
|
||||
transition!.ToState.Should().Be(LatticeState.RuntimeObserved);
|
||||
lattice.CurrentState.Should().Be(LatticeState.RuntimeObserved);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StaticReachable_Plus_RuntimeObserved_ConfirmsReachable()
|
||||
{
|
||||
// Arrange
|
||||
var lattice = new ReachabilityLattice();
|
||||
|
||||
// Act
|
||||
lattice.ApplyEvidence(EvidenceType.StaticReachable);
|
||||
var transition = lattice.ApplyEvidence(EvidenceType.RuntimeObserved);
|
||||
|
||||
// Assert
|
||||
transition.Should().NotBeNull();
|
||||
transition!.ToState.Should().Be(LatticeState.ConfirmedReachable);
|
||||
lattice.CurrentState.Should().Be(LatticeState.ConfirmedReachable);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StaticUnreachable_Plus_RuntimeUnobserved_ConfirmsUnreachable()
|
||||
{
|
||||
// Arrange
|
||||
var lattice = new ReachabilityLattice();
|
||||
|
||||
// Act
|
||||
lattice.ApplyEvidence(EvidenceType.StaticUnreachable);
|
||||
var transition = lattice.ApplyEvidence(EvidenceType.RuntimeUnobserved);
|
||||
|
||||
// Assert
|
||||
transition.Should().NotBeNull();
|
||||
transition!.ToState.Should().Be(LatticeState.ConfirmedUnreachable);
|
||||
lattice.CurrentState.Should().Be(LatticeState.ConfirmedUnreachable);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StaticUnreachable_Plus_RuntimeObserved_CreatesContest()
|
||||
{
|
||||
// Arrange
|
||||
var lattice = new ReachabilityLattice();
|
||||
|
||||
// Act
|
||||
lattice.ApplyEvidence(EvidenceType.StaticUnreachable);
|
||||
var transition = lattice.ApplyEvidence(EvidenceType.RuntimeObserved);
|
||||
|
||||
// Assert - conflict detected
|
||||
transition.Should().NotBeNull();
|
||||
transition!.ToState.Should().Be(LatticeState.Contested);
|
||||
lattice.CurrentState.Should().Be(LatticeState.Contested);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RuntimeObserved_Plus_StaticUnreachable_CreatesContest()
|
||||
{
|
||||
// Arrange
|
||||
var lattice = new ReachabilityLattice();
|
||||
|
||||
// Act
|
||||
lattice.ApplyEvidence(EvidenceType.RuntimeObserved);
|
||||
var transition = lattice.ApplyEvidence(EvidenceType.StaticUnreachable);
|
||||
|
||||
// Assert - conflict detected
|
||||
transition.Should().NotBeNull();
|
||||
transition!.ToState.Should().Be(LatticeState.Contested);
|
||||
lattice.CurrentState.Should().Be(LatticeState.Contested);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Contested_State_Stays_Contested()
|
||||
{
|
||||
// Arrange
|
||||
var lattice = new ReachabilityLattice();
|
||||
lattice.ApplyEvidence(EvidenceType.StaticUnreachable);
|
||||
lattice.ApplyEvidence(EvidenceType.RuntimeObserved);
|
||||
|
||||
// Act - try more evidence
|
||||
var transition1 = lattice.ApplyEvidence(EvidenceType.StaticReachable);
|
||||
var transition2 = lattice.ApplyEvidence(EvidenceType.RuntimeUnobserved);
|
||||
|
||||
// Assert - stays contested
|
||||
transition1!.ToState.Should().Be(LatticeState.Contested);
|
||||
transition2!.ToState.Should().Be(LatticeState.Contested);
|
||||
lattice.CurrentState.Should().Be(LatticeState.Contested);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Confidence_Is_Clamped_To_Valid_Range()
|
||||
{
|
||||
// Arrange
|
||||
var lattice = new ReachabilityLattice();
|
||||
|
||||
// Act - apply conflicting evidence that might cause negative confidence
|
||||
lattice.ApplyEvidence(EvidenceType.StaticUnreachable);
|
||||
lattice.ApplyEvidence(EvidenceType.RuntimeObserved);
|
||||
|
||||
// Assert
|
||||
lattice.Confidence.Should().BeGreaterThanOrEqualTo(0.0);
|
||||
lattice.Confidence.Should().BeLessThanOrEqualTo(1.0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reset_Returns_To_Unknown()
|
||||
{
|
||||
// Arrange
|
||||
var lattice = new ReachabilityLattice();
|
||||
lattice.ApplyEvidence(EvidenceType.StaticReachable);
|
||||
lattice.ApplyEvidence(EvidenceType.RuntimeObserved);
|
||||
|
||||
// Act
|
||||
lattice.Reset();
|
||||
|
||||
// Assert
|
||||
lattice.CurrentState.Should().Be(LatticeState.Unknown);
|
||||
lattice.Confidence.Should().Be(0.0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Combine_StaticReachable_RuntimeObserved_Returns_ConfirmedReachable()
|
||||
{
|
||||
// Arrange
|
||||
var staticResult = new StaticReachabilityResult
|
||||
{
|
||||
Symbol = CreateTestSymbol(),
|
||||
ArtifactDigest = "sha256:abc123",
|
||||
IsReachable = true,
|
||||
AnalyzedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
var runtimeResult = new RuntimeReachabilityResult
|
||||
{
|
||||
Symbol = CreateTestSymbol(),
|
||||
ArtifactDigest = "sha256:abc123",
|
||||
WasObserved = true,
|
||||
ObservationWindow = TimeSpan.FromDays(7),
|
||||
WindowStart = DateTimeOffset.UtcNow.AddDays(-7),
|
||||
WindowEnd = DateTimeOffset.UtcNow,
|
||||
HitCount = 100
|
||||
};
|
||||
|
||||
// Act
|
||||
var (state, confidence) = ReachabilityLattice.Combine(staticResult, runtimeResult);
|
||||
|
||||
// Assert
|
||||
state.Should().Be(LatticeState.ConfirmedReachable);
|
||||
confidence.Should().BeGreaterThan(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Combine_StaticUnreachable_RuntimeUnobserved_Returns_ConfirmedUnreachable()
|
||||
{
|
||||
// Arrange
|
||||
var staticResult = new StaticReachabilityResult
|
||||
{
|
||||
Symbol = CreateTestSymbol(),
|
||||
ArtifactDigest = "sha256:abc123",
|
||||
IsReachable = false,
|
||||
AnalyzedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
var runtimeResult = new RuntimeReachabilityResult
|
||||
{
|
||||
Symbol = CreateTestSymbol(),
|
||||
ArtifactDigest = "sha256:abc123",
|
||||
WasObserved = false,
|
||||
ObservationWindow = TimeSpan.FromDays(7),
|
||||
WindowStart = DateTimeOffset.UtcNow.AddDays(-7),
|
||||
WindowEnd = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
// Act
|
||||
var (state, confidence) = ReachabilityLattice.Combine(staticResult, runtimeResult);
|
||||
|
||||
// Assert
|
||||
state.Should().Be(LatticeState.ConfirmedUnreachable);
|
||||
confidence.Should().BeGreaterThan(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Combine_NullStatic_RuntimeObserved_Returns_RuntimeObserved()
|
||||
{
|
||||
// Arrange
|
||||
var runtimeResult = new RuntimeReachabilityResult
|
||||
{
|
||||
Symbol = CreateTestSymbol(),
|
||||
ArtifactDigest = "sha256:abc123",
|
||||
WasObserved = true,
|
||||
ObservationWindow = TimeSpan.FromDays(7),
|
||||
WindowStart = DateTimeOffset.UtcNow.AddDays(-7),
|
||||
WindowEnd = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
// Act
|
||||
var (state, _) = ReachabilityLattice.Combine(null, runtimeResult);
|
||||
|
||||
// Assert
|
||||
state.Should().Be(LatticeState.RuntimeObserved);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Combine_StaticReachable_NullRuntime_Returns_StaticReachable()
|
||||
{
|
||||
// Arrange
|
||||
var staticResult = new StaticReachabilityResult
|
||||
{
|
||||
Symbol = CreateTestSymbol(),
|
||||
ArtifactDigest = "sha256:abc123",
|
||||
IsReachable = true,
|
||||
AnalyzedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
// Act
|
||||
var (state, _) = ReachabilityLattice.Combine(staticResult, null);
|
||||
|
||||
// Assert
|
||||
state.Should().Be(LatticeState.StaticReachable);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Combine_BothNull_Returns_Unknown()
|
||||
{
|
||||
// Act
|
||||
var (state, confidence) = ReachabilityLattice.Combine(null, null);
|
||||
|
||||
// Assert
|
||||
state.Should().Be(LatticeState.Unknown);
|
||||
confidence.Should().Be(0);
|
||||
}
|
||||
|
||||
private static SymbolRef CreateTestSymbol() => new()
|
||||
{
|
||||
Purl = "pkg:npm/lodash@4.17.21",
|
||||
Namespace = "lodash",
|
||||
Type = "_",
|
||||
Method = "get"
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentAssertions" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.Reachability.Core\StellaOps.Reachability.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="FluentAssertions" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,212 @@
|
||||
// <copyright file="SymbolRefTests.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using FluentAssertions;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Reachability.Core.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="SymbolRef"/>.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class SymbolRefTests
|
||||
{
|
||||
[Fact]
|
||||
public void CanonicalId_Is_Deterministic()
|
||||
{
|
||||
// Arrange
|
||||
var symbol1 = new SymbolRef
|
||||
{
|
||||
Purl = "pkg:npm/lodash@4.17.21",
|
||||
Namespace = "lodash",
|
||||
Type = "_",
|
||||
Method = "get"
|
||||
};
|
||||
var symbol2 = new SymbolRef
|
||||
{
|
||||
Purl = "pkg:npm/lodash@4.17.21",
|
||||
Namespace = "lodash",
|
||||
Type = "_",
|
||||
Method = "get"
|
||||
};
|
||||
|
||||
// Act & Assert
|
||||
symbol1.CanonicalId.Should().Be(symbol2.CanonicalId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanonicalId_Is_64_Char_Hex()
|
||||
{
|
||||
// Arrange
|
||||
var symbol = new SymbolRef
|
||||
{
|
||||
Purl = "pkg:npm/lodash@4.17.21",
|
||||
Namespace = "lodash",
|
||||
Type = "_",
|
||||
Method = "get"
|
||||
};
|
||||
|
||||
// Act & Assert
|
||||
symbol.CanonicalId.Should().HaveLength(64);
|
||||
symbol.CanonicalId.Should().MatchRegex("^[a-f0-9]+$");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Different_Symbols_Have_Different_CanonicalIds()
|
||||
{
|
||||
// Arrange
|
||||
var symbol1 = new SymbolRef
|
||||
{
|
||||
Purl = "pkg:npm/lodash@4.17.21",
|
||||
Namespace = "lodash",
|
||||
Type = "_",
|
||||
Method = "get"
|
||||
};
|
||||
var symbol2 = new SymbolRef
|
||||
{
|
||||
Purl = "pkg:npm/lodash@4.17.21",
|
||||
Namespace = "lodash",
|
||||
Type = "_",
|
||||
Method = "set"
|
||||
};
|
||||
|
||||
// Act & Assert
|
||||
symbol1.CanonicalId.Should().NotBe(symbol2.CanonicalId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DisplayName_Without_Type_Excludes_Underscore()
|
||||
{
|
||||
// Arrange
|
||||
var symbol = new SymbolRef
|
||||
{
|
||||
Purl = "pkg:npm/lodash@4.17.21",
|
||||
Namespace = "lodash",
|
||||
Type = "_",
|
||||
Method = "get"
|
||||
};
|
||||
|
||||
// Act & Assert
|
||||
symbol.DisplayName.Should().Be("lodash.get");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DisplayName_With_Type_Includes_Type()
|
||||
{
|
||||
// Arrange
|
||||
var symbol = new SymbolRef
|
||||
{
|
||||
Purl = "pkg:nuget/System.Text.Json@8.0.0",
|
||||
Namespace = "System.Text.Json",
|
||||
Type = "JsonSerializer",
|
||||
Method = "Serialize",
|
||||
Signature = "(object)"
|
||||
};
|
||||
|
||||
// Act & Assert
|
||||
symbol.DisplayName.Should().Be("System.Text.Json.JsonSerializer.Serialize(object)");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromFullyQualified_Parses_Simple_Name()
|
||||
{
|
||||
// Act
|
||||
var symbol = SymbolRef.FromFullyQualified("pkg:npm/lodash@4.17.21", "get");
|
||||
|
||||
// Assert
|
||||
symbol.Namespace.Should().Be("_");
|
||||
symbol.Type.Should().Be("_");
|
||||
symbol.Method.Should().Be("get");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromFullyQualified_Parses_With_Namespace()
|
||||
{
|
||||
// Act
|
||||
var symbol = SymbolRef.FromFullyQualified("pkg:npm/lodash@4.17.21", "lodash.get");
|
||||
|
||||
// Assert
|
||||
symbol.Namespace.Should().Be("lodash");
|
||||
symbol.Type.Should().Be("_");
|
||||
symbol.Method.Should().Be("get");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromFullyQualified_Parses_With_Type()
|
||||
{
|
||||
// Act
|
||||
var symbol = SymbolRef.FromFullyQualified(
|
||||
"pkg:nuget/System.Text.Json@8.0.0",
|
||||
"System.Text.Json.JsonSerializer.Serialize");
|
||||
|
||||
// Assert
|
||||
symbol.Namespace.Should().Be("System.Text.Json");
|
||||
symbol.Type.Should().Be("JsonSerializer");
|
||||
symbol.Method.Should().Be("Serialize");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromFullyQualified_Extracts_Signature()
|
||||
{
|
||||
// Act
|
||||
var symbol = SymbolRef.FromFullyQualified(
|
||||
"pkg:nuget/System.Text.Json@8.0.0",
|
||||
"System.Text.Json.JsonSerializer.Serialize(object, Type)");
|
||||
|
||||
// Assert
|
||||
symbol.Method.Should().Be("Serialize");
|
||||
symbol.Signature.Should().Be("(object, Type)");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromFullyQualified_Handles_Empty_Signature()
|
||||
{
|
||||
// Act
|
||||
var symbol = SymbolRef.FromFullyQualified(
|
||||
"pkg:npm/lodash@4.17.21",
|
||||
"lodash.get");
|
||||
|
||||
// Assert
|
||||
symbol.Signature.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null, "test")]
|
||||
[InlineData("", "test")]
|
||||
[InlineData(" ", "test")]
|
||||
[InlineData("pkg:npm/test@1.0.0", null)]
|
||||
[InlineData("pkg:npm/test@1.0.0", "")]
|
||||
[InlineData("pkg:npm/test@1.0.0", " ")]
|
||||
public void FromFullyQualified_Throws_On_Invalid_Input(string? purl, string? name)
|
||||
{
|
||||
// Act & Assert
|
||||
var act = () => SymbolRef.FromFullyQualified(purl!, name!);
|
||||
act.Should().Throw<ArgumentException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Equality_Works_Correctly()
|
||||
{
|
||||
// Arrange
|
||||
var symbol1 = new SymbolRef
|
||||
{
|
||||
Purl = "pkg:npm/lodash@4.17.21",
|
||||
Namespace = "lodash",
|
||||
Type = "_",
|
||||
Method = "get"
|
||||
};
|
||||
var symbol2 = new SymbolRef
|
||||
{
|
||||
Purl = "pkg:npm/lodash@4.17.21",
|
||||
Namespace = "lodash",
|
||||
Type = "_",
|
||||
Method = "get"
|
||||
};
|
||||
|
||||
// Act & Assert
|
||||
symbol1.Should().Be(symbol2);
|
||||
(symbol1 == symbol2).Should().BeTrue();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
// <copyright file="ConfidenceCalculator.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Globalization;
|
||||
|
||||
namespace StellaOps.Reachability.Core;
|
||||
|
||||
/// <summary>
|
||||
/// Calculates evidence-weighted confidence scores for reachability results.
|
||||
/// </summary>
|
||||
public sealed class ConfidenceCalculator
|
||||
{
|
||||
private readonly ConfidenceWeights _weights;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ConfidenceCalculator"/> class.
|
||||
/// </summary>
|
||||
/// <param name="weights">Confidence weight configuration.</param>
|
||||
public ConfidenceCalculator(ConfidenceWeights? weights = null)
|
||||
{
|
||||
_weights = weights ?? ConfidenceWeights.Default;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calculates confidence from lattice state and accumulated evidence.
|
||||
/// </summary>
|
||||
/// <param name="state">The lattice state.</param>
|
||||
/// <param name="accumulatedConfidence">Raw confidence from transitions.</param>
|
||||
/// <returns>Normalized confidence score (0.0-1.0).</returns>
|
||||
public double Calculate(LatticeState state, double accumulatedConfidence)
|
||||
{
|
||||
var (rangeMin, rangeMax) = GetConfidenceRange(state);
|
||||
var rangeSize = rangeMax - rangeMin;
|
||||
|
||||
// Map accumulated confidence to the state's range
|
||||
var normalizedAccumulated = Math.Clamp(accumulatedConfidence, 0.0, 1.0);
|
||||
var confidence = rangeMin + (normalizedAccumulated * rangeSize);
|
||||
|
||||
return Math.Clamp(confidence, 0.0, 1.0);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calculates confidence from static and runtime results.
|
||||
/// </summary>
|
||||
public double Calculate(
|
||||
LatticeState state,
|
||||
StaticReachabilityResult? staticResult,
|
||||
RuntimeReachabilityResult? runtimeResult)
|
||||
{
|
||||
var baseConfidence = GetBaseConfidence(state);
|
||||
|
||||
// Add bonus for supporting evidence
|
||||
var bonus = 0.0;
|
||||
|
||||
if (staticResult is not null)
|
||||
{
|
||||
// More paths = higher confidence in reachability
|
||||
bonus += Math.Min(staticResult.PathCount * _weights.PathCountBonus, _weights.MaxPathBonus);
|
||||
|
||||
// Guards reduce confidence (conditional reachability)
|
||||
if (staticResult.Guards.Length > 0)
|
||||
{
|
||||
bonus -= staticResult.Guards.Length * _weights.GuardPenalty;
|
||||
}
|
||||
}
|
||||
|
||||
if (runtimeResult is not null && runtimeResult.WasObserved)
|
||||
{
|
||||
// More hits = higher confidence
|
||||
var hitBonus = Math.Log10(Math.Max(1, runtimeResult.HitCount)) * _weights.HitCountBonus;
|
||||
bonus += Math.Min(hitBonus, _weights.MaxHitBonus);
|
||||
|
||||
// Multiple environments increase confidence
|
||||
if (runtimeResult.Contexts.Length > 1)
|
||||
{
|
||||
bonus += (runtimeResult.Contexts.Length - 1) * _weights.EnvironmentBonus;
|
||||
}
|
||||
}
|
||||
|
||||
var (rangeMin, rangeMax) = GetConfidenceRange(state);
|
||||
var finalConfidence = Math.Clamp(baseConfidence + bonus, rangeMin, rangeMax);
|
||||
|
||||
return finalConfidence;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the confidence range for a lattice state.
|
||||
/// </summary>
|
||||
public static (double Min, double Max) GetConfidenceRange(LatticeState state)
|
||||
{
|
||||
return state switch
|
||||
{
|
||||
LatticeState.Unknown => (0.00, 0.29),
|
||||
LatticeState.StaticReachable => (0.30, 0.49),
|
||||
LatticeState.StaticUnreachable => (0.50, 0.69),
|
||||
LatticeState.RuntimeObserved => (0.70, 0.89),
|
||||
LatticeState.RuntimeUnobserved => (0.70, 0.89),
|
||||
LatticeState.ConfirmedReachable => (0.90, 1.00),
|
||||
LatticeState.ConfirmedUnreachable => (0.90, 1.00),
|
||||
LatticeState.Contested => (0.00, 0.00), // N/A - requires manual review
|
||||
_ => (0.00, 0.00)
|
||||
};
|
||||
}
|
||||
|
||||
private static double GetBaseConfidence(LatticeState state)
|
||||
{
|
||||
var (min, max) = GetConfidenceRange(state);
|
||||
return (min + max) / 2.0; // Start at midpoint
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configuration weights for confidence calculation.
|
||||
/// </summary>
|
||||
public sealed record ConfidenceWeights
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the bonus per additional path found.
|
||||
/// </summary>
|
||||
public double PathCountBonus { get; init; } = 0.02;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the maximum bonus from path count.
|
||||
/// </summary>
|
||||
public double MaxPathBonus { get; init; } = 0.10;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the penalty per guard condition.
|
||||
/// </summary>
|
||||
public double GuardPenalty { get; init; } = 0.03;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the bonus per log10(hit count).
|
||||
/// </summary>
|
||||
public double HitCountBonus { get; init; } = 0.02;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the maximum bonus from hit count.
|
||||
/// </summary>
|
||||
public double MaxHitBonus { get; init; } = 0.10;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the bonus per additional environment observed.
|
||||
/// </summary>
|
||||
public double EnvironmentBonus { get; init; } = 0.03;
|
||||
|
||||
/// <summary>
|
||||
/// Gets default weights.
|
||||
/// </summary>
|
||||
public static ConfidenceWeights Default { get; } = new();
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
// <copyright file="CveSymbolMapping.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.Reachability.Core.CveMapping;
|
||||
|
||||
/// <summary>
|
||||
/// Mapping from CVE identifier to vulnerable symbols.
|
||||
/// Sprint: SPRINT_20260109_009_003 Task: Create CveSymbolMapping model
|
||||
/// </summary>
|
||||
public sealed record CveSymbolMapping
|
||||
{
|
||||
/// <summary>
|
||||
/// CVE identifier (e.g., "CVE-2021-44228").
|
||||
/// </summary>
|
||||
public required string CveId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Vulnerable symbols associated with this CVE.
|
||||
/// </summary>
|
||||
public required ImmutableArray<VulnerableSymbol> Symbols { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Primary source of this mapping.
|
||||
/// </summary>
|
||||
public required MappingSource Source { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Overall confidence score for this mapping (0.0 to 1.0).
|
||||
/// </summary>
|
||||
public required double Confidence { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the mapping was extracted or last updated.
|
||||
/// </summary>
|
||||
public required DateTimeOffset ExtractedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// URL to the patch commit if available.
|
||||
/// </summary>
|
||||
public string? PatchCommitUrl { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Delta signature digest for binary matching.
|
||||
/// </summary>
|
||||
public string? DeltaSigDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// OSV advisory ID if enriched from OSV.
|
||||
/// </summary>
|
||||
public string? OsvAdvisoryId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Package URLs (PURLs) of affected packages.
|
||||
/// </summary>
|
||||
public ImmutableArray<string> AffectedPurls { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Content digest for deduplication and caching.
|
||||
/// </summary>
|
||||
public required string ContentDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new mapping with computed content digest.
|
||||
/// </summary>
|
||||
public static CveSymbolMapping Create(
|
||||
string cveId,
|
||||
IEnumerable<VulnerableSymbol> symbols,
|
||||
MappingSource source,
|
||||
double confidence,
|
||||
TimeProvider timeProvider,
|
||||
string? patchCommitUrl = null,
|
||||
string? osvAdvisoryId = null,
|
||||
IEnumerable<string>? affectedPurls = null)
|
||||
{
|
||||
var symbolsArray = symbols.ToImmutableArray();
|
||||
var purlsArray = affectedPurls?.ToImmutableArray() ?? [];
|
||||
var extractedAt = timeProvider.GetUtcNow();
|
||||
|
||||
var contentDigest = ComputeContentDigest(cveId, symbolsArray, source);
|
||||
|
||||
return new CveSymbolMapping
|
||||
{
|
||||
CveId = cveId,
|
||||
Symbols = symbolsArray,
|
||||
Source = source,
|
||||
Confidence = Math.Clamp(confidence, 0.0, 1.0),
|
||||
ExtractedAt = extractedAt,
|
||||
PatchCommitUrl = patchCommitUrl,
|
||||
OsvAdvisoryId = osvAdvisoryId,
|
||||
AffectedPurls = purlsArray,
|
||||
ContentDigest = contentDigest
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes a deterministic content digest for the mapping.
|
||||
/// </summary>
|
||||
public static string ComputeContentDigest(
|
||||
string cveId,
|
||||
ImmutableArray<VulnerableSymbol> symbols,
|
||||
MappingSource source)
|
||||
{
|
||||
// Sort symbol canonical IDs for determinism
|
||||
var sortedIds = symbols
|
||||
.Select(s => s.Symbol.CanonicalId)
|
||||
.Order()
|
||||
.ToList();
|
||||
|
||||
var content = new
|
||||
{
|
||||
cve = cveId.ToUpperInvariant(),
|
||||
source = source.ToString(),
|
||||
symbols = sortedIds
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(content);
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(json));
|
||||
return Convert.ToHexStringLower(hash);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Merges another mapping into this one, combining symbols.
|
||||
/// </summary>
|
||||
public CveSymbolMapping Merge(CveSymbolMapping other, TimeProvider timeProvider)
|
||||
{
|
||||
if (!string.Equals(CveId, other.CveId, StringComparison.OrdinalIgnoreCase))
|
||||
throw new ArgumentException($"Cannot merge mappings for different CVEs: {CveId} vs {other.CveId}");
|
||||
|
||||
// Combine symbols, deduplicating by canonical ID
|
||||
var existingIds = Symbols.Select(s => s.Symbol.CanonicalId).ToHashSet();
|
||||
var newSymbols = other.Symbols.Where(s => !existingIds.Contains(s.Symbol.CanonicalId));
|
||||
var mergedSymbols = Symbols.AddRange(newSymbols);
|
||||
|
||||
// Combine PURLs
|
||||
var mergedPurls = AffectedPurls.Union(other.AffectedPurls).ToImmutableArray();
|
||||
|
||||
// Take highest confidence
|
||||
var mergedConfidence = Math.Max(Confidence, other.Confidence);
|
||||
|
||||
// Prefer patch commit URL if we don't have one
|
||||
var patchUrl = PatchCommitUrl ?? other.PatchCommitUrl;
|
||||
|
||||
// Prefer OSV ID if we don't have one
|
||||
var osvId = OsvAdvisoryId ?? other.OsvAdvisoryId;
|
||||
|
||||
return Create(
|
||||
CveId,
|
||||
mergedSymbols,
|
||||
Source,
|
||||
mergedConfidence,
|
||||
timeProvider,
|
||||
patchUrl,
|
||||
osvId,
|
||||
mergedPurls);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
// <copyright file="CveSymbolMappingService.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Concurrent;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Reachability.Core.Symbols;
|
||||
|
||||
namespace StellaOps.Reachability.Core.CveMapping;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory implementation of CVE symbol mapping service.
|
||||
/// Sprint: SPRINT_20260109_009_003 Task: Implement CveSymbolMappingService
|
||||
/// </summary>
|
||||
public sealed class CveSymbolMappingService : ICveSymbolMappingService
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, CveSymbolMapping> _mappings = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly ConcurrentDictionary<string, HashSet<string>> _symbolToCves = new(StringComparer.Ordinal);
|
||||
private readonly IPatchSymbolExtractor? _patchExtractor;
|
||||
private readonly IOsvEnricher? _osvEnricher;
|
||||
private readonly ISymbolCanonicalizer _canonicalizer;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<CveSymbolMappingService> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new CVE symbol mapping service.
|
||||
/// </summary>
|
||||
public CveSymbolMappingService(
|
||||
ISymbolCanonicalizer canonicalizer,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<CveSymbolMappingService> logger,
|
||||
IPatchSymbolExtractor? patchExtractor = null,
|
||||
IOsvEnricher? osvEnricher = null)
|
||||
{
|
||||
_canonicalizer = canonicalizer;
|
||||
_timeProvider = timeProvider;
|
||||
_logger = logger;
|
||||
_patchExtractor = patchExtractor;
|
||||
_osvEnricher = osvEnricher;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<CveSymbolMapping?> GetMappingAsync(string cveId, CancellationToken ct)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
var normalizedId = NormalizeCveId(cveId);
|
||||
_mappings.TryGetValue(normalizedId, out var mapping);
|
||||
return Task.FromResult(mapping);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyDictionary<string, CveSymbolMapping>> GetMappingsBatchAsync(
|
||||
IEnumerable<string> cveIds,
|
||||
CancellationToken ct)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
var result = new Dictionary<string, CveSymbolMapping>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var cveId in cveIds)
|
||||
{
|
||||
var normalizedId = NormalizeCveId(cveId);
|
||||
if (_mappings.TryGetValue(normalizedId, out var mapping))
|
||||
{
|
||||
result[normalizedId] = mapping;
|
||||
}
|
||||
}
|
||||
|
||||
return Task.FromResult<IReadOnlyDictionary<string, CveSymbolMapping>>(result);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task IngestMappingAsync(CveSymbolMapping mapping, CancellationToken ct)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
var normalizedId = NormalizeCveId(mapping.CveId);
|
||||
|
||||
// Merge with existing mapping if present
|
||||
_mappings.AddOrUpdate(
|
||||
normalizedId,
|
||||
mapping,
|
||||
(_, existing) => existing.Merge(mapping, _timeProvider));
|
||||
|
||||
// Update symbol-to-CVE index
|
||||
foreach (var symbol in mapping.Symbols)
|
||||
{
|
||||
var cves = _symbolToCves.GetOrAdd(symbol.Symbol.CanonicalId, _ => new HashSet<string>(StringComparer.OrdinalIgnoreCase));
|
||||
lock (cves)
|
||||
{
|
||||
cves.Add(normalizedId);
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Ingested mapping for {CveId} with {SymbolCount} symbols from {Source}",
|
||||
normalizedId,
|
||||
mapping.Symbols.Length,
|
||||
mapping.Source);
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<CveSymbolMapping> ExtractFromPatchAsync(
|
||||
string cveId,
|
||||
string commitUrl,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (_patchExtractor is null)
|
||||
throw new InvalidOperationException("Patch extractor is not configured");
|
||||
|
||||
var normalizedId = NormalizeCveId(cveId);
|
||||
|
||||
_logger.LogInformation("Extracting symbols from patch for {CveId}: {CommitUrl}", normalizedId, commitUrl);
|
||||
|
||||
var result = await _patchExtractor.ExtractFromCommitUrlAsync(commitUrl, ct);
|
||||
|
||||
if (!result.Success)
|
||||
{
|
||||
throw new InvalidOperationException($"Failed to extract symbols from patch: {result.Error}");
|
||||
}
|
||||
|
||||
var mapping = CveSymbolMapping.Create(
|
||||
normalizedId,
|
||||
result.Symbols,
|
||||
MappingSource.PatchAnalysis,
|
||||
confidence: 0.8,
|
||||
_timeProvider,
|
||||
patchCommitUrl: commitUrl);
|
||||
|
||||
return mapping;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<CveSymbolMapping> EnrichWithOsvAsync(
|
||||
CveSymbolMapping mapping,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (_osvEnricher is null)
|
||||
{
|
||||
_logger.LogWarning("OSV enricher is not configured, returning original mapping");
|
||||
return mapping;
|
||||
}
|
||||
|
||||
var enrichment = await _osvEnricher.EnrichAsync(mapping.CveId, ct);
|
||||
|
||||
if (!enrichment.Found)
|
||||
{
|
||||
_logger.LogDebug("CVE {CveId} not found in OSV", mapping.CveId);
|
||||
return mapping;
|
||||
}
|
||||
|
||||
// Merge OSV symbols with existing
|
||||
var mergedSymbols = mapping.Symbols.ToList();
|
||||
var existingIds = mapping.Symbols.Select(s => s.Symbol.CanonicalId).ToHashSet();
|
||||
|
||||
foreach (var symbol in enrichment.Symbols)
|
||||
{
|
||||
if (!existingIds.Contains(symbol.Symbol.CanonicalId))
|
||||
{
|
||||
mergedSymbols.Add(symbol);
|
||||
}
|
||||
}
|
||||
|
||||
// Merge PURLs
|
||||
var mergedPurls = mapping.AffectedPurls.Union(enrichment.AffectedPurls).ToList();
|
||||
|
||||
return CveSymbolMapping.Create(
|
||||
mapping.CveId,
|
||||
mergedSymbols,
|
||||
mapping.Source,
|
||||
Math.Max(mapping.Confidence, 0.7),
|
||||
_timeProvider,
|
||||
mapping.PatchCommitUrl,
|
||||
enrichment.OsvId,
|
||||
mergedPurls);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<CveSymbolMapping>> SearchBySymbolAsync(
|
||||
string symbolPattern,
|
||||
int limit,
|
||||
CancellationToken ct)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
var pattern = symbolPattern.ToLowerInvariant();
|
||||
var isWildcard = pattern.Contains('*');
|
||||
|
||||
var results = new List<CveSymbolMapping>();
|
||||
|
||||
foreach (var mapping in _mappings.Values)
|
||||
{
|
||||
if (results.Count >= limit)
|
||||
break;
|
||||
|
||||
foreach (var symbol in mapping.Symbols)
|
||||
{
|
||||
var displayName = symbol.Symbol.DisplayName.ToLowerInvariant();
|
||||
|
||||
var matches = isWildcard
|
||||
? MatchesWildcard(displayName, pattern)
|
||||
: displayName.Contains(pattern);
|
||||
|
||||
if (matches)
|
||||
{
|
||||
results.Add(mapping);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Task.FromResult<IReadOnlyList<CveSymbolMapping>>(results);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<string>> GetCvesForSymbolAsync(
|
||||
string canonicalId,
|
||||
CancellationToken ct)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
if (_symbolToCves.TryGetValue(canonicalId, out var cves))
|
||||
{
|
||||
lock (cves)
|
||||
{
|
||||
return Task.FromResult<IReadOnlyList<string>>(cves.ToList());
|
||||
}
|
||||
}
|
||||
|
||||
return Task.FromResult<IReadOnlyList<string>>([]);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the total number of mappings.
|
||||
/// </summary>
|
||||
public int MappingCount => _mappings.Count;
|
||||
|
||||
/// <summary>
|
||||
/// Gets all CVE IDs that have mappings.
|
||||
/// </summary>
|
||||
public IEnumerable<string> GetAllCveIds() => _mappings.Keys;
|
||||
|
||||
/// <summary>
|
||||
/// Clears all mappings.
|
||||
/// </summary>
|
||||
public void Clear()
|
||||
{
|
||||
_mappings.Clear();
|
||||
_symbolToCves.Clear();
|
||||
}
|
||||
|
||||
private static string NormalizeCveId(string cveId)
|
||||
{
|
||||
// Normalize to uppercase CVE-YYYY-NNNNN format
|
||||
var normalized = cveId.Trim().ToUpperInvariant();
|
||||
|
||||
if (!normalized.StartsWith("CVE-", StringComparison.Ordinal))
|
||||
{
|
||||
throw new ArgumentException($"Invalid CVE ID format: {cveId}. Expected format: CVE-YYYY-NNNNN");
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private static bool MatchesWildcard(string text, string pattern)
|
||||
{
|
||||
// Simple wildcard matching (* matches any characters)
|
||||
var patternParts = pattern.Split('*');
|
||||
|
||||
var currentIndex = 0;
|
||||
foreach (var part in patternParts)
|
||||
{
|
||||
if (string.IsNullOrEmpty(part))
|
||||
continue;
|
||||
|
||||
var foundIndex = text.IndexOf(part, currentIndex, StringComparison.Ordinal);
|
||||
if (foundIndex < 0)
|
||||
return false;
|
||||
|
||||
currentIndex = foundIndex + part.Length;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
// <copyright file="ICveSymbolMappingService.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
namespace StellaOps.Reachability.Core.CveMapping;
|
||||
|
||||
/// <summary>
|
||||
/// Service for mapping CVE identifiers to vulnerable symbols.
|
||||
/// Sprint: SPRINT_20260109_009_003 Task: Create ICveSymbolMappingService interface
|
||||
/// </summary>
|
||||
public interface ICveSymbolMappingService
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the mapping for a specific CVE.
|
||||
/// </summary>
|
||||
/// <param name="cveId">CVE identifier (e.g., "CVE-2021-44228").</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Mapping if exists, null otherwise.</returns>
|
||||
Task<CveSymbolMapping?> GetMappingAsync(string cveId, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Gets mappings for multiple CVEs in a single call.
|
||||
/// </summary>
|
||||
/// <param name="cveIds">CVE identifiers to look up.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Dictionary mapping CVE ID to its mapping (only includes found mappings).</returns>
|
||||
Task<IReadOnlyDictionary<string, CveSymbolMapping>> GetMappingsBatchAsync(
|
||||
IEnumerable<string> cveIds,
|
||||
CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Ingests a mapping from any source.
|
||||
/// </summary>
|
||||
/// <param name="mapping">The mapping to ingest.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
Task IngestMappingAsync(CveSymbolMapping mapping, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Extracts a mapping by analyzing a patch commit.
|
||||
/// </summary>
|
||||
/// <param name="cveId">CVE identifier.</param>
|
||||
/// <param name="commitUrl">URL to the patch commit.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Extracted mapping.</returns>
|
||||
Task<CveSymbolMapping> ExtractFromPatchAsync(
|
||||
string cveId,
|
||||
string commitUrl,
|
||||
CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Enriches an existing mapping with data from OSV.
|
||||
/// </summary>
|
||||
/// <param name="mapping">Mapping to enrich.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Enriched mapping.</returns>
|
||||
Task<CveSymbolMapping> EnrichWithOsvAsync(
|
||||
CveSymbolMapping mapping,
|
||||
CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Searches for mappings by symbol pattern.
|
||||
/// </summary>
|
||||
/// <param name="symbolPattern">Pattern to match (supports wildcards).</param>
|
||||
/// <param name="limit">Maximum results to return.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Matching mappings.</returns>
|
||||
Task<IReadOnlyList<CveSymbolMapping>> SearchBySymbolAsync(
|
||||
string symbolPattern,
|
||||
int limit,
|
||||
CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Gets all CVEs that have mappings for a specific canonical symbol.
|
||||
/// </summary>
|
||||
/// <param name="canonicalId">The canonical symbol ID.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>List of CVE IDs that affect the symbol.</returns>
|
||||
Task<IReadOnlyList<string>> GetCvesForSymbolAsync(
|
||||
string canonicalId,
|
||||
CancellationToken ct);
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
// <copyright file="IOsvEnricher.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Reachability.Core.CveMapping;
|
||||
|
||||
/// <summary>
|
||||
/// Enriches CVE mappings with data from the OSV database.
|
||||
/// Sprint: SPRINT_20260109_009_003 Task: Create IOsvEnricher interface
|
||||
/// </summary>
|
||||
public interface IOsvEnricher
|
||||
{
|
||||
/// <summary>
|
||||
/// Enriches a mapping with OSV data.
|
||||
/// </summary>
|
||||
/// <param name="cveId">CVE identifier to look up.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Enrichment result.</returns>
|
||||
Task<OsvEnrichmentResult> EnrichAsync(string cveId, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Queries OSV for a vulnerability by ID.
|
||||
/// </summary>
|
||||
/// <param name="vulnId">Vulnerability ID (CVE, GHSA, etc.).</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>OSV vulnerability data if found.</returns>
|
||||
Task<OsvVulnerability?> GetVulnerabilityAsync(string vulnId, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Queries OSV for vulnerabilities affecting a package.
|
||||
/// </summary>
|
||||
/// <param name="ecosystem">Package ecosystem (npm, pypi, maven, etc.).</param>
|
||||
/// <param name="packageName">Package name.</param>
|
||||
/// <param name="version">Package version.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>List of affecting vulnerabilities.</returns>
|
||||
Task<IReadOnlyList<OsvVulnerability>> QueryByPackageAsync(
|
||||
string ecosystem,
|
||||
string packageName,
|
||||
string? version,
|
||||
CancellationToken ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of OSV enrichment.
|
||||
/// </summary>
|
||||
public sealed record OsvEnrichmentResult
|
||||
{
|
||||
/// <summary>
|
||||
/// CVE ID that was looked up.
|
||||
/// </summary>
|
||||
public required string CveId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the CVE was found in OSV.
|
||||
/// </summary>
|
||||
public required bool Found { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// OSV advisory ID if found.
|
||||
/// </summary>
|
||||
public string? OsvId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Package URLs of affected packages.
|
||||
/// </summary>
|
||||
public ImmutableArray<string> AffectedPurls { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Vulnerable symbols extracted from OSV.
|
||||
/// </summary>
|
||||
public ImmutableArray<VulnerableSymbol> Symbols { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Affected version ranges per package.
|
||||
/// </summary>
|
||||
public ImmutableArray<AffectedVersionRange> AffectedVersions { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Creates a "not found" result.
|
||||
/// </summary>
|
||||
public static OsvEnrichmentResult NotFound(string cveId) => new()
|
||||
{
|
||||
CveId = cveId,
|
||||
Found = false
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Affected version range from OSV.
|
||||
/// </summary>
|
||||
public sealed record AffectedVersionRange
|
||||
{
|
||||
/// <summary>
|
||||
/// Package URL.
|
||||
/// </summary>
|
||||
public required string Purl { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Introduced version (inclusive).
|
||||
/// </summary>
|
||||
public string? IntroducedVersion { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Fixed version (exclusive).
|
||||
/// </summary>
|
||||
public string? FixedVersion { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Last affected version if fixed version is unknown.
|
||||
/// </summary>
|
||||
public string? LastAffectedVersion { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// OSV vulnerability record (simplified).
|
||||
/// </summary>
|
||||
public sealed record OsvVulnerability
|
||||
{
|
||||
/// <summary>
|
||||
/// OSV vulnerability ID.
|
||||
/// </summary>
|
||||
public required string Id { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Summary description.
|
||||
/// </summary>
|
||||
public string? Summary { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Detailed description.
|
||||
/// </summary>
|
||||
public string? Details { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Aliases (CVE IDs, GHSA IDs, etc.).
|
||||
/// </summary>
|
||||
public ImmutableArray<string> Aliases { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Affected packages and versions.
|
||||
/// </summary>
|
||||
public ImmutableArray<OsvAffected> Affected { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Severity information.
|
||||
/// </summary>
|
||||
public ImmutableArray<OsvSeverity> Severity { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// References (URLs).
|
||||
/// </summary>
|
||||
public ImmutableArray<OsvReference> References { get; init; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// OSV affected package record.
|
||||
/// </summary>
|
||||
public sealed record OsvAffected
|
||||
{
|
||||
/// <summary>
|
||||
/// Package information.
|
||||
/// </summary>
|
||||
public OsvPackage? Package { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Version ranges.
|
||||
/// </summary>
|
||||
public ImmutableArray<OsvRange> Ranges { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Specific affected versions.
|
||||
/// </summary>
|
||||
public ImmutableArray<string> Versions { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Ecosystem-specific data (may contain function names).
|
||||
/// </summary>
|
||||
public ImmutableDictionary<string, object>? EcosystemSpecific { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// OSV package record.
|
||||
/// </summary>
|
||||
public sealed record OsvPackage
|
||||
{
|
||||
/// <summary>
|
||||
/// Package ecosystem.
|
||||
/// </summary>
|
||||
public required string Ecosystem { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Package name.
|
||||
/// </summary>
|
||||
public required string Name { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Package URL.
|
||||
/// </summary>
|
||||
public string? Purl { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// OSV version range.
|
||||
/// </summary>
|
||||
public sealed record OsvRange
|
||||
{
|
||||
/// <summary>
|
||||
/// Range type (SEMVER, ECOSYSTEM, GIT).
|
||||
/// </summary>
|
||||
public required string Type { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Events defining the range.
|
||||
/// </summary>
|
||||
public ImmutableArray<OsvEvent> Events { get; init; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// OSV range event.
|
||||
/// </summary>
|
||||
public sealed record OsvEvent
|
||||
{
|
||||
/// <summary>
|
||||
/// Introduced version.
|
||||
/// </summary>
|
||||
public string? Introduced { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Fixed version.
|
||||
/// </summary>
|
||||
public string? Fixed { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Last affected version.
|
||||
/// </summary>
|
||||
public string? LastAffected { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// OSV severity record.
|
||||
/// </summary>
|
||||
public sealed record OsvSeverity
|
||||
{
|
||||
/// <summary>
|
||||
/// Severity type (CVSS_V3, etc.).
|
||||
/// </summary>
|
||||
public required string Type { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Score or vector string.
|
||||
/// </summary>
|
||||
public required string Score { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// OSV reference record.
|
||||
/// </summary>
|
||||
public sealed record OsvReference
|
||||
{
|
||||
/// <summary>
|
||||
/// Reference type (ADVISORY, FIX, WEB, etc.).
|
||||
/// </summary>
|
||||
public required string Type { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Reference URL.
|
||||
/// </summary>
|
||||
public required string Url { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
// <copyright file="IPatchSymbolExtractor.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
namespace StellaOps.Reachability.Core.CveMapping;
|
||||
|
||||
/// <summary>
|
||||
/// Extracts vulnerable symbols from patch/diff data.
|
||||
/// Sprint: SPRINT_20260109_009_003 Task: Create IPatchSymbolExtractor interface
|
||||
/// </summary>
|
||||
public interface IPatchSymbolExtractor
|
||||
{
|
||||
/// <summary>
|
||||
/// Extracts symbols from a commit URL.
|
||||
/// </summary>
|
||||
/// <param name="commitUrl">URL to the commit (GitHub, GitLab, etc.).</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Extraction result with symbols.</returns>
|
||||
Task<PatchAnalysisResult> ExtractFromCommitUrlAsync(
|
||||
string commitUrl,
|
||||
CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Extracts symbols from raw unified diff content.
|
||||
/// </summary>
|
||||
/// <param name="diffContent">Raw unified diff text.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Extraction result with symbols.</returns>
|
||||
Task<PatchAnalysisResult> ExtractFromDiffAsync(
|
||||
string diffContent,
|
||||
CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Extracts symbols from a local git repository commit.
|
||||
/// </summary>
|
||||
/// <param name="repositoryPath">Path to the local repository.</param>
|
||||
/// <param name="commitSha">Commit SHA to analyze.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Extraction result with symbols.</returns>
|
||||
Task<PatchAnalysisResult> ExtractFromLocalCommitAsync(
|
||||
string repositoryPath,
|
||||
string commitSha,
|
||||
CancellationToken ct);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
// <copyright file="MappingSource.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
namespace StellaOps.Reachability.Core.CveMapping;
|
||||
|
||||
/// <summary>
|
||||
/// Source of CVE-symbol mapping.
|
||||
/// Sprint: SPRINT_20260109_009_003 Task: Create MappingSource enum
|
||||
/// </summary>
|
||||
public enum MappingSource
|
||||
{
|
||||
/// <summary>Unknown source.</summary>
|
||||
Unknown = 0,
|
||||
|
||||
/// <summary>Automated extraction from git diff/patch.</summary>
|
||||
PatchAnalysis = 1,
|
||||
|
||||
/// <summary>OSV database with function-level data.</summary>
|
||||
OsvDatabase = 2,
|
||||
|
||||
/// <summary>Manual security researcher curation.</summary>
|
||||
ManualCuration = 3,
|
||||
|
||||
/// <summary>Binary delta signature matching.</summary>
|
||||
DeltaSignature = 4,
|
||||
|
||||
/// <summary>AI-assisted extraction from CVE description.</summary>
|
||||
AiExtraction = 5,
|
||||
|
||||
/// <summary>Vendor security advisory.</summary>
|
||||
VendorAdvisory = 6
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
// <copyright file="PatchAnalysisResult.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Reachability.Core.CveMapping;
|
||||
|
||||
/// <summary>
|
||||
/// Result of analyzing a patch for vulnerable symbols.
|
||||
/// Sprint: SPRINT_20260109_009_003 Task: Create PatchAnalysisResult model
|
||||
/// </summary>
|
||||
public sealed record PatchAnalysisResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether the analysis was successful.
|
||||
/// </summary>
|
||||
public required bool Success { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Error message if analysis failed.
|
||||
/// </summary>
|
||||
public string? Error { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Extracted vulnerable symbols.
|
||||
/// </summary>
|
||||
public ImmutableArray<VulnerableSymbol> Symbols { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Files that were modified in the patch.
|
||||
/// </summary>
|
||||
public ImmutableArray<string> ModifiedFiles { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Total lines added across all files.
|
||||
/// </summary>
|
||||
public int LinesAdded { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Total lines removed across all files.
|
||||
/// </summary>
|
||||
public int LinesRemoved { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Commit SHA if available.
|
||||
/// </summary>
|
||||
public string? CommitSha { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Repository URL.
|
||||
/// </summary>
|
||||
public string? RepositoryUrl { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a successful result.
|
||||
/// </summary>
|
||||
public static PatchAnalysisResult Successful(
|
||||
IEnumerable<VulnerableSymbol> symbols,
|
||||
IEnumerable<string> modifiedFiles,
|
||||
int linesAdded = 0,
|
||||
int linesRemoved = 0,
|
||||
string? commitSha = null,
|
||||
string? repositoryUrl = null) => new()
|
||||
{
|
||||
Success = true,
|
||||
Symbols = symbols.ToImmutableArray(),
|
||||
ModifiedFiles = modifiedFiles.ToImmutableArray(),
|
||||
LinesAdded = linesAdded,
|
||||
LinesRemoved = linesRemoved,
|
||||
CommitSha = commitSha,
|
||||
RepositoryUrl = repositoryUrl
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Creates a failed result.
|
||||
/// </summary>
|
||||
public static PatchAnalysisResult Failed(string error) => new()
|
||||
{
|
||||
Success = false,
|
||||
Error = error
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
// <copyright file="VulnerabilityType.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
namespace StellaOps.Reachability.Core.CveMapping;
|
||||
|
||||
/// <summary>
|
||||
/// Type of vulnerability relationship.
|
||||
/// Sprint: SPRINT_20260109_009_003 Task: Create VulnerabilityType enum
|
||||
/// </summary>
|
||||
public enum VulnerabilityType
|
||||
{
|
||||
/// <summary>Unknown type.</summary>
|
||||
Unknown = 0,
|
||||
|
||||
/// <summary>Sink where untrusted data causes harm.</summary>
|
||||
Sink = 1,
|
||||
|
||||
/// <summary>Source of untrusted data.</summary>
|
||||
TaintSource = 2,
|
||||
|
||||
/// <summary>Entry point for gadget chain.</summary>
|
||||
GadgetEntry = 3,
|
||||
|
||||
/// <summary>Deserialization target.</summary>
|
||||
DeserializationTarget = 4,
|
||||
|
||||
/// <summary>Authentication bypass.</summary>
|
||||
AuthBypass = 5,
|
||||
|
||||
/// <summary>Cryptographic weakness.</summary>
|
||||
CryptoWeakness = 6,
|
||||
|
||||
/// <summary>Remote code execution entry point.</summary>
|
||||
RceEntry = 7,
|
||||
|
||||
/// <summary>SQL injection sink.</summary>
|
||||
SqlInjection = 8,
|
||||
|
||||
/// <summary>Path traversal sink.</summary>
|
||||
PathTraversal = 9,
|
||||
|
||||
/// <summary>Server-side request forgery sink.</summary>
|
||||
Ssrf = 10,
|
||||
|
||||
/// <summary>Cross-site scripting sink.</summary>
|
||||
Xss = 11
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
// <copyright file="VulnerableSymbol.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using StellaOps.Reachability.Core.Symbols;
|
||||
|
||||
namespace StellaOps.Reachability.Core.CveMapping;
|
||||
|
||||
/// <summary>
|
||||
/// A symbol identified as vulnerable.
|
||||
/// Sprint: SPRINT_20260109_009_003 Task: Create VulnerableSymbol model
|
||||
/// </summary>
|
||||
public sealed record VulnerableSymbol
|
||||
{
|
||||
/// <summary>
|
||||
/// Canonical symbol representation.
|
||||
/// </summary>
|
||||
public required CanonicalSymbol Symbol { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Type of vulnerability this symbol represents.
|
||||
/// </summary>
|
||||
public required VulnerabilityType Type { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Condition under which the vulnerability is triggered (optional).
|
||||
/// Example: "When input is not sanitized"
|
||||
/// </summary>
|
||||
public string? Condition { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Confidence score for this symbol mapping (0.0 to 1.0).
|
||||
/// </summary>
|
||||
public required double Confidence { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Evidence supporting this mapping.
|
||||
/// Example: "Modified in fix commit abc123"
|
||||
/// </summary>
|
||||
public string? Evidence { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Source file where the symbol was found (in patch).
|
||||
/// </summary>
|
||||
public string? SourceFile { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Line range in the source file or patch.
|
||||
/// </summary>
|
||||
public LineRange? LineRange { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a vulnerable symbol with minimal required fields.
|
||||
/// </summary>
|
||||
public static VulnerableSymbol Create(
|
||||
CanonicalSymbol symbol,
|
||||
VulnerabilityType type,
|
||||
double confidence = 0.5) => new()
|
||||
{
|
||||
Symbol = symbol,
|
||||
Type = type,
|
||||
Confidence = Math.Clamp(confidence, 0.0, 1.0)
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a line range in source code.
|
||||
/// </summary>
|
||||
/// <param name="Start">Start line (1-based).</param>
|
||||
/// <param name="End">End line (1-based, inclusive).</param>
|
||||
public readonly record struct LineRange(int Start, int End)
|
||||
{
|
||||
/// <summary>
|
||||
/// Number of lines in the range.
|
||||
/// </summary>
|
||||
public int Length => End - Start + 1;
|
||||
|
||||
/// <summary>
|
||||
/// Checks if a line number is within this range.
|
||||
/// </summary>
|
||||
public bool Contains(int line) => line >= Start && line <= End;
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
// <copyright file="EvidenceUriBuilder.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
namespace StellaOps.Reachability.Core;
|
||||
|
||||
/// <summary>
|
||||
/// Builds stella:// URIs for evidence references.
|
||||
/// </summary>
|
||||
public sealed class EvidenceUriBuilder
|
||||
{
|
||||
/// <summary>
|
||||
/// Builds a ReachGraph URI for an artifact.
|
||||
/// </summary>
|
||||
/// <param name="digest">Artifact digest (sha256:...).</param>
|
||||
/// <returns>stella://reachgraph/{digest}</returns>
|
||||
public string BuildReachGraphUri(string digest)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(digest);
|
||||
return $"stella://reachgraph/{NormalizeDigest(digest)}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds a ReachGraph slice URI for a specific symbol.
|
||||
/// </summary>
|
||||
/// <param name="digest">Artifact digest.</param>
|
||||
/// <param name="symbolId">Symbol canonical ID.</param>
|
||||
/// <returns>stella://reachgraph/{digest}/slice?symbol={symbolId}</returns>
|
||||
public string BuildReachGraphSliceUri(string digest, string symbolId)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(digest);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(symbolId);
|
||||
return $"stella://reachgraph/{NormalizeDigest(digest)}/slice?symbol={Uri.EscapeDataString(symbolId)}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds a runtime facts URI for an artifact.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">Tenant identifier.</param>
|
||||
/// <param name="artifactDigest">Artifact digest.</param>
|
||||
/// <returns>stella://signals/runtime/{tenantId}/{digest}</returns>
|
||||
public string BuildRuntimeFactsUri(string tenantId, string artifactDigest)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(artifactDigest);
|
||||
return $"stella://signals/runtime/{Uri.EscapeDataString(tenantId)}/{NormalizeDigest(artifactDigest)}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds a runtime facts URI for a specific symbol.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">Tenant identifier.</param>
|
||||
/// <param name="artifactDigest">Artifact digest.</param>
|
||||
/// <param name="symbolId">Symbol canonical ID.</param>
|
||||
/// <returns>stella://signals/runtime/{tenantId}/{digest}?symbol={symbolId}</returns>
|
||||
public string BuildRuntimeFactsUri(string tenantId, string artifactDigest, string symbolId)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(artifactDigest);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(symbolId);
|
||||
return $"stella://signals/runtime/{Uri.EscapeDataString(tenantId)}/{NormalizeDigest(artifactDigest)}?symbol={Uri.EscapeDataString(symbolId)}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds a CVE mapping URI.
|
||||
/// </summary>
|
||||
/// <param name="cveId">CVE identifier.</param>
|
||||
/// <returns>stella://cvemap/{cveId}</returns>
|
||||
public string BuildCveMappingUri(string cveId)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(cveId);
|
||||
return $"stella://cvemap/{Uri.EscapeDataString(cveId)}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds an attestation URI.
|
||||
/// </summary>
|
||||
/// <param name="digest">Attestation digest.</param>
|
||||
/// <returns>stella://attestation/{digest}</returns>
|
||||
public string BuildAttestationUri(string digest)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(digest);
|
||||
return $"stella://attestation/{NormalizeDigest(digest)}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds a hybrid result URI.
|
||||
/// </summary>
|
||||
/// <param name="contentDigest">Result content digest.</param>
|
||||
/// <returns>stella://hybrid/{digest}</returns>
|
||||
public string BuildHybridResultUri(string contentDigest)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(contentDigest);
|
||||
return $"stella://hybrid/{NormalizeDigest(contentDigest)}";
|
||||
}
|
||||
|
||||
private static string NormalizeDigest(string digest)
|
||||
{
|
||||
// Remove sha256: prefix if present for URL normalization
|
||||
if (digest.StartsWith("sha256:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return digest;
|
||||
}
|
||||
|
||||
// Assume sha256 if no prefix
|
||||
return $"sha256:{digest}";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses stella:// URIs.
|
||||
/// </summary>
|
||||
public sealed record EvidenceUri
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the URI scheme (always "stella").
|
||||
/// </summary>
|
||||
public string Scheme { get; init; } = "stella";
|
||||
|
||||
/// <summary>
|
||||
/// Gets the evidence type (reachgraph, signals, cvemap, attestation, hybrid).
|
||||
/// </summary>
|
||||
public required string Type { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the path segments.
|
||||
/// </summary>
|
||||
public required IReadOnlyList<string> Segments { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets query parameters.
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, string> Query { get; init; } =
|
||||
new Dictionary<string, string>();
|
||||
|
||||
/// <summary>
|
||||
/// Parses a stella:// URI.
|
||||
/// </summary>
|
||||
/// <param name="uri">The URI to parse.</param>
|
||||
/// <returns>Parsed URI, or null if invalid.</returns>
|
||||
public static EvidenceUri? Parse(string uri)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(uri))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!uri.StartsWith("stella://", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var withoutScheme = uri[9..]; // Remove "stella://"
|
||||
var queryIndex = withoutScheme.IndexOf('?', StringComparison.Ordinal);
|
||||
|
||||
var path = queryIndex >= 0 ? withoutScheme[..queryIndex] : withoutScheme;
|
||||
var query = queryIndex >= 0 ? withoutScheme[(queryIndex + 1)..] : string.Empty;
|
||||
|
||||
var segments = path.Split('/', StringSplitOptions.RemoveEmptyEntries);
|
||||
if (segments.Length == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var queryParams = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
if (!string.IsNullOrEmpty(query))
|
||||
{
|
||||
foreach (var pair in query.Split('&', StringSplitOptions.RemoveEmptyEntries))
|
||||
{
|
||||
var eqIndex = pair.IndexOf('=', StringComparison.Ordinal);
|
||||
if (eqIndex > 0)
|
||||
{
|
||||
var key = Uri.UnescapeDataString(pair[..eqIndex]);
|
||||
var value = Uri.UnescapeDataString(pair[(eqIndex + 1)..]);
|
||||
queryParams[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return new EvidenceUri
|
||||
{
|
||||
Type = segments[0],
|
||||
Segments = segments[1..].ToList(),
|
||||
Query = queryParams
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
// <copyright file="HybridQueryOptions.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
namespace StellaOps.Reachability.Core;
|
||||
|
||||
/// <summary>
|
||||
/// Options for hybrid reachability queries.
|
||||
/// </summary>
|
||||
public sealed record HybridQueryOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the runtime observation window to consider.
|
||||
/// Default: 7 days.
|
||||
/// </summary>
|
||||
public TimeSpan ObservationWindow { get; init; } = TimeSpan.FromDays(7);
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether to include static analysis.
|
||||
/// Default: true.
|
||||
/// </summary>
|
||||
public bool IncludeStatic { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether to include runtime analysis.
|
||||
/// Default: true.
|
||||
/// </summary>
|
||||
public bool IncludeRuntime { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the minimum confidence threshold for verdicts.
|
||||
/// Results below this are marked as under_investigation.
|
||||
/// Default: 0.5.
|
||||
/// </summary>
|
||||
public double MinConfidenceThreshold { get; init; } = 0.5;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the timeout for queries.
|
||||
/// Default: 30 seconds.
|
||||
/// </summary>
|
||||
public TimeSpan Timeout { get; init; } = TimeSpan.FromSeconds(30);
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether to include full evidence bundles.
|
||||
/// Default: true.
|
||||
/// </summary>
|
||||
public bool IncludeEvidence { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the tenant ID for multi-tenant scenarios.
|
||||
/// </summary>
|
||||
public string? TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets default options.
|
||||
/// </summary>
|
||||
public static HybridQueryOptions Default { get; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Gets options for static-only analysis.
|
||||
/// </summary>
|
||||
public static HybridQueryOptions StaticOnly { get; } = new()
|
||||
{
|
||||
IncludeStatic = true,
|
||||
IncludeRuntime = false
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Gets options for runtime-only analysis.
|
||||
/// </summary>
|
||||
public static HybridQueryOptions RuntimeOnly { get; } = new()
|
||||
{
|
||||
IncludeStatic = false,
|
||||
IncludeRuntime = true
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
// <copyright file="HybridReachabilityResult.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.Reachability.Core;
|
||||
|
||||
/// <summary>
|
||||
/// Result of hybrid reachability query combining static and runtime evidence.
|
||||
/// </summary>
|
||||
public sealed record HybridReachabilityResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the queried symbol.
|
||||
/// </summary>
|
||||
public required SymbolRef Symbol { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the artifact digest analyzed.
|
||||
/// </summary>
|
||||
public required string ArtifactDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the computed lattice state.
|
||||
/// </summary>
|
||||
public required LatticeState LatticeState { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the confidence score (0.0-1.0).
|
||||
/// </summary>
|
||||
public required double Confidence { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the static analysis result (if performed).
|
||||
/// </summary>
|
||||
public StaticReachabilityResult? StaticResult { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the runtime analysis result (if performed).
|
||||
/// </summary>
|
||||
public RuntimeReachabilityResult? RuntimeResult { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the VEX verdict recommendation.
|
||||
/// </summary>
|
||||
public required VerdictRecommendation Verdict { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the evidence bundle for this result.
|
||||
/// </summary>
|
||||
public required EvidenceBundle Evidence { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets when this result was computed.
|
||||
/// </summary>
|
||||
public required DateTimeOffset ComputedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the content digest for deterministic verification.
|
||||
/// SHA-256 of canonical JSON representation.
|
||||
/// </summary>
|
||||
public string ContentDigest => ComputeContentDigest();
|
||||
|
||||
private string ComputeContentDigest()
|
||||
{
|
||||
// Build canonical content for hashing
|
||||
var content = new
|
||||
{
|
||||
symbol = Symbol.CanonicalId,
|
||||
artifact = ArtifactDigest,
|
||||
latticeState = LatticeState.ToString(),
|
||||
confidence = Confidence.ToString("F6", CultureInfo.InvariantCulture),
|
||||
verdict = new
|
||||
{
|
||||
status = Verdict.VexStatus,
|
||||
justification = Verdict.Justification
|
||||
},
|
||||
evidenceUris = Evidence.Uris.OrderBy(u => u, StringComparer.Ordinal).ToArray()
|
||||
};
|
||||
|
||||
// Use deterministic JSON serialization
|
||||
var json = JsonSerializer.Serialize(content, new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = false
|
||||
});
|
||||
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(json));
|
||||
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// VEX verdict recommendation based on reachability analysis.
|
||||
/// </summary>
|
||||
public sealed record VerdictRecommendation
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the recommended VEX status.
|
||||
/// </summary>
|
||||
public required string VexStatus { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the justification for the verdict.
|
||||
/// </summary>
|
||||
public string? Justification { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets additional status notes.
|
||||
/// </summary>
|
||||
public string? StatusNotes { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether manual review is recommended.
|
||||
/// </summary>
|
||||
public bool RequiresManualReview { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a verdict for affected status.
|
||||
/// </summary>
|
||||
public static VerdictRecommendation Affected(string? notes = null) => new()
|
||||
{
|
||||
VexStatus = "affected",
|
||||
StatusNotes = notes
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Creates a verdict for not_affected with unreachable justification.
|
||||
/// </summary>
|
||||
public static VerdictRecommendation NotAffectedUnreachable(string? notes = null) => new()
|
||||
{
|
||||
VexStatus = "not_affected",
|
||||
Justification = "vulnerable_code_not_in_execute_path",
|
||||
StatusNotes = notes
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Creates a verdict for under_investigation.
|
||||
/// </summary>
|
||||
public static VerdictRecommendation UnderInvestigation(string? notes = null) => new()
|
||||
{
|
||||
VexStatus = "under_investigation",
|
||||
StatusNotes = notes
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Creates a verdict requiring manual review.
|
||||
/// </summary>
|
||||
public static VerdictRecommendation NeedsReview(string? notes = null) => new()
|
||||
{
|
||||
VexStatus = "under_investigation",
|
||||
StatusNotes = notes,
|
||||
RequiresManualReview = true
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bundle of evidence supporting a reachability result.
|
||||
/// </summary>
|
||||
public sealed record EvidenceBundle
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets evidence URIs (stella:// scheme).
|
||||
/// </summary>
|
||||
public ImmutableArray<string> Uris { get; init; } = ImmutableArray<string>.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the static evidence if available.
|
||||
/// </summary>
|
||||
public StaticEvidence? Static { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the runtime evidence if available.
|
||||
/// </summary>
|
||||
public RuntimeEvidence? Runtime { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets when evidence was collected.
|
||||
/// </summary>
|
||||
public required DateTimeOffset CollectedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates an empty evidence bundle.
|
||||
/// </summary>
|
||||
public static EvidenceBundle Empty(DateTimeOffset collectedAt) => new()
|
||||
{
|
||||
CollectedAt = collectedAt
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Static analysis evidence.
|
||||
/// </summary>
|
||||
public sealed record StaticEvidence
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the ReachGraph digest used.
|
||||
/// </summary>
|
||||
public required string ReachGraphDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the graph slice URI.
|
||||
/// </summary>
|
||||
public required string SliceUri { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the call path nodes if available.
|
||||
/// </summary>
|
||||
public ImmutableArray<string> CallPath { get; init; } = ImmutableArray<string>.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runtime observation evidence.
|
||||
/// </summary>
|
||||
public sealed record RuntimeEvidence
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the Signals facts URI.
|
||||
/// </summary>
|
||||
public required string FactsUri { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the observation window.
|
||||
/// </summary>
|
||||
public required TimeSpan ObservationWindow { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets sample trace IDs if available.
|
||||
/// </summary>
|
||||
public ImmutableArray<string> SampleTraceIds { get; init; } = ImmutableArray<string>.Empty;
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
// <copyright file="IReachGraphAdapter.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
namespace StellaOps.Reachability.Core;
|
||||
|
||||
/// <summary>
|
||||
/// Adapter interface for ReachGraph integration.
|
||||
/// </summary>
|
||||
public interface IReachGraphAdapter
|
||||
{
|
||||
/// <summary>
|
||||
/// Queries static reachability from the call graph.
|
||||
/// </summary>
|
||||
/// <param name="symbol">Symbol to query.</param>
|
||||
/// <param name="artifactDigest">Artifact digest.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Static reachability result.</returns>
|
||||
Task<StaticReachabilityResult> QueryAsync(
|
||||
SymbolRef symbol,
|
||||
string artifactDigest,
|
||||
CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Checks if a ReachGraph exists for the artifact.
|
||||
/// </summary>
|
||||
/// <param name="artifactDigest">Artifact digest.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>True if graph exists.</returns>
|
||||
Task<bool> HasGraphAsync(string artifactDigest, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Gets metadata about the ReachGraph.
|
||||
/// </summary>
|
||||
/// <param name="artifactDigest">Artifact digest.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Graph metadata.</returns>
|
||||
Task<ReachGraphMetadata?> GetMetadataAsync(string artifactDigest, CancellationToken ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Metadata about a ReachGraph.
|
||||
/// </summary>
|
||||
public sealed record ReachGraphMetadata
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the artifact digest.
|
||||
/// </summary>
|
||||
public required string ArtifactDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets when the graph was built.
|
||||
/// </summary>
|
||||
public required DateTimeOffset BuiltAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the graph digest.
|
||||
/// </summary>
|
||||
public required string GraphDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the number of nodes in the graph.
|
||||
/// </summary>
|
||||
public int NodeCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the number of edges in the graph.
|
||||
/// </summary>
|
||||
public int EdgeCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the entrypoint count.
|
||||
/// </summary>
|
||||
public int EntrypointCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the analyzer version that built this graph.
|
||||
/// </summary>
|
||||
public string? AnalyzerVersion { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
// <copyright file="IReachabilityIndex.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
namespace StellaOps.Reachability.Core;
|
||||
|
||||
/// <summary>
|
||||
/// Unified facade for hybrid reachability queries combining static call-graph
|
||||
/// analysis with runtime execution evidence.
|
||||
/// </summary>
|
||||
public interface IReachabilityIndex
|
||||
{
|
||||
/// <summary>
|
||||
/// Query static reachability from call graph.
|
||||
/// </summary>
|
||||
/// <param name="symbol">Symbol to query.</param>
|
||||
/// <param name="artifactDigest">Target artifact digest (sha256:...).</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Static reachability result.</returns>
|
||||
Task<StaticReachabilityResult> QueryStaticAsync(
|
||||
SymbolRef symbol,
|
||||
string artifactDigest,
|
||||
CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Query runtime reachability from observed facts.
|
||||
/// </summary>
|
||||
/// <param name="symbol">Symbol to query.</param>
|
||||
/// <param name="artifactDigest">Target artifact digest.</param>
|
||||
/// <param name="observationWindow">Time window to consider.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Runtime reachability result.</returns>
|
||||
Task<RuntimeReachabilityResult> QueryRuntimeAsync(
|
||||
SymbolRef symbol,
|
||||
string artifactDigest,
|
||||
TimeSpan observationWindow,
|
||||
CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Query hybrid reachability combining static and runtime evidence.
|
||||
/// </summary>
|
||||
/// <param name="symbol">Symbol to query.</param>
|
||||
/// <param name="artifactDigest">Target artifact digest.</param>
|
||||
/// <param name="options">Query options.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Hybrid reachability result with verdict recommendation.</returns>
|
||||
Task<HybridReachabilityResult> QueryHybridAsync(
|
||||
SymbolRef symbol,
|
||||
string artifactDigest,
|
||||
HybridQueryOptions options,
|
||||
CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Batch query for multiple symbols (CVE vulnerability analysis).
|
||||
/// </summary>
|
||||
/// <param name="symbols">Symbols to query.</param>
|
||||
/// <param name="artifactDigest">Target artifact digest.</param>
|
||||
/// <param name="options">Query options.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Results for all symbols.</returns>
|
||||
Task<IReadOnlyList<HybridReachabilityResult>> QueryBatchAsync(
|
||||
IEnumerable<SymbolRef> symbols,
|
||||
string artifactDigest,
|
||||
HybridQueryOptions options,
|
||||
CancellationToken ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Service for replaying and verifying reachability query determinism.
|
||||
/// </summary>
|
||||
public interface IReachabilityReplayService
|
||||
{
|
||||
/// <summary>
|
||||
/// Replays a query and verifies the result matches the expected digest.
|
||||
/// </summary>
|
||||
/// <param name="symbol">Symbol to query.</param>
|
||||
/// <param name="artifactDigest">Artifact digest.</param>
|
||||
/// <param name="expectedContentDigest">Expected result content digest.</param>
|
||||
/// <param name="options">Query options.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>True if replay matches, false if diverged.</returns>
|
||||
Task<ReplayVerificationResult> VerifyAsync(
|
||||
SymbolRef symbol,
|
||||
string artifactDigest,
|
||||
string expectedContentDigest,
|
||||
HybridQueryOptions options,
|
||||
CancellationToken ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of a replay verification.
|
||||
/// </summary>
|
||||
public sealed record ReplayVerificationResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets whether the replay matched the expected digest.
|
||||
/// </summary>
|
||||
public required bool IsMatch { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the expected content digest.
|
||||
/// </summary>
|
||||
public required string ExpectedDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the actual content digest from replay.
|
||||
/// </summary>
|
||||
public required string ActualDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the replayed result.
|
||||
/// </summary>
|
||||
public required HybridReachabilityResult ReplayedResult { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets divergence details if not a match.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string>? Divergences { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
// <copyright file="ISignalsAdapter.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
namespace StellaOps.Reachability.Core;
|
||||
|
||||
/// <summary>
|
||||
/// Adapter interface for Signals runtime facts integration.
|
||||
/// </summary>
|
||||
public interface ISignalsAdapter
|
||||
{
|
||||
/// <summary>
|
||||
/// Queries runtime observation facts.
|
||||
/// </summary>
|
||||
/// <param name="symbol">Symbol to query.</param>
|
||||
/// <param name="artifactDigest">Artifact digest.</param>
|
||||
/// <param name="observationWindow">Time window to search.</param>
|
||||
/// <param name="tenantId">Optional tenant ID.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Runtime reachability result.</returns>
|
||||
Task<RuntimeReachabilityResult> QueryAsync(
|
||||
SymbolRef symbol,
|
||||
string artifactDigest,
|
||||
TimeSpan observationWindow,
|
||||
string? tenantId,
|
||||
CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Checks if runtime facts exist for the artifact.
|
||||
/// </summary>
|
||||
/// <param name="artifactDigest">Artifact digest.</param>
|
||||
/// <param name="tenantId">Optional tenant ID.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>True if facts exist.</returns>
|
||||
Task<bool> HasFactsAsync(
|
||||
string artifactDigest,
|
||||
string? tenantId,
|
||||
CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Gets metadata about available runtime facts.
|
||||
/// </summary>
|
||||
/// <param name="artifactDigest">Artifact digest.</param>
|
||||
/// <param name="tenantId">Optional tenant ID.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Facts metadata.</returns>
|
||||
Task<SignalsMetadata?> GetMetadataAsync(
|
||||
string artifactDigest,
|
||||
string? tenantId,
|
||||
CancellationToken ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Metadata about available Signals runtime facts.
|
||||
/// </summary>
|
||||
public sealed record SignalsMetadata
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the artifact digest.
|
||||
/// </summary>
|
||||
public required string ArtifactDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the tenant ID.
|
||||
/// </summary>
|
||||
public string? TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the earliest observation time.
|
||||
/// </summary>
|
||||
public required DateTimeOffset EarliestObservation { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the latest observation time.
|
||||
/// </summary>
|
||||
public required DateTimeOffset LatestObservation { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the total number of observed symbols.
|
||||
/// </summary>
|
||||
public int SymbolCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets total observation count across all symbols.
|
||||
/// </summary>
|
||||
public long TotalObservations { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the environments with observations.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> Environments { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Gets the agent version that collected facts.
|
||||
/// </summary>
|
||||
public string? AgentVersion { get; init; }
|
||||
}
|
||||
84
src/__Libraries/StellaOps.Reachability.Core/LatticeState.cs
Normal file
84
src/__Libraries/StellaOps.Reachability.Core/LatticeState.cs
Normal file
@@ -0,0 +1,84 @@
|
||||
// <copyright file="LatticeState.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
namespace StellaOps.Reachability.Core;
|
||||
|
||||
/// <summary>
|
||||
/// 8-state reachability lattice model.
|
||||
/// States are ordered by evidence strength, progressing from Unknown
|
||||
/// toward confirmed states as more evidence accumulates.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>The lattice represents the progression of confidence in reachability:</para>
|
||||
/// <code>
|
||||
/// X (Contested)
|
||||
/// / \
|
||||
/// CR (Confirmed CU (Confirmed
|
||||
/// Reachable) Unreachable)
|
||||
/// | \ / |
|
||||
/// | \ / |
|
||||
/// RO (Runtime RU (Runtime
|
||||
/// Observed) Unobserved)
|
||||
/// | |
|
||||
/// | |
|
||||
/// SR (Static SU (Static
|
||||
/// Reachable) Unreachable)
|
||||
/// \ /
|
||||
/// \ /
|
||||
/// U (Unknown)
|
||||
/// </code>
|
||||
/// </remarks>
|
||||
public enum LatticeState
|
||||
{
|
||||
/// <summary>
|
||||
/// No analysis performed. Initial state for all symbols.
|
||||
/// VEX: under_investigation, Confidence: 0.00-0.29
|
||||
/// </summary>
|
||||
Unknown = 0,
|
||||
|
||||
/// <summary>
|
||||
/// Static call graph analysis shows path exists from entrypoint.
|
||||
/// VEX: under_investigation, Confidence: 0.30-0.49
|
||||
/// </summary>
|
||||
StaticReachable = 1,
|
||||
|
||||
/// <summary>
|
||||
/// Static call graph analysis proves no path exists.
|
||||
/// VEX: not_affected (vulnerable_code_not_in_execute_path), Confidence: 0.50-0.69
|
||||
/// </summary>
|
||||
StaticUnreachable = 2,
|
||||
|
||||
/// <summary>
|
||||
/// Symbol execution was observed at runtime within observation window.
|
||||
/// VEX: affected, Confidence: 0.70-0.89
|
||||
/// </summary>
|
||||
RuntimeObserved = 3,
|
||||
|
||||
/// <summary>
|
||||
/// Runtime observation window passed with no execution detected.
|
||||
/// VEX: not_affected (vulnerable_code_not_in_execute_path), Confidence: 0.70-0.89
|
||||
/// </summary>
|
||||
RuntimeUnobserved = 4,
|
||||
|
||||
/// <summary>
|
||||
/// Multiple independent sources confirm reachability.
|
||||
/// Static + Runtime both confirm execution path exists.
|
||||
/// VEX: affected, Confidence: 0.90-1.00
|
||||
/// </summary>
|
||||
ConfirmedReachable = 5,
|
||||
|
||||
/// <summary>
|
||||
/// Multiple independent sources confirm unreachability.
|
||||
/// Static proves no path AND runtime observed no execution.
|
||||
/// VEX: not_affected (vulnerable_code_not_in_execute_path), Confidence: 0.90-1.00
|
||||
/// </summary>
|
||||
ConfirmedUnreachable = 6,
|
||||
|
||||
/// <summary>
|
||||
/// Evidence conflict detected - requires manual review.
|
||||
/// Example: Static says unreachable but runtime observed execution.
|
||||
/// VEX: under_investigation (requires_manual_review), Confidence: N/A
|
||||
/// </summary>
|
||||
Contested = 7
|
||||
}
|
||||
266
src/__Libraries/StellaOps.Reachability.Core/ReachabilityIndex.cs
Normal file
266
src/__Libraries/StellaOps.Reachability.Core/ReachabilityIndex.cs
Normal file
@@ -0,0 +1,266 @@
|
||||
// <copyright file="ReachabilityIndex.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Reachability.Core;
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of <see cref="IReachabilityIndex"/>.
|
||||
/// </summary>
|
||||
public sealed class ReachabilityIndex : IReachabilityIndex
|
||||
{
|
||||
private readonly IReachGraphAdapter _reachGraphAdapter;
|
||||
private readonly ISignalsAdapter _signalsAdapter;
|
||||
private readonly ConfidenceCalculator _confidenceCalculator;
|
||||
private readonly EvidenceUriBuilder _evidenceUriBuilder;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ReachabilityIndex"/> class.
|
||||
/// </summary>
|
||||
public ReachabilityIndex(
|
||||
IReachGraphAdapter reachGraphAdapter,
|
||||
ISignalsAdapter signalsAdapter,
|
||||
TimeProvider timeProvider,
|
||||
ConfidenceWeights? confidenceWeights = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(reachGraphAdapter);
|
||||
ArgumentNullException.ThrowIfNull(signalsAdapter);
|
||||
ArgumentNullException.ThrowIfNull(timeProvider);
|
||||
|
||||
_reachGraphAdapter = reachGraphAdapter;
|
||||
_signalsAdapter = signalsAdapter;
|
||||
_timeProvider = timeProvider;
|
||||
_confidenceCalculator = new ConfidenceCalculator(confidenceWeights);
|
||||
_evidenceUriBuilder = new EvidenceUriBuilder();
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<StaticReachabilityResult> QueryStaticAsync(
|
||||
SymbolRef symbol,
|
||||
string artifactDigest,
|
||||
CancellationToken ct)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(symbol);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(artifactDigest);
|
||||
|
||||
return await _reachGraphAdapter.QueryAsync(symbol, artifactDigest, ct)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<RuntimeReachabilityResult> QueryRuntimeAsync(
|
||||
SymbolRef symbol,
|
||||
string artifactDigest,
|
||||
TimeSpan observationWindow,
|
||||
CancellationToken ct)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(symbol);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(artifactDigest);
|
||||
|
||||
return await _signalsAdapter.QueryAsync(
|
||||
symbol,
|
||||
artifactDigest,
|
||||
observationWindow,
|
||||
tenantId: null,
|
||||
ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<HybridReachabilityResult> QueryHybridAsync(
|
||||
SymbolRef symbol,
|
||||
string artifactDigest,
|
||||
HybridQueryOptions options,
|
||||
CancellationToken ct)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(symbol);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(artifactDigest);
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
// Query static and runtime in parallel when both enabled
|
||||
StaticReachabilityResult? staticResult = null;
|
||||
RuntimeReachabilityResult? runtimeResult = null;
|
||||
|
||||
var tasks = new List<Task>();
|
||||
|
||||
if (options.IncludeStatic)
|
||||
{
|
||||
tasks.Add(Task.Run(async () =>
|
||||
{
|
||||
staticResult = await _reachGraphAdapter.QueryAsync(symbol, artifactDigest, ct)
|
||||
.ConfigureAwait(false);
|
||||
}, ct));
|
||||
}
|
||||
|
||||
if (options.IncludeRuntime)
|
||||
{
|
||||
tasks.Add(Task.Run(async () =>
|
||||
{
|
||||
runtimeResult = await _signalsAdapter.QueryAsync(
|
||||
symbol,
|
||||
artifactDigest,
|
||||
options.ObservationWindow,
|
||||
options.TenantId,
|
||||
ct).ConfigureAwait(false);
|
||||
}, ct));
|
||||
}
|
||||
|
||||
await Task.WhenAll(tasks).ConfigureAwait(false);
|
||||
|
||||
// Combine evidence using lattice
|
||||
var (latticeState, _) = ReachabilityLattice.Combine(staticResult, runtimeResult);
|
||||
|
||||
// Calculate confidence
|
||||
var confidence = _confidenceCalculator.Calculate(latticeState, staticResult, runtimeResult);
|
||||
|
||||
// Build verdict
|
||||
var verdict = BuildVerdict(latticeState, confidence, options.MinConfidenceThreshold);
|
||||
|
||||
// Build evidence bundle
|
||||
var evidence = BuildEvidence(symbol, artifactDigest, staticResult, runtimeResult, options, now);
|
||||
|
||||
return new HybridReachabilityResult
|
||||
{
|
||||
Symbol = symbol,
|
||||
ArtifactDigest = artifactDigest,
|
||||
LatticeState = latticeState,
|
||||
Confidence = confidence,
|
||||
StaticResult = staticResult,
|
||||
RuntimeResult = runtimeResult,
|
||||
Verdict = verdict,
|
||||
Evidence = evidence,
|
||||
ComputedAt = now
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<IReadOnlyList<HybridReachabilityResult>> QueryBatchAsync(
|
||||
IEnumerable<SymbolRef> symbols,
|
||||
string artifactDigest,
|
||||
HybridQueryOptions options,
|
||||
CancellationToken ct)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(symbols);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(artifactDigest);
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
|
||||
var symbolList = symbols.ToList();
|
||||
var results = new List<HybridReachabilityResult>(symbolList.Count);
|
||||
|
||||
// Process sequentially to avoid overwhelming adapters
|
||||
// Could be parallelized with SemaphoreSlim if performance is critical
|
||||
foreach (var symbol in symbolList)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
var result = await QueryHybridAsync(symbol, artifactDigest, options, ct)
|
||||
.ConfigureAwait(false);
|
||||
results.Add(result);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private static VerdictRecommendation BuildVerdict(
|
||||
LatticeState state,
|
||||
double confidence,
|
||||
double minConfidence)
|
||||
{
|
||||
// Below minimum confidence = under investigation
|
||||
if (confidence < minConfidence && state != LatticeState.Contested)
|
||||
{
|
||||
return VerdictRecommendation.UnderInvestigation(
|
||||
$"Confidence {confidence:P0} below threshold {minConfidence:P0}");
|
||||
}
|
||||
|
||||
return state switch
|
||||
{
|
||||
LatticeState.Unknown =>
|
||||
VerdictRecommendation.UnderInvestigation("No analysis data available"),
|
||||
|
||||
LatticeState.StaticReachable =>
|
||||
VerdictRecommendation.UnderInvestigation(
|
||||
"Static analysis shows reachable path; runtime confirmation pending"),
|
||||
|
||||
LatticeState.StaticUnreachable =>
|
||||
VerdictRecommendation.NotAffectedUnreachable(
|
||||
"Static analysis proves no execution path exists"),
|
||||
|
||||
LatticeState.RuntimeObserved =>
|
||||
VerdictRecommendation.Affected(
|
||||
"Symbol execution observed at runtime"),
|
||||
|
||||
LatticeState.RuntimeUnobserved =>
|
||||
VerdictRecommendation.NotAffectedUnreachable(
|
||||
"No execution observed during observation window"),
|
||||
|
||||
LatticeState.ConfirmedReachable =>
|
||||
VerdictRecommendation.Affected(
|
||||
"Reachability confirmed by static analysis and runtime observation"),
|
||||
|
||||
LatticeState.ConfirmedUnreachable =>
|
||||
VerdictRecommendation.NotAffectedUnreachable(
|
||||
"Unreachability confirmed by static analysis and runtime observation"),
|
||||
|
||||
LatticeState.Contested =>
|
||||
VerdictRecommendation.NeedsReview(
|
||||
"Evidence conflict detected - static and runtime results disagree"),
|
||||
|
||||
_ => VerdictRecommendation.UnderInvestigation("Unknown state")
|
||||
};
|
||||
}
|
||||
|
||||
private EvidenceBundle BuildEvidence(
|
||||
SymbolRef symbol,
|
||||
string artifactDigest,
|
||||
StaticReachabilityResult? staticResult,
|
||||
RuntimeReachabilityResult? runtimeResult,
|
||||
HybridQueryOptions options,
|
||||
DateTimeOffset now)
|
||||
{
|
||||
var urisBuilder = ImmutableArray.CreateBuilder<string>();
|
||||
|
||||
StaticEvidence? staticEvidence = null;
|
||||
if (staticResult is not null)
|
||||
{
|
||||
var sliceUri = _evidenceUriBuilder.BuildReachGraphSliceUri(artifactDigest, symbol.CanonicalId);
|
||||
urisBuilder.Add(sliceUri);
|
||||
urisBuilder.AddRange(staticResult.EvidenceUris);
|
||||
|
||||
staticEvidence = new StaticEvidence
|
||||
{
|
||||
ReachGraphDigest = artifactDigest,
|
||||
SliceUri = sliceUri,
|
||||
CallPath = ImmutableArray<string>.Empty // Would come from detailed query
|
||||
};
|
||||
}
|
||||
|
||||
RuntimeEvidence? runtimeEvidence = null;
|
||||
if (runtimeResult is not null)
|
||||
{
|
||||
var factsUri = options.TenantId is not null
|
||||
? _evidenceUriBuilder.BuildRuntimeFactsUri(options.TenantId, artifactDigest, symbol.CanonicalId)
|
||||
: _evidenceUriBuilder.BuildRuntimeFactsUri("default", artifactDigest, symbol.CanonicalId);
|
||||
urisBuilder.Add(factsUri);
|
||||
urisBuilder.AddRange(runtimeResult.EvidenceUris);
|
||||
|
||||
runtimeEvidence = new RuntimeEvidence
|
||||
{
|
||||
FactsUri = factsUri,
|
||||
ObservationWindow = runtimeResult.ObservationWindow,
|
||||
SampleTraceIds = ImmutableArray<string>.Empty // Would come from detailed query
|
||||
};
|
||||
}
|
||||
|
||||
return new EvidenceBundle
|
||||
{
|
||||
Uris = urisBuilder.ToImmutable(),
|
||||
Static = staticEvidence,
|
||||
Runtime = runtimeEvidence,
|
||||
CollectedAt = now
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
// <copyright file="ReachabilityLattice.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Frozen;
|
||||
|
||||
namespace StellaOps.Reachability.Core;
|
||||
|
||||
/// <summary>
|
||||
/// State machine implementing the 8-state reachability lattice.
|
||||
/// Handles evidence accumulation and state transitions.
|
||||
/// </summary>
|
||||
public sealed class ReachabilityLattice
|
||||
{
|
||||
private static readonly FrozenDictionary<(LatticeState From, EvidenceType Evidence), LatticeTransition> Transitions;
|
||||
|
||||
static ReachabilityLattice()
|
||||
{
|
||||
var transitions = new Dictionary<(LatticeState, EvidenceType), LatticeTransition>
|
||||
{
|
||||
// From Unknown
|
||||
[(LatticeState.Unknown, EvidenceType.StaticReachable)] =
|
||||
new(LatticeState.StaticReachable, 0.30),
|
||||
[(LatticeState.Unknown, EvidenceType.StaticUnreachable)] =
|
||||
new(LatticeState.StaticUnreachable, 0.40),
|
||||
[(LatticeState.Unknown, EvidenceType.RuntimeObserved)] =
|
||||
new(LatticeState.RuntimeObserved, 0.50),
|
||||
[(LatticeState.Unknown, EvidenceType.RuntimeUnobserved)] =
|
||||
new(LatticeState.RuntimeUnobserved, 0.30),
|
||||
|
||||
// From StaticReachable
|
||||
[(LatticeState.StaticReachable, EvidenceType.RuntimeObserved)] =
|
||||
new(LatticeState.ConfirmedReachable, 0.30),
|
||||
[(LatticeState.StaticReachable, EvidenceType.RuntimeUnobserved)] =
|
||||
new(LatticeState.RuntimeUnobserved, 0.20),
|
||||
|
||||
// From StaticUnreachable
|
||||
[(LatticeState.StaticUnreachable, EvidenceType.RuntimeObserved)] =
|
||||
new(LatticeState.Contested, -0.20), // Conflict!
|
||||
[(LatticeState.StaticUnreachable, EvidenceType.RuntimeUnobserved)] =
|
||||
new(LatticeState.ConfirmedUnreachable, 0.20),
|
||||
|
||||
// From RuntimeObserved
|
||||
[(LatticeState.RuntimeObserved, EvidenceType.StaticReachable)] =
|
||||
new(LatticeState.ConfirmedReachable, 0.20),
|
||||
[(LatticeState.RuntimeObserved, EvidenceType.StaticUnreachable)] =
|
||||
new(LatticeState.Contested, -0.20), // Conflict!
|
||||
|
||||
// From RuntimeUnobserved
|
||||
[(LatticeState.RuntimeUnobserved, EvidenceType.StaticReachable)] =
|
||||
new(LatticeState.StaticReachable, 0.10),
|
||||
[(LatticeState.RuntimeUnobserved, EvidenceType.StaticUnreachable)] =
|
||||
new(LatticeState.ConfirmedUnreachable, 0.20),
|
||||
|
||||
// From ConfirmedReachable - stays confirmed
|
||||
[(LatticeState.ConfirmedReachable, EvidenceType.StaticReachable)] =
|
||||
new(LatticeState.ConfirmedReachable, 0.05),
|
||||
[(LatticeState.ConfirmedReachable, EvidenceType.RuntimeObserved)] =
|
||||
new(LatticeState.ConfirmedReachable, 0.05),
|
||||
[(LatticeState.ConfirmedReachable, EvidenceType.StaticUnreachable)] =
|
||||
new(LatticeState.Contested, -0.30),
|
||||
[(LatticeState.ConfirmedReachable, EvidenceType.RuntimeUnobserved)] =
|
||||
new(LatticeState.Contested, -0.20),
|
||||
|
||||
// From ConfirmedUnreachable - stays confirmed
|
||||
[(LatticeState.ConfirmedUnreachable, EvidenceType.StaticUnreachable)] =
|
||||
new(LatticeState.ConfirmedUnreachable, 0.05),
|
||||
[(LatticeState.ConfirmedUnreachable, EvidenceType.RuntimeUnobserved)] =
|
||||
new(LatticeState.ConfirmedUnreachable, 0.05),
|
||||
[(LatticeState.ConfirmedUnreachable, EvidenceType.StaticReachable)] =
|
||||
new(LatticeState.Contested, -0.30),
|
||||
[(LatticeState.ConfirmedUnreachable, EvidenceType.RuntimeObserved)] =
|
||||
new(LatticeState.Contested, -0.30),
|
||||
|
||||
// From Contested - stays contested until manual resolution
|
||||
[(LatticeState.Contested, EvidenceType.StaticReachable)] =
|
||||
new(LatticeState.Contested, 0.0),
|
||||
[(LatticeState.Contested, EvidenceType.StaticUnreachable)] =
|
||||
new(LatticeState.Contested, 0.0),
|
||||
[(LatticeState.Contested, EvidenceType.RuntimeObserved)] =
|
||||
new(LatticeState.Contested, 0.0),
|
||||
[(LatticeState.Contested, EvidenceType.RuntimeUnobserved)] =
|
||||
new(LatticeState.Contested, 0.0),
|
||||
};
|
||||
|
||||
Transitions = transitions.ToFrozenDictionary();
|
||||
}
|
||||
|
||||
private LatticeState _currentState = LatticeState.Unknown;
|
||||
private double _confidence;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current lattice state.
|
||||
/// </summary>
|
||||
public LatticeState CurrentState => _currentState;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the accumulated confidence score.
|
||||
/// </summary>
|
||||
public double Confidence => Math.Clamp(_confidence, 0.0, 1.0);
|
||||
|
||||
/// <summary>
|
||||
/// Applies new evidence to the lattice.
|
||||
/// </summary>
|
||||
/// <param name="evidence">The evidence type.</param>
|
||||
/// <returns>The resulting transition, or null if no transition defined.</returns>
|
||||
public LatticeTransition? ApplyEvidence(EvidenceType evidence)
|
||||
{
|
||||
var key = (_currentState, evidence);
|
||||
if (Transitions.TryGetValue(key, out var transition))
|
||||
{
|
||||
_currentState = transition.ToState;
|
||||
_confidence += transition.ConfidenceDelta;
|
||||
return transition;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Combines evidence from static and runtime analysis.
|
||||
/// </summary>
|
||||
public static (LatticeState State, double Confidence) Combine(
|
||||
StaticReachabilityResult? staticResult,
|
||||
RuntimeReachabilityResult? runtimeResult)
|
||||
{
|
||||
var lattice = new ReachabilityLattice();
|
||||
|
||||
if (staticResult is not null)
|
||||
{
|
||||
var staticEvidence = staticResult.IsReachable
|
||||
? EvidenceType.StaticReachable
|
||||
: EvidenceType.StaticUnreachable;
|
||||
lattice.ApplyEvidence(staticEvidence);
|
||||
}
|
||||
|
||||
if (runtimeResult is not null)
|
||||
{
|
||||
var runtimeEvidence = runtimeResult.WasObserved
|
||||
? EvidenceType.RuntimeObserved
|
||||
: EvidenceType.RuntimeUnobserved;
|
||||
lattice.ApplyEvidence(runtimeEvidence);
|
||||
}
|
||||
|
||||
return (lattice.CurrentState, lattice.Confidence);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resets the lattice to initial state.
|
||||
/// </summary>
|
||||
public void Reset()
|
||||
{
|
||||
_currentState = LatticeState.Unknown;
|
||||
_confidence = 0.0;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Type of evidence being applied to the lattice.
|
||||
/// </summary>
|
||||
public enum EvidenceType
|
||||
{
|
||||
/// <summary>Static analysis found a reachable path.</summary>
|
||||
StaticReachable,
|
||||
|
||||
/// <summary>Static analysis proved no path exists.</summary>
|
||||
StaticUnreachable,
|
||||
|
||||
/// <summary>Runtime observed symbol execution.</summary>
|
||||
RuntimeObserved,
|
||||
|
||||
/// <summary>Runtime observation window passed with no execution.</summary>
|
||||
RuntimeUnobserved
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a state transition in the lattice.
|
||||
/// </summary>
|
||||
/// <param name="ToState">The resulting state.</param>
|
||||
/// <param name="ConfidenceDelta">Change in confidence score.</param>
|
||||
public sealed record LatticeTransition(LatticeState ToState, double ConfidenceDelta);
|
||||
@@ -0,0 +1,130 @@
|
||||
// <copyright file="RuntimeReachabilityResult.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Reachability.Core;
|
||||
|
||||
/// <summary>
|
||||
/// Result of runtime observation analysis.
|
||||
/// </summary>
|
||||
public sealed record RuntimeReachabilityResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the queried symbol.
|
||||
/// </summary>
|
||||
public required SymbolRef Symbol { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the artifact digest analyzed.
|
||||
/// </summary>
|
||||
public required string ArtifactDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether the symbol was observed executing at runtime.
|
||||
/// </summary>
|
||||
public required bool WasObserved { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the observation window used for this query.
|
||||
/// </summary>
|
||||
public required TimeSpan ObservationWindow { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the start of the observation window.
|
||||
/// </summary>
|
||||
public required DateTimeOffset WindowStart { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the end of the observation window.
|
||||
/// </summary>
|
||||
public required DateTimeOffset WindowEnd { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the number of times this symbol was executed.
|
||||
/// </summary>
|
||||
public long HitCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets when the symbol was first observed (if any).
|
||||
/// </summary>
|
||||
public DateTimeOffset? FirstSeen { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets when the symbol was last observed (if any).
|
||||
/// </summary>
|
||||
public DateTimeOffset? LastSeen { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets execution contexts where this symbol was observed.
|
||||
/// </summary>
|
||||
public ImmutableArray<ExecutionContext> Contexts { get; init; } = ImmutableArray<ExecutionContext>.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the evidence URIs supporting this result.
|
||||
/// </summary>
|
||||
public ImmutableArray<string> EvidenceUris { get; init; } = ImmutableArray<string>.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the traffic profile during observation.
|
||||
/// </summary>
|
||||
public TrafficProfile? Traffic { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the agent that collected this data.
|
||||
/// </summary>
|
||||
public string? AgentVersion { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Context in which a symbol execution was observed.
|
||||
/// </summary>
|
||||
public sealed record ExecutionContext
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the container ID where execution occurred.
|
||||
/// </summary>
|
||||
public string? ContainerId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the process ID.
|
||||
/// </summary>
|
||||
public int? ProcessId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the HTTP route if applicable.
|
||||
/// </summary>
|
||||
public string? Route { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the environment name (production, staging, etc.).
|
||||
/// </summary>
|
||||
public string? Environment { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the frequency ratio (0.0-1.0) of executions in this context.
|
||||
/// </summary>
|
||||
public double Frequency { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Traffic profile during observation window.
|
||||
/// </summary>
|
||||
public sealed record TrafficProfile
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets total request count during observation.
|
||||
/// </summary>
|
||||
public long RequestCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the percentile of traffic coverage (e.g., "p95", "p99").
|
||||
/// </summary>
|
||||
public string? Percentile { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the environments included in observation.
|
||||
/// </summary>
|
||||
public ImmutableArray<string> Environments { get; init; } = ImmutableArray<string>.Empty;
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
// <copyright file="ServiceCollectionExtensions.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
|
||||
namespace StellaOps.Reachability.Core;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for registering Reachability Core services.
|
||||
/// </summary>
|
||||
public static class ServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds Reachability Core services.
|
||||
/// </summary>
|
||||
/// <param name="services">The service collection.</param>
|
||||
/// <param name="configureWeights">Optional confidence weight configuration.</param>
|
||||
/// <returns>The service collection for chaining.</returns>
|
||||
public static IServiceCollection AddReachabilityCore(
|
||||
this IServiceCollection services,
|
||||
Action<ConfidenceWeights>? configureWeights = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
|
||||
services.TryAddSingleton(TimeProvider.System);
|
||||
services.TryAddSingleton<EvidenceUriBuilder>();
|
||||
|
||||
if (configureWeights is not null)
|
||||
{
|
||||
var weights = new ConfidenceWeights();
|
||||
configureWeights(weights);
|
||||
services.TryAddSingleton(weights);
|
||||
}
|
||||
else
|
||||
{
|
||||
services.TryAddSingleton(ConfidenceWeights.Default);
|
||||
}
|
||||
|
||||
services.TryAddSingleton<ConfidenceCalculator>();
|
||||
services.TryAddSingleton<IReachabilityIndex, ReachabilityIndex>();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
// <copyright file="StaticReachabilityResult.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Reachability.Core;
|
||||
|
||||
/// <summary>
|
||||
/// Result of static call-graph reachability analysis.
|
||||
/// </summary>
|
||||
public sealed record StaticReachabilityResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the queried symbol.
|
||||
/// </summary>
|
||||
public required SymbolRef Symbol { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the artifact digest analyzed.
|
||||
/// </summary>
|
||||
public required string ArtifactDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether a path exists from any entrypoint to this symbol.
|
||||
/// </summary>
|
||||
public required bool IsReachable { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the number of distinct paths to this symbol.
|
||||
/// </summary>
|
||||
public int PathCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the shortest path length (call depth) from entrypoint.
|
||||
/// Null if not reachable.
|
||||
/// </summary>
|
||||
public int? ShortestPathLength { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the entrypoints that can reach this symbol.
|
||||
/// </summary>
|
||||
public ImmutableArray<string> Entrypoints { get; init; } = ImmutableArray<string>.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets guard conditions that must be true for reachability.
|
||||
/// Example: feature flags, configuration checks.
|
||||
/// </summary>
|
||||
public ImmutableArray<GuardCondition> Guards { get; init; } = ImmutableArray<GuardCondition>.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the evidence URIs supporting this result.
|
||||
/// </summary>
|
||||
public ImmutableArray<string> EvidenceUris { get; init; } = ImmutableArray<string>.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the analysis timestamp.
|
||||
/// </summary>
|
||||
public required DateTimeOffset AnalyzedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the analyzer version that produced this result.
|
||||
/// </summary>
|
||||
public string? AnalyzerVersion { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A guard condition that affects reachability.
|
||||
/// </summary>
|
||||
public sealed record GuardCondition
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the type of guard (FeatureFlag, Config, Argument, etc.).
|
||||
/// </summary>
|
||||
public required string Type { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the guard key/name.
|
||||
/// </summary>
|
||||
public required string Key { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the value required for reachability.
|
||||
/// </summary>
|
||||
public string? Value { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the expression if more complex than key=value.
|
||||
/// </summary>
|
||||
public string? Expression { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
127
src/__Libraries/StellaOps.Reachability.Core/SymbolRef.cs
Normal file
127
src/__Libraries/StellaOps.Reachability.Core/SymbolRef.cs
Normal file
@@ -0,0 +1,127 @@
|
||||
// <copyright file="SymbolRef.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace StellaOps.Reachability.Core;
|
||||
|
||||
/// <summary>
|
||||
/// Canonical reference to a symbol for reachability queries.
|
||||
/// </summary>
|
||||
public sealed record SymbolRef
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the Package URL (PURL) of the component containing this symbol.
|
||||
/// Example: pkg:npm/lodash@4.17.21
|
||||
/// </summary>
|
||||
public required string Purl { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the namespace (package/module) containing the symbol.
|
||||
/// Example: "lodash" or "System.Text.Json"
|
||||
/// </summary>
|
||||
public required string Namespace { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the type (class/struct) containing the symbol.
|
||||
/// Use "_" for module-level functions without a class.
|
||||
/// </summary>
|
||||
public required string Type { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the method/function name.
|
||||
/// </summary>
|
||||
public required string Method { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the parameter signature.
|
||||
/// Example: "(object, string)" or "(int, int)"
|
||||
/// </summary>
|
||||
public string Signature { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the canonical identifier (SHA-256 hash of normalized symbol).
|
||||
/// </summary>
|
||||
public string CanonicalId => ComputeCanonicalId();
|
||||
|
||||
/// <summary>
|
||||
/// Gets the display-friendly fully qualified name.
|
||||
/// </summary>
|
||||
public string DisplayName => Type == "_"
|
||||
? $"{Namespace}.{Method}{Signature}"
|
||||
: $"{Namespace}.{Type}.{Method}{Signature}";
|
||||
|
||||
/// <summary>
|
||||
/// Creates a SymbolRef from a fully qualified method name.
|
||||
/// </summary>
|
||||
/// <param name="purl">Package URL.</param>
|
||||
/// <param name="fullyQualifiedName">e.g., "Namespace.Type.Method(params)"</param>
|
||||
public static SymbolRef FromFullyQualified(string purl, string fullyQualifiedName)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(purl);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(fullyQualifiedName);
|
||||
|
||||
var signature = string.Empty;
|
||||
var nameWithoutSig = fullyQualifiedName;
|
||||
|
||||
var parenIndex = fullyQualifiedName.IndexOf('(', StringComparison.Ordinal);
|
||||
if (parenIndex >= 0)
|
||||
{
|
||||
signature = fullyQualifiedName[parenIndex..];
|
||||
nameWithoutSig = fullyQualifiedName[..parenIndex];
|
||||
}
|
||||
|
||||
var parts = nameWithoutSig.Split('.');
|
||||
if (parts.Length < 2)
|
||||
{
|
||||
return new SymbolRef
|
||||
{
|
||||
Purl = purl,
|
||||
Namespace = "_",
|
||||
Type = "_",
|
||||
Method = nameWithoutSig,
|
||||
Signature = signature
|
||||
};
|
||||
}
|
||||
|
||||
var method = parts[^1];
|
||||
var typeOrNamespace = parts[^2];
|
||||
|
||||
// Heuristic: if second-to-last starts with uppercase, it's likely a type
|
||||
if (parts.Length >= 3 && char.IsUpper(typeOrNamespace[0]))
|
||||
{
|
||||
var ns = string.Join(".", parts[..^2]);
|
||||
return new SymbolRef
|
||||
{
|
||||
Purl = purl,
|
||||
Namespace = ns,
|
||||
Type = typeOrNamespace,
|
||||
Method = method,
|
||||
Signature = signature
|
||||
};
|
||||
}
|
||||
|
||||
return new SymbolRef
|
||||
{
|
||||
Purl = purl,
|
||||
Namespace = string.Join(".", parts[..^1]),
|
||||
Type = "_",
|
||||
Method = method,
|
||||
Signature = signature
|
||||
};
|
||||
}
|
||||
|
||||
private string ComputeCanonicalId()
|
||||
{
|
||||
// Canonical format: purl|namespace|type|method|signature
|
||||
var canonical = string.Create(
|
||||
CultureInfo.InvariantCulture,
|
||||
$"{Purl}|{Namespace}|{Type}|{Method}|{Signature}");
|
||||
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(canonical));
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
// <copyright file="CanonicalSymbol.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace StellaOps.Reachability.Core.Symbols;
|
||||
|
||||
/// <summary>
|
||||
/// Canonicalized symbol in portable format for cross-source matching.
|
||||
/// Sprint: SPRINT_20260109_009_002 Task: Implement canonical symbol model
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// All string fields are normalized to lowercase for case-insensitive matching.
|
||||
/// The CanonicalId provides a stable SHA-256 hash for exact matching.
|
||||
/// </remarks>
|
||||
public sealed record CanonicalSymbol
|
||||
{
|
||||
/// <summary>
|
||||
/// Package URL (e.g., pkg:npm/lodash@4.17.21).
|
||||
/// May be null if package cannot be determined.
|
||||
/// </summary>
|
||||
public string? Purl { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Namespace/package (lowercase, dot-separated).
|
||||
/// Example: "org.apache.log4j.core.lookup"
|
||||
/// </summary>
|
||||
public required string Namespace { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Type/class name (lowercase).
|
||||
/// Example: "jndilookup"
|
||||
/// Use "_" for languages without types (JS module-level functions).
|
||||
/// </summary>
|
||||
public required string Type { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Method/function name (lowercase).
|
||||
/// Example: "lookup"
|
||||
/// </summary>
|
||||
public required string Method { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Simplified signature (lowercase, type names only).
|
||||
/// Example: "(string)" or "(object, string, cancellationtoken)"
|
||||
/// </summary>
|
||||
public required string Signature { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Canonical ID: SHA-256 of "{purl}|{namespace}|{type}|{method}|{signature}".
|
||||
/// Provides stable identity across sources.
|
||||
/// </summary>
|
||||
public required string CanonicalId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable display name.
|
||||
/// Example: "org.apache.log4j.core.lookup.JndiLookup.lookup(String)"
|
||||
/// </summary>
|
||||
public required string DisplayName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Original raw symbol for debugging.
|
||||
/// </summary>
|
||||
public string? OriginalSymbol { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Source that produced this canonical symbol.
|
||||
/// </summary>
|
||||
public required SymbolSource Source { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a canonical symbol with computed canonical ID.
|
||||
/// </summary>
|
||||
public static CanonicalSymbol Create(
|
||||
string @namespace,
|
||||
string type,
|
||||
string method,
|
||||
string signature,
|
||||
SymbolSource source,
|
||||
string? purl = null,
|
||||
string? originalSymbol = null)
|
||||
{
|
||||
var ns = @namespace.ToLowerInvariant();
|
||||
var t = type.ToLowerInvariant();
|
||||
var m = method.ToLowerInvariant();
|
||||
var sig = signature.ToLowerInvariant();
|
||||
var p = purl?.ToLowerInvariant();
|
||||
|
||||
var canonicalId = ComputeCanonicalId(p, ns, t, m, sig);
|
||||
var displayName = BuildDisplayName(ns, t, m, sig);
|
||||
|
||||
return new CanonicalSymbol
|
||||
{
|
||||
Purl = p,
|
||||
Namespace = ns,
|
||||
Type = t,
|
||||
Method = m,
|
||||
Signature = sig,
|
||||
CanonicalId = canonicalId,
|
||||
DisplayName = displayName,
|
||||
OriginalSymbol = originalSymbol,
|
||||
Source = source
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes a deterministic canonical ID from symbol components.
|
||||
/// </summary>
|
||||
public static string ComputeCanonicalId(
|
||||
string? purl,
|
||||
string @namespace,
|
||||
string type,
|
||||
string method,
|
||||
string signature)
|
||||
{
|
||||
var input = $"{purl ?? string.Empty}|{@namespace}|{type}|{method}|{signature}";
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(input));
|
||||
return Convert.ToHexStringLower(hash);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds a human-readable display name.
|
||||
/// </summary>
|
||||
private static string BuildDisplayName(
|
||||
string @namespace,
|
||||
string type,
|
||||
string method,
|
||||
string signature)
|
||||
{
|
||||
if (type == "_")
|
||||
{
|
||||
// Module-level function
|
||||
return $"{@namespace}.{method}{signature}";
|
||||
}
|
||||
|
||||
return $"{@namespace}.{type}.{method}{signature}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
// <copyright file="DotNetSymbolNormalizer.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace StellaOps.Reachability.Core.Symbols;
|
||||
|
||||
/// <summary>
|
||||
/// Normalizes .NET symbols from Roslyn, IL metadata, ETW, and EventPipe.
|
||||
/// Sprint: SPRINT_20260109_009_002 Task: Implement .NET normalizer
|
||||
/// </summary>
|
||||
public sealed partial class DotNetSymbolNormalizer : ISymbolNormalizer
|
||||
{
|
||||
private static readonly HashSet<SymbolSource> Sources =
|
||||
[
|
||||
SymbolSource.Roslyn,
|
||||
SymbolSource.ILMetadata,
|
||||
SymbolSource.EtwClr,
|
||||
SymbolSource.EventPipe
|
||||
];
|
||||
|
||||
/// <inheritdoc/>
|
||||
public IReadOnlySet<SymbolSource> SupportedSources => Sources;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public bool CanNormalize(SymbolSource source) => Sources.Contains(source);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public CanonicalSymbol? Normalize(RawSymbol raw)
|
||||
{
|
||||
TryNormalize(raw, out var canonical, out _);
|
||||
return canonical;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public bool TryNormalize(RawSymbol raw, out CanonicalSymbol? canonical, out string? error)
|
||||
{
|
||||
canonical = null;
|
||||
error = null;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(raw.Value))
|
||||
{
|
||||
error = "Symbol value is empty";
|
||||
return false;
|
||||
}
|
||||
|
||||
// Try different .NET symbol formats
|
||||
if (TryParseFullSignature(raw, out canonical))
|
||||
return true;
|
||||
|
||||
if (TryParseRoslynFormat(raw, out canonical))
|
||||
return true;
|
||||
|
||||
if (TryParseEtwFormat(raw, out canonical))
|
||||
return true;
|
||||
|
||||
if (TryParseSimpleFormat(raw, out canonical))
|
||||
return true;
|
||||
|
||||
error = $"Cannot parse .NET symbol: {raw.Value}";
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses: "System.Void Namespace.Type::Method(Params)"
|
||||
/// </summary>
|
||||
private static bool TryParseFullSignature(RawSymbol raw, out CanonicalSymbol? canonical)
|
||||
{
|
||||
canonical = null;
|
||||
|
||||
// Pattern: [ReturnType] Namespace.Type::Method(Params)
|
||||
var match = FullSignatureRegex().Match(raw.Value);
|
||||
if (!match.Success)
|
||||
return false;
|
||||
|
||||
var fullType = match.Groups["fulltype"].Value;
|
||||
var method = match.Groups["method"].Value;
|
||||
var @params = match.Groups["params"].Value;
|
||||
|
||||
// Split namespace from type
|
||||
var lastDot = fullType.LastIndexOf('.');
|
||||
if (lastDot <= 0)
|
||||
return false;
|
||||
|
||||
var ns = fullType[..lastDot];
|
||||
var type = fullType[(lastDot + 1)..];
|
||||
var signature = SimplifySignature(@params);
|
||||
|
||||
canonical = CanonicalSymbol.Create(ns, type, method, signature, raw.Source, raw.Purl, raw.Value);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses: "Namespace.Type.Method(Params)" (Roslyn format)
|
||||
/// </summary>
|
||||
private static bool TryParseRoslynFormat(RawSymbol raw, out CanonicalSymbol? canonical)
|
||||
{
|
||||
canonical = null;
|
||||
|
||||
// Pattern: Namespace.Type.Method(Params) or Namespace.Type.Method
|
||||
var match = RoslynFormatRegex().Match(raw.Value);
|
||||
if (!match.Success)
|
||||
return false;
|
||||
|
||||
var parts = match.Groups["fullpath"].Value.Split('.');
|
||||
if (parts.Length < 2)
|
||||
return false;
|
||||
|
||||
var method = parts[^1];
|
||||
var type = parts[^2];
|
||||
var ns = string.Join(".", parts[..^2]);
|
||||
var signature = SimplifySignature(match.Groups["params"].Value);
|
||||
|
||||
canonical = CanonicalSymbol.Create(ns, type, method, signature, raw.Source, raw.Purl, raw.Value);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses: "MethodID=0x... ModuleID=0x..." (ETW format)
|
||||
/// This is a simplified parse that extracts any method name present.
|
||||
/// </summary>
|
||||
private static bool TryParseEtwFormat(RawSymbol raw, out CanonicalSymbol? canonical)
|
||||
{
|
||||
canonical = null;
|
||||
|
||||
// ETW symbols are often just IDs without names - we need resolved symbols
|
||||
if (raw.Value.Contains("MethodID=", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// Check metadata for resolved name
|
||||
if (raw.Metadata?.TryGetValue("ResolvedName", out var resolved) == true &&
|
||||
!string.IsNullOrWhiteSpace(resolved))
|
||||
{
|
||||
var resolvedRaw = new RawSymbol(resolved, raw.Source, raw.Purl, raw.Metadata);
|
||||
return TryParseFullSignature(resolvedRaw, out canonical) ||
|
||||
TryParseRoslynFormat(resolvedRaw, out canonical);
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses: "Namespace.Type::Method" (simple double-colon format)
|
||||
/// </summary>
|
||||
private static bool TryParseSimpleFormat(RawSymbol raw, out CanonicalSymbol? canonical)
|
||||
{
|
||||
canonical = null;
|
||||
|
||||
// Pattern: Namespace.Type::Method
|
||||
var match = SimpleFormatRegex().Match(raw.Value);
|
||||
if (!match.Success)
|
||||
return false;
|
||||
|
||||
var fullType = match.Groups["fulltype"].Value;
|
||||
var method = match.Groups["method"].Value;
|
||||
|
||||
var lastDot = fullType.LastIndexOf('.');
|
||||
if (lastDot <= 0)
|
||||
return false;
|
||||
|
||||
var ns = fullType[..lastDot];
|
||||
var type = fullType[(lastDot + 1)..];
|
||||
|
||||
canonical = CanonicalSymbol.Create(ns, type, method, "()", raw.Source, raw.Purl, raw.Value);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Simplifies a parameter list to just type names.
|
||||
/// </summary>
|
||||
private static string SimplifySignature(string fullParams)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(fullParams))
|
||||
return "()";
|
||||
|
||||
// Split by comma, take last segment of each type name
|
||||
var parts = fullParams.Split(',')
|
||||
.Select(p =>
|
||||
{
|
||||
var trimmed = p.Trim();
|
||||
// Handle generics: List<String> -> list
|
||||
var genericIndex = trimmed.IndexOf('<');
|
||||
if (genericIndex > 0)
|
||||
trimmed = trimmed[..genericIndex];
|
||||
// Take last part after dots
|
||||
var lastDot = trimmed.LastIndexOf('.');
|
||||
return (lastDot >= 0 ? trimmed[(lastDot + 1)..] : trimmed).ToLowerInvariant();
|
||||
})
|
||||
.Where(p => !string.IsNullOrEmpty(p));
|
||||
|
||||
return $"({string.Join(", ", parts)})";
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"^(?:[\w.<>]+\s+)?(?<fulltype>[\w.]+)::(?<method>\w+)\((?<params>[^)]*)\)$")]
|
||||
private static partial Regex FullSignatureRegex();
|
||||
|
||||
[GeneratedRegex(@"^(?<fullpath>[\w.]+)(?:\((?<params>[^)]*)\))?$")]
|
||||
private static partial Regex RoslynFormatRegex();
|
||||
|
||||
[GeneratedRegex(@"^(?<fulltype>[\w.]+)::(?<method>\w+)$")]
|
||||
private static partial Regex SimpleFormatRegex();
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
// <copyright file="ISymbolCanonicalizer.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
namespace StellaOps.Reachability.Core.Symbols;
|
||||
|
||||
/// <summary>
|
||||
/// Canonicalizes symbols from various sources into a portable format.
|
||||
/// Sprint: SPRINT_20260109_009_002 Task: Create canonicalizer interface
|
||||
/// </summary>
|
||||
public interface ISymbolCanonicalizer
|
||||
{
|
||||
/// <summary>
|
||||
/// Canonicalize a raw symbol to portable format.
|
||||
/// </summary>
|
||||
/// <param name="raw">Raw symbol from source.</param>
|
||||
/// <returns>Canonical symbol with stable ID, or null if parsing fails.</returns>
|
||||
CanonicalSymbol? Canonicalize(RawSymbol raw);
|
||||
|
||||
/// <summary>
|
||||
/// Tries to canonicalize a raw symbol.
|
||||
/// </summary>
|
||||
/// <param name="raw">The raw symbol to canonicalize.</param>
|
||||
/// <param name="canonical">The resulting canonical symbol if successful.</param>
|
||||
/// <param name="error">Error message if canonicalization failed.</param>
|
||||
/// <returns>True if canonicalization succeeded.</returns>
|
||||
bool TryCanonicalize(RawSymbol raw, out CanonicalSymbol? canonical, out string? error);
|
||||
|
||||
/// <summary>
|
||||
/// Match two canonical symbols with configurable tolerance.
|
||||
/// </summary>
|
||||
/// <param name="a">First symbol.</param>
|
||||
/// <param name="b">Second symbol.</param>
|
||||
/// <param name="options">Match options (null for default).</param>
|
||||
/// <returns>Match result with confidence score.</returns>
|
||||
SymbolMatchResult Match(CanonicalSymbol a, CanonicalSymbol b, SymbolMatchOptions? options = null);
|
||||
|
||||
/// <summary>
|
||||
/// Batch canonicalize symbols.
|
||||
/// </summary>
|
||||
/// <param name="symbols">Raw symbols to canonicalize.</param>
|
||||
/// <returns>Successfully canonicalized symbols (failed ones are omitted).</returns>
|
||||
IReadOnlyList<CanonicalSymbol> CanonicalizeBatch(IEnumerable<RawSymbol> symbols);
|
||||
|
||||
/// <summary>
|
||||
/// Batch canonicalize symbols with error tracking.
|
||||
/// </summary>
|
||||
/// <param name="symbols">Raw symbols to canonicalize.</param>
|
||||
/// <returns>Results with both successes and failures.</returns>
|
||||
CanonicalizationBatchResult CanonicalizeBatchWithErrors(IEnumerable<RawSymbol> symbols);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of batch canonicalization.
|
||||
/// </summary>
|
||||
/// <param name="Successful">Successfully canonicalized symbols.</param>
|
||||
/// <param name="Failed">Failed canonicalizations with error messages.</param>
|
||||
public sealed record CanonicalizationBatchResult(
|
||||
IReadOnlyList<CanonicalSymbol> Successful,
|
||||
IReadOnlyList<CanonicalizationFailure> Failed);
|
||||
|
||||
/// <summary>
|
||||
/// A failed canonicalization.
|
||||
/// </summary>
|
||||
/// <param name="Raw">The raw symbol that failed.</param>
|
||||
/// <param name="Error">Error message explaining the failure.</param>
|
||||
public sealed record CanonicalizationFailure(RawSymbol Raw, string Error);
|
||||
@@ -0,0 +1,38 @@
|
||||
// <copyright file="ISymbolNormalizer.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
namespace StellaOps.Reachability.Core.Symbols;
|
||||
|
||||
/// <summary>
|
||||
/// Normalizes symbols from a specific source to canonical format.
|
||||
/// Sprint: SPRINT_20260109_009_002 Task: Create normalizer interface
|
||||
/// </summary>
|
||||
public interface ISymbolNormalizer
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the symbol sources this normalizer handles.
|
||||
/// </summary>
|
||||
IReadOnlySet<SymbolSource> SupportedSources { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Checks if this normalizer can handle the given source.
|
||||
/// </summary>
|
||||
bool CanNormalize(SymbolSource source);
|
||||
|
||||
/// <summary>
|
||||
/// Normalizes a raw symbol to canonical format.
|
||||
/// </summary>
|
||||
/// <param name="raw">The raw symbol to normalize.</param>
|
||||
/// <returns>The canonical symbol, or null if parsing fails.</returns>
|
||||
CanonicalSymbol? Normalize(RawSymbol raw);
|
||||
|
||||
/// <summary>
|
||||
/// Tries to normalize a raw symbol, returning success status.
|
||||
/// </summary>
|
||||
/// <param name="raw">The raw symbol to normalize.</param>
|
||||
/// <param name="canonical">The resulting canonical symbol if successful.</param>
|
||||
/// <param name="error">Error message if normalization failed.</param>
|
||||
/// <returns>True if normalization succeeded.</returns>
|
||||
bool TryNormalize(RawSymbol raw, out CanonicalSymbol? canonical, out string? error);
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
// <copyright file="JavaSymbolNormalizer.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace StellaOps.Reachability.Core.Symbols;
|
||||
|
||||
/// <summary>
|
||||
/// Normalizes Java symbols from ASM bytecode, JFR, and JVMTI.
|
||||
/// Sprint: SPRINT_20260109_009_002 Task: Implement Java normalizer
|
||||
/// </summary>
|
||||
public sealed partial class JavaSymbolNormalizer : ISymbolNormalizer
|
||||
{
|
||||
private static readonly HashSet<SymbolSource> Sources =
|
||||
[
|
||||
SymbolSource.JavaAsm,
|
||||
SymbolSource.JavaJfr,
|
||||
SymbolSource.JavaJvmti
|
||||
];
|
||||
|
||||
/// <inheritdoc/>
|
||||
public IReadOnlySet<SymbolSource> SupportedSources => Sources;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public bool CanNormalize(SymbolSource source) => Sources.Contains(source);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public CanonicalSymbol? Normalize(RawSymbol raw)
|
||||
{
|
||||
TryNormalize(raw, out var canonical, out _);
|
||||
return canonical;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public bool TryNormalize(RawSymbol raw, out CanonicalSymbol? canonical, out string? error)
|
||||
{
|
||||
canonical = null;
|
||||
error = null;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(raw.Value))
|
||||
{
|
||||
error = "Symbol value is empty";
|
||||
return false;
|
||||
}
|
||||
|
||||
// Try different Java symbol formats
|
||||
if (TryParseAsmFormat(raw, out canonical))
|
||||
return true;
|
||||
|
||||
if (TryParseJfrFormat(raw, out canonical))
|
||||
return true;
|
||||
|
||||
if (TryParsePatchFormat(raw, out canonical))
|
||||
return true;
|
||||
|
||||
error = $"Cannot parse Java symbol: {raw.Value}";
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses: "org/apache/log4j/core/lookup/JndiLookup.lookup(Ljava/lang/String;)Ljava/lang/String;"
|
||||
/// </summary>
|
||||
private static bool TryParseAsmFormat(RawSymbol raw, out CanonicalSymbol? canonical)
|
||||
{
|
||||
canonical = null;
|
||||
|
||||
// Pattern: package/path/Class.method(descriptor)returnType
|
||||
var match = AsmFormatRegex().Match(raw.Value);
|
||||
if (!match.Success)
|
||||
return false;
|
||||
|
||||
var pkgPath = match.Groups["pkg"].Value;
|
||||
var className = match.Groups["class"].Value;
|
||||
var method = match.Groups["method"].Value;
|
||||
var descriptor = match.Groups["desc"].Value;
|
||||
|
||||
var ns = pkgPath.Replace('/', '.').ToLowerInvariant();
|
||||
var type = className.ToLowerInvariant();
|
||||
var signature = ParseJvmDescriptor(descriptor);
|
||||
|
||||
canonical = CanonicalSymbol.Create(ns, type, method, signature, raw.Source, raw.Purl, raw.Value);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses: "org.apache.log4j.core.lookup.JndiLookup.lookup(String)" (JFR format)
|
||||
/// </summary>
|
||||
private static bool TryParseJfrFormat(RawSymbol raw, out CanonicalSymbol? canonical)
|
||||
{
|
||||
canonical = null;
|
||||
|
||||
// Pattern: package.Class.method(SimpleTypes)
|
||||
var match = JfrFormatRegex().Match(raw.Value);
|
||||
if (!match.Success)
|
||||
return false;
|
||||
|
||||
var parts = match.Groups["fullpath"].Value.Split('.');
|
||||
if (parts.Length < 2)
|
||||
return false;
|
||||
|
||||
var method = parts[^1];
|
||||
var type = parts[^2];
|
||||
var ns = string.Join(".", parts[..^2]);
|
||||
var @params = match.Groups["params"].Value;
|
||||
|
||||
// JFR uses simple type names already
|
||||
var signature = SimplifyJfrParams(@params);
|
||||
|
||||
canonical = CanonicalSymbol.Create(ns, type, method, signature, raw.Source, raw.Purl, raw.Value);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses: "org.apache.logging.log4j.core.lookup.JndiLookup#lookup" (patch analysis format)
|
||||
/// </summary>
|
||||
private static bool TryParsePatchFormat(RawSymbol raw, out CanonicalSymbol? canonical)
|
||||
{
|
||||
canonical = null;
|
||||
|
||||
// Pattern: package.Class#method
|
||||
var match = PatchFormatRegex().Match(raw.Value);
|
||||
if (!match.Success)
|
||||
return false;
|
||||
|
||||
var parts = match.Groups["fullpath"].Value.Split('.');
|
||||
if (parts.Length < 2)
|
||||
return false;
|
||||
|
||||
var type = parts[^1];
|
||||
var ns = string.Join(".", parts[..^1]);
|
||||
var method = match.Groups["method"].Value;
|
||||
|
||||
canonical = CanonicalSymbol.Create(ns, type, method, "()", raw.Source, raw.Purl, raw.Value);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses a JVM type descriptor into simple type names.
|
||||
/// </summary>
|
||||
private static string ParseJvmDescriptor(string descriptor)
|
||||
{
|
||||
if (string.IsNullOrEmpty(descriptor))
|
||||
return "()";
|
||||
|
||||
var types = new List<string>();
|
||||
var i = 0;
|
||||
|
||||
while (i < descriptor.Length)
|
||||
{
|
||||
var (type, consumed) = ParseOneJvmType(descriptor, i);
|
||||
if (consumed == 0)
|
||||
break;
|
||||
types.Add(type);
|
||||
i += consumed;
|
||||
}
|
||||
|
||||
return $"({string.Join(", ", types)})";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses a single JVM type from a descriptor.
|
||||
/// </summary>
|
||||
private static (string Type, int Consumed) ParseOneJvmType(string descriptor, int start)
|
||||
{
|
||||
if (start >= descriptor.Length)
|
||||
return (string.Empty, 0);
|
||||
|
||||
var c = descriptor[start];
|
||||
|
||||
return c switch
|
||||
{
|
||||
'B' => ("byte", 1),
|
||||
'C' => ("char", 1),
|
||||
'D' => ("double", 1),
|
||||
'F' => ("float", 1),
|
||||
'I' => ("int", 1),
|
||||
'J' => ("long", 1),
|
||||
'S' => ("short", 1),
|
||||
'Z' => ("boolean", 1),
|
||||
'V' => ("void", 1),
|
||||
'[' => ParseArrayType(descriptor, start),
|
||||
'L' => ParseObjectType(descriptor, start),
|
||||
_ => (string.Empty, 0)
|
||||
};
|
||||
}
|
||||
|
||||
private static (string Type, int Consumed) ParseArrayType(string descriptor, int start)
|
||||
{
|
||||
var (elementType, consumed) = ParseOneJvmType(descriptor, start + 1);
|
||||
return ($"{elementType}[]", consumed + 1);
|
||||
}
|
||||
|
||||
private static (string Type, int Consumed) ParseObjectType(string descriptor, int start)
|
||||
{
|
||||
// Lpackage/Class; -> class
|
||||
var semi = descriptor.IndexOf(';', start);
|
||||
if (semi < 0)
|
||||
return (string.Empty, 0);
|
||||
|
||||
var fullPath = descriptor[(start + 1)..semi];
|
||||
var lastSlash = fullPath.LastIndexOf('/');
|
||||
var className = lastSlash >= 0 ? fullPath[(lastSlash + 1)..] : fullPath;
|
||||
|
||||
return (className.ToLowerInvariant(), semi - start + 1);
|
||||
}
|
||||
|
||||
private static string SimplifyJfrParams(string @params)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(@params))
|
||||
return "()";
|
||||
|
||||
var parts = @params.Split(',')
|
||||
.Select(p => p.Trim().ToLowerInvariant())
|
||||
.Where(p => !string.IsNullOrEmpty(p));
|
||||
|
||||
return $"({string.Join(", ", parts)})";
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"^(?<pkg>[\w/]+)/(?<class>\w+)\.(?<method>\w+)\((?<desc>[^)]*)\)")]
|
||||
private static partial Regex AsmFormatRegex();
|
||||
|
||||
[GeneratedRegex(@"^(?<fullpath>[\w.]+)(?:\((?<params>[^)]*)\))?$")]
|
||||
private static partial Regex JfrFormatRegex();
|
||||
|
||||
[GeneratedRegex(@"^(?<fullpath>[\w.]+)#(?<method>\w+)$")]
|
||||
private static partial Regex PatchFormatRegex();
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
// <copyright file="RawSymbol.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
namespace StellaOps.Reachability.Core.Symbols;
|
||||
|
||||
/// <summary>
|
||||
/// Raw symbol input before canonicalization.
|
||||
/// Sprint: SPRINT_20260109_009_002 Task: Implement raw symbol model
|
||||
/// </summary>
|
||||
/// <param name="Value">The raw symbol string as received from the source.</param>
|
||||
/// <param name="Source">The source that produced this symbol.</param>
|
||||
/// <param name="Purl">Optional package URL for the symbol's owning package.</param>
|
||||
/// <param name="Metadata">Optional additional metadata from the source.</param>
|
||||
public sealed record RawSymbol(
|
||||
string Value,
|
||||
SymbolSource Source,
|
||||
string? Purl = null,
|
||||
IReadOnlyDictionary<string, string>? Metadata = null)
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a raw symbol from a value and source.
|
||||
/// </summary>
|
||||
public static RawSymbol Create(string value, SymbolSource source) =>
|
||||
new(value, source);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a raw symbol with a package URL.
|
||||
/// </summary>
|
||||
public static RawSymbol Create(string value, SymbolSource source, string purl) =>
|
||||
new(value, source, purl);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
// <copyright file="SymbolCanonicalizer.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
namespace StellaOps.Reachability.Core.Symbols;
|
||||
|
||||
/// <summary>
|
||||
/// Main canonicalizer that delegates to source-specific normalizers.
|
||||
/// Sprint: SPRINT_20260109_009_002 Task: Implement symbol canonicalizer
|
||||
/// </summary>
|
||||
public sealed class SymbolCanonicalizer : ISymbolCanonicalizer
|
||||
{
|
||||
private readonly IReadOnlyList<ISymbolNormalizer> _normalizers;
|
||||
private readonly SymbolMatcher _matcher;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance with default normalizers.
|
||||
/// </summary>
|
||||
public SymbolCanonicalizer()
|
||||
: this([new DotNetSymbolNormalizer(), new JavaSymbolNormalizer()])
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance with custom normalizers.
|
||||
/// </summary>
|
||||
public SymbolCanonicalizer(IEnumerable<ISymbolNormalizer> normalizers)
|
||||
{
|
||||
_normalizers = normalizers.ToList();
|
||||
_matcher = new SymbolMatcher();
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public CanonicalSymbol? Canonicalize(RawSymbol raw)
|
||||
{
|
||||
TryCanonicalize(raw, out var canonical, out _);
|
||||
return canonical;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public bool TryCanonicalize(RawSymbol raw, out CanonicalSymbol? canonical, out string? error)
|
||||
{
|
||||
canonical = null;
|
||||
error = null;
|
||||
|
||||
// Find a normalizer that supports this source
|
||||
var normalizer = _normalizers.FirstOrDefault(n => n.CanNormalize(raw.Source));
|
||||
|
||||
if (normalizer == null)
|
||||
{
|
||||
// Try all normalizers as fallback
|
||||
foreach (var n in _normalizers)
|
||||
{
|
||||
if (n.TryNormalize(raw, out canonical, out _))
|
||||
return true;
|
||||
}
|
||||
|
||||
error = $"No normalizer found for source {raw.Source}";
|
||||
return false;
|
||||
}
|
||||
|
||||
return normalizer.TryNormalize(raw, out canonical, out error);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public SymbolMatchResult Match(CanonicalSymbol a, CanonicalSymbol b, SymbolMatchOptions? options = null)
|
||||
{
|
||||
return _matcher.Match(a, b, options);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public IReadOnlyList<CanonicalSymbol> CanonicalizeBatch(IEnumerable<RawSymbol> symbols)
|
||||
{
|
||||
var result = CanonicalizeBatchWithErrors(symbols);
|
||||
return result.Successful;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public CanonicalizationBatchResult CanonicalizeBatchWithErrors(IEnumerable<RawSymbol> symbols)
|
||||
{
|
||||
var successful = new List<CanonicalSymbol>();
|
||||
var failed = new List<CanonicalizationFailure>();
|
||||
|
||||
foreach (var raw in symbols)
|
||||
{
|
||||
if (TryCanonicalize(raw, out var canonical, out var error) && canonical != null)
|
||||
{
|
||||
successful.Add(canonical);
|
||||
}
|
||||
else
|
||||
{
|
||||
failed.Add(new CanonicalizationFailure(raw, error ?? "Unknown error"));
|
||||
}
|
||||
}
|
||||
|
||||
return new CanonicalizationBatchResult(successful, failed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
// <copyright file="SymbolMatchOptions.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
namespace StellaOps.Reachability.Core.Symbols;
|
||||
|
||||
/// <summary>
|
||||
/// Options for symbol matching behavior.
|
||||
/// Sprint: SPRINT_20260109_009_002 Task: Implement match options
|
||||
/// </summary>
|
||||
public sealed record SymbolMatchOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Minimum confidence threshold for a fuzzy match to be accepted.
|
||||
/// Default: 0.7 (70%).
|
||||
/// </summary>
|
||||
public double MinimumConfidence { get; init; } = 0.7;
|
||||
|
||||
/// <summary>
|
||||
/// Minimum similarity threshold for namespace matching.
|
||||
/// Default: 0.8 (80%).
|
||||
/// </summary>
|
||||
public double NamespaceThreshold { get; init; } = 0.8;
|
||||
|
||||
/// <summary>
|
||||
/// Minimum similarity threshold for type matching.
|
||||
/// Default: 0.9 (90%).
|
||||
/// </summary>
|
||||
public double TypeThreshold { get; init; } = 0.9;
|
||||
|
||||
/// <summary>
|
||||
/// Minimum similarity threshold for signature matching.
|
||||
/// Default: 0.6 (60%) - signatures may differ due to overloads.
|
||||
/// </summary>
|
||||
public double SignatureThreshold { get; init; } = 0.6;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to consider PURL in matching.
|
||||
/// Default: true.
|
||||
/// </summary>
|
||||
public bool ConsiderPurl { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to allow cross-source matching (e.g., Roslyn to EtwClr).
|
||||
/// Default: true.
|
||||
/// </summary>
|
||||
public bool AllowCrossSourceMatching { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Default options with reasonable thresholds.
|
||||
/// </summary>
|
||||
public static SymbolMatchOptions Default { get; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Strict options requiring higher confidence.
|
||||
/// </summary>
|
||||
public static SymbolMatchOptions Strict { get; } = new()
|
||||
{
|
||||
MinimumConfidence = 0.9,
|
||||
NamespaceThreshold = 0.95,
|
||||
TypeThreshold = 0.95,
|
||||
SignatureThreshold = 0.8
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Lenient options accepting lower confidence.
|
||||
/// </summary>
|
||||
public static SymbolMatchOptions Lenient { get; } = new()
|
||||
{
|
||||
MinimumConfidence = 0.5,
|
||||
NamespaceThreshold = 0.6,
|
||||
TypeThreshold = 0.7,
|
||||
SignatureThreshold = 0.4
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
// <copyright file="SymbolMatchResult.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
namespace StellaOps.Reachability.Core.Symbols;
|
||||
|
||||
/// <summary>
|
||||
/// Result of matching two canonical symbols.
|
||||
/// Sprint: SPRINT_20260109_009_002 Task: Implement match result model
|
||||
/// </summary>
|
||||
/// <param name="MatchType">Type of match found.</param>
|
||||
/// <param name="Confidence">Confidence score (0.0 to 1.0).</param>
|
||||
/// <param name="Reason">Human-readable explanation of match/no-match.</param>
|
||||
public sealed record SymbolMatchResult(
|
||||
SymbolMatchType MatchType,
|
||||
double Confidence,
|
||||
string? Reason = null)
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether a match was found (exact or fuzzy).
|
||||
/// </summary>
|
||||
public bool IsMatch => MatchType is SymbolMatchType.Exact or SymbolMatchType.Fuzzy;
|
||||
|
||||
/// <summary>
|
||||
/// Creates an exact match result.
|
||||
/// </summary>
|
||||
public static SymbolMatchResult Exact(double confidence = 1.0) =>
|
||||
new(SymbolMatchType.Exact, confidence, "Exact canonical ID match");
|
||||
|
||||
/// <summary>
|
||||
/// Creates a fuzzy match result.
|
||||
/// </summary>
|
||||
public static SymbolMatchResult Fuzzy(double confidence, string reason) =>
|
||||
new(SymbolMatchType.Fuzzy, confidence, reason);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a no-match result.
|
||||
/// </summary>
|
||||
public static SymbolMatchResult NoMatch(string? reason = null) =>
|
||||
new(SymbolMatchType.NoMatch, 0.0, reason ?? "No match found");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Type of symbol match.
|
||||
/// </summary>
|
||||
public enum SymbolMatchType
|
||||
{
|
||||
/// <summary>No match found.</summary>
|
||||
NoMatch = 0,
|
||||
|
||||
/// <summary>Exact canonical ID match.</summary>
|
||||
Exact = 1,
|
||||
|
||||
/// <summary>Fuzzy match based on components.</summary>
|
||||
Fuzzy = 2
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
// <copyright file="SymbolMatcher.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
namespace StellaOps.Reachability.Core.Symbols;
|
||||
|
||||
/// <summary>
|
||||
/// Matches canonical symbols with configurable tolerance.
|
||||
/// Sprint: SPRINT_20260109_009_002 Task: Implement symbol matcher
|
||||
/// </summary>
|
||||
public sealed class SymbolMatcher
|
||||
{
|
||||
/// <summary>
|
||||
/// Matches two canonical symbols.
|
||||
/// </summary>
|
||||
public SymbolMatchResult Match(CanonicalSymbol a, CanonicalSymbol b, SymbolMatchOptions? options = null)
|
||||
{
|
||||
options ??= SymbolMatchOptions.Default;
|
||||
|
||||
// 1. Exact canonical ID match
|
||||
if (a.CanonicalId == b.CanonicalId)
|
||||
{
|
||||
return SymbolMatchResult.Exact();
|
||||
}
|
||||
|
||||
// 2. Check PURL mismatch (if both have PURLs and they differ, likely no match)
|
||||
if (options.ConsiderPurl &&
|
||||
!string.IsNullOrEmpty(a.Purl) &&
|
||||
!string.IsNullOrEmpty(b.Purl) &&
|
||||
a.Purl != b.Purl)
|
||||
{
|
||||
return SymbolMatchResult.NoMatch("Different PURLs indicate different packages");
|
||||
}
|
||||
|
||||
// 3. Namespace + Type + Method match (signature may differ due to overloads)
|
||||
if (a.Namespace == b.Namespace && a.Type == b.Type && a.Method == b.Method)
|
||||
{
|
||||
var sigSimilarity = ComputeSignatureSimilarity(a.Signature, b.Signature);
|
||||
if (sigSimilarity >= options.SignatureThreshold)
|
||||
{
|
||||
var confidence = 0.8 + sigSimilarity * 0.15;
|
||||
return SymbolMatchResult.Fuzzy(confidence, "Same namespace, type, and method with similar signature");
|
||||
}
|
||||
|
||||
// Same method but different signature - might be overload
|
||||
return SymbolMatchResult.Fuzzy(0.7, "Same namespace, type, and method (possible overload)");
|
||||
}
|
||||
|
||||
// 4. Method name match with namespace/type similarity
|
||||
if (a.Method == b.Method)
|
||||
{
|
||||
var nsSimilarity = ComputeNamespaceSimilarity(a.Namespace, b.Namespace);
|
||||
var typeSimilarity = ComputeLevenshteinSimilarity(a.Type, b.Type);
|
||||
|
||||
if (nsSimilarity >= options.NamespaceThreshold && typeSimilarity >= options.TypeThreshold)
|
||||
{
|
||||
var confidence = 0.5 + nsSimilarity * 0.2 + typeSimilarity * 0.2;
|
||||
if (confidence >= options.MinimumConfidence)
|
||||
{
|
||||
return SymbolMatchResult.Fuzzy(confidence, "Same method with similar namespace and type");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 5. No match
|
||||
return SymbolMatchResult.NoMatch();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes similarity between two namespaces.
|
||||
/// Uses segment-based comparison.
|
||||
/// </summary>
|
||||
private static double ComputeNamespaceSimilarity(string a, string b)
|
||||
{
|
||||
if (a == b)
|
||||
return 1.0;
|
||||
|
||||
var aParts = a.Split('.');
|
||||
var bParts = b.Split('.');
|
||||
|
||||
// Count matching segments from the start
|
||||
var matchingStart = 0;
|
||||
var minLen = Math.Min(aParts.Length, bParts.Length);
|
||||
|
||||
for (var i = 0; i < minLen; i++)
|
||||
{
|
||||
if (aParts[i] == bParts[i])
|
||||
matchingStart++;
|
||||
else
|
||||
break;
|
||||
}
|
||||
|
||||
// Count matching segments from the end
|
||||
var matchingEnd = 0;
|
||||
for (var i = 0; i < minLen - matchingStart; i++)
|
||||
{
|
||||
if (aParts[^(i + 1)] == bParts[^(i + 1)])
|
||||
matchingEnd++;
|
||||
else
|
||||
break;
|
||||
}
|
||||
|
||||
var totalMatching = matchingStart + matchingEnd;
|
||||
var maxLen = Math.Max(aParts.Length, bParts.Length);
|
||||
|
||||
return (double)totalMatching / maxLen;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes similarity between two signatures.
|
||||
/// </summary>
|
||||
private static double ComputeSignatureSimilarity(string a, string b)
|
||||
{
|
||||
if (a == b)
|
||||
return 1.0;
|
||||
|
||||
// Extract parameter types
|
||||
var aTypes = ExtractParamTypes(a);
|
||||
var bTypes = ExtractParamTypes(b);
|
||||
|
||||
if (aTypes.Count == 0 && bTypes.Count == 0)
|
||||
return 1.0;
|
||||
|
||||
if (aTypes.Count != bTypes.Count)
|
||||
return 0.5; // Different arity
|
||||
|
||||
// Compare each parameter
|
||||
var matches = aTypes.Zip(bTypes)
|
||||
.Count(p => p.First == p.Second || IsCompatibleType(p.First, p.Second));
|
||||
|
||||
return (double)matches / aTypes.Count;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts parameter types from a signature string.
|
||||
/// </summary>
|
||||
private static List<string> ExtractParamTypes(string signature)
|
||||
{
|
||||
// "(string, int)" -> ["string", "int"]
|
||||
var inner = signature.Trim('(', ')');
|
||||
if (string.IsNullOrWhiteSpace(inner))
|
||||
return [];
|
||||
|
||||
return inner.Split(',')
|
||||
.Select(s => s.Trim())
|
||||
.Where(s => !string.IsNullOrEmpty(s))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if two types are compatible (e.g., String vs string).
|
||||
/// </summary>
|
||||
private static bool IsCompatibleType(string a, string b)
|
||||
{
|
||||
// Common type aliases
|
||||
var aliases = new Dictionary<string, string>
|
||||
{
|
||||
["string"] = "string",
|
||||
["system.string"] = "string",
|
||||
["java.lang.string"] = "string",
|
||||
["int"] = "int",
|
||||
["int32"] = "int",
|
||||
["system.int32"] = "int",
|
||||
["integer"] = "int",
|
||||
["bool"] = "bool",
|
||||
["boolean"] = "bool",
|
||||
["system.boolean"] = "bool",
|
||||
["object"] = "object",
|
||||
["system.object"] = "object",
|
||||
["java.lang.object"] = "object"
|
||||
};
|
||||
|
||||
var normalizedA = aliases.GetValueOrDefault(a, a);
|
||||
var normalizedB = aliases.GetValueOrDefault(b, b);
|
||||
|
||||
return normalizedA == normalizedB;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes Levenshtein distance-based similarity.
|
||||
/// </summary>
|
||||
private static double ComputeLevenshteinSimilarity(string a, string b)
|
||||
{
|
||||
if (a == b)
|
||||
return 1.0;
|
||||
|
||||
var distance = LevenshteinDistance(a, b);
|
||||
var maxLen = Math.Max(a.Length, b.Length);
|
||||
|
||||
if (maxLen == 0)
|
||||
return 1.0;
|
||||
|
||||
return 1.0 - (double)distance / maxLen;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes Levenshtein edit distance.
|
||||
/// </summary>
|
||||
private static int LevenshteinDistance(string a, string b)
|
||||
{
|
||||
if (string.IsNullOrEmpty(a))
|
||||
return b?.Length ?? 0;
|
||||
if (string.IsNullOrEmpty(b))
|
||||
return a.Length;
|
||||
|
||||
var m = a.Length;
|
||||
var n = b.Length;
|
||||
var dp = new int[m + 1, n + 1];
|
||||
|
||||
for (var i = 0; i <= m; i++)
|
||||
dp[i, 0] = i;
|
||||
for (var j = 0; j <= n; j++)
|
||||
dp[0, j] = j;
|
||||
|
||||
for (var i = 1; i <= m; i++)
|
||||
{
|
||||
for (var j = 1; j <= n; j++)
|
||||
{
|
||||
var cost = a[i - 1] == b[j - 1] ? 0 : 1;
|
||||
dp[i, j] = Math.Min(
|
||||
Math.Min(dp[i - 1, j] + 1, dp[i, j - 1] + 1),
|
||||
dp[i - 1, j - 1] + cost);
|
||||
}
|
||||
}
|
||||
|
||||
return dp[m, n];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
// <copyright file="SymbolSource.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
namespace StellaOps.Reachability.Core.Symbols;
|
||||
|
||||
/// <summary>
|
||||
/// Source of symbol information.
|
||||
/// Sprint: SPRINT_20260109_009_002 Task: Implement symbol source enum
|
||||
/// </summary>
|
||||
public enum SymbolSource
|
||||
{
|
||||
/// <summary>Unknown source.</summary>
|
||||
Unknown = 0,
|
||||
|
||||
// .NET sources (10-19)
|
||||
/// <summary>Roslyn semantic analysis.</summary>
|
||||
Roslyn = 10,
|
||||
|
||||
/// <summary>IL metadata reflection.</summary>
|
||||
ILMetadata = 11,
|
||||
|
||||
/// <summary>ETW CLR provider.</summary>
|
||||
EtwClr = 12,
|
||||
|
||||
/// <summary>.NET EventPipe.</summary>
|
||||
EventPipe = 13,
|
||||
|
||||
// Java sources (20-29)
|
||||
/// <summary>ASM bytecode analysis.</summary>
|
||||
JavaAsm = 20,
|
||||
|
||||
/// <summary>Java Flight Recorder.</summary>
|
||||
JavaJfr = 21,
|
||||
|
||||
/// <summary>JVMTI agent.</summary>
|
||||
JavaJvmti = 22,
|
||||
|
||||
// Native sources (30-39)
|
||||
/// <summary>ELF symbol table.</summary>
|
||||
ElfSymtab = 30,
|
||||
|
||||
/// <summary>PE export table.</summary>
|
||||
PeExport = 31,
|
||||
|
||||
/// <summary>DWARF debug info.</summary>
|
||||
Dwarf = 32,
|
||||
|
||||
/// <summary>PDB debug info.</summary>
|
||||
Pdb = 33,
|
||||
|
||||
/// <summary>eBPF uprobe.</summary>
|
||||
EbpfUprobe = 34,
|
||||
|
||||
// Script sources (40-49)
|
||||
/// <summary>V8 profiler (Node.js).</summary>
|
||||
V8Profiler = 40,
|
||||
|
||||
/// <summary>Python sys.settrace.</summary>
|
||||
PythonTrace = 41,
|
||||
|
||||
/// <summary>PHP Xdebug.</summary>
|
||||
PhpXdebug = 42,
|
||||
|
||||
// Manual/derived sources (50-59)
|
||||
/// <summary>Patch analysis extraction.</summary>
|
||||
PatchAnalysis = 50,
|
||||
|
||||
/// <summary>Manual curation.</summary>
|
||||
ManualCuration = 51
|
||||
}
|
||||
24
src/__Libraries/StellaOps.Replay.Core.Tests/AGENTS.md
Normal file
24
src/__Libraries/StellaOps.Replay.Core.Tests/AGENTS.md
Normal file
@@ -0,0 +1,24 @@
|
||||
# Replay.Core Tests Charter
|
||||
|
||||
## Mission
|
||||
- Validate Replay.Core behavior with deterministic unit and integration tests.
|
||||
|
||||
## Responsibilities
|
||||
- Keep test inputs deterministic (fixed IDs, timestamps, and seeded data).
|
||||
- Tag tests with TestCategories and avoid running integration work in unit suites.
|
||||
- Avoid culture-dependent parsing in fixtures.
|
||||
|
||||
## Required Reading
|
||||
- docs/modules/replay/architecture.md
|
||||
- docs/modules/platform/architecture-overview.md
|
||||
|
||||
## Working Directory & Scope
|
||||
- Primary: src/__Libraries/StellaOps.Replay.Core.Tests
|
||||
|
||||
## Testing Expectations
|
||||
- Cover manifest validation, exporter behavior, proof serialization, and determinism paths.
|
||||
- Ensure tests do not rely on ambient environment or system time.
|
||||
|
||||
## Working Agreement
|
||||
- Update sprint status in docs/implplan/SPRINT_*.md and local TASKS.md.
|
||||
- Keep outputs deterministic and ASCII-only in comments and logs.
|
||||
10
src/__Libraries/StellaOps.Replay.Core.Tests/TASKS.md
Normal file
10
src/__Libraries/StellaOps.Replay.Core.Tests/TASKS.md
Normal file
@@ -0,0 +1,10 @@
|
||||
# Replay.Core Tests Task Board
|
||||
|
||||
This board mirrors active sprint tasks for this module.
|
||||
Source of truth: `docs/implplan/permament/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| AUDIT-0106-M | DONE | Revalidated 2026-01-08; maintainability audit for Replay.Core.Tests. |
|
||||
| AUDIT-0106-T | DONE | Revalidated 2026-01-08; test coverage audit for Replay.Core.Tests. |
|
||||
| AUDIT-0106-A | TODO | Pending approval (revalidated 2026-01-08). |
|
||||
10
src/__Libraries/StellaOps.Replay.Core/TASKS.md
Normal file
10
src/__Libraries/StellaOps.Replay.Core/TASKS.md
Normal file
@@ -0,0 +1,10 @@
|
||||
# Replay.Core Task Board
|
||||
|
||||
This board mirrors active sprint tasks for this module.
|
||||
Source of truth: `docs/implplan/permament/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| AUDIT-0107-M | DONE | Revalidated 2026-01-08; maintainability audit for Replay.Core. |
|
||||
| AUDIT-0107-T | DONE | Revalidated 2026-01-08; test coverage audit for Replay.Core. |
|
||||
| AUDIT-0107-A | TODO | Pending approval (revalidated 2026-01-08). |
|
||||
27
src/__Libraries/StellaOps.Replay/AGENTS.md
Normal file
27
src/__Libraries/StellaOps.Replay/AGENTS.md
Normal file
@@ -0,0 +1,27 @@
|
||||
# Replay Library Charter
|
||||
|
||||
## Mission
|
||||
- Provide deterministic replay execution and snapshot loading for verification and audit workflows.
|
||||
|
||||
## Responsibilities
|
||||
- Preserve determinism with injected time and invariant parsing.
|
||||
- Validate snapshot digests for length, format, and path safety.
|
||||
- Keep production dependencies free of test-only libraries.
|
||||
- Maintain offline-first behavior and stable outputs.
|
||||
|
||||
## Required Reading
|
||||
- docs/modules/replay/architecture.md
|
||||
- docs/modules/replay/replay-proof-schema.md
|
||||
- docs/modules/platform/architecture-overview.md
|
||||
|
||||
## Working Directory and Scope
|
||||
- Primary: src/__Libraries/StellaOps.Replay
|
||||
- Tests: src/__Libraries/__Tests/StellaOps.Replay.Tests
|
||||
|
||||
## Testing Expectations
|
||||
- Cover replay failure timestamps, digest validation failures, and loader path safety.
|
||||
- Use deterministic inputs (fixed timestamps, IDs, and seeded data).
|
||||
|
||||
## Working Agreement
|
||||
- Update sprint status in docs/implplan/SPRINT_*.md and local TASKS.md.
|
||||
- Keep outputs deterministic and ASCII-only in logs and comments.
|
||||
10
src/__Libraries/StellaOps.Replay/TASKS.md
Normal file
10
src/__Libraries/StellaOps.Replay/TASKS.md
Normal file
@@ -0,0 +1,10 @@
|
||||
# Replay Library Task Board
|
||||
|
||||
This board mirrors active sprint tasks for this module.
|
||||
Source of truth: `docs/implplan/permament/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| AUDIT-0108-M | DONE | Revalidated 2026-01-08; maintainability audit for Replay library. |
|
||||
| AUDIT-0108-T | DONE | Revalidated 2026-01-08; test coverage audit for Replay library. |
|
||||
| AUDIT-0108-A | TODO | Pending approval (revalidated 2026-01-08). |
|
||||
24
src/__Libraries/StellaOps.Resolver.Tests/AGENTS.md
Normal file
24
src/__Libraries/StellaOps.Resolver.Tests/AGENTS.md
Normal file
@@ -0,0 +1,24 @@
|
||||
# Resolver Tests Charter
|
||||
|
||||
## Mission
|
||||
- Validate deterministic resolver behavior, graph validation rules, and digest stability.
|
||||
|
||||
## Responsibilities
|
||||
- Keep test inputs deterministic (fixed IDs, timestamps, and seeded data).
|
||||
- Avoid culture-dependent parsing and non-ASCII literals in fixtures.
|
||||
- Keep property-based tests bounded and reproducible.
|
||||
|
||||
## Required Reading
|
||||
- docs/modules/reach-graph/architecture.md
|
||||
- docs/modules/platform/architecture-overview.md
|
||||
|
||||
## Working Directory and Scope
|
||||
- Primary: src/__Libraries/StellaOps.Resolver.Tests
|
||||
|
||||
## Testing Expectations
|
||||
- Cover cycle detection, graph normalization, and digest invariants.
|
||||
- Assert deterministic ordering and stable hashes across runs.
|
||||
|
||||
## Working Agreement
|
||||
- Update sprint status in docs/implplan/SPRINT_*.md and local TASKS.md.
|
||||
- Keep outputs deterministic and ASCII-only in logs and comments.
|
||||
10
src/__Libraries/StellaOps.Resolver.Tests/TASKS.md
Normal file
10
src/__Libraries/StellaOps.Resolver.Tests/TASKS.md
Normal file
@@ -0,0 +1,10 @@
|
||||
# Resolver Tests Task Board
|
||||
|
||||
This board mirrors active sprint tasks for this module.
|
||||
Source of truth: `docs/implplan/permament/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| AUDIT-0109-M | DONE | Revalidated 2026-01-08; maintainability audit for Resolver tests. |
|
||||
| AUDIT-0109-T | DONE | Revalidated 2026-01-08; test coverage audit for Resolver tests. |
|
||||
| AUDIT-0109-A | TODO | Pending approval (revalidated 2026-01-08). |
|
||||
25
src/__Libraries/StellaOps.Resolver/AGENTS.md
Normal file
25
src/__Libraries/StellaOps.Resolver/AGENTS.md
Normal file
@@ -0,0 +1,25 @@
|
||||
# Resolver Library Charter
|
||||
|
||||
## Mission
|
||||
- Provide deterministic graph resolution with stable ordering, digests, and validation.
|
||||
|
||||
## Responsibilities
|
||||
- Enforce determinism by avoiding ambient time and culture-sensitive behavior.
|
||||
- Maintain canonical serialization and stable ordering guarantees.
|
||||
- Keep outputs ASCII-only in logs and comments.
|
||||
|
||||
## Required Reading
|
||||
- docs/modules/reach-graph/architecture.md
|
||||
- docs/modules/platform/architecture-overview.md
|
||||
|
||||
## Working Directory and Scope
|
||||
- Primary: src/__Libraries/StellaOps.Resolver
|
||||
- Tests: src/__Libraries/StellaOps.Resolver.Tests
|
||||
|
||||
## Testing Expectations
|
||||
- Cover graph validation, traversal ordering, digest stability, and resolvedAt handling.
|
||||
- Validate NFC normalization and deterministic ordering across runs.
|
||||
|
||||
## Working Agreement
|
||||
- Update sprint status in docs/implplan/SPRINT_*.md and local TASKS.md.
|
||||
- Keep outputs deterministic and ASCII-only in logs and comments.
|
||||
10
src/__Libraries/StellaOps.Resolver/TASKS.md
Normal file
10
src/__Libraries/StellaOps.Resolver/TASKS.md
Normal file
@@ -0,0 +1,10 @@
|
||||
# Resolver Library Task Board
|
||||
|
||||
This board mirrors active sprint tasks for this module.
|
||||
Source of truth: `docs/implplan/permament/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| AUDIT-0110-M | DONE | Revalidated 2026-01-08; maintainability audit for Resolver library. |
|
||||
| AUDIT-0110-T | DONE | Revalidated 2026-01-08; test coverage audit for Resolver library. |
|
||||
| AUDIT-0110-A | TODO | Pending approval (revalidated 2026-01-08). |
|
||||
10
src/__Libraries/StellaOps.Signals.Contracts/TASKS.md
Normal file
10
src/__Libraries/StellaOps.Signals.Contracts/TASKS.md
Normal file
@@ -0,0 +1,10 @@
|
||||
# Signals.Contracts Task Board
|
||||
|
||||
This board mirrors active sprint tasks for this module.
|
||||
Source of truth: `docs/implplan/permament/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| AUDIT-0111-M | DONE | Revalidated 2026-01-08; maintainability audit for Signals.Contracts. |
|
||||
| AUDIT-0111-T | DONE | Revalidated 2026-01-08; test coverage audit for Signals.Contracts. |
|
||||
| AUDIT-0111-A | TODO | Pending approval (revalidated 2026-01-08). |
|
||||
@@ -346,18 +346,18 @@ public sealed class Spdx3Parser : ISpdx3Parser
|
||||
}
|
||||
|
||||
// Parse config source digests
|
||||
var configSourceDigests = ImmutableArray<Spdx3Hash>.Empty;
|
||||
var configSourceDigests = ImmutableArray<Spdx3BuildHash>.Empty;
|
||||
if (element.TryGetProperty("build_configSourceDigest", out var digestsElement) &&
|
||||
digestsElement.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
var digests = new List<Spdx3Hash>();
|
||||
var digests = new List<Spdx3BuildHash>();
|
||||
foreach (var digestEl in digestsElement.EnumerateArray())
|
||||
{
|
||||
if (digestEl.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
var algorithm = GetStringProperty(digestEl, "algorithm") ?? "sha256";
|
||||
var hashValue = GetStringProperty(digestEl, "hashValue") ?? string.Empty;
|
||||
digests.Add(new Spdx3Hash { Algorithm = algorithm, HashValue = hashValue });
|
||||
digests.Add(new Spdx3BuildHash { Algorithm = algorithm, HashValue = hashValue });
|
||||
}
|
||||
}
|
||||
configSourceDigests = digests.ToImmutableArray();
|
||||
|
||||
10
src/__Libraries/StellaOps.Spdx3/TASKS.md
Normal file
10
src/__Libraries/StellaOps.Spdx3/TASKS.md
Normal file
@@ -0,0 +1,10 @@
|
||||
# SPDX3 Library Task Board
|
||||
|
||||
This board mirrors active sprint tasks for this module.
|
||||
Source of truth: `docs/implplan/permament/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| AUDIT-0112-M | DONE | Revalidated 2026-01-08; maintainability audit for SPDX3 library. |
|
||||
| AUDIT-0112-T | DONE | Revalidated 2026-01-08; test coverage audit for SPDX3 library. |
|
||||
| AUDIT-0112-A | TODO | Pending approval (revalidated 2026-01-08). |
|
||||
@@ -0,0 +1,306 @@
|
||||
// <copyright file="CveSymbolMappingServiceTests.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using StellaOps.Reachability.Core.CveMapping;
|
||||
using StellaOps.Reachability.Core.Symbols;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Reachability.Core.Tests.CveMapping;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for <see cref="CveSymbolMappingService"/>.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public class CveSymbolMappingServiceTests
|
||||
{
|
||||
private readonly FakeTimeProvider _timeProvider;
|
||||
private readonly SymbolCanonicalizer _canonicalizer;
|
||||
private readonly CveSymbolMappingService _service;
|
||||
|
||||
public CveSymbolMappingServiceTests()
|
||||
{
|
||||
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2026, 1, 9, 12, 0, 0, TimeSpan.Zero));
|
||||
_canonicalizer = new SymbolCanonicalizer();
|
||||
_service = new CveSymbolMappingService(
|
||||
_canonicalizer,
|
||||
_timeProvider,
|
||||
NullLogger<CveSymbolMappingService>.Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetMappingAsync_NotFound_ReturnsNull()
|
||||
{
|
||||
// Act
|
||||
var result = await _service.GetMappingAsync("CVE-2021-44228", CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IngestMapping_ThenGetMapping_ReturnsMapping()
|
||||
{
|
||||
// Arrange
|
||||
var symbol = CreateTestSymbol("org.apache.log4j.core.lookup", "jndilookup", "lookup");
|
||||
var mapping = CveSymbolMapping.Create(
|
||||
"CVE-2021-44228",
|
||||
[VulnerableSymbol.Create(symbol, VulnerabilityType.Sink, 0.9)],
|
||||
MappingSource.PatchAnalysis,
|
||||
0.9,
|
||||
_timeProvider);
|
||||
|
||||
// Act
|
||||
await _service.IngestMappingAsync(mapping, CancellationToken.None);
|
||||
var result = await _service.GetMappingAsync("CVE-2021-44228", CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result!.CveId.Should().Be("CVE-2021-44228");
|
||||
result.Symbols.Should().HaveCount(1);
|
||||
result.Source.Should().Be(MappingSource.PatchAnalysis);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IngestMapping_CaseInsensitiveCveId()
|
||||
{
|
||||
// Arrange
|
||||
var symbol = CreateTestSymbol("org.example", "service", "method");
|
||||
var mapping = CveSymbolMapping.Create(
|
||||
"cve-2023-12345",
|
||||
[VulnerableSymbol.Create(symbol, VulnerabilityType.Unknown, 0.5)],
|
||||
MappingSource.ManualCuration,
|
||||
0.5,
|
||||
_timeProvider);
|
||||
|
||||
await _service.IngestMappingAsync(mapping, CancellationToken.None);
|
||||
|
||||
// Act - Query with different cases
|
||||
var result1 = await _service.GetMappingAsync("CVE-2023-12345", CancellationToken.None);
|
||||
var result2 = await _service.GetMappingAsync("cve-2023-12345", CancellationToken.None);
|
||||
var result3 = await _service.GetMappingAsync("Cve-2023-12345", CancellationToken.None);
|
||||
|
||||
// Assert - All queries return the same mapping (lookup is case-insensitive)
|
||||
result1.Should().NotBeNull();
|
||||
result2.Should().NotBeNull();
|
||||
result3.Should().NotBeNull();
|
||||
// The stored CVE ID retains original case from mapping creation
|
||||
result1!.CveId.Should().Be(result2!.CveId);
|
||||
result1.CveId.Should().Be(result3!.CveId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetMappingsBatchAsync_ReturnsOnlyFound()
|
||||
{
|
||||
// Arrange
|
||||
var symbol1 = CreateTestSymbol("org.example", "a", "method1");
|
||||
var symbol2 = CreateTestSymbol("org.example", "b", "method2");
|
||||
|
||||
var mapping1 = CveSymbolMapping.Create(
|
||||
"CVE-2021-0001",
|
||||
[VulnerableSymbol.Create(symbol1, VulnerabilityType.Sink, 0.8)],
|
||||
MappingSource.PatchAnalysis,
|
||||
0.8,
|
||||
_timeProvider);
|
||||
|
||||
var mapping2 = CveSymbolMapping.Create(
|
||||
"CVE-2021-0002",
|
||||
[VulnerableSymbol.Create(symbol2, VulnerabilityType.TaintSource, 0.7)],
|
||||
MappingSource.OsvDatabase,
|
||||
0.7,
|
||||
_timeProvider);
|
||||
|
||||
await _service.IngestMappingAsync(mapping1, CancellationToken.None);
|
||||
await _service.IngestMappingAsync(mapping2, CancellationToken.None);
|
||||
|
||||
// Act
|
||||
var result = await _service.GetMappingsBatchAsync(
|
||||
["CVE-2021-0001", "CVE-2021-0002", "CVE-2021-9999"],
|
||||
CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Should().HaveCount(2);
|
||||
result.Should().ContainKey("CVE-2021-0001");
|
||||
result.Should().ContainKey("CVE-2021-0002");
|
||||
result.Should().NotContainKey("CVE-2021-9999");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IngestMapping_MergesDuplicates()
|
||||
{
|
||||
// Arrange
|
||||
var symbol1 = CreateTestSymbol("org.example", "service", "method1");
|
||||
var symbol2 = CreateTestSymbol("org.example", "service", "method2");
|
||||
|
||||
var mapping1 = CveSymbolMapping.Create(
|
||||
"CVE-2021-0001",
|
||||
[VulnerableSymbol.Create(symbol1, VulnerabilityType.Sink, 0.8)],
|
||||
MappingSource.PatchAnalysis,
|
||||
0.8,
|
||||
_timeProvider);
|
||||
|
||||
var mapping2 = CveSymbolMapping.Create(
|
||||
"CVE-2021-0001",
|
||||
[VulnerableSymbol.Create(symbol2, VulnerabilityType.TaintSource, 0.7)],
|
||||
MappingSource.OsvDatabase,
|
||||
0.7,
|
||||
_timeProvider);
|
||||
|
||||
// Act
|
||||
await _service.IngestMappingAsync(mapping1, CancellationToken.None);
|
||||
await _service.IngestMappingAsync(mapping2, CancellationToken.None);
|
||||
|
||||
var result = await _service.GetMappingAsync("CVE-2021-0001", CancellationToken.None);
|
||||
|
||||
// Assert - Should have both symbols merged
|
||||
result.Should().NotBeNull();
|
||||
result!.Symbols.Should().HaveCount(2);
|
||||
result.Confidence.Should().Be(0.8); // Max of both
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetCvesForSymbolAsync_ReturnsMatchingCves()
|
||||
{
|
||||
// Arrange
|
||||
var symbol = CreateTestSymbol("org.apache.log4j.core.lookup", "jndilookup", "lookup");
|
||||
|
||||
var mapping1 = CveSymbolMapping.Create(
|
||||
"CVE-2021-44228",
|
||||
[VulnerableSymbol.Create(symbol, VulnerabilityType.Sink, 0.9)],
|
||||
MappingSource.PatchAnalysis,
|
||||
0.9,
|
||||
_timeProvider);
|
||||
|
||||
var mapping2 = CveSymbolMapping.Create(
|
||||
"CVE-2021-45046",
|
||||
[VulnerableSymbol.Create(symbol, VulnerabilityType.Sink, 0.85)],
|
||||
MappingSource.PatchAnalysis,
|
||||
0.85,
|
||||
_timeProvider);
|
||||
|
||||
await _service.IngestMappingAsync(mapping1, CancellationToken.None);
|
||||
await _service.IngestMappingAsync(mapping2, CancellationToken.None);
|
||||
|
||||
// Act
|
||||
var result = await _service.GetCvesForSymbolAsync(symbol.CanonicalId, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Should().HaveCount(2);
|
||||
result.Should().Contain("CVE-2021-44228");
|
||||
result.Should().Contain("CVE-2021-45046");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SearchBySymbolAsync_FindsMatchingMappings()
|
||||
{
|
||||
// Arrange
|
||||
var symbol1 = CreateTestSymbol("org.apache.log4j.core.lookup", "jndilookup", "lookup");
|
||||
var symbol2 = CreateTestSymbol("org.springframework.beans", "beanwrapper", "setPropertyValue");
|
||||
|
||||
var mapping1 = CveSymbolMapping.Create(
|
||||
"CVE-2021-44228",
|
||||
[VulnerableSymbol.Create(symbol1, VulnerabilityType.Sink, 0.9)],
|
||||
MappingSource.PatchAnalysis,
|
||||
0.9,
|
||||
_timeProvider);
|
||||
|
||||
var mapping2 = CveSymbolMapping.Create(
|
||||
"CVE-2022-22965",
|
||||
[VulnerableSymbol.Create(symbol2, VulnerabilityType.Sink, 0.9)],
|
||||
MappingSource.PatchAnalysis,
|
||||
0.9,
|
||||
_timeProvider);
|
||||
|
||||
await _service.IngestMappingAsync(mapping1, CancellationToken.None);
|
||||
await _service.IngestMappingAsync(mapping2, CancellationToken.None);
|
||||
|
||||
// Act - Search for log4j
|
||||
var result = await _service.SearchBySymbolAsync("log4j", 10, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Should().HaveCount(1);
|
||||
result[0].CveId.Should().Be("CVE-2021-44228");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SearchBySymbolAsync_WildcardMatching()
|
||||
{
|
||||
// Arrange
|
||||
var symbol1 = CreateTestSymbol("org.apache.log4j.core.lookup", "jndilookup", "lookup");
|
||||
var symbol2 = CreateTestSymbol("org.apache.log4j.core.appender", "socketappender", "send");
|
||||
|
||||
var mapping1 = CveSymbolMapping.Create(
|
||||
"CVE-2021-44228",
|
||||
[VulnerableSymbol.Create(symbol1, VulnerabilityType.Sink, 0.9)],
|
||||
MappingSource.PatchAnalysis,
|
||||
0.9,
|
||||
_timeProvider);
|
||||
|
||||
var mapping2 = CveSymbolMapping.Create(
|
||||
"CVE-2021-44832",
|
||||
[VulnerableSymbol.Create(symbol2, VulnerabilityType.Sink, 0.8)],
|
||||
MappingSource.PatchAnalysis,
|
||||
0.8,
|
||||
_timeProvider);
|
||||
|
||||
await _service.IngestMappingAsync(mapping1, CancellationToken.None);
|
||||
await _service.IngestMappingAsync(mapping2, CancellationToken.None);
|
||||
|
||||
// Act - Wildcard search
|
||||
var result = await _service.SearchBySymbolAsync("*.log4j.*", 10, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Should().HaveCount(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IngestMapping_InvalidCveId_Throws()
|
||||
{
|
||||
// Arrange
|
||||
var symbol = CreateTestSymbol("org.example", "service", "method");
|
||||
|
||||
// Act & Assert - "invalid" is not a valid CVE format
|
||||
var act = () => CveSymbolMapping.Create(
|
||||
"invalid",
|
||||
[VulnerableSymbol.Create(symbol, VulnerabilityType.Unknown, 0.5)],
|
||||
MappingSource.ManualCuration,
|
||||
0.5,
|
||||
_timeProvider);
|
||||
|
||||
// The service should validate on ingest
|
||||
var mapping = act(); // Create doesn't validate format
|
||||
|
||||
var ingestAct = async () => await _service.IngestMappingAsync(mapping, CancellationToken.None);
|
||||
ingestAct.Should().ThrowAsync<ArgumentException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Clear_RemovesAllMappings()
|
||||
{
|
||||
// Arrange
|
||||
var symbol = CreateTestSymbol("org.example", "service", "method");
|
||||
var mapping = CveSymbolMapping.Create(
|
||||
"CVE-2021-0001",
|
||||
[VulnerableSymbol.Create(symbol, VulnerabilityType.Sink, 0.8)],
|
||||
MappingSource.PatchAnalysis,
|
||||
0.8,
|
||||
_timeProvider);
|
||||
|
||||
await _service.IngestMappingAsync(mapping, CancellationToken.None);
|
||||
_service.MappingCount.Should().Be(1);
|
||||
|
||||
// Act
|
||||
_service.Clear();
|
||||
|
||||
// Assert
|
||||
_service.MappingCount.Should().Be(0);
|
||||
}
|
||||
|
||||
private CanonicalSymbol CreateTestSymbol(string ns, string type, string method)
|
||||
{
|
||||
return CanonicalSymbol.Create(ns, type, method, "()", SymbolSource.JavaAsm);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
// <copyright file="CveSymbolMappingTests.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using StellaOps.Reachability.Core.CveMapping;
|
||||
using StellaOps.Reachability.Core.Symbols;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Reachability.Core.Tests.CveMapping;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for <see cref="CveSymbolMapping"/>.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public class CveSymbolMappingTests
|
||||
{
|
||||
private readonly FakeTimeProvider _timeProvider;
|
||||
|
||||
public CveSymbolMappingTests()
|
||||
{
|
||||
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2026, 1, 9, 12, 0, 0, TimeSpan.Zero));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Create_SetsAllProperties()
|
||||
{
|
||||
// Arrange
|
||||
var symbol = CreateTestSymbol("org.example", "service", "method");
|
||||
var vulnSymbol = VulnerableSymbol.Create(symbol, VulnerabilityType.Sink, 0.9);
|
||||
|
||||
// Act
|
||||
var mapping = CveSymbolMapping.Create(
|
||||
"CVE-2021-44228",
|
||||
[vulnSymbol],
|
||||
MappingSource.PatchAnalysis,
|
||||
0.85,
|
||||
_timeProvider,
|
||||
patchCommitUrl: "https://github.com/apache/logging-log4j2/commit/abc123",
|
||||
osvAdvisoryId: "GHSA-jfh8-c2jp-5v3q");
|
||||
|
||||
// Assert
|
||||
mapping.CveId.Should().Be("CVE-2021-44228");
|
||||
mapping.Symbols.Should().HaveCount(1);
|
||||
mapping.Source.Should().Be(MappingSource.PatchAnalysis);
|
||||
mapping.Confidence.Should().Be(0.85);
|
||||
mapping.ExtractedAt.Should().Be(_timeProvider.GetUtcNow());
|
||||
mapping.PatchCommitUrl.Should().Be("https://github.com/apache/logging-log4j2/commit/abc123");
|
||||
mapping.OsvAdvisoryId.Should().Be("GHSA-jfh8-c2jp-5v3q");
|
||||
mapping.ContentDigest.Should().NotBeNullOrEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Create_ClampsConfidence()
|
||||
{
|
||||
// Arrange
|
||||
var symbol = CreateTestSymbol("org.example", "service", "method");
|
||||
var vulnSymbol = VulnerableSymbol.Create(symbol, VulnerabilityType.Unknown, 0.5);
|
||||
|
||||
// Act
|
||||
var mapping1 = CveSymbolMapping.Create(
|
||||
"CVE-2021-0001",
|
||||
[vulnSymbol],
|
||||
MappingSource.ManualCuration,
|
||||
1.5, // Over 1.0
|
||||
_timeProvider);
|
||||
|
||||
var mapping2 = CveSymbolMapping.Create(
|
||||
"CVE-2021-0002",
|
||||
[vulnSymbol],
|
||||
MappingSource.ManualCuration,
|
||||
-0.5, // Below 0.0
|
||||
_timeProvider);
|
||||
|
||||
// Assert
|
||||
mapping1.Confidence.Should().Be(1.0);
|
||||
mapping2.Confidence.Should().Be(0.0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ContentDigest_IsDeterministic()
|
||||
{
|
||||
// Arrange
|
||||
var symbol = CreateTestSymbol("org.example", "service", "method");
|
||||
var vulnSymbol = VulnerableSymbol.Create(symbol, VulnerabilityType.Sink, 0.9);
|
||||
|
||||
// Act - Create two mappings with same content
|
||||
var mapping1 = CveSymbolMapping.Create(
|
||||
"CVE-2021-44228",
|
||||
[vulnSymbol],
|
||||
MappingSource.PatchAnalysis,
|
||||
0.85,
|
||||
_timeProvider);
|
||||
|
||||
var mapping2 = CveSymbolMapping.Create(
|
||||
"CVE-2021-44228",
|
||||
[vulnSymbol],
|
||||
MappingSource.PatchAnalysis,
|
||||
0.85,
|
||||
_timeProvider);
|
||||
|
||||
// Assert - Same content digest
|
||||
mapping1.ContentDigest.Should().Be(mapping2.ContentDigest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ContentDigest_DiffersForDifferentSymbols()
|
||||
{
|
||||
// Arrange
|
||||
var symbol1 = CreateTestSymbol("org.example", "service", "method1");
|
||||
var symbol2 = CreateTestSymbol("org.example", "service", "method2");
|
||||
|
||||
// Act
|
||||
var mapping1 = CveSymbolMapping.Create(
|
||||
"CVE-2021-44228",
|
||||
[VulnerableSymbol.Create(symbol1, VulnerabilityType.Sink, 0.9)],
|
||||
MappingSource.PatchAnalysis,
|
||||
0.85,
|
||||
_timeProvider);
|
||||
|
||||
var mapping2 = CveSymbolMapping.Create(
|
||||
"CVE-2021-44228",
|
||||
[VulnerableSymbol.Create(symbol2, VulnerabilityType.Sink, 0.9)],
|
||||
MappingSource.PatchAnalysis,
|
||||
0.85,
|
||||
_timeProvider);
|
||||
|
||||
// Assert - Different content digest
|
||||
mapping1.ContentDigest.Should().NotBe(mapping2.ContentDigest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Merge_CombinesSymbols()
|
||||
{
|
||||
// Arrange
|
||||
var symbol1 = CreateTestSymbol("org.example", "service", "method1");
|
||||
var symbol2 = CreateTestSymbol("org.example", "service", "method2");
|
||||
|
||||
var mapping1 = CveSymbolMapping.Create(
|
||||
"CVE-2021-44228",
|
||||
[VulnerableSymbol.Create(symbol1, VulnerabilityType.Sink, 0.8)],
|
||||
MappingSource.PatchAnalysis,
|
||||
0.8,
|
||||
_timeProvider);
|
||||
|
||||
var mapping2 = CveSymbolMapping.Create(
|
||||
"CVE-2021-44228",
|
||||
[VulnerableSymbol.Create(symbol2, VulnerabilityType.TaintSource, 0.7)],
|
||||
MappingSource.OsvDatabase,
|
||||
0.7,
|
||||
_timeProvider);
|
||||
|
||||
// Act
|
||||
var merged = mapping1.Merge(mapping2, _timeProvider);
|
||||
|
||||
// Assert
|
||||
merged.CveId.Should().Be("CVE-2021-44228");
|
||||
merged.Symbols.Should().HaveCount(2);
|
||||
merged.Confidence.Should().Be(0.8); // Max of both
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Merge_DifferentCves_Throws()
|
||||
{
|
||||
// Arrange
|
||||
var symbol = CreateTestSymbol("org.example", "service", "method");
|
||||
|
||||
var mapping1 = CveSymbolMapping.Create(
|
||||
"CVE-2021-44228",
|
||||
[VulnerableSymbol.Create(symbol, VulnerabilityType.Sink, 0.8)],
|
||||
MappingSource.PatchAnalysis,
|
||||
0.8,
|
||||
_timeProvider);
|
||||
|
||||
var mapping2 = CveSymbolMapping.Create(
|
||||
"CVE-2022-22965",
|
||||
[VulnerableSymbol.Create(symbol, VulnerabilityType.Sink, 0.7)],
|
||||
MappingSource.PatchAnalysis,
|
||||
0.7,
|
||||
_timeProvider);
|
||||
|
||||
// Act & Assert
|
||||
var act = () => mapping1.Merge(mapping2, _timeProvider);
|
||||
act.Should().Throw<ArgumentException>()
|
||||
.WithMessage("*different CVEs*");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Merge_DeduplicatesSymbols()
|
||||
{
|
||||
// Arrange
|
||||
var symbol = CreateTestSymbol("org.example", "service", "method");
|
||||
|
||||
var mapping1 = CveSymbolMapping.Create(
|
||||
"CVE-2021-44228",
|
||||
[VulnerableSymbol.Create(symbol, VulnerabilityType.Sink, 0.8)],
|
||||
MappingSource.PatchAnalysis,
|
||||
0.8,
|
||||
_timeProvider);
|
||||
|
||||
var mapping2 = CveSymbolMapping.Create(
|
||||
"CVE-2021-44228",
|
||||
[VulnerableSymbol.Create(symbol, VulnerabilityType.Sink, 0.9)], // Same symbol, different confidence
|
||||
MappingSource.OsvDatabase,
|
||||
0.9,
|
||||
_timeProvider);
|
||||
|
||||
// Act
|
||||
var merged = mapping1.Merge(mapping2, _timeProvider);
|
||||
|
||||
// Assert - Should not duplicate
|
||||
merged.Symbols.Should().HaveCount(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Merge_CombinesPurls()
|
||||
{
|
||||
// Arrange
|
||||
var symbol = CreateTestSymbol("org.example", "service", "method");
|
||||
|
||||
var mapping1 = CveSymbolMapping.Create(
|
||||
"CVE-2021-44228",
|
||||
[VulnerableSymbol.Create(symbol, VulnerabilityType.Sink, 0.8)],
|
||||
MappingSource.PatchAnalysis,
|
||||
0.8,
|
||||
_timeProvider,
|
||||
affectedPurls: ["pkg:maven/org.apache.logging.log4j/log4j-core@2.14.0"]);
|
||||
|
||||
var mapping2 = CveSymbolMapping.Create(
|
||||
"CVE-2021-44228",
|
||||
[VulnerableSymbol.Create(symbol, VulnerabilityType.Sink, 0.9)],
|
||||
MappingSource.OsvDatabase,
|
||||
0.9,
|
||||
_timeProvider,
|
||||
affectedPurls: ["pkg:maven/org.apache.logging.log4j/log4j-api@2.14.0"]);
|
||||
|
||||
// Act
|
||||
var merged = mapping1.Merge(mapping2, _timeProvider);
|
||||
|
||||
// Assert
|
||||
merged.AffectedPurls.Should().HaveCount(2);
|
||||
}
|
||||
|
||||
private CanonicalSymbol CreateTestSymbol(string ns, string type, string method)
|
||||
{
|
||||
return CanonicalSymbol.Create(ns, type, method, "()", SymbolSource.JavaAsm);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
// <copyright file="VulnerableSymbolTests.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using FluentAssertions;
|
||||
using StellaOps.Reachability.Core.CveMapping;
|
||||
using StellaOps.Reachability.Core.Symbols;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Reachability.Core.Tests.CveMapping;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for <see cref="VulnerableSymbol"/> and <see cref="LineRange"/>.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public class VulnerableSymbolTests
|
||||
{
|
||||
[Fact]
|
||||
public void Create_SetsRequiredFields()
|
||||
{
|
||||
// Arrange
|
||||
var symbol = CanonicalSymbol.Create("org.example", "service", "method", "()", SymbolSource.JavaAsm);
|
||||
|
||||
// Act
|
||||
var vulnSymbol = VulnerableSymbol.Create(symbol, VulnerabilityType.Sink, 0.85);
|
||||
|
||||
// Assert
|
||||
vulnSymbol.Symbol.Should().Be(symbol);
|
||||
vulnSymbol.Type.Should().Be(VulnerabilityType.Sink);
|
||||
vulnSymbol.Confidence.Should().Be(0.85);
|
||||
vulnSymbol.Condition.Should().BeNull();
|
||||
vulnSymbol.Evidence.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Create_ClampsConfidence()
|
||||
{
|
||||
// Arrange
|
||||
var symbol = CanonicalSymbol.Create("org.example", "service", "method", "()", SymbolSource.JavaAsm);
|
||||
|
||||
// Act
|
||||
var over = VulnerableSymbol.Create(symbol, VulnerabilityType.Unknown, 1.5);
|
||||
var under = VulnerableSymbol.Create(symbol, VulnerabilityType.Unknown, -0.5);
|
||||
|
||||
// Assert
|
||||
over.Confidence.Should().Be(1.0);
|
||||
under.Confidence.Should().Be(0.0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Create_WithOptionalFields()
|
||||
{
|
||||
// Arrange
|
||||
var symbol = CanonicalSymbol.Create("org.example", "service", "method", "(string)", SymbolSource.Roslyn);
|
||||
|
||||
// Act
|
||||
var vulnSymbol = new VulnerableSymbol
|
||||
{
|
||||
Symbol = symbol,
|
||||
Type = VulnerabilityType.SqlInjection,
|
||||
Confidence = 0.95,
|
||||
Condition = "When user input is passed directly",
|
||||
Evidence = "Modified in commit abc123",
|
||||
SourceFile = "src/main/java/Example.java",
|
||||
LineRange = new LineRange(42, 58)
|
||||
};
|
||||
|
||||
// Assert
|
||||
vulnSymbol.Condition.Should().Be("When user input is passed directly");
|
||||
vulnSymbol.Evidence.Should().Be("Modified in commit abc123");
|
||||
vulnSymbol.SourceFile.Should().Be("src/main/java/Example.java");
|
||||
vulnSymbol.LineRange.Should().Be(new LineRange(42, 58));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LineRange_Length_CalculatesCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var range = new LineRange(10, 20);
|
||||
|
||||
// Act & Assert
|
||||
range.Length.Should().Be(11); // 10,11,12,13,14,15,16,17,18,19,20 = 11 lines
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LineRange_Contains_WorksCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var range = new LineRange(10, 20);
|
||||
|
||||
// Act & Assert
|
||||
range.Contains(10).Should().BeTrue();
|
||||
range.Contains(15).Should().BeTrue();
|
||||
range.Contains(20).Should().BeTrue();
|
||||
range.Contains(9).Should().BeFalse();
|
||||
range.Contains(21).Should().BeFalse();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(VulnerabilityType.Sink)]
|
||||
[InlineData(VulnerabilityType.TaintSource)]
|
||||
[InlineData(VulnerabilityType.GadgetEntry)]
|
||||
[InlineData(VulnerabilityType.DeserializationTarget)]
|
||||
[InlineData(VulnerabilityType.AuthBypass)]
|
||||
[InlineData(VulnerabilityType.CryptoWeakness)]
|
||||
[InlineData(VulnerabilityType.RceEntry)]
|
||||
[InlineData(VulnerabilityType.SqlInjection)]
|
||||
[InlineData(VulnerabilityType.PathTraversal)]
|
||||
[InlineData(VulnerabilityType.Ssrf)]
|
||||
[InlineData(VulnerabilityType.Xss)]
|
||||
public void VulnerabilityType_AllValuesSupported(VulnerabilityType type)
|
||||
{
|
||||
// Arrange
|
||||
var symbol = CanonicalSymbol.Create("org.example", "service", "method", "()", SymbolSource.JavaAsm);
|
||||
|
||||
// Act
|
||||
var vulnSymbol = VulnerableSymbol.Create(symbol, type, 0.5);
|
||||
|
||||
// Assert
|
||||
vulnSymbol.Type.Should().Be(type);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
<RootNamespace>StellaOps.Reachability.Core.Tests</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentAssertions" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||
<PackageReference Include="xunit.v3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" />
|
||||
<PackageReference Include="coverlet.collector" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\StellaOps.Reachability.Core\StellaOps.Reachability.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,215 @@
|
||||
// <copyright file="DotNetSymbolNormalizerTests.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using FluentAssertions;
|
||||
using StellaOps.Reachability.Core.Symbols;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Reachability.Core.Tests.Symbols;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for <see cref="DotNetSymbolNormalizer"/>.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public class DotNetSymbolNormalizerTests
|
||||
{
|
||||
private readonly DotNetSymbolNormalizer _normalizer = new();
|
||||
|
||||
[Fact]
|
||||
public void SupportedSources_ContainsDotNetSources()
|
||||
{
|
||||
_normalizer.SupportedSources.Should().Contain(SymbolSource.Roslyn);
|
||||
_normalizer.SupportedSources.Should().Contain(SymbolSource.ILMetadata);
|
||||
_normalizer.SupportedSources.Should().Contain(SymbolSource.EtwClr);
|
||||
_normalizer.SupportedSources.Should().Contain(SymbolSource.EventPipe);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(SymbolSource.Roslyn, true)]
|
||||
[InlineData(SymbolSource.ILMetadata, true)]
|
||||
[InlineData(SymbolSource.JavaAsm, false)]
|
||||
[InlineData(SymbolSource.Unknown, false)]
|
||||
public void CanNormalize_ReturnsCorrectResult(SymbolSource source, bool expected)
|
||||
{
|
||||
_normalizer.CanNormalize(source).Should().Be(expected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Normalize_FullSignature_ParsesCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var raw = new RawSymbol(
|
||||
"System.Void StellaOps.Scanner.Core.SbomGenerator::GenerateAsync(System.Threading.CancellationToken)",
|
||||
SymbolSource.ILMetadata);
|
||||
|
||||
// Act
|
||||
var canonical = _normalizer.Normalize(raw);
|
||||
|
||||
// Assert
|
||||
canonical.Should().NotBeNull();
|
||||
canonical!.Namespace.Should().Be("stellaops.scanner.core");
|
||||
canonical.Type.Should().Be("sbomgenerator");
|
||||
canonical.Method.Should().Be("generateasync");
|
||||
canonical.Signature.Should().Be("(cancellationtoken)");
|
||||
canonical.Source.Should().Be(SymbolSource.ILMetadata);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Normalize_FullSignature_MultipleParams_ParsesCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var raw = new RawSymbol(
|
||||
"System.Boolean MyApp.Services.UserService::ValidateUser(System.String, System.Int32, System.String)",
|
||||
SymbolSource.ILMetadata);
|
||||
|
||||
// Act
|
||||
var canonical = _normalizer.Normalize(raw);
|
||||
|
||||
// Assert
|
||||
canonical.Should().NotBeNull();
|
||||
canonical!.Namespace.Should().Be("myapp.services");
|
||||
canonical.Type.Should().Be("userservice");
|
||||
canonical.Method.Should().Be("validateuser");
|
||||
canonical.Signature.Should().Be("(string, int32, string)");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Normalize_RoslynFormat_ParsesCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var raw = new RawSymbol(
|
||||
"StellaOps.Scanner.Core.SbomGenerator.GenerateAsync(CancellationToken)",
|
||||
SymbolSource.Roslyn);
|
||||
|
||||
// Act
|
||||
var canonical = _normalizer.Normalize(raw);
|
||||
|
||||
// Assert
|
||||
canonical.Should().NotBeNull();
|
||||
canonical!.Namespace.Should().Be("stellaops.scanner.core");
|
||||
canonical.Type.Should().Be("sbomgenerator");
|
||||
canonical.Method.Should().Be("generateasync");
|
||||
canonical.Signature.Should().Be("(cancellationtoken)");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Normalize_RoslynFormat_NoParams_ParsesCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var raw = new RawSymbol(
|
||||
"StellaOps.Scanner.Core.SbomGenerator.GenerateAsync",
|
||||
SymbolSource.Roslyn);
|
||||
|
||||
// Act
|
||||
var canonical = _normalizer.Normalize(raw);
|
||||
|
||||
// Assert
|
||||
canonical.Should().NotBeNull();
|
||||
canonical!.Namespace.Should().Be("stellaops.scanner.core");
|
||||
canonical.Type.Should().Be("sbomgenerator");
|
||||
canonical.Method.Should().Be("generateasync");
|
||||
canonical.Signature.Should().Be("()");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Normalize_SimpleDoubleColon_ParsesCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var raw = new RawSymbol(
|
||||
"StellaOps.Scanner.Core.SbomGenerator::GenerateAsync",
|
||||
SymbolSource.ILMetadata);
|
||||
|
||||
// Act
|
||||
var canonical = _normalizer.Normalize(raw);
|
||||
|
||||
// Assert
|
||||
canonical.Should().NotBeNull();
|
||||
canonical!.Namespace.Should().Be("stellaops.scanner.core");
|
||||
canonical.Type.Should().Be("sbomgenerator");
|
||||
canonical.Method.Should().Be("generateasync");
|
||||
canonical.Signature.Should().Be("()");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Normalize_InvalidSymbol_ReturnsNull()
|
||||
{
|
||||
// Arrange
|
||||
var raw = new RawSymbol("not a valid symbol", SymbolSource.Roslyn);
|
||||
|
||||
// Act
|
||||
var canonical = _normalizer.Normalize(raw);
|
||||
|
||||
// Assert
|
||||
canonical.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Normalize_EmptyValue_ReturnsNull()
|
||||
{
|
||||
// Arrange
|
||||
var raw = new RawSymbol("", SymbolSource.Roslyn);
|
||||
|
||||
// Act
|
||||
var result = _normalizer.TryNormalize(raw, out var canonical, out var error);
|
||||
|
||||
// Assert
|
||||
result.Should().BeFalse();
|
||||
canonical.Should().BeNull();
|
||||
error.Should().Contain("empty");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Normalize_PreservesPurl()
|
||||
{
|
||||
// Arrange
|
||||
var raw = new RawSymbol(
|
||||
"StellaOps.Scanner.Core.SbomGenerator.GenerateAsync",
|
||||
SymbolSource.Roslyn,
|
||||
"pkg:nuget/StellaOps.Scanner.Core@1.0.0");
|
||||
|
||||
// Act
|
||||
var canonical = _normalizer.Normalize(raw);
|
||||
|
||||
// Assert
|
||||
canonical.Should().NotBeNull();
|
||||
canonical!.Purl.Should().Be("pkg:nuget/stellaops.scanner.core@1.0.0");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Normalize_PreservesOriginalSymbol()
|
||||
{
|
||||
// Arrange
|
||||
var original = "StellaOps.Scanner.Core.SbomGenerator.GenerateAsync";
|
||||
var raw = new RawSymbol(original, SymbolSource.Roslyn);
|
||||
|
||||
// Act
|
||||
var canonical = _normalizer.Normalize(raw);
|
||||
|
||||
// Assert
|
||||
canonical.Should().NotBeNull();
|
||||
canonical!.OriginalSymbol.Should().Be(original);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Normalize_GeneratesStableCanonicalId()
|
||||
{
|
||||
// Arrange
|
||||
var raw1 = new RawSymbol(
|
||||
"System.Void StellaOps.Scanner.Core.SbomGenerator::GenerateAsync(System.Threading.CancellationToken)",
|
||||
SymbolSource.ILMetadata);
|
||||
var raw2 = new RawSymbol(
|
||||
"StellaOps.Scanner.Core.SbomGenerator.GenerateAsync(CancellationToken)",
|
||||
SymbolSource.Roslyn);
|
||||
|
||||
// Act
|
||||
var canonical1 = _normalizer.Normalize(raw1);
|
||||
var canonical2 = _normalizer.Normalize(raw2);
|
||||
|
||||
// Assert
|
||||
canonical1.Should().NotBeNull();
|
||||
canonical2.Should().NotBeNull();
|
||||
canonical1!.CanonicalId.Should().Be(canonical2!.CanonicalId,
|
||||
"Same symbol from different sources should have same canonical ID");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
// <copyright file="JavaSymbolNormalizerTests.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using FluentAssertions;
|
||||
using StellaOps.Reachability.Core.Symbols;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Reachability.Core.Tests.Symbols;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for <see cref="JavaSymbolNormalizer"/>.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public class JavaSymbolNormalizerTests
|
||||
{
|
||||
private readonly JavaSymbolNormalizer _normalizer = new();
|
||||
|
||||
[Fact]
|
||||
public void SupportedSources_ContainsJavaSources()
|
||||
{
|
||||
_normalizer.SupportedSources.Should().Contain(SymbolSource.JavaAsm);
|
||||
_normalizer.SupportedSources.Should().Contain(SymbolSource.JavaJfr);
|
||||
_normalizer.SupportedSources.Should().Contain(SymbolSource.JavaJvmti);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(SymbolSource.JavaAsm, true)]
|
||||
[InlineData(SymbolSource.JavaJfr, true)]
|
||||
[InlineData(SymbolSource.Roslyn, false)]
|
||||
[InlineData(SymbolSource.Unknown, false)]
|
||||
public void CanNormalize_ReturnsCorrectResult(SymbolSource source, bool expected)
|
||||
{
|
||||
_normalizer.CanNormalize(source).Should().Be(expected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Normalize_AsmFormat_ParsesCorrectly()
|
||||
{
|
||||
// Arrange - ASM bytecode format
|
||||
var raw = new RawSymbol(
|
||||
"org/apache/log4j/core/lookup/JndiLookup.lookup(Ljava/lang/String;)Ljava/lang/String;",
|
||||
SymbolSource.JavaAsm);
|
||||
|
||||
// Act
|
||||
var canonical = _normalizer.Normalize(raw);
|
||||
|
||||
// Assert
|
||||
canonical.Should().NotBeNull();
|
||||
canonical!.Namespace.Should().Be("org.apache.log4j.core.lookup");
|
||||
canonical.Type.Should().Be("jndilookup");
|
||||
canonical.Method.Should().Be("lookup");
|
||||
canonical.Signature.Should().Be("(string)");
|
||||
canonical.Source.Should().Be(SymbolSource.JavaAsm);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Normalize_AsmFormat_MultipleParams_ParsesCorrectly()
|
||||
{
|
||||
// Arrange - Multiple parameters
|
||||
var raw = new RawSymbol(
|
||||
"com/example/Service.process(Ljava/lang/String;IZ)V",
|
||||
SymbolSource.JavaAsm);
|
||||
|
||||
// Act
|
||||
var canonical = _normalizer.Normalize(raw);
|
||||
|
||||
// Assert
|
||||
canonical.Should().NotBeNull();
|
||||
canonical!.Namespace.Should().Be("com.example");
|
||||
canonical.Type.Should().Be("service");
|
||||
canonical.Method.Should().Be("process");
|
||||
canonical.Signature.Should().Be("(string, int, boolean)");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Normalize_AsmFormat_ArrayParam_ParsesCorrectly()
|
||||
{
|
||||
// Arrange - Array parameter
|
||||
var raw = new RawSymbol(
|
||||
"com/example/Service.processBytes([B)V",
|
||||
SymbolSource.JavaAsm);
|
||||
|
||||
// Act
|
||||
var canonical = _normalizer.Normalize(raw);
|
||||
|
||||
// Assert
|
||||
canonical.Should().NotBeNull();
|
||||
canonical!.Signature.Should().Be("(byte[])");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Normalize_JfrFormat_ParsesCorrectly()
|
||||
{
|
||||
// Arrange - JFR format (simpler)
|
||||
var raw = new RawSymbol(
|
||||
"org.apache.log4j.core.lookup.JndiLookup.lookup(String)",
|
||||
SymbolSource.JavaJfr);
|
||||
|
||||
// Act
|
||||
var canonical = _normalizer.Normalize(raw);
|
||||
|
||||
// Assert
|
||||
canonical.Should().NotBeNull();
|
||||
canonical!.Namespace.Should().Be("org.apache.log4j.core.lookup");
|
||||
canonical.Type.Should().Be("jndilookup");
|
||||
canonical.Method.Should().Be("lookup");
|
||||
canonical.Signature.Should().Be("(string)");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Normalize_PatchFormat_ParsesCorrectly()
|
||||
{
|
||||
// Arrange - Patch analysis format with #
|
||||
var raw = new RawSymbol(
|
||||
"org.apache.logging.log4j.core.lookup.JndiLookup#lookup",
|
||||
SymbolSource.PatchAnalysis);
|
||||
|
||||
// Act
|
||||
var canonical = _normalizer.Normalize(raw);
|
||||
|
||||
// Assert
|
||||
canonical.Should().NotBeNull();
|
||||
canonical!.Namespace.Should().Be("org.apache.logging.log4j.core.lookup");
|
||||
canonical.Type.Should().Be("jndilookup");
|
||||
canonical.Method.Should().Be("lookup");
|
||||
canonical.Signature.Should().Be("()");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Normalize_InvalidSymbol_ReturnsNull()
|
||||
{
|
||||
// Arrange
|
||||
var raw = new RawSymbol("not a valid symbol", SymbolSource.JavaAsm);
|
||||
|
||||
// Act
|
||||
var canonical = _normalizer.Normalize(raw);
|
||||
|
||||
// Assert
|
||||
canonical.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Normalize_EmptyValue_ReturnsNull()
|
||||
{
|
||||
// Arrange
|
||||
var raw = new RawSymbol("", SymbolSource.JavaAsm);
|
||||
|
||||
// Act
|
||||
var result = _normalizer.TryNormalize(raw, out var canonical, out var error);
|
||||
|
||||
// Assert
|
||||
result.Should().BeFalse();
|
||||
canonical.Should().BeNull();
|
||||
error.Should().Contain("empty");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Normalize_PreservesPurl()
|
||||
{
|
||||
// Arrange
|
||||
var raw = new RawSymbol(
|
||||
"org/apache/log4j/core/lookup/JndiLookup.lookup(Ljava/lang/String;)Ljava/lang/String;",
|
||||
SymbolSource.JavaAsm,
|
||||
"pkg:maven/org.apache.logging.log4j/log4j-core@2.14.0");
|
||||
|
||||
// Act
|
||||
var canonical = _normalizer.Normalize(raw);
|
||||
|
||||
// Assert
|
||||
canonical.Should().NotBeNull();
|
||||
canonical!.Purl.Should().Be("pkg:maven/org.apache.logging.log4j/log4j-core@2.14.0");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Normalize_CrossSourceMatching_SameCanonicalId()
|
||||
{
|
||||
// Arrange - Same symbol from ASM and JFR
|
||||
var asmRaw = new RawSymbol(
|
||||
"org/apache/log4j/core/lookup/JndiLookup.lookup(Ljava/lang/String;)Ljava/lang/String;",
|
||||
SymbolSource.JavaAsm);
|
||||
var jfrRaw = new RawSymbol(
|
||||
"org.apache.log4j.core.lookup.JndiLookup.lookup(String)",
|
||||
SymbolSource.JavaJfr);
|
||||
|
||||
// Act
|
||||
var asmCanonical = _normalizer.Normalize(asmRaw);
|
||||
var jfrCanonical = _normalizer.Normalize(jfrRaw);
|
||||
|
||||
// Assert
|
||||
asmCanonical.Should().NotBeNull();
|
||||
jfrCanonical.Should().NotBeNull();
|
||||
asmCanonical!.CanonicalId.Should().Be(jfrCanonical!.CanonicalId,
|
||||
"Same symbol from ASM and JFR should have same canonical ID");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Normalize_JvmDescriptor_AllPrimitives()
|
||||
{
|
||||
// Arrange - All primitive types
|
||||
var raw = new RawSymbol(
|
||||
"com/example/Util.test(BCDFIJSZ)V",
|
||||
SymbolSource.JavaAsm);
|
||||
|
||||
// Act
|
||||
var canonical = _normalizer.Normalize(raw);
|
||||
|
||||
// Assert
|
||||
canonical.Should().NotBeNull();
|
||||
canonical!.Signature.Should().Be("(byte, char, double, float, int, long, short, boolean)");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Normalize_JvmDescriptor_NestedArray()
|
||||
{
|
||||
// Arrange - 2D array
|
||||
var raw = new RawSymbol(
|
||||
"com/example/Matrix.multiply([[D)[[D",
|
||||
SymbolSource.JavaAsm);
|
||||
|
||||
// Act
|
||||
var canonical = _normalizer.Normalize(raw);
|
||||
|
||||
// Assert
|
||||
canonical.Should().NotBeNull();
|
||||
canonical!.Signature.Should().Be("(double[][])");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Normalize_JvmDescriptor_ObjectArray()
|
||||
{
|
||||
// Arrange - Object array
|
||||
var raw = new RawSymbol(
|
||||
"com/example/Util.process([Ljava/lang/String;)V",
|
||||
SymbolSource.JavaAsm);
|
||||
|
||||
// Act
|
||||
var canonical = _normalizer.Normalize(raw);
|
||||
|
||||
// Assert
|
||||
canonical.Should().NotBeNull();
|
||||
canonical!.Signature.Should().Be("(string[])");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
// <copyright file="SymbolCanonicalizerTests.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using FluentAssertions;
|
||||
using StellaOps.Reachability.Core.Symbols;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Reachability.Core.Tests.Symbols;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for <see cref="SymbolCanonicalizer"/>.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public class SymbolCanonicalizerTests
|
||||
{
|
||||
private readonly SymbolCanonicalizer _canonicalizer = new();
|
||||
|
||||
[Fact]
|
||||
public void Canonicalize_DotNetSymbol_UsesCorrectNormalizer()
|
||||
{
|
||||
// Arrange
|
||||
var raw = new RawSymbol(
|
||||
"StellaOps.Scanner.Core.SbomGenerator.GenerateAsync(CancellationToken)",
|
||||
SymbolSource.Roslyn);
|
||||
|
||||
// Act
|
||||
var canonical = _canonicalizer.Canonicalize(raw);
|
||||
|
||||
// Assert
|
||||
canonical.Should().NotBeNull();
|
||||
canonical!.Namespace.Should().Be("stellaops.scanner.core");
|
||||
canonical.Type.Should().Be("sbomgenerator");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Canonicalize_JavaSymbol_UsesCorrectNormalizer()
|
||||
{
|
||||
// Arrange
|
||||
var raw = new RawSymbol(
|
||||
"org/apache/log4j/core/lookup/JndiLookup.lookup(Ljava/lang/String;)Ljava/lang/String;",
|
||||
SymbolSource.JavaAsm);
|
||||
|
||||
// Act
|
||||
var canonical = _canonicalizer.Canonicalize(raw);
|
||||
|
||||
// Assert
|
||||
canonical.Should().NotBeNull();
|
||||
canonical!.Namespace.Should().Be("org.apache.log4j.core.lookup");
|
||||
canonical.Type.Should().Be("jndilookup");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Canonicalize_UnknownSource_TriesFallback()
|
||||
{
|
||||
// Arrange - Unknown source but valid .NET format
|
||||
var raw = new RawSymbol(
|
||||
"StellaOps.Scanner.Core.SbomGenerator.GenerateAsync",
|
||||
SymbolSource.Unknown);
|
||||
|
||||
// Act
|
||||
var canonical = _canonicalizer.Canonicalize(raw);
|
||||
|
||||
// Assert
|
||||
canonical.Should().NotBeNull();
|
||||
canonical!.Method.Should().Be("generateasync");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanonicalizeBatch_ReturnsOnlySuccessful()
|
||||
{
|
||||
// Arrange
|
||||
var symbols = new[]
|
||||
{
|
||||
new RawSymbol("StellaOps.Scanner.Core.A.MethodA", SymbolSource.Roslyn),
|
||||
new RawSymbol("invalid symbol", SymbolSource.Roslyn),
|
||||
new RawSymbol("StellaOps.Scanner.Core.B.MethodB", SymbolSource.Roslyn)
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = _canonicalizer.CanonicalizeBatch(symbols);
|
||||
|
||||
// Assert
|
||||
result.Should().HaveCount(2);
|
||||
result.Select(c => c.Method).Should().Contain("methoda");
|
||||
result.Select(c => c.Method).Should().Contain("methodb");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanonicalizeBatchWithErrors_ReturnsAllResults()
|
||||
{
|
||||
// Arrange
|
||||
var symbols = new[]
|
||||
{
|
||||
new RawSymbol("StellaOps.Scanner.Core.A.MethodA", SymbolSource.Roslyn),
|
||||
new RawSymbol("invalid symbol", SymbolSource.Roslyn),
|
||||
new RawSymbol("StellaOps.Scanner.Core.B.MethodB", SymbolSource.Roslyn)
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = _canonicalizer.CanonicalizeBatchWithErrors(symbols);
|
||||
|
||||
// Assert
|
||||
result.Successful.Should().HaveCount(2);
|
||||
result.Failed.Should().HaveCount(1);
|
||||
result.Failed[0].Raw.Value.Should().Be("invalid symbol");
|
||||
result.Failed[0].Error.Should().NotBeNullOrEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Match_DelegatestoMatcher()
|
||||
{
|
||||
// Arrange
|
||||
var a = CanonicalSymbol.Create(
|
||||
"org.example", "service", "process", "(string)", SymbolSource.Roslyn);
|
||||
var b = CanonicalSymbol.Create(
|
||||
"org.example", "service", "process", "(string)", SymbolSource.JavaAsm);
|
||||
|
||||
// Act
|
||||
var result = _canonicalizer.Match(a, b);
|
||||
|
||||
// Assert
|
||||
result.MatchType.Should().Be(SymbolMatchType.Exact);
|
||||
result.Confidence.Should().Be(1.0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CrossSourceMatching_Log4jExample()
|
||||
{
|
||||
// Arrange - Log4Shell vulnerability symbols from different sources
|
||||
var asmSymbol = new RawSymbol(
|
||||
"org/apache/log4j/core/lookup/JndiLookup.lookup(Ljava/lang/String;)Ljava/lang/String;",
|
||||
SymbolSource.JavaAsm,
|
||||
"pkg:maven/org.apache.logging.log4j/log4j-core@2.14.0");
|
||||
|
||||
var jfrSymbol = new RawSymbol(
|
||||
"org.apache.log4j.core.lookup.JndiLookup.lookup(String)",
|
||||
SymbolSource.JavaJfr,
|
||||
"pkg:maven/org.apache.logging.log4j/log4j-core@2.14.0");
|
||||
|
||||
// Act
|
||||
var asmCanonical = _canonicalizer.Canonicalize(asmSymbol);
|
||||
var jfrCanonical = _canonicalizer.Canonicalize(jfrSymbol);
|
||||
var matchResult = _canonicalizer.Match(asmCanonical!, jfrCanonical!);
|
||||
|
||||
// Assert
|
||||
asmCanonical.Should().NotBeNull();
|
||||
jfrCanonical.Should().NotBeNull();
|
||||
matchResult.MatchType.Should().Be(SymbolMatchType.Exact);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CrossSourceMatching_DotNetExample()
|
||||
{
|
||||
// Arrange - Same .NET symbol from different sources
|
||||
var roslynSymbol = new RawSymbol(
|
||||
"StellaOps.Scanner.Core.SbomGenerator.GenerateAsync(CancellationToken)",
|
||||
SymbolSource.Roslyn);
|
||||
|
||||
var ilSymbol = new RawSymbol(
|
||||
"System.Threading.Tasks.Task StellaOps.Scanner.Core.SbomGenerator::GenerateAsync(System.Threading.CancellationToken)",
|
||||
SymbolSource.ILMetadata);
|
||||
|
||||
// Act
|
||||
var roslynCanonical = _canonicalizer.Canonicalize(roslynSymbol);
|
||||
var ilCanonical = _canonicalizer.Canonicalize(ilSymbol);
|
||||
var matchResult = _canonicalizer.Match(roslynCanonical!, ilCanonical!);
|
||||
|
||||
// Assert
|
||||
roslynCanonical.Should().NotBeNull();
|
||||
ilCanonical.Should().NotBeNull();
|
||||
matchResult.MatchType.Should().Be(SymbolMatchType.Exact);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Determinism_SameInputProducesSameOutput()
|
||||
{
|
||||
// Arrange
|
||||
var raw = new RawSymbol(
|
||||
"org/apache/log4j/core/lookup/JndiLookup.lookup(Ljava/lang/String;)Ljava/lang/String;",
|
||||
SymbolSource.JavaAsm);
|
||||
|
||||
// Act - Run multiple times
|
||||
var results = Enumerable.Range(0, 100)
|
||||
.Select(_ => _canonicalizer.Canonicalize(raw))
|
||||
.ToList();
|
||||
|
||||
// Assert - All should be identical
|
||||
var first = results[0];
|
||||
results.Should().AllSatisfy(r =>
|
||||
{
|
||||
r.Should().NotBeNull();
|
||||
r!.CanonicalId.Should().Be(first!.CanonicalId);
|
||||
r.Namespace.Should().Be(first.Namespace);
|
||||
r.Type.Should().Be(first.Type);
|
||||
r.Method.Should().Be(first.Method);
|
||||
r.Signature.Should().Be(first.Signature);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
// <copyright file="SymbolMatcherTests.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using FluentAssertions;
|
||||
using StellaOps.Reachability.Core.Symbols;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Reachability.Core.Tests.Symbols;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for <see cref="SymbolMatcher"/>.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public class SymbolMatcherTests
|
||||
{
|
||||
private readonly SymbolMatcher _matcher = new();
|
||||
|
||||
[Fact]
|
||||
public void Match_ExactCanonicalId_ReturnsExact()
|
||||
{
|
||||
// Arrange
|
||||
var a = CanonicalSymbol.Create(
|
||||
"org.example", "service", "process", "(string)", SymbolSource.Roslyn);
|
||||
var b = CanonicalSymbol.Create(
|
||||
"org.example", "service", "process", "(string)", SymbolSource.JavaAsm);
|
||||
|
||||
// Act
|
||||
var result = _matcher.Match(a, b);
|
||||
|
||||
// Assert
|
||||
result.MatchType.Should().Be(SymbolMatchType.Exact);
|
||||
result.Confidence.Should().Be(1.0);
|
||||
result.IsMatch.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Match_SameMethodDifferentSignature_ReturnsFuzzy()
|
||||
{
|
||||
// Arrange - Same method but different overload
|
||||
var a = CanonicalSymbol.Create(
|
||||
"org.example", "service", "process", "(string)", SymbolSource.Roslyn);
|
||||
var b = CanonicalSymbol.Create(
|
||||
"org.example", "service", "process", "(string, int)", SymbolSource.Roslyn);
|
||||
|
||||
// Act
|
||||
var result = _matcher.Match(a, b);
|
||||
|
||||
// Assert
|
||||
result.MatchType.Should().Be(SymbolMatchType.Fuzzy);
|
||||
result.Confidence.Should().BeGreaterThanOrEqualTo(0.7);
|
||||
result.IsMatch.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Match_DifferentMethod_ReturnsNoMatch()
|
||||
{
|
||||
// Arrange
|
||||
var a = CanonicalSymbol.Create(
|
||||
"org.example", "service", "process", "(string)", SymbolSource.Roslyn);
|
||||
var b = CanonicalSymbol.Create(
|
||||
"org.example", "service", "validate", "(string)", SymbolSource.Roslyn);
|
||||
|
||||
// Act
|
||||
var result = _matcher.Match(a, b);
|
||||
|
||||
// Assert
|
||||
result.MatchType.Should().Be(SymbolMatchType.NoMatch);
|
||||
result.IsMatch.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Match_DifferentPurl_ReturnsNoMatch()
|
||||
{
|
||||
// Arrange - Different packages
|
||||
var a = CanonicalSymbol.Create(
|
||||
"org.example", "service", "process", "(string)", SymbolSource.Roslyn, "pkg:npm/pkg-a@1.0.0");
|
||||
var b = CanonicalSymbol.Create(
|
||||
"org.example", "service", "process", "(string)", SymbolSource.Roslyn, "pkg:npm/pkg-b@1.0.0");
|
||||
|
||||
// Act
|
||||
var result = _matcher.Match(a, b);
|
||||
|
||||
// Assert
|
||||
result.MatchType.Should().Be(SymbolMatchType.NoMatch);
|
||||
result.Reason.Should().Contain("PURL");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Match_DifferentPurl_IgnoredWhenDisabled()
|
||||
{
|
||||
// Arrange
|
||||
var a = CanonicalSymbol.Create(
|
||||
"org.example", "service", "process", "(string)", SymbolSource.Roslyn, "pkg:npm/pkg-a@1.0.0");
|
||||
var b = CanonicalSymbol.Create(
|
||||
"org.example", "service", "process", "(string)", SymbolSource.Roslyn, "pkg:npm/pkg-b@1.0.0");
|
||||
var options = new SymbolMatchOptions { ConsiderPurl = false };
|
||||
|
||||
// Act
|
||||
var result = _matcher.Match(a, b, options);
|
||||
|
||||
// Assert - Even with ConsiderPurl=false, canonical IDs differ due to different PURLs in the hash
|
||||
// So we get a fuzzy match (same namespace/type/method) rather than no match
|
||||
result.MatchType.Should().Be(SymbolMatchType.Fuzzy);
|
||||
result.IsMatch.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Match_SimilarNamespace_ReturnsFuzzy()
|
||||
{
|
||||
// Arrange - Similar namespace (logging variation)
|
||||
var a = CanonicalSymbol.Create(
|
||||
"org.apache.log4j.core.lookup", "jndilookup", "lookup", "(string)", SymbolSource.JavaAsm);
|
||||
var b = CanonicalSymbol.Create(
|
||||
"org.apache.logging.log4j.core.lookup", "jndilookup", "lookup", "(string)", SymbolSource.JavaAsm);
|
||||
|
||||
// Act with lenient options
|
||||
var result = _matcher.Match(a, b, SymbolMatchOptions.Lenient);
|
||||
|
||||
// Assert
|
||||
result.MatchType.Should().Be(SymbolMatchType.Fuzzy);
|
||||
result.Confidence.Should().BeGreaterThan(0.5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Match_StrictOptions_RequiresHigherConfidence()
|
||||
{
|
||||
// Arrange - Similar but not identical
|
||||
var a = CanonicalSymbol.Create(
|
||||
"org.example.services", "userservice", "validate", "(string)", SymbolSource.Roslyn);
|
||||
var b = CanonicalSymbol.Create(
|
||||
"org.example.svc", "uservalidator", "validate", "(string)", SymbolSource.Roslyn);
|
||||
|
||||
// Act
|
||||
var defaultResult = _matcher.Match(a, b);
|
||||
var strictResult = _matcher.Match(a, b, SymbolMatchOptions.Strict);
|
||||
|
||||
// Assert - Strict should reject matches that default accepts
|
||||
// (or at least have different confidence thresholds)
|
||||
strictResult.Confidence.Should().BeLessThanOrEqualTo(defaultResult.Confidence);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Match_CompatibleTypes_MatchesSuccessfully()
|
||||
{
|
||||
// Arrange - System.String vs string
|
||||
var a = CanonicalSymbol.Create(
|
||||
"org.example", "service", "process", "(string)", SymbolSource.Roslyn);
|
||||
var b = CanonicalSymbol.Create(
|
||||
"org.example", "service", "process", "(system.string)", SymbolSource.ILMetadata);
|
||||
|
||||
// Act - These should be fuzzy match since signatures differ but types are compatible
|
||||
var result = _matcher.Match(a, b);
|
||||
|
||||
// Assert
|
||||
result.MatchType.Should().Be(SymbolMatchType.Fuzzy);
|
||||
result.Confidence.Should().BeGreaterThanOrEqualTo(0.7);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Match_Symmetric_SameResultBothDirections()
|
||||
{
|
||||
// Arrange
|
||||
var a = CanonicalSymbol.Create(
|
||||
"org.example", "service", "process", "(string, int)", SymbolSource.Roslyn);
|
||||
var b = CanonicalSymbol.Create(
|
||||
"org.example", "service", "process", "(string)", SymbolSource.Roslyn);
|
||||
|
||||
// Act
|
||||
var resultAB = _matcher.Match(a, b);
|
||||
var resultBA = _matcher.Match(b, a);
|
||||
|
||||
// Assert
|
||||
resultAB.MatchType.Should().Be(resultBA.MatchType);
|
||||
resultAB.Confidence.Should().Be(resultBA.Confidence);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Match_EmptySignatures_Matches()
|
||||
{
|
||||
// Arrange - Both have empty signatures
|
||||
var a = CanonicalSymbol.Create(
|
||||
"org.example", "service", "run", "()", SymbolSource.Roslyn);
|
||||
var b = CanonicalSymbol.Create(
|
||||
"org.example", "service", "run", "()", SymbolSource.JavaAsm);
|
||||
|
||||
// Act
|
||||
var result = _matcher.Match(a, b);
|
||||
|
||||
// Assert
|
||||
result.MatchType.Should().Be(SymbolMatchType.Exact);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user