- Introduced `VexStatusChipComponent` to display VEX status with color coding and tooltips. - Implemented integration tests for reachability drift detection, covering various scenarios including drift detection, determinism, and error handling. - Enhanced `ScannerToSignalsReachabilityTests` with a null implementation of `ICallGraphSyncService` for better test isolation. - Updated project references to include the new Reachability Drift library.
537 lines
20 KiB
C#
537 lines
20 KiB
C#
// -----------------------------------------------------------------------------
|
|
// SurfaceAwareReachabilityIntegrationTests.cs
|
|
// Sprint: SPRINT_3700_0004_0001_reachability_integration (REACH-013)
|
|
// Description: End-to-end integration tests for surface-aware reachability analysis.
|
|
// -----------------------------------------------------------------------------
|
|
|
|
using System;
|
|
using System.Collections.Generic;
|
|
using System.Linq;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using FluentAssertions;
|
|
using Microsoft.Extensions.Caching.Memory;
|
|
using Microsoft.Extensions.Logging;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using StellaOps.Scanner.Reachability.Surfaces;
|
|
using Xunit;
|
|
|
|
namespace StellaOps.Scanner.Reachability.Tests;
|
|
|
|
/// <summary>
|
|
/// Integration tests for the surface-aware reachability analyzer.
|
|
/// Tests the complete flow from vulnerability input through surface query to reachability result.
|
|
/// </summary>
|
|
public sealed class SurfaceAwareReachabilityIntegrationTests : IDisposable
|
|
{
|
|
private readonly InMemorySurfaceRepository _surfaceRepo;
|
|
private readonly InMemoryCallGraphAccessor _callGraphAccessor;
|
|
private readonly InMemoryReachabilityGraphService _graphService;
|
|
private readonly SurfaceQueryService _surfaceQueryService;
|
|
private readonly SurfaceAwareReachabilityAnalyzer _analyzer;
|
|
private readonly IMemoryCache _cache;
|
|
|
|
public SurfaceAwareReachabilityIntegrationTests()
|
|
{
|
|
_surfaceRepo = new InMemorySurfaceRepository();
|
|
_callGraphAccessor = new InMemoryCallGraphAccessor();
|
|
_graphService = new InMemoryReachabilityGraphService();
|
|
_cache = new MemoryCache(new MemoryCacheOptions());
|
|
|
|
_surfaceQueryService = new SurfaceQueryService(
|
|
_surfaceRepo,
|
|
_cache,
|
|
NullLogger<SurfaceQueryService>.Instance,
|
|
new SurfaceQueryOptions { EnableCaching = true });
|
|
|
|
_analyzer = new SurfaceAwareReachabilityAnalyzer(
|
|
_surfaceQueryService,
|
|
_graphService,
|
|
NullLogger<SurfaceAwareReachabilityAnalyzer>.Instance);
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
_cache.Dispose();
|
|
}
|
|
|
|
#region Confirmed Reachable Tests
|
|
|
|
[Fact]
|
|
public async Task AnalyzeAsync_WhenTriggerMethodIsReachable_ReturnsConfirmedTier()
|
|
{
|
|
// Arrange: Create a call graph with path to vulnerable method
|
|
// Entrypoint → Controller → Service → VulnerableLib.Deserialize()
|
|
_callGraphAccessor.AddEntrypoint("API.UsersController::GetUser");
|
|
_callGraphAccessor.AddEdge("API.UsersController::GetUser", "API.UserService::FetchUser");
|
|
_callGraphAccessor.AddEdge("API.UserService::FetchUser", "Newtonsoft.Json.JsonConvert::DeserializeObject");
|
|
|
|
// Add surface with trigger method
|
|
var surfaceId = Guid.NewGuid();
|
|
_surfaceRepo.AddSurface(new SurfaceInfo
|
|
{
|
|
Id = surfaceId,
|
|
CveId = "CVE-2023-1234",
|
|
Ecosystem = "nuget",
|
|
PackageName = "Newtonsoft.Json",
|
|
VulnVersion = "12.0.1",
|
|
FixedVersion = "12.0.3",
|
|
ComputedAt = DateTimeOffset.UtcNow,
|
|
TriggerCount = 1
|
|
});
|
|
_surfaceRepo.AddTriggers(surfaceId, new List<TriggerMethodInfo>
|
|
{
|
|
new() { MethodKey = "Newtonsoft.Json.JsonConvert::DeserializeObject", MethodName = "DeserializeObject", DeclaringType = "JsonConvert" }
|
|
});
|
|
|
|
// Configure graph service to find path
|
|
_graphService.AddReachablePath(
|
|
entrypoint: "API.UsersController::GetUser",
|
|
sink: "Newtonsoft.Json.JsonConvert::DeserializeObject",
|
|
pathMethods: new[] { "API.UsersController::GetUser", "API.UserService::FetchUser", "Newtonsoft.Json.JsonConvert::DeserializeObject" });
|
|
|
|
var request = new SurfaceAwareReachabilityRequest
|
|
{
|
|
Vulnerabilities = new List<VulnerabilityInfo>
|
|
{
|
|
new()
|
|
{
|
|
CveId = "CVE-2023-1234",
|
|
Ecosystem = "nuget",
|
|
PackageName = "Newtonsoft.Json",
|
|
Version = "12.0.1"
|
|
}
|
|
},
|
|
CallGraph = _callGraphAccessor
|
|
};
|
|
|
|
// Act
|
|
var result = await _analyzer.AnalyzeAsync(request);
|
|
|
|
// Assert
|
|
result.Findings.Should().HaveCount(1);
|
|
var finding = result.Findings[0];
|
|
finding.ConfidenceTier.Should().Be(ReachabilityConfidenceTier.Confirmed);
|
|
finding.SinkSource.Should().Be(SinkSource.Surface);
|
|
finding.Witnesses.Should().NotBeEmpty();
|
|
result.ConfirmedReachable.Should().Be(1);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AnalyzeAsync_WhenMultipleTriggerMethodsAreReachable_ReturnsMultipleWitnesses()
|
|
{
|
|
// Arrange: Create call graph with paths to multiple triggers
|
|
_callGraphAccessor.AddEntrypoint("API.Controller::Action1");
|
|
_callGraphAccessor.AddEntrypoint("API.Controller::Action2");
|
|
_callGraphAccessor.AddEdge("API.Controller::Action1", "VulnLib::Method1");
|
|
_callGraphAccessor.AddEdge("API.Controller::Action2", "VulnLib::Method2");
|
|
|
|
var surfaceId = Guid.NewGuid();
|
|
_surfaceRepo.AddSurface(new SurfaceInfo
|
|
{
|
|
Id = surfaceId,
|
|
CveId = "CVE-2024-5678",
|
|
Ecosystem = "npm",
|
|
PackageName = "vulnerable-lib",
|
|
VulnVersion = "1.0.0",
|
|
FixedVersion = "1.0.1",
|
|
ComputedAt = DateTimeOffset.UtcNow,
|
|
TriggerCount = 2
|
|
});
|
|
_surfaceRepo.AddTriggers(surfaceId, new List<TriggerMethodInfo>
|
|
{
|
|
new() { MethodKey = "VulnLib::Method1", MethodName = "Method1", DeclaringType = "VulnLib" },
|
|
new() { MethodKey = "VulnLib::Method2", MethodName = "Method2", DeclaringType = "VulnLib" }
|
|
});
|
|
|
|
_graphService.AddReachablePath("API.Controller::Action1", "VulnLib::Method1",
|
|
new[] { "API.Controller::Action1", "VulnLib::Method1" });
|
|
_graphService.AddReachablePath("API.Controller::Action2", "VulnLib::Method2",
|
|
new[] { "API.Controller::Action2", "VulnLib::Method2" });
|
|
|
|
var request = new SurfaceAwareReachabilityRequest
|
|
{
|
|
Vulnerabilities = new List<VulnerabilityInfo>
|
|
{
|
|
new() { CveId = "CVE-2024-5678", Ecosystem = "npm", PackageName = "vulnerable-lib", Version = "1.0.0" }
|
|
},
|
|
CallGraph = _callGraphAccessor
|
|
};
|
|
|
|
// Act
|
|
var result = await _analyzer.AnalyzeAsync(request);
|
|
|
|
// Assert
|
|
result.Findings.Should().HaveCount(1);
|
|
var finding = result.Findings[0];
|
|
finding.ConfidenceTier.Should().Be(ReachabilityConfidenceTier.Confirmed);
|
|
finding.Witnesses.Should().HaveCountGreaterOrEqualTo(2);
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Unreachable Tests
|
|
|
|
[Fact]
|
|
public async Task AnalyzeAsync_WhenTriggerMethodNotReachable_ReturnsUnreachableTier()
|
|
{
|
|
// Arrange: Surface exists but no path to trigger
|
|
_callGraphAccessor.AddEntrypoint("API.Controller::Action");
|
|
_callGraphAccessor.AddEdge("API.Controller::Action", "SafeLib::SafeMethod");
|
|
|
|
var surfaceId = Guid.NewGuid();
|
|
_surfaceRepo.AddSurface(new SurfaceInfo
|
|
{
|
|
Id = surfaceId,
|
|
CveId = "CVE-2023-9999",
|
|
Ecosystem = "nuget",
|
|
PackageName = "Vulnerable.Package",
|
|
VulnVersion = "2.0.0",
|
|
FixedVersion = "2.0.1",
|
|
ComputedAt = DateTimeOffset.UtcNow,
|
|
TriggerCount = 1
|
|
});
|
|
_surfaceRepo.AddTriggers(surfaceId, new List<TriggerMethodInfo>
|
|
{
|
|
new() { MethodKey = "Vulnerable.Package::DangerousMethod", MethodName = "DangerousMethod", DeclaringType = "Vulnerable.Package" }
|
|
});
|
|
|
|
// No paths configured in graph service = unreachable
|
|
|
|
var request = new SurfaceAwareReachabilityRequest
|
|
{
|
|
Vulnerabilities = new List<VulnerabilityInfo>
|
|
{
|
|
new() { CveId = "CVE-2023-9999", Ecosystem = "nuget", PackageName = "Vulnerable.Package", Version = "2.0.0" }
|
|
},
|
|
CallGraph = _callGraphAccessor
|
|
};
|
|
|
|
// Act
|
|
var result = await _analyzer.AnalyzeAsync(request);
|
|
|
|
// Assert
|
|
result.Findings.Should().HaveCount(1);
|
|
var finding = result.Findings[0];
|
|
finding.ConfidenceTier.Should().Be(ReachabilityConfidenceTier.Unreachable);
|
|
finding.SinkSource.Should().Be(SinkSource.Surface);
|
|
finding.Witnesses.Should().BeEmpty();
|
|
result.Unreachable.Should().Be(1);
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Likely Reachable (Fallback) Tests
|
|
|
|
[Fact]
|
|
public async Task AnalyzeAsync_WhenNoSurfaceButPackageApiCalled_ReturnsLikelyTier()
|
|
{
|
|
// Arrange: No surface exists, but package API is called
|
|
_callGraphAccessor.AddEntrypoint("API.Controller::Action");
|
|
_callGraphAccessor.AddEdge("API.Controller::Action", "UnknownLib.Client::DoSomething");
|
|
|
|
// Configure graph service for fallback path detection
|
|
_graphService.AddReachablePath("API.Controller::Action", "UnknownLib.Client::DoSomething",
|
|
new[] { "API.Controller::Action", "UnknownLib.Client::DoSomething" });
|
|
|
|
// No surface - will trigger fallback mode
|
|
var request = new SurfaceAwareReachabilityRequest
|
|
{
|
|
Vulnerabilities = new List<VulnerabilityInfo>
|
|
{
|
|
new() { CveId = "CVE-2024-0001", Ecosystem = "nuget", PackageName = "UnknownLib", Version = "1.0.0" }
|
|
},
|
|
CallGraph = _callGraphAccessor
|
|
};
|
|
|
|
// Act
|
|
var result = await _analyzer.AnalyzeAsync(request);
|
|
|
|
// Assert
|
|
result.Findings.Should().HaveCount(1);
|
|
var finding = result.Findings[0];
|
|
// Without surface, should be either Likely or Present depending on fallback analysis
|
|
finding.SinkSource.Should().BeOneOf(SinkSource.PackageApi, SinkSource.FallbackAll);
|
|
finding.ConfidenceTier.Should().BeOneOf(
|
|
ReachabilityConfidenceTier.Likely,
|
|
ReachabilityConfidenceTier.Present);
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Present Only Tests
|
|
|
|
[Fact]
|
|
public async Task AnalyzeAsync_WhenNoCallGraphData_ReturnsPresentTier()
|
|
{
|
|
// Arrange: No surface, no call graph paths
|
|
var request = new SurfaceAwareReachabilityRequest
|
|
{
|
|
Vulnerabilities = new List<VulnerabilityInfo>
|
|
{
|
|
new() { CveId = "CVE-2024-9999", Ecosystem = "npm", PackageName = "mystery-lib", Version = "0.0.1" }
|
|
},
|
|
CallGraph = null // No call graph available
|
|
};
|
|
|
|
// Act
|
|
var result = await _analyzer.AnalyzeAsync(request);
|
|
|
|
// Assert
|
|
result.Findings.Should().HaveCount(1);
|
|
var finding = result.Findings[0];
|
|
finding.ConfidenceTier.Should().Be(ReachabilityConfidenceTier.Present);
|
|
finding.SinkSource.Should().Be(SinkSource.FallbackAll);
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Multiple Vulnerabilities Tests
|
|
|
|
[Fact]
|
|
public async Task AnalyzeAsync_WithMultipleVulnerabilities_ReturnsCorrectTiersForEach()
|
|
{
|
|
// Arrange: Set up mixed scenario
|
|
_callGraphAccessor.AddEntrypoint("API.Controller::Action");
|
|
_callGraphAccessor.AddEdge("API.Controller::Action", "Lib1::Method");
|
|
|
|
// Vuln 1: Surface + path = Confirmed
|
|
var surface1 = Guid.NewGuid();
|
|
_surfaceRepo.AddSurface(new SurfaceInfo
|
|
{
|
|
Id = surface1,
|
|
CveId = "CVE-2024-0001",
|
|
Ecosystem = "nuget",
|
|
PackageName = "Lib1",
|
|
VulnVersion = "1.0.0",
|
|
FixedVersion = "1.0.1",
|
|
ComputedAt = DateTimeOffset.UtcNow,
|
|
TriggerCount = 1
|
|
});
|
|
_surfaceRepo.AddTriggers(surface1, new List<TriggerMethodInfo>
|
|
{
|
|
new() { MethodKey = "Lib1::Method", MethodName = "Method", DeclaringType = "Lib1" }
|
|
});
|
|
_graphService.AddReachablePath("API.Controller::Action", "Lib1::Method",
|
|
new[] { "API.Controller::Action", "Lib1::Method" });
|
|
|
|
// Vuln 2: Surface but no path = Unreachable
|
|
var surface2 = Guid.NewGuid();
|
|
_surfaceRepo.AddSurface(new SurfaceInfo
|
|
{
|
|
Id = surface2,
|
|
CveId = "CVE-2024-0002",
|
|
Ecosystem = "nuget",
|
|
PackageName = "Lib2",
|
|
VulnVersion = "2.0.0",
|
|
FixedVersion = "2.0.1",
|
|
ComputedAt = DateTimeOffset.UtcNow,
|
|
TriggerCount = 1
|
|
});
|
|
_surfaceRepo.AddTriggers(surface2, new List<TriggerMethodInfo>
|
|
{
|
|
new() { MethodKey = "Lib2::DangerousMethod", MethodName = "DangerousMethod", DeclaringType = "Lib2" }
|
|
});
|
|
// No path to Lib2 = unreachable
|
|
|
|
var request = new SurfaceAwareReachabilityRequest
|
|
{
|
|
Vulnerabilities = new List<VulnerabilityInfo>
|
|
{
|
|
new() { CveId = "CVE-2024-0001", Ecosystem = "nuget", PackageName = "Lib1", Version = "1.0.0" },
|
|
new() { CveId = "CVE-2024-0002", Ecosystem = "nuget", PackageName = "Lib2", Version = "2.0.0" }
|
|
},
|
|
CallGraph = _callGraphAccessor
|
|
};
|
|
|
|
// Act
|
|
var result = await _analyzer.AnalyzeAsync(request);
|
|
|
|
// Assert
|
|
result.Findings.Should().HaveCount(2);
|
|
result.ConfirmedReachable.Should().Be(1);
|
|
result.Unreachable.Should().Be(1);
|
|
|
|
var confirmed = result.Findings.First(f => f.CveId == "CVE-2024-0001");
|
|
confirmed.ConfidenceTier.Should().Be(ReachabilityConfidenceTier.Confirmed);
|
|
|
|
var unreachable = result.Findings.First(f => f.CveId == "CVE-2024-0002");
|
|
unreachable.ConfidenceTier.Should().Be(ReachabilityConfidenceTier.Unreachable);
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Surface Caching Tests
|
|
|
|
[Fact]
|
|
public async Task AnalyzeAsync_CachesSurfaceQueries_DoesNotQueryTwice()
|
|
{
|
|
// Arrange
|
|
var surfaceId = Guid.NewGuid();
|
|
_surfaceRepo.AddSurface(new SurfaceInfo
|
|
{
|
|
Id = surfaceId,
|
|
CveId = "CVE-2024-CACHED",
|
|
Ecosystem = "nuget",
|
|
PackageName = "CachedLib",
|
|
VulnVersion = "1.0.0",
|
|
FixedVersion = "1.0.1",
|
|
ComputedAt = DateTimeOffset.UtcNow,
|
|
TriggerCount = 1
|
|
});
|
|
_surfaceRepo.AddTriggers(surfaceId, new List<TriggerMethodInfo>
|
|
{
|
|
new() { MethodKey = "CachedLib::Method", MethodName = "Method", DeclaringType = "CachedLib" }
|
|
});
|
|
|
|
_callGraphAccessor.AddEntrypoint("App::Main");
|
|
_callGraphAccessor.AddEdge("App::Main", "CachedLib::Method");
|
|
_graphService.AddReachablePath("App::Main", "CachedLib::Method",
|
|
new[] { "App::Main", "CachedLib::Method" });
|
|
|
|
var request = new SurfaceAwareReachabilityRequest
|
|
{
|
|
Vulnerabilities = new List<VulnerabilityInfo>
|
|
{
|
|
new() { CveId = "CVE-2024-CACHED", Ecosystem = "nuget", PackageName = "CachedLib", Version = "1.0.0" }
|
|
},
|
|
CallGraph = _callGraphAccessor
|
|
};
|
|
|
|
// Act: Query twice
|
|
await _analyzer.AnalyzeAsync(request);
|
|
var initialQueryCount = _surfaceRepo.QueryCount;
|
|
|
|
await _analyzer.AnalyzeAsync(request);
|
|
var finalQueryCount = _surfaceRepo.QueryCount;
|
|
|
|
// Assert: Should use cache, not query again
|
|
finalQueryCount.Should().Be(initialQueryCount, "second analysis should use cached surface data");
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Test Infrastructure
|
|
|
|
/// <summary>
|
|
/// In-memory implementation of ISurfaceRepository for testing.
|
|
/// </summary>
|
|
private sealed class InMemorySurfaceRepository : ISurfaceRepository
|
|
{
|
|
private readonly Dictionary<string, SurfaceInfo> _surfaces = new();
|
|
private readonly Dictionary<Guid, List<TriggerMethodInfo>> _triggers = new();
|
|
private readonly Dictionary<Guid, List<string>> _sinks = new();
|
|
|
|
public int QueryCount { get; private set; }
|
|
|
|
public void AddSurface(SurfaceInfo surface)
|
|
{
|
|
var key = $"{surface.CveId}|{surface.Ecosystem}|{surface.PackageName}|{surface.VulnVersion}";
|
|
_surfaces[key] = surface;
|
|
}
|
|
|
|
public void AddTriggers(Guid surfaceId, List<TriggerMethodInfo> triggers)
|
|
{
|
|
_triggers[surfaceId] = triggers;
|
|
}
|
|
|
|
public void AddSinks(Guid surfaceId, List<string> sinks)
|
|
{
|
|
_sinks[surfaceId] = sinks;
|
|
}
|
|
|
|
public Task<SurfaceInfo?> GetSurfaceAsync(string cveId, string ecosystem, string packageName, string version, CancellationToken ct = default)
|
|
{
|
|
QueryCount++;
|
|
var key = $"{cveId}|{ecosystem}|{packageName}|{version}";
|
|
_surfaces.TryGetValue(key, out var surface);
|
|
return Task.FromResult(surface);
|
|
}
|
|
|
|
public Task<IReadOnlyList<TriggerMethodInfo>> GetTriggersAsync(Guid surfaceId, int maxCount, CancellationToken ct = default)
|
|
{
|
|
return Task.FromResult<IReadOnlyList<TriggerMethodInfo>>(
|
|
_triggers.TryGetValue(surfaceId, out var triggers) ? triggers : new List<TriggerMethodInfo>());
|
|
}
|
|
|
|
public Task<IReadOnlyList<string>> GetSinksAsync(Guid surfaceId, CancellationToken ct = default)
|
|
{
|
|
return Task.FromResult<IReadOnlyList<string>>(
|
|
_sinks.TryGetValue(surfaceId, out var sinks) ? sinks : new List<string>());
|
|
}
|
|
|
|
public Task<bool> ExistsAsync(string cveId, string ecosystem, string packageName, string version, CancellationToken ct = default)
|
|
{
|
|
var key = $"{cveId}|{ecosystem}|{packageName}|{version}";
|
|
return Task.FromResult(_surfaces.ContainsKey(key));
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// In-memory implementation of ICallGraphAccessor for testing.
|
|
/// </summary>
|
|
private sealed class InMemoryCallGraphAccessor : ICallGraphAccessor
|
|
{
|
|
private readonly HashSet<string> _entrypoints = new();
|
|
private readonly Dictionary<string, List<string>> _callees = new();
|
|
private readonly HashSet<string> _methods = new();
|
|
|
|
public void AddEntrypoint(string methodKey)
|
|
{
|
|
_entrypoints.Add(methodKey);
|
|
_methods.Add(methodKey);
|
|
}
|
|
|
|
public void AddEdge(string caller, string callee)
|
|
{
|
|
if (!_callees.ContainsKey(caller))
|
|
_callees[caller] = new List<string>();
|
|
|
|
_callees[caller].Add(callee);
|
|
_methods.Add(caller);
|
|
_methods.Add(callee);
|
|
}
|
|
|
|
public IReadOnlyList<string> GetEntrypoints() => _entrypoints.ToList();
|
|
|
|
public IReadOnlyList<string> GetCallees(string methodKey) =>
|
|
_callees.TryGetValue(methodKey, out var callees) ? callees : new List<string>();
|
|
|
|
public bool ContainsMethod(string methodKey) => _methods.Contains(methodKey);
|
|
}
|
|
|
|
/// <summary>
|
|
/// In-memory implementation of IReachabilityGraphService for testing.
|
|
/// </summary>
|
|
private sealed class InMemoryReachabilityGraphService : IReachabilityGraphService
|
|
{
|
|
private readonly List<ReachablePath> _paths = new();
|
|
|
|
public void AddReachablePath(string entrypoint, string sink, string[] pathMethods)
|
|
{
|
|
_paths.Add(new ReachablePath
|
|
{
|
|
EntrypointMethodKey = entrypoint,
|
|
SinkMethodKey = sink,
|
|
PathLength = pathMethods.Length,
|
|
PathMethodKeys = pathMethods.ToList()
|
|
});
|
|
}
|
|
|
|
public Task<IReadOnlyList<ReachablePath>> FindPathsToSinksAsync(
|
|
ICallGraphAccessor callGraph,
|
|
IReadOnlyList<string> sinkMethodKeys,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
// Return paths that match any of the requested sinks
|
|
var matchingPaths = _paths
|
|
.Where(p => sinkMethodKeys.Contains(p.SinkMethodKey))
|
|
.ToList();
|
|
|
|
return Task.FromResult<IReadOnlyList<ReachablePath>>(matchingPaths);
|
|
}
|
|
}
|
|
|
|
#endregion
|
|
}
|