sprints work

This commit is contained in:
master
2026-01-10 20:32:13 +02:00
parent 0d5eda86fc
commit 17d0631b8e
189 changed files with 40667 additions and 497 deletions

View File

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