Add property-based tests for SBOM/VEX document ordering and Unicode normalization determinism
- Implement `SbomVexOrderingDeterminismProperties` for testing component list and vulnerability metadata hash consistency. - Create `UnicodeNormalizationDeterminismProperties` to validate NFC normalization and Unicode string handling. - Add project file for `StellaOps.Testing.Determinism.Properties` with necessary dependencies. - Introduce CI/CD template validation tests including YAML syntax checks and documentation content verification. - Create validation script for CI/CD templates ensuring all required files and structures are present.
This commit is contained in:
562
src/Signals/StellaOps.Signals/Api/HotSymbolsController.cs
Normal file
562
src/Signals/StellaOps.Signals/Api/HotSymbolsController.cs
Normal file
@@ -0,0 +1,562 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// HotSymbolsController.cs
|
||||
// Sprint: SPRINT_20251226_010_SIGNALS_runtime_stack
|
||||
// Task: STACK-12 — API endpoint: GET /api/v1/signals/hot-symbols?image=<digest>
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Signals.Models;
|
||||
using StellaOps.Signals.Persistence;
|
||||
|
||||
namespace StellaOps.Signals.Api;
|
||||
|
||||
/// <summary>
|
||||
/// API controller for hot symbol index queries.
|
||||
/// Provides endpoints for querying runtime-observed function symbols.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/v1/signals")]
|
||||
[Produces("application/json")]
|
||||
public sealed class HotSymbolsController : ControllerBase
|
||||
{
|
||||
private readonly IHotSymbolRepository _repository;
|
||||
private readonly ILogger<HotSymbolsController> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="HotSymbolsController"/> class.
|
||||
/// </summary>
|
||||
public HotSymbolsController(
|
||||
IHotSymbolRepository repository,
|
||||
ILogger<HotSymbolsController> logger)
|
||||
{
|
||||
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets hot symbols for a container image.
|
||||
/// </summary>
|
||||
/// <param name="image">Container image digest (sha256:xxx).</param>
|
||||
/// <param name="buildId">Optional Build-ID filter.</param>
|
||||
/// <param name="function">Optional function name pattern (supports wildcards).</param>
|
||||
/// <param name="module">Optional module name filter.</param>
|
||||
/// <param name="minCount">Minimum observation count threshold.</param>
|
||||
/// <param name="securityOnly">Only return security-relevant symbols.</param>
|
||||
/// <param name="windowHours">Time window in hours (default: 24).</param>
|
||||
/// <param name="limit">Maximum results (default: 100, max: 1000).</param>
|
||||
/// <param name="offset">Pagination offset.</param>
|
||||
/// <param name="sort">Sort order.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>List of hot symbols matching the criteria.</returns>
|
||||
[HttpGet("hot-symbols")]
|
||||
[ProducesResponseType(typeof(HotSymbolApiResponse), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status500InternalServerError)]
|
||||
public async Task<ActionResult<HotSymbolApiResponse>> GetHotSymbols(
|
||||
[FromQuery(Name = "image"), Required] string image,
|
||||
[FromQuery(Name = "build_id")] string? buildId = null,
|
||||
[FromQuery(Name = "function")] string? function = null,
|
||||
[FromQuery(Name = "module")] string? module = null,
|
||||
[FromQuery(Name = "min_count")] long? minCount = null,
|
||||
[FromQuery(Name = "security_only")] bool? securityOnly = null,
|
||||
[FromQuery(Name = "window_hours")] int windowHours = 24,
|
||||
[FromQuery(Name = "limit")] int limit = 100,
|
||||
[FromQuery(Name = "offset")] int offset = 0,
|
||||
[FromQuery(Name = "sort")] string? sort = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Validate image digest format
|
||||
if (string.IsNullOrWhiteSpace(image))
|
||||
{
|
||||
return BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Invalid image digest",
|
||||
Detail = "The 'image' query parameter is required and must be a valid digest.",
|
||||
Status = StatusCodes.Status400BadRequest,
|
||||
});
|
||||
}
|
||||
|
||||
if (!IsValidDigest(image))
|
||||
{
|
||||
return BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Invalid image digest format",
|
||||
Detail = "The image digest must be in format 'sha256:...' or 'sha512:...'.",
|
||||
Status = StatusCodes.Status400BadRequest,
|
||||
});
|
||||
}
|
||||
|
||||
// Clamp limit
|
||||
limit = Math.Clamp(limit, 1, 1000);
|
||||
|
||||
// Parse sort order
|
||||
var sortOrder = ParseSortOrder(sort);
|
||||
|
||||
var query = new HotSymbolQuery
|
||||
{
|
||||
ImageDigest = image,
|
||||
BuildId = buildId,
|
||||
FunctionPattern = function,
|
||||
ModuleName = module,
|
||||
MinObservationCount = minCount,
|
||||
OnlySecurityRelevant = securityOnly,
|
||||
TimeWindow = TimeSpan.FromHours(windowHours),
|
||||
Limit = limit,
|
||||
Offset = offset,
|
||||
SortOrder = sortOrder,
|
||||
};
|
||||
|
||||
_logger.LogDebug(
|
||||
"Querying hot symbols for image {Image}, limit={Limit}, offset={Offset}",
|
||||
image, limit, offset);
|
||||
|
||||
try
|
||||
{
|
||||
var result = await _repository.QueryAsync(query, cancellationToken);
|
||||
|
||||
var response = new HotSymbolApiResponse
|
||||
{
|
||||
Symbols = result.Symbols.Select(MapToApiSymbol).ToList(),
|
||||
TotalCount = result.TotalCount,
|
||||
Limit = limit,
|
||||
Offset = offset,
|
||||
WindowHours = windowHours,
|
||||
ExecutionTimeMs = (int)result.Metadata.ExecutionTime.TotalMilliseconds,
|
||||
};
|
||||
|
||||
return Ok(response);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error querying hot symbols for image {Image}", image);
|
||||
return StatusCode(StatusCodes.Status500InternalServerError, new ProblemDetails
|
||||
{
|
||||
Title = "Internal server error",
|
||||
Detail = "An error occurred while querying hot symbols.",
|
||||
Status = StatusCodes.Status500InternalServerError,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets top hot symbols for a container image.
|
||||
/// </summary>
|
||||
/// <param name="image">Container image digest (sha256:xxx).</param>
|
||||
/// <param name="topN">Number of top symbols to return (default: 10, max: 100).</param>
|
||||
/// <param name="windowHours">Time window in hours (default: 24).</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Top N hot symbols by observation count.</returns>
|
||||
[HttpGet("hot-symbols/top")]
|
||||
[ProducesResponseType(typeof(TopHotSymbolsResponse), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
|
||||
public async Task<ActionResult<TopHotSymbolsResponse>> GetTopHotSymbols(
|
||||
[FromQuery(Name = "image"), Required] string image,
|
||||
[FromQuery(Name = "top")] int topN = 10,
|
||||
[FromQuery(Name = "window_hours")] int windowHours = 24,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!IsValidDigest(image))
|
||||
{
|
||||
return BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Invalid image digest format",
|
||||
Detail = "The image digest must be in format 'sha256:...' or 'sha512:...'.",
|
||||
Status = StatusCodes.Status400BadRequest,
|
||||
});
|
||||
}
|
||||
|
||||
topN = Math.Clamp(topN, 1, 100);
|
||||
|
||||
var symbols = await _repository.GetTopHotSymbolsAsync(
|
||||
image,
|
||||
topN,
|
||||
TimeSpan.FromHours(windowHours),
|
||||
cancellationToken);
|
||||
|
||||
return Ok(new TopHotSymbolsResponse
|
||||
{
|
||||
Symbols = symbols.Select(MapToApiSymbol).ToList(),
|
||||
TopN = topN,
|
||||
WindowHours = windowHours,
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets statistics for hot symbols of a container image.
|
||||
/// </summary>
|
||||
/// <param name="image">Container image digest (sha256:xxx).</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Aggregated statistics for the image.</returns>
|
||||
[HttpGet("hot-symbols/stats")]
|
||||
[ProducesResponseType(typeof(HotSymbolStatsResponse), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
|
||||
public async Task<ActionResult<HotSymbolStatsResponse>> GetHotSymbolStats(
|
||||
[FromQuery(Name = "image"), Required] string image,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!IsValidDigest(image))
|
||||
{
|
||||
return BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Invalid image digest format",
|
||||
Detail = "The image digest must be in format 'sha256:...' or 'sha512:...'.",
|
||||
Status = StatusCodes.Status400BadRequest,
|
||||
});
|
||||
}
|
||||
|
||||
var stats = await _repository.GetStatisticsAsync(image, cancellationToken);
|
||||
|
||||
return Ok(new HotSymbolStatsResponse
|
||||
{
|
||||
TotalSymbols = stats.TotalSymbols,
|
||||
TotalObservations = stats.TotalObservations,
|
||||
UniqueBuildIds = stats.UniqueBuildIds,
|
||||
SecurityRelevantSymbols = stats.SecurityRelevantSymbols,
|
||||
SymbolsWithCves = stats.SymbolsWithCves,
|
||||
EarliestObservation = stats.EarliestObservation,
|
||||
LatestObservation = stats.LatestObservation,
|
||||
TopModules = stats.TopModules.Select(m => new ModuleStatDto
|
||||
{
|
||||
ModuleName = m.ModuleName,
|
||||
BuildId = m.BuildId,
|
||||
ObservationCount = m.ObservationCount,
|
||||
SymbolCount = m.SymbolCount,
|
||||
}).ToList(),
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets symbols correlated with reachability data.
|
||||
/// </summary>
|
||||
/// <param name="image">Container image digest (sha256:xxx).</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Correlated symbols with reachability state.</returns>
|
||||
[HttpGet("hot-symbols/correlated")]
|
||||
[ProducesResponseType(typeof(CorrelatedSymbolsResponse), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
|
||||
public async Task<ActionResult<CorrelatedSymbolsResponse>> GetCorrelatedSymbols(
|
||||
[FromQuery(Name = "image"), Required] string image,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!IsValidDigest(image))
|
||||
{
|
||||
return BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Invalid image digest format",
|
||||
Detail = "The image digest must be in format 'sha256:...' or 'sha512:...'.",
|
||||
Status = StatusCodes.Status400BadRequest,
|
||||
});
|
||||
}
|
||||
|
||||
var correlations = await _repository.CorrelateWithReachabilityAsync(image, cancellationToken);
|
||||
|
||||
return Ok(new CorrelatedSymbolsResponse
|
||||
{
|
||||
Correlations = correlations.Select(c => new CorrelationDto
|
||||
{
|
||||
Symbol = MapToApiSymbol(c.Symbol),
|
||||
InReachabilityModel = c.InReachabilityModel,
|
||||
ReachabilityState = c.ReachabilityState,
|
||||
Purl = c.Purl,
|
||||
Vulnerabilities = c.Vulnerabilities?.ToList() ?? [],
|
||||
ConfidenceScore = c.ConfidenceScore,
|
||||
Method = c.Method.ToString(),
|
||||
}).ToList(),
|
||||
});
|
||||
}
|
||||
|
||||
private static bool IsValidDigest(string digest)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(digest))
|
||||
return false;
|
||||
|
||||
return digest.StartsWith("sha256:", StringComparison.OrdinalIgnoreCase)
|
||||
|| digest.StartsWith("sha512:", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static HotSymbolSortOrder ParseSortOrder(string? sort)
|
||||
{
|
||||
return sort?.ToLowerInvariant() switch
|
||||
{
|
||||
"count_asc" => HotSymbolSortOrder.ObservationCountAsc,
|
||||
"count_desc" => HotSymbolSortOrder.ObservationCountDesc,
|
||||
"last_seen_asc" => HotSymbolSortOrder.LastSeenAsc,
|
||||
"last_seen_desc" => HotSymbolSortOrder.LastSeenDesc,
|
||||
"name_asc" => HotSymbolSortOrder.FunctionNameAsc,
|
||||
_ => HotSymbolSortOrder.ObservationCountDesc,
|
||||
};
|
||||
}
|
||||
|
||||
private static HotSymbolDto MapToApiSymbol(HotSymbolEntry entry)
|
||||
{
|
||||
return new HotSymbolDto
|
||||
{
|
||||
Id = entry.Id,
|
||||
ImageDigest = entry.ImageDigest,
|
||||
BuildId = entry.BuildId,
|
||||
SymbolId = entry.SymbolId,
|
||||
FunctionName = entry.FunctionName,
|
||||
ModuleName = entry.ModuleName,
|
||||
ObservationCount = entry.ObservationCount,
|
||||
FirstSeen = entry.FirstSeen,
|
||||
LastSeen = entry.LastSeen,
|
||||
IsSecurityRelevant = entry.IsSecurityRelevant,
|
||||
AssociatedCves = entry.AssociatedCves?.ToList() ?? [],
|
||||
Purl = entry.Purl,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
#region API DTOs
|
||||
|
||||
/// <summary>
|
||||
/// API response for hot symbols query.
|
||||
/// </summary>
|
||||
public sealed record HotSymbolApiResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// List of hot symbols.
|
||||
/// </summary>
|
||||
public required IReadOnlyList<HotSymbolDto> Symbols { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Total count matching the query.
|
||||
/// </summary>
|
||||
public required int TotalCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Limit used in query.
|
||||
/// </summary>
|
||||
public required int Limit { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Offset used in query.
|
||||
/// </summary>
|
||||
public required int Offset { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Time window in hours.
|
||||
/// </summary>
|
||||
public required int WindowHours { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Query execution time in milliseconds.
|
||||
/// </summary>
|
||||
public required int ExecutionTimeMs { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Hot symbol DTO for API responses.
|
||||
/// </summary>
|
||||
public sealed record HotSymbolDto
|
||||
{
|
||||
/// <summary>
|
||||
/// Unique identifier.
|
||||
/// </summary>
|
||||
public required Guid Id { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Container image digest.
|
||||
/// </summary>
|
||||
public required string ImageDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// ELF Build-ID.
|
||||
/// </summary>
|
||||
public required string BuildId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Canonical symbol identifier.
|
||||
/// </summary>
|
||||
public required string SymbolId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Demangled function name.
|
||||
/// </summary>
|
||||
public required string FunctionName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Module name.
|
||||
/// </summary>
|
||||
public string? ModuleName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Observation count.
|
||||
/// </summary>
|
||||
public required long ObservationCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// First seen timestamp.
|
||||
/// </summary>
|
||||
public required DateTime FirstSeen { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Last seen timestamp.
|
||||
/// </summary>
|
||||
public required DateTime LastSeen { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether security-relevant.
|
||||
/// </summary>
|
||||
public required bool IsSecurityRelevant { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Associated CVE IDs.
|
||||
/// </summary>
|
||||
public required IReadOnlyList<string> AssociatedCves { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Package URL if correlated.
|
||||
/// </summary>
|
||||
public string? Purl { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response for top hot symbols.
|
||||
/// </summary>
|
||||
public sealed record TopHotSymbolsResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// Top symbols by observation count.
|
||||
/// </summary>
|
||||
public required IReadOnlyList<HotSymbolDto> Symbols { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Requested top N.
|
||||
/// </summary>
|
||||
public required int TopN { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Time window in hours.
|
||||
/// </summary>
|
||||
public required int WindowHours { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response for hot symbol statistics.
|
||||
/// </summary>
|
||||
public sealed record HotSymbolStatsResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// Total unique symbols.
|
||||
/// </summary>
|
||||
public required int TotalSymbols { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Total observations.
|
||||
/// </summary>
|
||||
public required long TotalObservations { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Unique Build-IDs.
|
||||
/// </summary>
|
||||
public required int UniqueBuildIds { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Security-relevant symbols.
|
||||
/// </summary>
|
||||
public required int SecurityRelevantSymbols { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Symbols with CVEs.
|
||||
/// </summary>
|
||||
public required int SymbolsWithCves { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Earliest observation.
|
||||
/// </summary>
|
||||
public required DateTime EarliestObservation { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Latest observation.
|
||||
/// </summary>
|
||||
public required DateTime LatestObservation { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Top modules.
|
||||
/// </summary>
|
||||
public required IReadOnlyList<ModuleStatDto> TopModules { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Module statistics DTO.
|
||||
/// </summary>
|
||||
public sealed record ModuleStatDto
|
||||
{
|
||||
/// <summary>
|
||||
/// Module name.
|
||||
/// </summary>
|
||||
public required string ModuleName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Build-ID.
|
||||
/// </summary>
|
||||
public required string BuildId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Observation count.
|
||||
/// </summary>
|
||||
public required long ObservationCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Symbol count.
|
||||
/// </summary>
|
||||
public required int SymbolCount { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response for correlated symbols.
|
||||
/// </summary>
|
||||
public sealed record CorrelatedSymbolsResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// List of correlations.
|
||||
/// </summary>
|
||||
public required IReadOnlyList<CorrelationDto> Correlations { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Correlation DTO.
|
||||
/// </summary>
|
||||
public sealed record CorrelationDto
|
||||
{
|
||||
/// <summary>
|
||||
/// The hot symbol.
|
||||
/// </summary>
|
||||
public required HotSymbolDto Symbol { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether in reachability model.
|
||||
/// </summary>
|
||||
public required bool InReachabilityModel { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Reachability state.
|
||||
/// </summary>
|
||||
public string? ReachabilityState { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Package URL.
|
||||
/// </summary>
|
||||
public string? Purl { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Vulnerabilities.
|
||||
/// </summary>
|
||||
public required IReadOnlyList<string> Vulnerabilities { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Confidence score.
|
||||
/// </summary>
|
||||
public required double ConfidenceScore { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Correlation method.
|
||||
/// </summary>
|
||||
public required string Method { get; init; }
|
||||
}
|
||||
|
||||
#endregion
|
||||
355
src/Signals/StellaOps.Signals/Models/HotSymbolIndex.cs
Normal file
355
src/Signals/StellaOps.Signals/Models/HotSymbolIndex.cs
Normal file
@@ -0,0 +1,355 @@
|
||||
namespace StellaOps.Signals.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Hot symbol index models for runtime observation tracking.
|
||||
/// Sprint: SPRINT_20251226_010_SIGNALS_runtime_stack
|
||||
/// Tasks: STACK-10, STACK-11, STACK-12
|
||||
///
|
||||
/// Tracks function observation counts to correlate runtime behavior with reachability models.
|
||||
/// </summary>
|
||||
|
||||
/// <summary>
|
||||
/// Represents a hot symbol entry in the index.
|
||||
/// </summary>
|
||||
public sealed record HotSymbolEntry
|
||||
{
|
||||
/// <summary>
|
||||
/// Unique identifier for this entry.
|
||||
/// </summary>
|
||||
public required Guid Id { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Container image digest (sha256:xxx).
|
||||
/// </summary>
|
||||
public required string ImageDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// ELF Build-ID of the binary containing the symbol.
|
||||
/// </summary>
|
||||
public required string BuildId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Canonical symbol identifier (buildid:function+offset).
|
||||
/// </summary>
|
||||
public required string SymbolId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Demangled function name.
|
||||
/// </summary>
|
||||
public required string FunctionName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Module or binary name.
|
||||
/// </summary>
|
||||
public string? ModuleName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Total observation count within the window.
|
||||
/// </summary>
|
||||
public required long ObservationCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// First observation timestamp.
|
||||
/// </summary>
|
||||
public required DateTime FirstSeen { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Last observation timestamp.
|
||||
/// </summary>
|
||||
public required DateTime LastSeen { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Time window start for these observations.
|
||||
/// </summary>
|
||||
public required DateTime WindowStart { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Time window end for these observations.
|
||||
/// </summary>
|
||||
public required DateTime WindowEnd { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Tenant ID for multi-tenant isolation.
|
||||
/// </summary>
|
||||
public Guid? TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this symbol is security-relevant (entry point, sink, etc).
|
||||
/// </summary>
|
||||
public bool IsSecurityRelevant { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Associated CVE IDs if this symbol is vulnerable.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string>? AssociatedCves { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Package URL (purl) if correlated with SBOM.
|
||||
/// </summary>
|
||||
public string? Purl { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request for querying hot symbols.
|
||||
/// </summary>
|
||||
public sealed record HotSymbolQuery
|
||||
{
|
||||
/// <summary>
|
||||
/// Filter by image digest.
|
||||
/// </summary>
|
||||
public string? ImageDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Filter by Build-ID.
|
||||
/// </summary>
|
||||
public string? BuildId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Filter by function name pattern (supports wildcards).
|
||||
/// </summary>
|
||||
public string? FunctionPattern { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Filter by module name.
|
||||
/// </summary>
|
||||
public string? ModuleName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Minimum observation count threshold.
|
||||
/// </summary>
|
||||
public long? MinObservationCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Only return security-relevant symbols.
|
||||
/// </summary>
|
||||
public bool? OnlySecurityRelevant { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Time window to query.
|
||||
/// </summary>
|
||||
public TimeSpan? TimeWindow { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Tenant ID filter.
|
||||
/// </summary>
|
||||
public Guid? TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Maximum number of results.
|
||||
/// </summary>
|
||||
public int Limit { get; init; } = 100;
|
||||
|
||||
/// <summary>
|
||||
/// Offset for pagination.
|
||||
/// </summary>
|
||||
public int Offset { get; init; } = 0;
|
||||
|
||||
/// <summary>
|
||||
/// Sort order.
|
||||
/// </summary>
|
||||
public HotSymbolSortOrder SortOrder { get; init; } = HotSymbolSortOrder.ObservationCountDesc;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sort order for hot symbol queries.
|
||||
/// </summary>
|
||||
public enum HotSymbolSortOrder
|
||||
{
|
||||
ObservationCountDesc,
|
||||
ObservationCountAsc,
|
||||
LastSeenDesc,
|
||||
LastSeenAsc,
|
||||
FunctionNameAsc,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response from hot symbol query.
|
||||
/// </summary>
|
||||
public sealed record HotSymbolQueryResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// Matching hot symbol entries.
|
||||
/// </summary>
|
||||
public required IReadOnlyList<HotSymbolEntry> Symbols { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Total count matching the query (before pagination).
|
||||
/// </summary>
|
||||
public required int TotalCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Query metadata.
|
||||
/// </summary>
|
||||
public required QueryMetadata Metadata { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Query metadata.
|
||||
/// </summary>
|
||||
public sealed record QueryMetadata
|
||||
{
|
||||
/// <summary>
|
||||
/// Time taken to execute the query.
|
||||
/// </summary>
|
||||
public required TimeSpan ExecutionTime { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Window start used for the query.
|
||||
/// </summary>
|
||||
public required DateTime WindowStart { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Window end used for the query.
|
||||
/// </summary>
|
||||
public required DateTime WindowEnd { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to ingest hot symbol observations.
|
||||
/// </summary>
|
||||
public sealed record HotSymbolIngestRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Image digest being observed.
|
||||
/// </summary>
|
||||
public required string ImageDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// List of symbol observations.
|
||||
/// </summary>
|
||||
public required IReadOnlyList<SymbolObservation> Observations { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Tenant ID for multi-tenant isolation.
|
||||
/// </summary>
|
||||
public Guid? TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Source of the observations.
|
||||
/// </summary>
|
||||
public string? Source { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Single symbol observation for ingestion.
|
||||
/// </summary>
|
||||
public sealed record SymbolObservation
|
||||
{
|
||||
/// <summary>
|
||||
/// ELF Build-ID.
|
||||
/// </summary>
|
||||
public required string BuildId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Function name.
|
||||
/// </summary>
|
||||
public required string FunctionName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Module name.
|
||||
/// </summary>
|
||||
public string? ModuleName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Observation count.
|
||||
/// </summary>
|
||||
public required long Count { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Timestamp of observation.
|
||||
/// </summary>
|
||||
public required DateTime Timestamp { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response from hot symbol ingestion.
|
||||
/// </summary>
|
||||
public sealed record HotSymbolIngestResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// Number of observations ingested.
|
||||
/// </summary>
|
||||
public required int IngestedCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of new symbols created.
|
||||
/// </summary>
|
||||
public required int NewSymbolsCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of existing symbols updated.
|
||||
/// </summary>
|
||||
public required int UpdatedSymbolsCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Time taken for ingestion.
|
||||
/// </summary>
|
||||
public required TimeSpan ProcessingTime { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Correlation result between hot symbols and reachability.
|
||||
/// </summary>
|
||||
public sealed record SymbolCorrelationResult
|
||||
{
|
||||
/// <summary>
|
||||
/// The hot symbol.
|
||||
/// </summary>
|
||||
public required HotSymbolEntry Symbol { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this symbol appears in the reachability model.
|
||||
/// </summary>
|
||||
public required bool InReachabilityModel { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Reachability state if in model.
|
||||
/// </summary>
|
||||
public string? ReachabilityState { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Package URL if correlated with SBOM.
|
||||
/// </summary>
|
||||
public string? Purl { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Associated vulnerabilities.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string>? Vulnerabilities { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Confidence score for the correlation (0.0 - 1.0).
|
||||
/// </summary>
|
||||
public double ConfidenceScore { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Correlation method used.
|
||||
/// </summary>
|
||||
public CorrelationMethod Method { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Method used for symbol correlation.
|
||||
/// </summary>
|
||||
public enum CorrelationMethod
|
||||
{
|
||||
/// <summary>
|
||||
/// Exact Build-ID and symbol match.
|
||||
/// </summary>
|
||||
ExactMatch,
|
||||
|
||||
/// <summary>
|
||||
/// Function name match with version tolerance.
|
||||
/// </summary>
|
||||
FunctionNameMatch,
|
||||
|
||||
/// <summary>
|
||||
/// Package URL match.
|
||||
/// </summary>
|
||||
PurlMatch,
|
||||
|
||||
/// <summary>
|
||||
/// Heuristic matching.
|
||||
/// </summary>
|
||||
Heuristic,
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
using StellaOps.Signals.Models;
|
||||
|
||||
namespace StellaOps.Signals.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// Repository interface for hot symbol index persistence.
|
||||
/// Sprint: SPRINT_20251226_010_SIGNALS_runtime_stack
|
||||
/// Task: STACK-11
|
||||
/// </summary>
|
||||
public interface IHotSymbolRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// Queries hot symbols based on the provided criteria.
|
||||
/// </summary>
|
||||
Task<HotSymbolQueryResponse> QueryAsync(
|
||||
HotSymbolQuery query,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a specific hot symbol by ID.
|
||||
/// </summary>
|
||||
Task<HotSymbolEntry?> GetByIdAsync(
|
||||
Guid id,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets hot symbols by image digest.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<HotSymbolEntry>> GetByImageDigestAsync(
|
||||
string imageDigest,
|
||||
int limit = 100,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets hot symbols by Build-ID.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<HotSymbolEntry>> GetByBuildIdAsync(
|
||||
string buildId,
|
||||
int limit = 100,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Upserts a hot symbol entry (insert or update observation count).
|
||||
/// </summary>
|
||||
Task<HotSymbolEntry> UpsertAsync(
|
||||
HotSymbolEntry entry,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Ingests a batch of symbol observations.
|
||||
/// </summary>
|
||||
Task<HotSymbolIngestResponse> IngestBatchAsync(
|
||||
HotSymbolIngestRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes hot symbols older than the specified cutoff.
|
||||
/// </summary>
|
||||
Task<int> DeleteOlderThanAsync(
|
||||
DateTime cutoff,
|
||||
Guid? tenantId = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the top N hot symbols by observation count.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<HotSymbolEntry>> GetTopHotSymbolsAsync(
|
||||
string imageDigest,
|
||||
int topN = 10,
|
||||
TimeSpan? timeWindow = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Correlates hot symbols with reachability data.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<SymbolCorrelationResult>> CorrelateWithReachabilityAsync(
|
||||
string imageDigest,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets aggregated statistics for an image.
|
||||
/// </summary>
|
||||
Task<HotSymbolStatistics> GetStatisticsAsync(
|
||||
string imageDigest,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Aggregated statistics for hot symbols.
|
||||
/// </summary>
|
||||
public sealed record HotSymbolStatistics
|
||||
{
|
||||
/// <summary>
|
||||
/// Total unique symbols observed.
|
||||
/// </summary>
|
||||
public required int TotalSymbols { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Total observation count across all symbols.
|
||||
/// </summary>
|
||||
public required long TotalObservations { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Unique Build-IDs observed.
|
||||
/// </summary>
|
||||
public required int UniqueBuildIds { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Security-relevant symbols count.
|
||||
/// </summary>
|
||||
public required int SecurityRelevantSymbols { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Symbols with CVE associations.
|
||||
/// </summary>
|
||||
public required int SymbolsWithCves { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Time range covered.
|
||||
/// </summary>
|
||||
public required DateTime EarliestObservation { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Latest observation time.
|
||||
/// </summary>
|
||||
public required DateTime LatestObservation { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Top modules by observation count.
|
||||
/// </summary>
|
||||
public required IReadOnlyList<ModuleObservationSummary> TopModules { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Module observation summary.
|
||||
/// </summary>
|
||||
public sealed record ModuleObservationSummary
|
||||
{
|
||||
/// <summary>
|
||||
/// Module name.
|
||||
/// </summary>
|
||||
public required string ModuleName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Build-ID of the module.
|
||||
/// </summary>
|
||||
public required string BuildId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Total observation count.
|
||||
/// </summary>
|
||||
public required long ObservationCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of unique symbols.
|
||||
/// </summary>
|
||||
public required int SymbolCount { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,833 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// IFuncProofLinkingService.cs
|
||||
// Sprint: SPRINT_20251226_010_SIGNALS_runtime_stack
|
||||
// Task: STACK-14 — Link to FuncProof: verify observed symbol exists in funcproof
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
namespace StellaOps.Signals.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service for linking runtime-observed symbols with FuncProof evidence.
|
||||
/// Verifies that observed symbols exist in the binary's funcproof document.
|
||||
/// </summary>
|
||||
public interface IFuncProofLinkingService
|
||||
{
|
||||
/// <summary>
|
||||
/// Verifies that an observed symbol exists in the FuncProof for the binary.
|
||||
/// </summary>
|
||||
/// <param name="request">The verification request.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The verification result.</returns>
|
||||
Task<FuncProofVerificationResult> VerifySymbolAsync(
|
||||
FuncProofVerificationRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Verifies a batch of observed symbols against FuncProof.
|
||||
/// </summary>
|
||||
/// <param name="requests">The verification requests.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The verification results.</returns>
|
||||
Task<IReadOnlyList<FuncProofVerificationResult>> VerifyBatchAsync(
|
||||
IEnumerable<FuncProofVerificationRequest> requests,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets FuncProof details for a binary by Build-ID.
|
||||
/// </summary>
|
||||
/// <param name="buildId">ELF Build-ID.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>FuncProof summary or null if not found.</returns>
|
||||
Task<FuncProofSummary?> GetFuncProofByBuildIdAsync(
|
||||
string buildId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Checks if a symbol is in a reachable path to a vulnerable sink.
|
||||
/// </summary>
|
||||
/// <param name="buildId">ELF Build-ID.</param>
|
||||
/// <param name="symbolDigest">Symbol digest to check.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Reachability information for the symbol.</returns>
|
||||
Task<SymbolReachabilityInfo?> GetSymbolReachabilityAsync(
|
||||
string buildId,
|
||||
string symbolDigest,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets all symbols from a FuncProof that were observed at runtime.
|
||||
/// </summary>
|
||||
/// <param name="buildId">ELF Build-ID.</param>
|
||||
/// <param name="imageDigest">Container image digest for runtime observations.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Observed symbol coverage information.</returns>
|
||||
Task<FuncProofCoverageResult> GetObservedCoverageAsync(
|
||||
string buildId,
|
||||
string imageDigest,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request for FuncProof symbol verification.
|
||||
/// </summary>
|
||||
public sealed record FuncProofVerificationRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// ELF Build-ID of the binary.
|
||||
/// </summary>
|
||||
public required string BuildId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Function name observed at runtime.
|
||||
/// </summary>
|
||||
public required string FunctionName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Offset within the function (optional, for precise matching).
|
||||
/// </summary>
|
||||
public ulong? Offset { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Container image digest (optional, for context).
|
||||
/// </summary>
|
||||
public string? ImageDigest { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of FuncProof verification.
|
||||
/// </summary>
|
||||
public sealed record FuncProofVerificationResult
|
||||
{
|
||||
/// <summary>
|
||||
/// The original request.
|
||||
/// </summary>
|
||||
public required FuncProofVerificationRequest Request { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the symbol was found in FuncProof.
|
||||
/// </summary>
|
||||
public required bool Found { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether FuncProof exists for this Build-ID.
|
||||
/// </summary>
|
||||
public required bool FuncProofExists { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Symbol digest from FuncProof if found.
|
||||
/// </summary>
|
||||
public string? SymbolDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Start address of the function.
|
||||
/// </summary>
|
||||
public string? StartAddress { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// End address of the function.
|
||||
/// </summary>
|
||||
public string? EndAddress { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Size of the function in bytes.
|
||||
/// </summary>
|
||||
public long? Size { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Hash of the function bytes from FuncProof.
|
||||
/// </summary>
|
||||
public string? FunctionHash { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Confidence level from FuncProof (1.0 = DWARF, 0.8 = symtab, 0.5 = heuristic).
|
||||
/// </summary>
|
||||
public double? Confidence { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this function is an entrypoint.
|
||||
/// </summary>
|
||||
public bool IsEntrypoint { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Type of entrypoint if applicable.
|
||||
/// </summary>
|
||||
public string? EntrypointType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this function is a vulnerable sink.
|
||||
/// </summary>
|
||||
public bool IsSink { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// CVE ID if this is a sink.
|
||||
/// </summary>
|
||||
public string? SinkVulnId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Source file path if available.
|
||||
/// </summary>
|
||||
public string? SourceFile { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Source line number if available.
|
||||
/// </summary>
|
||||
public int? SourceLine { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Matching method used.
|
||||
/// </summary>
|
||||
public FuncProofMatchMethod MatchMethod { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Error message if verification failed.
|
||||
/// </summary>
|
||||
public string? Error { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Method used to match the symbol in FuncProof.
|
||||
/// </summary>
|
||||
public enum FuncProofMatchMethod
|
||||
{
|
||||
/// <summary>
|
||||
/// Exact symbol name match.
|
||||
/// </summary>
|
||||
ExactName,
|
||||
|
||||
/// <summary>
|
||||
/// Demangled name match.
|
||||
/// </summary>
|
||||
DemangledName,
|
||||
|
||||
/// <summary>
|
||||
/// Address range match.
|
||||
/// </summary>
|
||||
AddressRange,
|
||||
|
||||
/// <summary>
|
||||
/// Symbol digest match.
|
||||
/// </summary>
|
||||
SymbolDigest,
|
||||
|
||||
/// <summary>
|
||||
/// No match found.
|
||||
/// </summary>
|
||||
NoMatch,
|
||||
|
||||
/// <summary>
|
||||
/// FuncProof not available for this binary.
|
||||
/// </summary>
|
||||
FuncProofNotFound,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Summary of a FuncProof document.
|
||||
/// </summary>
|
||||
public sealed record FuncProofSummary
|
||||
{
|
||||
/// <summary>
|
||||
/// FuncProof document ID.
|
||||
/// </summary>
|
||||
public required string ProofId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// ELF Build-ID.
|
||||
/// </summary>
|
||||
public required string BuildId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Build-ID type.
|
||||
/// </summary>
|
||||
public required string BuildIdType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// SHA-256 of the binary file.
|
||||
/// </summary>
|
||||
public required string FileSha256 { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Binary format (elf, pe, macho).
|
||||
/// </summary>
|
||||
public required string BinaryFormat { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Target architecture.
|
||||
/// </summary>
|
||||
public required string Architecture { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the binary is stripped.
|
||||
/// </summary>
|
||||
public bool IsStripped { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Total number of functions in the proof.
|
||||
/// </summary>
|
||||
public required int FunctionCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of entrypoints.
|
||||
/// </summary>
|
||||
public required int EntrypointCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of vulnerable sinks.
|
||||
/// </summary>
|
||||
public required int SinkCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of entry→sink traces.
|
||||
/// </summary>
|
||||
public required int TraceCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the proof was generated.
|
||||
/// </summary>
|
||||
public required DateTimeOffset GeneratedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Generator version.
|
||||
/// </summary>
|
||||
public required string GeneratorVersion { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reachability information for a symbol.
|
||||
/// </summary>
|
||||
public sealed record SymbolReachabilityInfo
|
||||
{
|
||||
/// <summary>
|
||||
/// Symbol digest.
|
||||
/// </summary>
|
||||
public required string SymbolDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Function name.
|
||||
/// </summary>
|
||||
public required string FunctionName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this symbol is reachable from an entrypoint.
|
||||
/// </summary>
|
||||
public required bool IsReachable { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this symbol can reach a vulnerable sink.
|
||||
/// </summary>
|
||||
public required bool ReachesSink { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Entrypoints that can reach this symbol.
|
||||
/// </summary>
|
||||
public required IReadOnlyList<string> ReachableFromEntrypoints { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Sinks that this symbol can reach.
|
||||
/// </summary>
|
||||
public required IReadOnlyList<SinkInfo> ReachesSinks { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Minimum hop count from any entrypoint.
|
||||
/// </summary>
|
||||
public int? MinHopsFromEntry { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Minimum hop count to nearest sink.
|
||||
/// </summary>
|
||||
public int? MinHopsToSink { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Information about a vulnerable sink.
|
||||
/// </summary>
|
||||
public sealed record SinkInfo
|
||||
{
|
||||
/// <summary>
|
||||
/// Sink symbol digest.
|
||||
/// </summary>
|
||||
public required string SymbolDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Sink function name.
|
||||
/// </summary>
|
||||
public required string FunctionName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Vulnerability ID.
|
||||
/// </summary>
|
||||
public required string VulnId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Hop count to this sink.
|
||||
/// </summary>
|
||||
public required int HopCount { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Coverage result showing observed vs. total symbols in FuncProof.
|
||||
/// </summary>
|
||||
public sealed record FuncProofCoverageResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Build-ID of the binary.
|
||||
/// </summary>
|
||||
public required string BuildId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Image digest for runtime observations.
|
||||
/// </summary>
|
||||
public required string ImageDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Total functions in FuncProof.
|
||||
/// </summary>
|
||||
public required int TotalFunctions { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Functions observed at runtime.
|
||||
/// </summary>
|
||||
public required int ObservedFunctions { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Coverage percentage.
|
||||
/// </summary>
|
||||
public double CoveragePercent => TotalFunctions > 0
|
||||
? (ObservedFunctions / (double)TotalFunctions) * 100.0
|
||||
: 0.0;
|
||||
|
||||
/// <summary>
|
||||
/// Entrypoints observed.
|
||||
/// </summary>
|
||||
public required int ObservedEntrypoints { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Total entrypoints in FuncProof.
|
||||
/// </summary>
|
||||
public required int TotalEntrypoints { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Sinks observed (critical if observed).
|
||||
/// </summary>
|
||||
public required int ObservedSinks { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Total sinks in FuncProof.
|
||||
/// </summary>
|
||||
public required int TotalSinks { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// List of observed sink details (security-critical).
|
||||
/// </summary>
|
||||
public required IReadOnlyList<ObservedSinkDetail> ObservedSinkDetails { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Functions observed but not in FuncProof (potential JIT or dynamic code).
|
||||
/// </summary>
|
||||
public required int UnmappedObservations { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Detail of an observed sink.
|
||||
/// </summary>
|
||||
public sealed record ObservedSinkDetail
|
||||
{
|
||||
/// <summary>
|
||||
/// Function name.
|
||||
/// </summary>
|
||||
public required string FunctionName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Symbol digest.
|
||||
/// </summary>
|
||||
public required string SymbolDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Vulnerability ID.
|
||||
/// </summary>
|
||||
public required string VulnId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Observation count.
|
||||
/// </summary>
|
||||
public required long ObservationCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Last observation time.
|
||||
/// </summary>
|
||||
public required DateTime LastSeen { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of FuncProof linking service.
|
||||
/// </summary>
|
||||
public sealed class FuncProofLinkingService : IFuncProofLinkingService
|
||||
{
|
||||
private readonly IFuncProofRepository _funcProofRepository;
|
||||
private readonly IHotSymbolQueryService _hotSymbolQueryService;
|
||||
private readonly ILogger<FuncProofLinkingService> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="FuncProofLinkingService"/> class.
|
||||
/// </summary>
|
||||
public FuncProofLinkingService(
|
||||
IFuncProofRepository funcProofRepository,
|
||||
IHotSymbolQueryService hotSymbolQueryService,
|
||||
ILogger<FuncProofLinkingService> logger)
|
||||
{
|
||||
_funcProofRepository = funcProofRepository ?? throw new ArgumentNullException(nameof(funcProofRepository));
|
||||
_hotSymbolQueryService = hotSymbolQueryService ?? throw new ArgumentNullException(nameof(hotSymbolQueryService));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<FuncProofVerificationResult> VerifySymbolAsync(
|
||||
FuncProofVerificationRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
_logger.LogDebug(
|
||||
"Verifying symbol: buildId={BuildId}, function={Function}",
|
||||
request.BuildId, request.FunctionName);
|
||||
|
||||
// Check if FuncProof exists for this Build-ID
|
||||
var funcProof = await _funcProofRepository.GetByBuildIdAsync(request.BuildId, cancellationToken);
|
||||
if (funcProof is null)
|
||||
{
|
||||
return new FuncProofVerificationResult
|
||||
{
|
||||
Request = request,
|
||||
Found = false,
|
||||
FuncProofExists = false,
|
||||
MatchMethod = FuncProofMatchMethod.FuncProofNotFound,
|
||||
Error = $"No FuncProof found for Build-ID: {request.BuildId}",
|
||||
};
|
||||
}
|
||||
|
||||
// Try exact name match first
|
||||
var functionInfo = await _funcProofRepository.FindFunctionByNameAsync(
|
||||
request.BuildId,
|
||||
request.FunctionName,
|
||||
cancellationToken);
|
||||
|
||||
if (functionInfo is not null)
|
||||
{
|
||||
return new FuncProofVerificationResult
|
||||
{
|
||||
Request = request,
|
||||
Found = true,
|
||||
FuncProofExists = true,
|
||||
SymbolDigest = functionInfo.SymbolDigest,
|
||||
StartAddress = functionInfo.Start,
|
||||
EndAddress = functionInfo.End,
|
||||
Size = functionInfo.Size,
|
||||
FunctionHash = functionInfo.Hash,
|
||||
Confidence = functionInfo.Confidence,
|
||||
IsEntrypoint = functionInfo.IsEntrypoint,
|
||||
EntrypointType = functionInfo.EntrypointType,
|
||||
IsSink = functionInfo.IsSink,
|
||||
SinkVulnId = functionInfo.SinkVulnId,
|
||||
SourceFile = functionInfo.SourceFile,
|
||||
SourceLine = functionInfo.SourceLine,
|
||||
MatchMethod = FuncProofMatchMethod.ExactName,
|
||||
};
|
||||
}
|
||||
|
||||
// Try address range match if offset is provided
|
||||
if (request.Offset.HasValue)
|
||||
{
|
||||
var byAddress = await _funcProofRepository.FindFunctionByAddressAsync(
|
||||
request.BuildId,
|
||||
request.Offset.Value,
|
||||
cancellationToken);
|
||||
|
||||
if (byAddress is not null)
|
||||
{
|
||||
return new FuncProofVerificationResult
|
||||
{
|
||||
Request = request,
|
||||
Found = true,
|
||||
FuncProofExists = true,
|
||||
SymbolDigest = byAddress.SymbolDigest,
|
||||
StartAddress = byAddress.Start,
|
||||
EndAddress = byAddress.End,
|
||||
Size = byAddress.Size,
|
||||
FunctionHash = byAddress.Hash,
|
||||
Confidence = byAddress.Confidence,
|
||||
IsEntrypoint = byAddress.IsEntrypoint,
|
||||
IsSink = byAddress.IsSink,
|
||||
SinkVulnId = byAddress.SinkVulnId,
|
||||
MatchMethod = FuncProofMatchMethod.AddressRange,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Not found
|
||||
return new FuncProofVerificationResult
|
||||
{
|
||||
Request = request,
|
||||
Found = false,
|
||||
FuncProofExists = true,
|
||||
MatchMethod = FuncProofMatchMethod.NoMatch,
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<FuncProofVerificationResult>> VerifyBatchAsync(
|
||||
IEnumerable<FuncProofVerificationRequest> requests,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var results = new List<FuncProofVerificationResult>();
|
||||
foreach (var request in requests)
|
||||
{
|
||||
var result = await VerifySymbolAsync(request, cancellationToken);
|
||||
results.Add(result);
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<FuncProofSummary?> GetFuncProofByBuildIdAsync(
|
||||
string buildId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _funcProofRepository.GetSummaryByBuildIdAsync(buildId, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<SymbolReachabilityInfo?> GetSymbolReachabilityAsync(
|
||||
string buildId,
|
||||
string symbolDigest,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _funcProofRepository.GetSymbolReachabilityAsync(
|
||||
buildId,
|
||||
symbolDigest,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<FuncProofCoverageResult> GetObservedCoverageAsync(
|
||||
string buildId,
|
||||
string imageDigest,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var summary = await _funcProofRepository.GetSummaryByBuildIdAsync(buildId, cancellationToken);
|
||||
if (summary is null)
|
||||
{
|
||||
return new FuncProofCoverageResult
|
||||
{
|
||||
BuildId = buildId,
|
||||
ImageDigest = imageDigest,
|
||||
TotalFunctions = 0,
|
||||
ObservedFunctions = 0,
|
||||
ObservedEntrypoints = 0,
|
||||
TotalEntrypoints = 0,
|
||||
ObservedSinks = 0,
|
||||
TotalSinks = 0,
|
||||
ObservedSinkDetails = [],
|
||||
UnmappedObservations = 0,
|
||||
};
|
||||
}
|
||||
|
||||
// Get observed symbols from hot symbol index
|
||||
var observedSymbols = await _hotSymbolQueryService.GetSymbolsByBuildIdAsync(
|
||||
imageDigest,
|
||||
buildId,
|
||||
cancellationToken);
|
||||
|
||||
// Match observed symbols with FuncProof functions
|
||||
var matchResults = await MatchObservedSymbolsAsync(buildId, observedSymbols, cancellationToken);
|
||||
|
||||
return new FuncProofCoverageResult
|
||||
{
|
||||
BuildId = buildId,
|
||||
ImageDigest = imageDigest,
|
||||
TotalFunctions = summary.FunctionCount,
|
||||
ObservedFunctions = matchResults.MatchedCount,
|
||||
ObservedEntrypoints = matchResults.ObservedEntrypoints,
|
||||
TotalEntrypoints = summary.EntrypointCount,
|
||||
ObservedSinks = matchResults.ObservedSinks.Count,
|
||||
TotalSinks = summary.SinkCount,
|
||||
ObservedSinkDetails = matchResults.ObservedSinks,
|
||||
UnmappedObservations = matchResults.UnmappedCount,
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<SymbolMatchResult> MatchObservedSymbolsAsync(
|
||||
string buildId,
|
||||
IReadOnlyList<ObservedSymbolInfo> observedSymbols,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var matchedCount = 0;
|
||||
var unmappedCount = 0;
|
||||
var observedEntrypoints = 0;
|
||||
var observedSinks = new List<ObservedSinkDetail>();
|
||||
|
||||
foreach (var observed in observedSymbols)
|
||||
{
|
||||
var funcInfo = await _funcProofRepository.FindFunctionByNameAsync(
|
||||
buildId,
|
||||
observed.FunctionName,
|
||||
cancellationToken);
|
||||
|
||||
if (funcInfo is not null)
|
||||
{
|
||||
matchedCount++;
|
||||
|
||||
if (funcInfo.IsEntrypoint)
|
||||
{
|
||||
observedEntrypoints++;
|
||||
}
|
||||
|
||||
if (funcInfo.IsSink && funcInfo.SinkVulnId is not null)
|
||||
{
|
||||
observedSinks.Add(new ObservedSinkDetail
|
||||
{
|
||||
FunctionName = funcInfo.Symbol,
|
||||
SymbolDigest = funcInfo.SymbolDigest,
|
||||
VulnId = funcInfo.SinkVulnId,
|
||||
ObservationCount = observed.ObservationCount,
|
||||
LastSeen = observed.LastSeen,
|
||||
});
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
unmappedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
return new SymbolMatchResult
|
||||
{
|
||||
MatchedCount = matchedCount,
|
||||
UnmappedCount = unmappedCount,
|
||||
ObservedEntrypoints = observedEntrypoints,
|
||||
ObservedSinks = observedSinks,
|
||||
};
|
||||
}
|
||||
|
||||
private sealed record SymbolMatchResult
|
||||
{
|
||||
public required int MatchedCount { get; init; }
|
||||
public required int UnmappedCount { get; init; }
|
||||
public required int ObservedEntrypoints { get; init; }
|
||||
public required IReadOnlyList<ObservedSinkDetail> ObservedSinks { get; init; }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Repository interface for FuncProof data access.
|
||||
/// </summary>
|
||||
public interface IFuncProofRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets FuncProof by Build-ID.
|
||||
/// </summary>
|
||||
Task<FuncProofDocument?> GetByBuildIdAsync(
|
||||
string buildId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets FuncProof summary by Build-ID.
|
||||
/// </summary>
|
||||
Task<FuncProofSummary?> GetSummaryByBuildIdAsync(
|
||||
string buildId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Finds a function by name.
|
||||
/// </summary>
|
||||
Task<FuncProofFunctionInfo?> FindFunctionByNameAsync(
|
||||
string buildId,
|
||||
string functionName,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Finds a function by address.
|
||||
/// </summary>
|
||||
Task<FuncProofFunctionInfo?> FindFunctionByAddressAsync(
|
||||
string buildId,
|
||||
ulong address,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets symbol reachability information.
|
||||
/// </summary>
|
||||
Task<SymbolReachabilityInfo?> GetSymbolReachabilityAsync(
|
||||
string buildId,
|
||||
string symbolDigest,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// FuncProof document wrapper.
|
||||
/// </summary>
|
||||
public sealed record FuncProofDocument
|
||||
{
|
||||
/// <summary>
|
||||
/// Proof ID.
|
||||
/// </summary>
|
||||
public required string ProofId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Build-ID.
|
||||
/// </summary>
|
||||
public required string BuildId { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Function information from FuncProof.
|
||||
/// </summary>
|
||||
public sealed record FuncProofFunctionInfo
|
||||
{
|
||||
public required string Symbol { get; init; }
|
||||
public string? MangledName { get; init; }
|
||||
public required string SymbolDigest { get; init; }
|
||||
public required string Start { get; init; }
|
||||
public required string End { get; init; }
|
||||
public required long Size { get; init; }
|
||||
public required string Hash { get; init; }
|
||||
public double Confidence { get; init; }
|
||||
public string? SourceFile { get; init; }
|
||||
public int? SourceLine { get; init; }
|
||||
public bool IsEntrypoint { get; init; }
|
||||
public string? EntrypointType { get; init; }
|
||||
public bool IsSink { get; init; }
|
||||
public string? SinkVulnId { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Query service for hot symbols.
|
||||
/// </summary>
|
||||
public interface IHotSymbolQueryService
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets observed symbols by Build-ID.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<ObservedSymbolInfo>> GetSymbolsByBuildIdAsync(
|
||||
string imageDigest,
|
||||
string buildId,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Observed symbol information.
|
||||
/// </summary>
|
||||
public sealed record ObservedSymbolInfo
|
||||
{
|
||||
/// <summary>
|
||||
/// Function name.
|
||||
/// </summary>
|
||||
public required string FunctionName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Observation count.
|
||||
/// </summary>
|
||||
public required long ObservationCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Last seen timestamp.
|
||||
/// </summary>
|
||||
public required DateTime LastSeen { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,486 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// ISbomCorrelationService.cs
|
||||
// Sprint: SPRINT_20251226_010_SIGNALS_runtime_stack
|
||||
// Task: STACK-13 — Correlate stacks with SBOM: (image-digest, Build-ID, function) → purl
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
namespace StellaOps.Signals.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service for correlating runtime stack observations with SBOM data.
|
||||
/// Maps (image-digest, Build-ID, function) tuples to package URLs (purls).
|
||||
/// </summary>
|
||||
public interface ISbomCorrelationService
|
||||
{
|
||||
/// <summary>
|
||||
/// Correlates a single symbol observation with SBOM data.
|
||||
/// </summary>
|
||||
/// <param name="request">The correlation request.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The correlation result with purl if found.</returns>
|
||||
Task<SbomCorrelationResult> CorrelateAsync(
|
||||
SbomCorrelationRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Correlates a batch of symbol observations with SBOM data.
|
||||
/// </summary>
|
||||
/// <param name="requests">The correlation requests.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The correlation results.</returns>
|
||||
Task<IReadOnlyList<SbomCorrelationResult>> CorrelateBatchAsync(
|
||||
IEnumerable<SbomCorrelationRequest> requests,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets all purls for binaries in an image based on Build-ID.
|
||||
/// </summary>
|
||||
/// <param name="imageDigest">The container image digest.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Map of Build-ID to purl.</returns>
|
||||
Task<IReadOnlyDictionary<string, string>> GetBuildIdToPurlMapAsync(
|
||||
string imageDigest,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Validates that Build-IDs in stack observations match known SBOM components.
|
||||
/// </summary>
|
||||
/// <param name="imageDigest">The container image digest.</param>
|
||||
/// <param name="buildIds">Build-IDs from stack observations.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Validation result with matched and unmatched Build-IDs.</returns>
|
||||
Task<BuildIdValidationResult> ValidateBuildIdsAsync(
|
||||
string imageDigest,
|
||||
IEnumerable<string> buildIds,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request for SBOM correlation.
|
||||
/// </summary>
|
||||
public sealed record SbomCorrelationRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Container image digest (sha256:xxx).
|
||||
/// </summary>
|
||||
public required string ImageDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// ELF Build-ID of the binary.
|
||||
/// </summary>
|
||||
public required string BuildId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Function name observed.
|
||||
/// </summary>
|
||||
public required string FunctionName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Module/binary name (optional, helps disambiguation).
|
||||
/// </summary>
|
||||
public string? ModuleName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Function offset within the binary.
|
||||
/// </summary>
|
||||
public ulong? Offset { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of SBOM correlation.
|
||||
/// </summary>
|
||||
public sealed record SbomCorrelationResult
|
||||
{
|
||||
/// <summary>
|
||||
/// The original request.
|
||||
/// </summary>
|
||||
public required SbomCorrelationRequest Request { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether correlation was successful.
|
||||
/// </summary>
|
||||
public required bool Found { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Package URL if found.
|
||||
/// </summary>
|
||||
public string? Purl { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Package name extracted from purl.
|
||||
/// </summary>
|
||||
public string? PackageName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Package version extracted from purl.
|
||||
/// </summary>
|
||||
public string? PackageVersion { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// SBOM component ID if found.
|
||||
/// </summary>
|
||||
public string? ComponentId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Path to the binary within the container.
|
||||
/// </summary>
|
||||
public string? BinaryPath { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Confidence score for the match (0.0 - 1.0).
|
||||
/// </summary>
|
||||
public double Confidence { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Correlation method used.
|
||||
/// </summary>
|
||||
public SbomCorrelationMethod Method { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Associated vulnerabilities for this package.
|
||||
/// </summary>
|
||||
public IReadOnlyList<VulnerabilityReference>? Vulnerabilities { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Correlation method used to match SBOM data.
|
||||
/// </summary>
|
||||
public enum SbomCorrelationMethod
|
||||
{
|
||||
/// <summary>
|
||||
/// Exact Build-ID match in SBOM.
|
||||
/// </summary>
|
||||
BuildIdMatch,
|
||||
|
||||
/// <summary>
|
||||
/// File path matching in SBOM.
|
||||
/// </summary>
|
||||
FilePathMatch,
|
||||
|
||||
/// <summary>
|
||||
/// Package name heuristic matching.
|
||||
/// </summary>
|
||||
PackageNameHeuristic,
|
||||
|
||||
/// <summary>
|
||||
/// No match found.
|
||||
/// </summary>
|
||||
NoMatch,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reference to a vulnerability.
|
||||
/// </summary>
|
||||
public sealed record VulnerabilityReference
|
||||
{
|
||||
/// <summary>
|
||||
/// Vulnerability ID (e.g., CVE-2024-1234).
|
||||
/// </summary>
|
||||
public required string VulnId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Severity level.
|
||||
/// </summary>
|
||||
public string? Severity { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// CVSS score if available.
|
||||
/// </summary>
|
||||
public double? CvssScore { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this CVE is in KEV list.
|
||||
/// </summary>
|
||||
public bool IsKev { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// VEX status if available.
|
||||
/// </summary>
|
||||
public string? VexStatus { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of Build-ID validation against SBOM.
|
||||
/// </summary>
|
||||
public sealed record BuildIdValidationResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Image digest queried.
|
||||
/// </summary>
|
||||
public required string ImageDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Build-IDs that matched SBOM components.
|
||||
/// </summary>
|
||||
public required IReadOnlyList<MatchedBuildId> MatchedBuildIds { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Build-IDs not found in SBOM.
|
||||
/// </summary>
|
||||
public required IReadOnlyList<string> UnmatchedBuildIds { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Total Build-IDs in SBOM for this image.
|
||||
/// </summary>
|
||||
public required int TotalSbomBuildIds { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Match rate (matched / total queried).
|
||||
/// </summary>
|
||||
public double MatchRate => MatchedBuildIds.Count / (double)(MatchedBuildIds.Count + UnmatchedBuildIds.Count);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A matched Build-ID with its SBOM component.
|
||||
/// </summary>
|
||||
public sealed record MatchedBuildId
|
||||
{
|
||||
/// <summary>
|
||||
/// The Build-ID.
|
||||
/// </summary>
|
||||
public required string BuildId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Package URL.
|
||||
/// </summary>
|
||||
public required string Purl { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Component name.
|
||||
/// </summary>
|
||||
public required string ComponentName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Component version.
|
||||
/// </summary>
|
||||
public string? ComponentVersion { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// File path in container.
|
||||
/// </summary>
|
||||
public string? FilePath { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of SBOM correlation service.
|
||||
/// Uses in-memory caching for Build-ID to purl mappings.
|
||||
/// </summary>
|
||||
public sealed class SbomCorrelationService : ISbomCorrelationService
|
||||
{
|
||||
private readonly ISbomRepository _sbomRepository;
|
||||
private readonly ILogger<SbomCorrelationService> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="SbomCorrelationService"/> class.
|
||||
/// </summary>
|
||||
public SbomCorrelationService(
|
||||
ISbomRepository sbomRepository,
|
||||
ILogger<SbomCorrelationService> logger)
|
||||
{
|
||||
_sbomRepository = sbomRepository ?? throw new ArgumentNullException(nameof(sbomRepository));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<SbomCorrelationResult> CorrelateAsync(
|
||||
SbomCorrelationRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
_logger.LogDebug(
|
||||
"Correlating symbol: image={Image}, buildId={BuildId}, function={Function}",
|
||||
request.ImageDigest, request.BuildId, request.FunctionName);
|
||||
|
||||
// Try Build-ID match first (highest confidence)
|
||||
var buildIdMatch = await _sbomRepository.FindByBuildIdAsync(
|
||||
request.ImageDigest,
|
||||
request.BuildId,
|
||||
cancellationToken);
|
||||
|
||||
if (buildIdMatch is not null)
|
||||
{
|
||||
return new SbomCorrelationResult
|
||||
{
|
||||
Request = request,
|
||||
Found = true,
|
||||
Purl = buildIdMatch.Purl,
|
||||
PackageName = buildIdMatch.PackageName,
|
||||
PackageVersion = buildIdMatch.PackageVersion,
|
||||
ComponentId = buildIdMatch.ComponentId,
|
||||
BinaryPath = buildIdMatch.FilePath,
|
||||
Confidence = 1.0,
|
||||
Method = SbomCorrelationMethod.BuildIdMatch,
|
||||
Vulnerabilities = buildIdMatch.Vulnerabilities,
|
||||
};
|
||||
}
|
||||
|
||||
// Try file path match if module name is provided
|
||||
if (!string.IsNullOrWhiteSpace(request.ModuleName))
|
||||
{
|
||||
var pathMatch = await _sbomRepository.FindByFilePathAsync(
|
||||
request.ImageDigest,
|
||||
request.ModuleName,
|
||||
cancellationToken);
|
||||
|
||||
if (pathMatch is not null)
|
||||
{
|
||||
return new SbomCorrelationResult
|
||||
{
|
||||
Request = request,
|
||||
Found = true,
|
||||
Purl = pathMatch.Purl,
|
||||
PackageName = pathMatch.PackageName,
|
||||
PackageVersion = pathMatch.PackageVersion,
|
||||
ComponentId = pathMatch.ComponentId,
|
||||
BinaryPath = pathMatch.FilePath,
|
||||
Confidence = 0.8,
|
||||
Method = SbomCorrelationMethod.FilePathMatch,
|
||||
Vulnerabilities = pathMatch.Vulnerabilities,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// No match found
|
||||
return new SbomCorrelationResult
|
||||
{
|
||||
Request = request,
|
||||
Found = false,
|
||||
Confidence = 0.0,
|
||||
Method = SbomCorrelationMethod.NoMatch,
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<SbomCorrelationResult>> CorrelateBatchAsync(
|
||||
IEnumerable<SbomCorrelationRequest> requests,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var results = new List<SbomCorrelationResult>();
|
||||
foreach (var request in requests)
|
||||
{
|
||||
var result = await CorrelateAsync(request, cancellationToken);
|
||||
results.Add(result);
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyDictionary<string, string>> GetBuildIdToPurlMapAsync(
|
||||
string imageDigest,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _sbomRepository.GetBuildIdMapAsync(imageDigest, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<BuildIdValidationResult> ValidateBuildIdsAsync(
|
||||
string imageDigest,
|
||||
IEnumerable<string> buildIds,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var buildIdList = buildIds.ToList();
|
||||
var sbomMap = await GetBuildIdToPurlMapAsync(imageDigest, cancellationToken);
|
||||
|
||||
var matched = new List<MatchedBuildId>();
|
||||
var unmatched = new List<string>();
|
||||
|
||||
foreach (var buildId in buildIdList)
|
||||
{
|
||||
if (sbomMap.TryGetValue(buildId, out var purl))
|
||||
{
|
||||
var component = await _sbomRepository.FindByBuildIdAsync(imageDigest, buildId, cancellationToken);
|
||||
matched.Add(new MatchedBuildId
|
||||
{
|
||||
BuildId = buildId,
|
||||
Purl = purl,
|
||||
ComponentName = component?.PackageName ?? "unknown",
|
||||
ComponentVersion = component?.PackageVersion,
|
||||
FilePath = component?.FilePath,
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
unmatched.Add(buildId);
|
||||
}
|
||||
}
|
||||
|
||||
return new BuildIdValidationResult
|
||||
{
|
||||
ImageDigest = imageDigest,
|
||||
MatchedBuildIds = matched,
|
||||
UnmatchedBuildIds = unmatched,
|
||||
TotalSbomBuildIds = sbomMap.Count,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Repository interface for SBOM data access.
|
||||
/// </summary>
|
||||
public interface ISbomRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// Finds a component by Build-ID.
|
||||
/// </summary>
|
||||
Task<SbomComponentInfo?> FindByBuildIdAsync(
|
||||
string imageDigest,
|
||||
string buildId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Finds a component by file path.
|
||||
/// </summary>
|
||||
Task<SbomComponentInfo?> FindByFilePathAsync(
|
||||
string imageDigest,
|
||||
string filePath,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets Build-ID to purl mapping for an image.
|
||||
/// </summary>
|
||||
Task<IReadOnlyDictionary<string, string>> GetBuildIdMapAsync(
|
||||
string imageDigest,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SBOM component information.
|
||||
/// </summary>
|
||||
public sealed record SbomComponentInfo
|
||||
{
|
||||
/// <summary>
|
||||
/// Component ID in SBOM.
|
||||
/// </summary>
|
||||
public required string ComponentId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Package URL.
|
||||
/// </summary>
|
||||
public required string Purl { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Package name.
|
||||
/// </summary>
|
||||
public required string PackageName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Package version.
|
||||
/// </summary>
|
||||
public string? PackageVersion { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Build-ID of the binary.
|
||||
/// </summary>
|
||||
public string? BuildId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// File path in container.
|
||||
/// </summary>
|
||||
public string? FilePath { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Associated vulnerabilities.
|
||||
/// </summary>
|
||||
public IReadOnlyList<VulnerabilityReference>? Vulnerabilities { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,404 @@
|
||||
namespace StellaOps.Signals.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Symbol canonicalization service interface.
|
||||
/// Sprint: SPRINT_20251226_010_SIGNALS_runtime_stack
|
||||
/// Task: STACK-06
|
||||
///
|
||||
/// Resolves program counter addresses to canonical (Build-ID, function, offset) tuples.
|
||||
/// </summary>
|
||||
public interface ISymbolCanonicalizationService
|
||||
{
|
||||
/// <summary>
|
||||
/// Resolves a program counter address to a canonical symbol.
|
||||
/// </summary>
|
||||
/// <param name="address">The program counter address.</param>
|
||||
/// <param name="buildId">The ELF Build-ID of the binary.</param>
|
||||
/// <param name="binaryPath">Optional path to the binary for symbol lookup.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The resolved symbol, or null if resolution failed.</returns>
|
||||
Task<CanonicalSymbol?> ResolveAsync(
|
||||
ulong address,
|
||||
string? buildId,
|
||||
string? binaryPath = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Resolves multiple addresses in batch for efficiency.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<CanonicalSymbol?>> ResolveBatchAsync(
|
||||
IReadOnlyList<SymbolResolutionRequest> requests,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Resolves a Java frame using JVMTI metadata.
|
||||
/// </summary>
|
||||
Task<CanonicalSymbol?> ResolveJavaFrameAsync(
|
||||
ulong address,
|
||||
JavaFrameMetadata metadata,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Resolves a .NET frame using DAC (Data Access Component).
|
||||
/// </summary>
|
||||
Task<CanonicalSymbol?> ResolveDotNetFrameAsync(
|
||||
ulong address,
|
||||
DotNetFrameMetadata metadata,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Resolves a Python frame using interpreter symbols.
|
||||
/// </summary>
|
||||
Task<CanonicalSymbol?> ResolvePythonFrameAsync(
|
||||
ulong address,
|
||||
PythonFrameMetadata metadata,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Checks if a Build-ID is in the local symbol cache.
|
||||
/// </summary>
|
||||
Task<bool> IsInCacheAsync(string buildId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Adds symbols for a Build-ID to the cache.
|
||||
/// </summary>
|
||||
Task CacheSymbolsAsync(
|
||||
string buildId,
|
||||
IReadOnlyList<SymbolEntry> symbols,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request for symbol resolution.
|
||||
/// </summary>
|
||||
public sealed record SymbolResolutionRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Program counter address.
|
||||
/// </summary>
|
||||
public required ulong Address { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// ELF Build-ID of the binary.
|
||||
/// </summary>
|
||||
public string? BuildId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Path to the binary file.
|
||||
/// </summary>
|
||||
public string? BinaryPath { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Runtime type hint for managed runtimes.
|
||||
/// </summary>
|
||||
public RuntimeType RuntimeType { get; init; } = RuntimeType.Native;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runtime type for symbol resolution hints.
|
||||
/// </summary>
|
||||
public enum RuntimeType
|
||||
{
|
||||
/// <summary>
|
||||
/// Native code (C, C++, Rust, Go, etc.).
|
||||
/// </summary>
|
||||
Native,
|
||||
|
||||
/// <summary>
|
||||
/// Java Virtual Machine.
|
||||
/// </summary>
|
||||
Java,
|
||||
|
||||
/// <summary>
|
||||
/// .NET Common Language Runtime.
|
||||
/// </summary>
|
||||
DotNet,
|
||||
|
||||
/// <summary>
|
||||
/// Python interpreter.
|
||||
/// </summary>
|
||||
Python,
|
||||
|
||||
/// <summary>
|
||||
/// Node.js / V8.
|
||||
/// </summary>
|
||||
NodeJs,
|
||||
|
||||
/// <summary>
|
||||
/// Ruby interpreter.
|
||||
/// </summary>
|
||||
Ruby,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Canonical symbol representation.
|
||||
/// </summary>
|
||||
public sealed record CanonicalSymbol
|
||||
{
|
||||
/// <summary>
|
||||
/// Original address that was resolved.
|
||||
/// </summary>
|
||||
public required ulong Address { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// ELF Build-ID of the containing binary.
|
||||
/// </summary>
|
||||
public required string BuildId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Demangled function name.
|
||||
/// </summary>
|
||||
public required string FunctionName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Offset within the function.
|
||||
/// </summary>
|
||||
public required ulong Offset { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Module or binary name.
|
||||
/// </summary>
|
||||
public string? ModuleName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Source file path (if debug info available).
|
||||
/// </summary>
|
||||
public string? SourceFile { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Source line number.
|
||||
/// </summary>
|
||||
public int? SourceLine { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this symbol is from a trusted source.
|
||||
/// </summary>
|
||||
public bool IsTrusted { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Resolution method used.
|
||||
/// </summary>
|
||||
public SymbolResolutionMethod ResolutionMethod { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Returns the canonical string format.
|
||||
/// </summary>
|
||||
public string ToCanonicalString()
|
||||
{
|
||||
return $"{BuildId[..Math.Min(16, BuildId.Length)]}:{FunctionName}+0x{Offset:x}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses a canonical string format.
|
||||
/// </summary>
|
||||
public static CanonicalSymbol? Parse(string canonical)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(canonical))
|
||||
return null;
|
||||
|
||||
// Format: "buildid:function+0xoffset"
|
||||
var colonIdx = canonical.IndexOf(':');
|
||||
if (colonIdx < 0)
|
||||
return null;
|
||||
|
||||
var buildId = canonical[..colonIdx];
|
||||
var rest = canonical[(colonIdx + 1)..];
|
||||
|
||||
var plusIdx = rest.LastIndexOf('+');
|
||||
if (plusIdx < 0)
|
||||
return null;
|
||||
|
||||
var functionName = rest[..plusIdx];
|
||||
var offsetStr = rest[(plusIdx + 1)..];
|
||||
|
||||
if (!offsetStr.StartsWith("0x", StringComparison.OrdinalIgnoreCase))
|
||||
return null;
|
||||
|
||||
if (!ulong.TryParse(offsetStr[2..], System.Globalization.NumberStyles.HexNumber, null, out var offset))
|
||||
return null;
|
||||
|
||||
return new CanonicalSymbol
|
||||
{
|
||||
Address = 0, // Not recoverable from canonical string
|
||||
BuildId = buildId,
|
||||
FunctionName = functionName,
|
||||
Offset = offset,
|
||||
ResolutionMethod = SymbolResolutionMethod.Parsed,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Method used to resolve the symbol.
|
||||
/// </summary>
|
||||
public enum SymbolResolutionMethod
|
||||
{
|
||||
/// <summary>
|
||||
/// Resolved from ELF symbol table.
|
||||
/// </summary>
|
||||
ElfSymtab,
|
||||
|
||||
/// <summary>
|
||||
/// Resolved from DWARF debug info.
|
||||
/// </summary>
|
||||
DwarfDebugInfo,
|
||||
|
||||
/// <summary>
|
||||
/// Resolved from local symbol cache.
|
||||
/// </summary>
|
||||
LocalCache,
|
||||
|
||||
/// <summary>
|
||||
/// Resolved from debuginfod server.
|
||||
/// </summary>
|
||||
Debuginfod,
|
||||
|
||||
/// <summary>
|
||||
/// Resolved from JIT metadata (Java/V8/etc).
|
||||
/// </summary>
|
||||
JitMetadata,
|
||||
|
||||
/// <summary>
|
||||
/// Resolved from runtime-specific mechanism.
|
||||
/// </summary>
|
||||
RuntimeSpecific,
|
||||
|
||||
/// <summary>
|
||||
/// Parsed from canonical string format.
|
||||
/// </summary>
|
||||
Parsed,
|
||||
|
||||
/// <summary>
|
||||
/// Could not resolve, using address only.
|
||||
/// </summary>
|
||||
Unresolved,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Symbol entry for cache storage.
|
||||
/// </summary>
|
||||
public sealed record SymbolEntry
|
||||
{
|
||||
/// <summary>
|
||||
/// Start address of the symbol.
|
||||
/// </summary>
|
||||
public required ulong StartAddress { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Size of the symbol in bytes.
|
||||
/// </summary>
|
||||
public required ulong Size { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Symbol name (demangled).
|
||||
/// </summary>
|
||||
public required string Name { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Symbol type.
|
||||
/// </summary>
|
||||
public SymbolType Type { get; init; } = SymbolType.Function;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Type of symbol.
|
||||
/// </summary>
|
||||
public enum SymbolType
|
||||
{
|
||||
Function,
|
||||
Object,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Metadata for Java frame resolution.
|
||||
/// </summary>
|
||||
public sealed record JavaFrameMetadata
|
||||
{
|
||||
/// <summary>
|
||||
/// Class name.
|
||||
/// </summary>
|
||||
public string? ClassName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Method name.
|
||||
/// </summary>
|
||||
public string? MethodName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Method signature.
|
||||
/// </summary>
|
||||
public string? Signature { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Bytecode index.
|
||||
/// </summary>
|
||||
public int? BytecodeIndex { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this is a JIT-compiled frame.
|
||||
/// </summary>
|
||||
public bool IsJit { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Metadata for .NET frame resolution.
|
||||
/// </summary>
|
||||
public sealed record DotNetFrameMetadata
|
||||
{
|
||||
/// <summary>
|
||||
/// Type name.
|
||||
/// </summary>
|
||||
public string? TypeName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Method name.
|
||||
/// </summary>
|
||||
public string? MethodName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Method token.
|
||||
/// </summary>
|
||||
public uint? MethodToken { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// IL offset.
|
||||
/// </summary>
|
||||
public int? IlOffset { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Assembly name.
|
||||
/// </summary>
|
||||
public string? AssemblyName { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Metadata for Python frame resolution.
|
||||
/// </summary>
|
||||
public sealed record PythonFrameMetadata
|
||||
{
|
||||
/// <summary>
|
||||
/// Module name.
|
||||
/// </summary>
|
||||
public string? ModuleName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Function name.
|
||||
/// </summary>
|
||||
public string? FunctionName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Source file path.
|
||||
/// </summary>
|
||||
public string? SourceFile { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Line number.
|
||||
/// </summary>
|
||||
public int? LineNumber { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Python version.
|
||||
/// </summary>
|
||||
public string? PythonVersion { get; init; }
|
||||
}
|
||||
420
src/Signals/StellaOps.Signals/Services/SlimSymbolCache.cs
Normal file
420
src/Signals/StellaOps.Signals/Services/SlimSymbolCache.cs
Normal file
@@ -0,0 +1,420 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace StellaOps.Signals.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Slim symbol cache for production environments.
|
||||
/// Sprint: SPRINT_20251226_010_SIGNALS_runtime_stack
|
||||
/// Task: STACK-09
|
||||
///
|
||||
/// Provides lightweight symbol caching without requiring full debuginfod.
|
||||
/// Optimized for pod-level storage with optional cluster-level sync.
|
||||
/// </summary>
|
||||
public sealed class SlimSymbolCache : IDisposable
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, SymbolTableEntry> _cache = new();
|
||||
private readonly SemaphoreSlim _loadLock = new(1, 1);
|
||||
private readonly string? _persistencePath;
|
||||
private readonly SlimSymbolCacheOptions _options;
|
||||
private readonly Timer? _cleanupTimer;
|
||||
private long _hitCount;
|
||||
private long _missCount;
|
||||
private bool _disposed;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new slim symbol cache.
|
||||
/// </summary>
|
||||
public SlimSymbolCache(SlimSymbolCacheOptions options)
|
||||
{
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
_persistencePath = options.PersistencePath;
|
||||
|
||||
if (options.EnableAutoCleanup)
|
||||
{
|
||||
_cleanupTimer = new Timer(
|
||||
_ => Cleanup(),
|
||||
null,
|
||||
options.CleanupInterval,
|
||||
options.CleanupInterval);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(_persistencePath) && Directory.Exists(_persistencePath))
|
||||
{
|
||||
LoadFromDisk();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tries to resolve a symbol from the cache.
|
||||
/// </summary>
|
||||
public bool TryResolve(
|
||||
string buildId,
|
||||
ulong address,
|
||||
out CanonicalSymbol? symbol)
|
||||
{
|
||||
symbol = null;
|
||||
|
||||
if (!_cache.TryGetValue(buildId, out var entry))
|
||||
{
|
||||
Interlocked.Increment(ref _missCount);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Binary search for the containing symbol
|
||||
var symbols = entry.Symbols;
|
||||
var left = 0;
|
||||
var right = symbols.Count - 1;
|
||||
|
||||
while (left <= right)
|
||||
{
|
||||
var mid = left + (right - left) / 2;
|
||||
var sym = symbols[mid];
|
||||
|
||||
if (address >= sym.StartAddress && address < sym.StartAddress + sym.Size)
|
||||
{
|
||||
Interlocked.Increment(ref _hitCount);
|
||||
entry.LastAccess = DateTime.UtcNow;
|
||||
|
||||
symbol = new CanonicalSymbol
|
||||
{
|
||||
Address = address,
|
||||
BuildId = buildId,
|
||||
FunctionName = sym.Name,
|
||||
Offset = address - sym.StartAddress,
|
||||
ModuleName = entry.ModuleName,
|
||||
IsTrusted = entry.IsTrusted,
|
||||
ResolutionMethod = SymbolResolutionMethod.LocalCache,
|
||||
};
|
||||
return true;
|
||||
}
|
||||
|
||||
if (address < sym.StartAddress)
|
||||
right = mid - 1;
|
||||
else
|
||||
left = mid + 1;
|
||||
}
|
||||
|
||||
// Address not in any known symbol range
|
||||
Interlocked.Increment(ref _missCount);
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds symbols for a Build-ID to the cache.
|
||||
/// </summary>
|
||||
public void Add(
|
||||
string buildId,
|
||||
string? moduleName,
|
||||
IReadOnlyList<SymbolEntry> symbols,
|
||||
bool isTrusted = false)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(buildId);
|
||||
|
||||
if (_cache.Count >= _options.MaxEntries)
|
||||
{
|
||||
EvictOldest();
|
||||
}
|
||||
|
||||
// Sort symbols by start address for binary search
|
||||
var sorted = symbols.OrderBy(s => s.StartAddress).ToList();
|
||||
|
||||
var entry = new SymbolTableEntry
|
||||
{
|
||||
BuildId = buildId,
|
||||
ModuleName = moduleName,
|
||||
Symbols = sorted,
|
||||
LoadedAt = DateTime.UtcNow,
|
||||
LastAccess = DateTime.UtcNow,
|
||||
IsTrusted = isTrusted,
|
||||
};
|
||||
|
||||
_cache[buildId] = entry;
|
||||
|
||||
if (!string.IsNullOrEmpty(_persistencePath) && _options.PersistOnAdd)
|
||||
{
|
||||
PersistEntry(buildId, entry);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if a Build-ID is in the cache.
|
||||
/// </summary>
|
||||
public bool Contains(string buildId)
|
||||
{
|
||||
return _cache.ContainsKey(buildId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets cache statistics.
|
||||
/// </summary>
|
||||
public CacheStatistics GetStatistics()
|
||||
{
|
||||
var totalHits = Interlocked.Read(ref _hitCount);
|
||||
var totalMisses = Interlocked.Read(ref _missCount);
|
||||
var totalRequests = totalHits + totalMisses;
|
||||
|
||||
long totalSymbols = 0;
|
||||
foreach (var entry in _cache.Values)
|
||||
{
|
||||
totalSymbols += entry.Symbols.Count;
|
||||
}
|
||||
|
||||
return new CacheStatistics
|
||||
{
|
||||
EntryCount = _cache.Count,
|
||||
TotalSymbols = totalSymbols,
|
||||
HitCount = totalHits,
|
||||
MissCount = totalMisses,
|
||||
HitRate = totalRequests > 0 ? (double)totalHits / totalRequests : 0,
|
||||
EstimatedMemoryBytes = EstimateMemoryUsage(),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clears the cache.
|
||||
/// </summary>
|
||||
public void Clear()
|
||||
{
|
||||
_cache.Clear();
|
||||
Interlocked.Exchange(ref _hitCount, 0);
|
||||
Interlocked.Exchange(ref _missCount, 0);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes a specific Build-ID from the cache.
|
||||
/// </summary>
|
||||
public bool Remove(string buildId)
|
||||
{
|
||||
return _cache.TryRemove(buildId, out _);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Persists the cache to disk.
|
||||
/// </summary>
|
||||
public async Task PersistAllAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrEmpty(_persistencePath))
|
||||
return;
|
||||
|
||||
await _loadLock.WaitAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(_persistencePath);
|
||||
|
||||
foreach (var (buildId, entry) in _cache)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
PersistEntry(buildId, entry);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_loadLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private void LoadFromDisk()
|
||||
{
|
||||
if (string.IsNullOrEmpty(_persistencePath))
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
foreach (var file in Directory.GetFiles(_persistencePath, "*.symbols"))
|
||||
{
|
||||
var buildId = Path.GetFileNameWithoutExtension(file);
|
||||
var lines = File.ReadAllLines(file);
|
||||
|
||||
if (lines.Length < 2) continue;
|
||||
|
||||
var header = lines[0].Split('\t');
|
||||
var moduleName = header.Length > 0 ? header[0] : null;
|
||||
var isTrusted = header.Length > 1 && header[1] == "1";
|
||||
|
||||
var symbols = new List<SymbolEntry>();
|
||||
for (var i = 1; i < lines.Length; i++)
|
||||
{
|
||||
var parts = lines[i].Split('\t');
|
||||
if (parts.Length < 3) continue;
|
||||
|
||||
if (ulong.TryParse(parts[0], out var start) &&
|
||||
ulong.TryParse(parts[1], out var size))
|
||||
{
|
||||
symbols.Add(new SymbolEntry
|
||||
{
|
||||
StartAddress = start,
|
||||
Size = size,
|
||||
Name = parts[2],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (symbols.Count > 0)
|
||||
{
|
||||
Add(buildId, moduleName, symbols, isTrusted);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore load errors - cache will be rebuilt
|
||||
}
|
||||
}
|
||||
|
||||
private void PersistEntry(string buildId, SymbolTableEntry entry)
|
||||
{
|
||||
if (string.IsNullOrEmpty(_persistencePath))
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(_persistencePath);
|
||||
var path = Path.Combine(_persistencePath, $"{SanitizeBuildId(buildId)}.symbols");
|
||||
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine($"{entry.ModuleName ?? ""}\t{(entry.IsTrusted ? "1" : "0")}");
|
||||
|
||||
foreach (var sym in entry.Symbols)
|
||||
{
|
||||
sb.AppendLine($"{sym.StartAddress}\t{sym.Size}\t{sym.Name}");
|
||||
}
|
||||
|
||||
File.WriteAllText(path, sb.ToString());
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore persist errors
|
||||
}
|
||||
}
|
||||
|
||||
private void Cleanup()
|
||||
{
|
||||
var cutoff = DateTime.UtcNow - _options.EntryTtl;
|
||||
var toRemove = new List<string>();
|
||||
|
||||
foreach (var (buildId, entry) in _cache)
|
||||
{
|
||||
if (entry.LastAccess < cutoff)
|
||||
{
|
||||
toRemove.Add(buildId);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var buildId in toRemove)
|
||||
{
|
||||
_cache.TryRemove(buildId, out _);
|
||||
}
|
||||
}
|
||||
|
||||
private void EvictOldest()
|
||||
{
|
||||
var oldest = _cache
|
||||
.OrderBy(kvp => kvp.Value.LastAccess)
|
||||
.Take(_options.EvictionBatchSize)
|
||||
.Select(kvp => kvp.Key)
|
||||
.ToList();
|
||||
|
||||
foreach (var buildId in oldest)
|
||||
{
|
||||
_cache.TryRemove(buildId, out _);
|
||||
}
|
||||
}
|
||||
|
||||
private long EstimateMemoryUsage()
|
||||
{
|
||||
long total = 0;
|
||||
foreach (var entry in _cache.Values)
|
||||
{
|
||||
// Rough estimate: 100 bytes per symbol entry
|
||||
total += entry.Symbols.Count * 100;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
private static string SanitizeBuildId(string buildId)
|
||||
{
|
||||
// Remove any characters that aren't safe for filenames
|
||||
var safe = new StringBuilder();
|
||||
foreach (var c in buildId)
|
||||
{
|
||||
if (char.IsLetterOrDigit(c) || c == '-' || c == '_')
|
||||
safe.Append(c);
|
||||
}
|
||||
return safe.ToString();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
|
||||
_cleanupTimer?.Dispose();
|
||||
_loadLock.Dispose();
|
||||
}
|
||||
|
||||
private sealed class SymbolTableEntry
|
||||
{
|
||||
public required string BuildId { get; init; }
|
||||
public string? ModuleName { get; init; }
|
||||
public required IReadOnlyList<SymbolEntry> Symbols { get; init; }
|
||||
public required DateTime LoadedAt { get; init; }
|
||||
public DateTime LastAccess { get; set; }
|
||||
public bool IsTrusted { get; init; }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Options for the slim symbol cache.
|
||||
/// </summary>
|
||||
public sealed record SlimSymbolCacheOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Maximum number of Build-ID entries to cache.
|
||||
/// </summary>
|
||||
public int MaxEntries { get; init; } = 1000;
|
||||
|
||||
/// <summary>
|
||||
/// Time-to-live for cache entries.
|
||||
/// </summary>
|
||||
public TimeSpan EntryTtl { get; init; } = TimeSpan.FromHours(24);
|
||||
|
||||
/// <summary>
|
||||
/// Path for persistence (null to disable).
|
||||
/// </summary>
|
||||
public string? PersistencePath { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether to persist entries immediately on add.
|
||||
/// </summary>
|
||||
public bool PersistOnAdd { get; init; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to enable automatic cleanup.
|
||||
/// </summary>
|
||||
public bool EnableAutoCleanup { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Interval for cleanup runs.
|
||||
/// </summary>
|
||||
public TimeSpan CleanupInterval { get; init; } = TimeSpan.FromMinutes(15);
|
||||
|
||||
/// <summary>
|
||||
/// Number of entries to evict when at capacity.
|
||||
/// </summary>
|
||||
public int EvictionBatchSize { get; init; } = 100;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cache statistics.
|
||||
/// </summary>
|
||||
public sealed record CacheStatistics
|
||||
{
|
||||
public required int EntryCount { get; init; }
|
||||
public required long TotalSymbols { get; init; }
|
||||
public required long HitCount { get; init; }
|
||||
public required long MissCount { get; init; }
|
||||
public required double HitRate { get; init; }
|
||||
public required long EstimatedMemoryBytes { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
using StellaOps.Signals.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Signals.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for SlimSymbolCache.
|
||||
/// Sprint: SPRINT_20251226_010_SIGNALS_runtime_stack
|
||||
/// Task: STACK-17
|
||||
/// </summary>
|
||||
public sealed class SlimSymbolCacheTests : IDisposable
|
||||
{
|
||||
private readonly SlimSymbolCache _cache;
|
||||
private readonly string _tempPath;
|
||||
|
||||
public SlimSymbolCacheTests()
|
||||
{
|
||||
_tempPath = Path.Combine(Path.GetTempPath(), $"symbol-cache-test-{Guid.NewGuid()}");
|
||||
_cache = new SlimSymbolCache(new SlimSymbolCacheOptions
|
||||
{
|
||||
MaxEntries = 100,
|
||||
EntryTtl = TimeSpan.FromHours(1),
|
||||
PersistencePath = _tempPath,
|
||||
PersistOnAdd = true,
|
||||
EnableAutoCleanup = false,
|
||||
});
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_cache.Dispose();
|
||||
if (Directory.Exists(_tempPath))
|
||||
{
|
||||
Directory.Delete(_tempPath, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Add_ShouldAddSymbolsToCache()
|
||||
{
|
||||
// Arrange
|
||||
var buildId = "abcd1234";
|
||||
var symbols = new List<SymbolEntry>
|
||||
{
|
||||
new() { StartAddress = 0x1000, Size = 100, Name = "main" },
|
||||
new() { StartAddress = 0x1100, Size = 50, Name = "parse" },
|
||||
};
|
||||
|
||||
// Act
|
||||
_cache.Add(buildId, "libtest.so", symbols);
|
||||
|
||||
// Assert
|
||||
Assert.True(_cache.Contains(buildId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryResolve_ShouldResolveKnownAddress()
|
||||
{
|
||||
// Arrange
|
||||
var buildId = "abcd1234";
|
||||
var symbols = new List<SymbolEntry>
|
||||
{
|
||||
new() { StartAddress = 0x1000, Size = 100, Name = "main" },
|
||||
};
|
||||
_cache.Add(buildId, "libtest.so", symbols);
|
||||
|
||||
// Act
|
||||
var found = _cache.TryResolve(buildId, 0x1050, out var symbol);
|
||||
|
||||
// Assert
|
||||
Assert.True(found);
|
||||
Assert.NotNull(symbol);
|
||||
Assert.Equal("main", symbol.FunctionName);
|
||||
Assert.Equal(0x50UL, symbol.Offset);
|
||||
Assert.Equal(buildId, symbol.BuildId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryResolve_ShouldReturnFalseForUnknownBuildId()
|
||||
{
|
||||
// Act
|
||||
var found = _cache.TryResolve("unknown", 0x1000, out var symbol);
|
||||
|
||||
// Assert
|
||||
Assert.False(found);
|
||||
Assert.Null(symbol);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryResolve_ShouldReturnFalseForAddressOutsideSymbols()
|
||||
{
|
||||
// Arrange
|
||||
var buildId = "abcd1234";
|
||||
var symbols = new List<SymbolEntry>
|
||||
{
|
||||
new() { StartAddress = 0x1000, Size = 100, Name = "main" },
|
||||
};
|
||||
_cache.Add(buildId, "libtest.so", symbols);
|
||||
|
||||
// Act
|
||||
var found = _cache.TryResolve(buildId, 0x2000, out var symbol);
|
||||
|
||||
// Assert
|
||||
Assert.False(found);
|
||||
Assert.Null(symbol);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryResolve_ShouldUseBinarySearchForLargeSymbolTable()
|
||||
{
|
||||
// Arrange
|
||||
var buildId = "large-table";
|
||||
var symbols = Enumerable.Range(0, 1000)
|
||||
.Select(i => new SymbolEntry
|
||||
{
|
||||
StartAddress = (ulong)(i * 100),
|
||||
Size = 50,
|
||||
Name = $"func_{i}",
|
||||
})
|
||||
.ToList();
|
||||
|
||||
_cache.Add(buildId, "liblarge.so", symbols);
|
||||
|
||||
// Act - resolve address in the middle
|
||||
var found = _cache.TryResolve(buildId, 50025, out var symbol);
|
||||
|
||||
// Assert
|
||||
Assert.True(found);
|
||||
Assert.NotNull(symbol);
|
||||
Assert.Equal("func_500", symbol.FunctionName);
|
||||
Assert.Equal(25UL, symbol.Offset);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetStatistics_ShouldReturnCorrectStats()
|
||||
{
|
||||
// Arrange
|
||||
var symbols = new List<SymbolEntry>
|
||||
{
|
||||
new() { StartAddress = 0x1000, Size = 100, Name = "main" },
|
||||
new() { StartAddress = 0x1100, Size = 50, Name = "parse" },
|
||||
};
|
||||
_cache.Add("build1", "lib1.so", symbols);
|
||||
_cache.Add("build2", "lib2.so", symbols);
|
||||
|
||||
_cache.TryResolve("build1", 0x1050, out _); // hit
|
||||
_cache.TryResolve("unknown", 0x1000, out _); // miss
|
||||
|
||||
// Act
|
||||
var stats = _cache.GetStatistics();
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, stats.EntryCount);
|
||||
Assert.Equal(4, stats.TotalSymbols);
|
||||
Assert.Equal(1, stats.HitCount);
|
||||
Assert.Equal(1, stats.MissCount);
|
||||
Assert.Equal(0.5, stats.HitRate);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Clear_ShouldRemoveAllEntries()
|
||||
{
|
||||
// Arrange
|
||||
var symbols = new List<SymbolEntry>
|
||||
{
|
||||
new() { StartAddress = 0x1000, Size = 100, Name = "main" },
|
||||
};
|
||||
_cache.Add("build1", "lib1.so", symbols);
|
||||
_cache.Add("build2", "lib2.so", symbols);
|
||||
|
||||
// Act
|
||||
_cache.Clear();
|
||||
|
||||
// Assert
|
||||
Assert.False(_cache.Contains("build1"));
|
||||
Assert.False(_cache.Contains("build2"));
|
||||
var stats = _cache.GetStatistics();
|
||||
Assert.Equal(0, stats.EntryCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Remove_ShouldRemoveSpecificEntry()
|
||||
{
|
||||
// Arrange
|
||||
var symbols = new List<SymbolEntry>
|
||||
{
|
||||
new() { StartAddress = 0x1000, Size = 100, Name = "main" },
|
||||
};
|
||||
_cache.Add("build1", "lib1.so", symbols);
|
||||
_cache.Add("build2", "lib2.so", symbols);
|
||||
|
||||
// Act
|
||||
var removed = _cache.Remove("build1");
|
||||
|
||||
// Assert
|
||||
Assert.True(removed);
|
||||
Assert.False(_cache.Contains("build1"));
|
||||
Assert.True(_cache.Contains("build2"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Persistence_ShouldWriteToDisk()
|
||||
{
|
||||
// Arrange
|
||||
var symbols = new List<SymbolEntry>
|
||||
{
|
||||
new() { StartAddress = 0x1000, Size = 100, Name = "main" },
|
||||
};
|
||||
|
||||
// Act
|
||||
_cache.Add("persist-test", "libtest.so", symbols);
|
||||
|
||||
// Assert
|
||||
var files = Directory.GetFiles(_tempPath, "*.symbols");
|
||||
Assert.NotEmpty(files);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TrustedSymbols_ShouldBeFlaggedCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var symbols = new List<SymbolEntry>
|
||||
{
|
||||
new() { StartAddress = 0x1000, Size = 100, Name = "main" },
|
||||
};
|
||||
_cache.Add("trusted-build", "libtrusted.so", symbols, isTrusted: true);
|
||||
|
||||
// Act
|
||||
_cache.TryResolve("trusted-build", 0x1050, out var symbol);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(symbol);
|
||||
Assert.True(symbol.IsTrusted);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests for CanonicalSymbol.
|
||||
/// </summary>
|
||||
public sealed class CanonicalSymbolTests
|
||||
{
|
||||
[Fact]
|
||||
public void ToCanonicalString_ShouldFormatCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var symbol = new CanonicalSymbol
|
||||
{
|
||||
Address = 0x1234,
|
||||
BuildId = "abcdef1234567890",
|
||||
FunctionName = "process_request",
|
||||
Offset = 0x50,
|
||||
};
|
||||
|
||||
// Act
|
||||
var canonical = symbol.ToCanonicalString();
|
||||
|
||||
// Assert
|
||||
Assert.Equal("abcdef1234567890:process_request+0x50", canonical);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_ShouldParseCanonicalString()
|
||||
{
|
||||
// Arrange
|
||||
var canonical = "abcdef12:main+0x100";
|
||||
|
||||
// Act
|
||||
var symbol = CanonicalSymbol.Parse(canonical);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(symbol);
|
||||
Assert.Equal("abcdef12", symbol.BuildId);
|
||||
Assert.Equal("main", symbol.FunctionName);
|
||||
Assert.Equal(0x100UL, symbol.Offset);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_ShouldReturnNullForInvalidFormat()
|
||||
{
|
||||
Assert.Null(CanonicalSymbol.Parse(""));
|
||||
Assert.Null(CanonicalSymbol.Parse("no-colon"));
|
||||
Assert.Null(CanonicalSymbol.Parse("build:no-plus"));
|
||||
Assert.Null(CanonicalSymbol.Parse("build:func+invalid"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RoundTrip_ShouldPreserveData()
|
||||
{
|
||||
// Arrange
|
||||
var original = new CanonicalSymbol
|
||||
{
|
||||
Address = 0,
|
||||
BuildId = "deadbeef",
|
||||
FunctionName = "test_func",
|
||||
Offset = 0x42,
|
||||
};
|
||||
|
||||
// Act
|
||||
var canonical = original.ToCanonicalString();
|
||||
var parsed = CanonicalSymbol.Parse(canonical);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(parsed);
|
||||
Assert.Equal(original.BuildId, parsed.BuildId);
|
||||
Assert.Equal(original.FunctionName, parsed.FunctionName);
|
||||
Assert.Equal(original.Offset, parsed.Offset);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user