sprints work
This commit is contained in:
@@ -0,0 +1,547 @@
|
||||
// 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 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; }
|
||||
}
|
||||
Reference in New Issue
Block a user