Sprints completed: - SPRINT_20260110_012_* (golden set diff layer - 10 sprints) - SPRINT_20260110_013_* (advisory chat - 4 sprints) Build fixes applied: - Fix namespace conflicts with Microsoft.Extensions.Options.Options.Create - Fix VexDecisionReachabilityIntegrationTests API drift (major rewrite) - Fix VexSchemaValidationTests FluentAssertions method name - Fix FixChainGateIntegrationTests ambiguous type references - Fix AdvisoryAI test files required properties and namespace aliases - Add stub types for CveMappingController (ICveSymbolMappingService) - Fix VerdictBuilderService static context issue Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
549 lines
19 KiB
C#
549 lines
19 KiB
C#
// 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;
|
|
|
|
/// <summary>
|
|
/// CVE-Symbol Mapping API for querying vulnerable symbols.
|
|
/// Maps CVE identifiers to affected functions/methods for reachability analysis.
|
|
/// </summary>
|
|
[ApiController]
|
|
[Route("v1/cve-mappings")]
|
|
[Produces("application/json")]
|
|
public class CveMappingController : ControllerBase
|
|
{
|
|
private readonly ICveSymbolMappingService _mappingService;
|
|
private readonly ILogger<CveMappingController> _logger;
|
|
|
|
public CveMappingController(
|
|
ICveSymbolMappingService mappingService,
|
|
ILogger<CveMappingController> logger)
|
|
{
|
|
_mappingService = mappingService;
|
|
_logger = logger;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Get all symbol mappings for a CVE.
|
|
/// </summary>
|
|
/// <param name="cveId">The CVE identifier (e.g., CVE-2021-44228).</param>
|
|
/// <param name="cancellationToken">Cancellation token.</param>
|
|
/// <returns>List of vulnerable symbols for the CVE.</returns>
|
|
[HttpGet("{cveId}")]
|
|
[EnableRateLimiting("reachgraph-read")]
|
|
[ProducesResponseType(typeof(CveMappingResponse), StatusCodes.Status200OK)]
|
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
|
[ResponseCache(Duration = 3600, VaryByQueryKeys = new[] { "cveId" })]
|
|
public async Task<IActionResult> 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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Get mappings for a specific package.
|
|
/// </summary>
|
|
/// <param name="purl">Package URL (URL-encoded).</param>
|
|
/// <param name="cancellationToken">Cancellation token.</param>
|
|
/// <returns>List of CVE mappings affecting the package.</returns>
|
|
[HttpGet("by-package")]
|
|
[EnableRateLimiting("reachgraph-read")]
|
|
[ProducesResponseType(typeof(PackageMappingsResponse), StatusCodes.Status200OK)]
|
|
public async Task<IActionResult> 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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Search for mappings by symbol name.
|
|
/// </summary>
|
|
/// <param name="symbol">Symbol name or pattern.</param>
|
|
/// <param name="language">Optional programming language filter.</param>
|
|
/// <param name="cancellationToken">Cancellation token.</param>
|
|
/// <returns>List of CVE mappings matching the symbol.</returns>
|
|
[HttpGet("by-symbol")]
|
|
[EnableRateLimiting("reachgraph-read")]
|
|
[ProducesResponseType(typeof(SymbolMappingsResponse), StatusCodes.Status200OK)]
|
|
public async Task<IActionResult> 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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Add or update a CVE-symbol mapping.
|
|
/// </summary>
|
|
/// <param name="request">The mapping to add.</param>
|
|
/// <param name="cancellationToken">Cancellation token.</param>
|
|
/// <returns>The created or updated mapping.</returns>
|
|
[HttpPost]
|
|
[EnableRateLimiting("reachgraph-write")]
|
|
[ProducesResponseType(typeof(CveMappingDto), StatusCodes.Status201Created)]
|
|
[ProducesResponseType(typeof(CveMappingDto), StatusCodes.Status200OK)]
|
|
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
|
public async Task<IActionResult> 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<MappingSource>(request.Source, ignoreCase: true, out var source))
|
|
{
|
|
source = MappingSource.Unknown;
|
|
}
|
|
|
|
if (!Enum.TryParse<VulnerabilityType>(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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Analyze a commit/patch to extract vulnerable symbols.
|
|
/// </summary>
|
|
/// <param name="request">The patch analysis request.</param>
|
|
/// <param name="cancellationToken">Cancellation token.</param>
|
|
/// <returns>Extracted symbols from the patch.</returns>
|
|
[HttpPost("analyze-patch")]
|
|
[EnableRateLimiting("reachgraph-write")]
|
|
[ProducesResponseType(typeof(PatchAnalysisResponse), StatusCodes.Status200OK)]
|
|
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
|
public async Task<IActionResult> 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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Enrich CVE mapping from OSV database.
|
|
/// </summary>
|
|
/// <param name="cveId">CVE to enrich.</param>
|
|
/// <param name="cancellationToken">Cancellation token.</param>
|
|
/// <returns>Enriched mapping data from OSV.</returns>
|
|
[HttpPost("{cveId}/enrich")]
|
|
[EnableRateLimiting("reachgraph-write")]
|
|
[ProducesResponseType(typeof(EnrichmentResponse), StatusCodes.Status200OK)]
|
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
|
public async Task<IActionResult> 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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Get mapping statistics.
|
|
/// </summary>
|
|
/// <param name="cancellationToken">Cancellation token.</param>
|
|
/// <returns>Statistics about the mapping corpus.</returns>
|
|
[HttpGet("stats")]
|
|
[EnableRateLimiting("reachgraph-read")]
|
|
[ProducesResponseType(typeof(MappingStatsResponse), StatusCodes.Status200OK)]
|
|
[ResponseCache(Duration = 300)]
|
|
public async Task<IActionResult> 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
|
|
// ============================================================================
|
|
|
|
/// <summary>
|
|
/// Response containing CVE mappings.
|
|
/// </summary>
|
|
public record CveMappingResponse
|
|
{
|
|
public required string CveId { get; init; }
|
|
public int MappingCount { get; init; }
|
|
public required List<CveMappingDto> Mappings { get; init; }
|
|
}
|
|
|
|
/// <summary>
|
|
/// Response for package-based query.
|
|
/// </summary>
|
|
public record PackageMappingsResponse
|
|
{
|
|
public required string Purl { get; init; }
|
|
public int MappingCount { get; init; }
|
|
public required List<CveMappingDto> Mappings { get; init; }
|
|
}
|
|
|
|
/// <summary>
|
|
/// Response for symbol-based query.
|
|
/// </summary>
|
|
public record SymbolMappingsResponse
|
|
{
|
|
public required string Symbol { get; init; }
|
|
public string? Language { get; init; }
|
|
public int MappingCount { get; init; }
|
|
public required List<CveMappingDto> Mappings { get; init; }
|
|
}
|
|
|
|
/// <summary>
|
|
/// CVE mapping data transfer object.
|
|
/// </summary>
|
|
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<string>? AffectedVersions { get; init; }
|
|
public List<string>? FixedVersions { get; init; }
|
|
public string? EvidenceUri { get; init; }
|
|
}
|
|
|
|
/// <summary>
|
|
/// Request to add/update a CVE mapping.
|
|
/// </summary>
|
|
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<string>? AffectedVersions { get; init; }
|
|
public List<string>? FixedVersions { get; init; }
|
|
public string? EvidenceUri { get; init; }
|
|
}
|
|
|
|
/// <summary>
|
|
/// Request to analyze a patch.
|
|
/// </summary>
|
|
public record AnalyzePatchRequest
|
|
{
|
|
public string? CommitUrl { get; init; }
|
|
public string? DiffContent { get; init; }
|
|
}
|
|
|
|
/// <summary>
|
|
/// Response from patch analysis.
|
|
/// </summary>
|
|
public record PatchAnalysisResponse
|
|
{
|
|
public string? CommitUrl { get; init; }
|
|
public required List<ExtractedSymbolDto> ExtractedSymbols { get; init; }
|
|
public DateTimeOffset AnalyzedAt { get; init; }
|
|
}
|
|
|
|
/// <summary>
|
|
/// Extracted symbol from patch.
|
|
/// </summary>
|
|
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; }
|
|
}
|
|
|
|
/// <summary>
|
|
/// Response from OSV enrichment.
|
|
/// </summary>
|
|
public record EnrichmentResponse
|
|
{
|
|
public required string CveId { get; init; }
|
|
public int EnrichedCount { get; init; }
|
|
public required List<CveMappingDto> Mappings { get; init; }
|
|
}
|
|
|
|
/// <summary>
|
|
/// Mapping statistics response.
|
|
/// </summary>
|
|
public record MappingStatsResponse
|
|
{
|
|
public int TotalMappings { get; init; }
|
|
public int UniqueCves { get; init; }
|
|
public int UniquePackages { get; init; }
|
|
public Dictionary<string, int>? BySource { get; init; }
|
|
public Dictionary<string, int>? ByVulnerabilityType { get; init; }
|
|
public double AverageConfidence { get; init; }
|
|
public DateTimeOffset LastUpdated { get; init; }
|
|
}
|