// Licensed to StellaOps under the AGPL-3.0-or-later license. // Sprint: SPRINT_20260109_009_003_BE_cve_symbol_mapping // Task: Implement API endpoints using System.Collections.Immutable; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.RateLimiting; using StellaOps.Reachability.Core.CveMapping; namespace StellaOps.ReachGraph.WebService.Controllers; /// /// CVE-Symbol Mapping API for querying vulnerable symbols. /// Maps CVE identifiers to affected functions/methods for reachability analysis. /// [ApiController] [Route("v1/cve-mappings")] [Produces("application/json")] public class CveMappingController : ControllerBase { private readonly ICveSymbolMappingService _mappingService; private readonly ILogger _logger; public CveMappingController( ICveSymbolMappingService mappingService, ILogger logger) { _mappingService = mappingService; _logger = logger; } /// /// Get all symbol mappings for a CVE. /// /// The CVE identifier (e.g., CVE-2021-44228). /// Cancellation token. /// List of vulnerable symbols for the CVE. [HttpGet("{cveId}")] [EnableRateLimiting("reachgraph-read")] [ProducesResponseType(typeof(CveMappingResponse), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] [ResponseCache(Duration = 3600, VaryByQueryKeys = new[] { "cveId" })] public async Task GetByCveIdAsync( [FromRoute] string cveId, CancellationToken cancellationToken) { _logger.LogDebug("Fetching mappings for CVE {CveId}", cveId); var mappings = await _mappingService.GetMappingsForCveAsync(cveId, cancellationToken); if (mappings.Count == 0) { return NotFound(new ProblemDetails { Title = "CVE not found", Detail = $"No symbol mappings found for CVE {cveId}", Status = StatusCodes.Status404NotFound }); } var response = new CveMappingResponse { CveId = cveId, MappingCount = mappings.Count, Mappings = mappings.Select(m => new CveMappingDto { Purl = m.Purl, Symbol = m.Symbol.Symbol, CanonicalId = m.Symbol.CanonicalId, FilePath = m.Symbol.FilePath, StartLine = m.Symbol.StartLine, EndLine = m.Symbol.EndLine, Source = m.Source.ToString(), Confidence = m.Confidence, VulnerabilityType = m.VulnerabilityType.ToString(), AffectedVersions = m.AffectedVersions.ToList(), FixedVersions = m.FixedVersions.ToList(), EvidenceUri = m.EvidenceUri }).ToList() }; return Ok(response); } /// /// Get mappings for a specific package. /// /// Package URL (URL-encoded). /// Cancellation token. /// List of CVE mappings affecting the package. [HttpGet("by-package")] [EnableRateLimiting("reachgraph-read")] [ProducesResponseType(typeof(PackageMappingsResponse), StatusCodes.Status200OK)] public async Task GetByPackageAsync( [FromQuery] string purl, CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(purl)) { return BadRequest(new ProblemDetails { Title = "Invalid request", Detail = "Package URL (purl) is required", Status = StatusCodes.Status400BadRequest }); } _logger.LogDebug("Fetching mappings for package {Purl}", purl); var mappings = await _mappingService.GetMappingsForPackageAsync(purl, cancellationToken); var response = new PackageMappingsResponse { Purl = purl, MappingCount = mappings.Count, Mappings = mappings.Select(m => new CveMappingDto { CveId = m.CveId, Purl = m.Purl, Symbol = m.Symbol.Symbol, CanonicalId = m.Symbol.CanonicalId, FilePath = m.Symbol.FilePath, StartLine = m.Symbol.StartLine, EndLine = m.Symbol.EndLine, Source = m.Source.ToString(), Confidence = m.Confidence, VulnerabilityType = m.VulnerabilityType.ToString(), AffectedVersions = m.AffectedVersions.ToList(), FixedVersions = m.FixedVersions.ToList(), EvidenceUri = m.EvidenceUri }).ToList() }; return Ok(response); } /// /// Search for mappings by symbol name. /// /// Symbol name or pattern. /// Optional programming language filter. /// Cancellation token. /// List of CVE mappings matching the symbol. [HttpGet("by-symbol")] [EnableRateLimiting("reachgraph-read")] [ProducesResponseType(typeof(SymbolMappingsResponse), StatusCodes.Status200OK)] public async Task GetBySymbolAsync( [FromQuery] string symbol, [FromQuery] string? language, CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(symbol)) { return BadRequest(new ProblemDetails { Title = "Invalid request", Detail = "Symbol name is required", Status = StatusCodes.Status400BadRequest }); } _logger.LogDebug("Searching mappings for symbol {Symbol}, language {Language}", symbol, language ?? "any"); var mappings = await _mappingService.SearchBySymbolAsync(symbol, language, cancellationToken); var response = new SymbolMappingsResponse { Symbol = symbol, Language = language, MappingCount = mappings.Count, Mappings = mappings.Select(m => new CveMappingDto { CveId = m.CveId, Purl = m.Purl, Symbol = m.Symbol.Symbol, CanonicalId = m.Symbol.CanonicalId, FilePath = m.Symbol.FilePath, StartLine = m.Symbol.StartLine, EndLine = m.Symbol.EndLine, Source = m.Source.ToString(), Confidence = m.Confidence, VulnerabilityType = m.VulnerabilityType.ToString(), AffectedVersions = m.AffectedVersions.ToList(), FixedVersions = m.FixedVersions.ToList(), EvidenceUri = m.EvidenceUri }).ToList() }; return Ok(response); } /// /// Add or update a CVE-symbol mapping. /// /// The mapping to add. /// Cancellation token. /// The created or updated mapping. [HttpPost] [EnableRateLimiting("reachgraph-write")] [ProducesResponseType(typeof(CveMappingDto), StatusCodes.Status201Created)] [ProducesResponseType(typeof(CveMappingDto), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status400BadRequest)] public async Task UpsertMappingAsync( [FromBody] UpsertCveMappingRequest request, CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(request.CveId)) { return BadRequest(new ProblemDetails { Title = "Invalid request", Detail = "CVE ID is required", Status = StatusCodes.Status400BadRequest }); } if (string.IsNullOrWhiteSpace(request.Purl)) { return BadRequest(new ProblemDetails { Title = "Invalid request", Detail = "Package URL (purl) is required", Status = StatusCodes.Status400BadRequest }); } if (string.IsNullOrWhiteSpace(request.Symbol)) { return BadRequest(new ProblemDetails { Title = "Invalid request", Detail = "Symbol name is required", Status = StatusCodes.Status400BadRequest }); } _logger.LogInformation("Upserting mapping: CVE {CveId}, Package {Purl}, Symbol {Symbol}", request.CveId, request.Purl, request.Symbol); if (!Enum.TryParse(request.Source, ignoreCase: true, out var source)) { source = MappingSource.Unknown; } if (!Enum.TryParse(request.VulnerabilityType, ignoreCase: true, out var vulnType)) { vulnType = VulnerabilityType.Unknown; } var mapping = new CveSymbolMapping { CveId = request.CveId, Purl = request.Purl, Symbol = new VulnerableSymbol { Symbol = request.Symbol, CanonicalId = request.CanonicalId, FilePath = request.FilePath, StartLine = request.StartLine, EndLine = request.EndLine }, Source = source, Confidence = request.Confidence ?? 0.5, VulnerabilityType = vulnType, AffectedVersions = request.AffectedVersions?.ToImmutableArray() ?? [], FixedVersions = request.FixedVersions?.ToImmutableArray() ?? [], EvidenceUri = request.EvidenceUri }; var result = await _mappingService.AddOrUpdateMappingAsync(mapping, cancellationToken); var response = new CveMappingDto { CveId = result.CveId, Purl = result.Purl, Symbol = result.Symbol.Symbol, CanonicalId = result.Symbol.CanonicalId, FilePath = result.Symbol.FilePath, StartLine = result.Symbol.StartLine, EndLine = result.Symbol.EndLine, Source = result.Source.ToString(), Confidence = result.Confidence, VulnerabilityType = result.VulnerabilityType.ToString(), AffectedVersions = result.AffectedVersions.ToList(), FixedVersions = result.FixedVersions.ToList(), EvidenceUri = result.EvidenceUri }; return CreatedAtAction(nameof(GetByCveIdAsync), new { cveId = result.CveId }, response); } /// /// Analyze a commit/patch to extract vulnerable symbols. /// /// The patch analysis request. /// Cancellation token. /// Extracted symbols from the patch. [HttpPost("analyze-patch")] [EnableRateLimiting("reachgraph-write")] [ProducesResponseType(typeof(PatchAnalysisResponse), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status400BadRequest)] public async Task AnalyzePatchAsync( [FromBody] AnalyzePatchRequest request, CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(request.CommitUrl) && string.IsNullOrWhiteSpace(request.DiffContent)) { return BadRequest(new ProblemDetails { Title = "Invalid request", Detail = "Either CommitUrl or DiffContent is required", Status = StatusCodes.Status400BadRequest }); } _logger.LogDebug("Analyzing patch: {CommitUrl}", request.CommitUrl ?? "(inline diff)"); var result = await _mappingService.AnalyzePatchAsync( request.CommitUrl, request.DiffContent, cancellationToken); var response = new PatchAnalysisResponse { CommitUrl = request.CommitUrl, ExtractedSymbols = result.ExtractedSymbols.Select(s => new ExtractedSymbolDto { Symbol = s.Symbol, FilePath = s.FilePath, StartLine = s.StartLine, EndLine = s.EndLine, ChangeType = s.ChangeType.ToString(), Language = s.Language }).ToList(), AnalyzedAt = result.AnalyzedAt }; return Ok(response); } /// /// Enrich CVE mapping from OSV database. /// /// CVE to enrich. /// Cancellation token. /// Enriched mapping data from OSV. [HttpPost("{cveId}/enrich")] [EnableRateLimiting("reachgraph-write")] [ProducesResponseType(typeof(EnrichmentResponse), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task EnrichFromOsvAsync( [FromRoute] string cveId, CancellationToken cancellationToken) { _logger.LogInformation("Enriching CVE {CveId} from OSV", cveId); var enrichedMappings = await _mappingService.EnrichFromOsvAsync(cveId, cancellationToken); if (enrichedMappings.Count == 0) { return NotFound(new ProblemDetails { Title = "CVE not found in OSV", Detail = $"No OSV data found for CVE {cveId}", Status = StatusCodes.Status404NotFound }); } var response = new EnrichmentResponse { CveId = cveId, EnrichedCount = enrichedMappings.Count, Mappings = enrichedMappings.Select(m => new CveMappingDto { CveId = m.CveId, Purl = m.Purl, Symbol = m.Symbol.Symbol, CanonicalId = m.Symbol.CanonicalId, FilePath = m.Symbol.FilePath, Source = m.Source.ToString(), Confidence = m.Confidence, VulnerabilityType = m.VulnerabilityType.ToString(), AffectedVersions = m.AffectedVersions.ToList(), FixedVersions = m.FixedVersions.ToList() }).ToList() }; return Ok(response); } /// /// Get mapping statistics. /// /// Cancellation token. /// Statistics about the mapping corpus. [HttpGet("stats")] [EnableRateLimiting("reachgraph-read")] [ProducesResponseType(typeof(MappingStatsResponse), StatusCodes.Status200OK)] [ResponseCache(Duration = 300)] public async Task GetStatsAsync(CancellationToken cancellationToken) { var stats = await _mappingService.GetStatsAsync(cancellationToken); var response = new MappingStatsResponse { TotalMappings = stats.TotalMappings, UniqueCves = stats.UniqueCves, UniquePackages = stats.UniquePackages, BySource = stats.BySource, ByVulnerabilityType = stats.ByVulnerabilityType, AverageConfidence = stats.AverageConfidence, LastUpdated = stats.LastUpdated }; return Ok(response); } } // ============================================================================ // DTOs // ============================================================================ /// /// Response containing CVE mappings. /// public record CveMappingResponse { public required string CveId { get; init; } public int MappingCount { get; init; } public required List Mappings { get; init; } } /// /// Response for package-based query. /// public record PackageMappingsResponse { public required string Purl { get; init; } public int MappingCount { get; init; } public required List Mappings { get; init; } } /// /// Response for symbol-based query. /// public record SymbolMappingsResponse { public required string Symbol { get; init; } public string? Language { get; init; } public int MappingCount { get; init; } public required List Mappings { get; init; } } /// /// CVE mapping data transfer object. /// public record CveMappingDto { public string? CveId { get; init; } public required string Purl { get; init; } public required string Symbol { get; init; } public string? CanonicalId { get; init; } public string? FilePath { get; init; } public int? StartLine { get; init; } public int? EndLine { get; init; } public required string Source { get; init; } public double Confidence { get; init; } public required string VulnerabilityType { get; init; } public List? AffectedVersions { get; init; } public List? FixedVersions { get; init; } public string? EvidenceUri { get; init; } } /// /// Request to add/update a CVE mapping. /// public record UpsertCveMappingRequest { public required string CveId { get; init; } public required string Purl { get; init; } public required string Symbol { get; init; } public string? CanonicalId { get; init; } public string? FilePath { get; init; } public int? StartLine { get; init; } public int? EndLine { get; init; } public string? Source { get; init; } public double? Confidence { get; init; } public string? VulnerabilityType { get; init; } public List? AffectedVersions { get; init; } public List? FixedVersions { get; init; } public string? EvidenceUri { get; init; } } /// /// Request to analyze a patch. /// public record AnalyzePatchRequest { public string? CommitUrl { get; init; } public string? DiffContent { get; init; } } /// /// Response from patch analysis. /// public record PatchAnalysisResponse { public string? CommitUrl { get; init; } public required List ExtractedSymbols { get; init; } public DateTimeOffset AnalyzedAt { get; init; } } /// /// Extracted symbol from patch. /// public record ExtractedSymbolDto { public required string Symbol { get; init; } public string? FilePath { get; init; } public int? StartLine { get; init; } public int? EndLine { get; init; } public required string ChangeType { get; init; } public string? Language { get; init; } } /// /// Response from OSV enrichment. /// public record EnrichmentResponse { public required string CveId { get; init; } public int EnrichedCount { get; init; } public required List Mappings { get; init; } } /// /// Mapping statistics response. /// public record MappingStatsResponse { public int TotalMappings { get; init; } public int UniqueCves { get; init; } public int UniquePackages { get; init; } public Dictionary? BySource { get; init; } public Dictionary? ByVulnerabilityType { get; init; } public double AverageConfidence { get; init; } public DateTimeOffset LastUpdated { get; init; } }