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:
StellaOps Bot
2025-12-26 15:17:15 +02:00
parent 7792749bb4
commit 907783f625
354 changed files with 79727 additions and 1346 deletions

View 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

View 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,
}

View File

@@ -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; }
}

View File

@@ -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; }
}

View File

@@ -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; }
}

View File

@@ -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; }
}

View 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; }
}

View File

@@ -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);
}
}