Add unit and integration tests for VexCandidateEmitter and SmartDiff repositories
- Implemented comprehensive unit tests for VexCandidateEmitter to validate candidate emission logic based on various scenarios including absent and present APIs, confidence thresholds, and rate limiting. - Added integration tests for SmartDiff PostgreSQL repositories, covering snapshot storage and retrieval, candidate storage, and material risk change handling. - Ensured tests validate correct behavior for storing, retrieving, and querying snapshots and candidates, including edge cases and expected outcomes.
This commit is contained in:
@@ -0,0 +1,332 @@
|
||||
using System.Collections.Immutable;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using StellaOps.Scanner.SmartDiff.Detection;
|
||||
using StellaOps.Scanner.Storage.Postgres;
|
||||
using StellaOps.Scanner.WebService.Security;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Smart-Diff API endpoints for material risk changes and VEX candidates.
|
||||
/// Per Sprint 3500.3 - Smart-Diff Detection Rules.
|
||||
/// </summary>
|
||||
internal static class SmartDiffEndpoints
|
||||
{
|
||||
public static void MapSmartDiffEndpoints(this RouteGroupBuilder apiGroup, string prefix = "/smart-diff")
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(apiGroup);
|
||||
|
||||
var group = apiGroup.MapGroup(prefix);
|
||||
|
||||
// Material risk changes endpoints
|
||||
group.MapGet("/scans/{scanId}/changes", HandleGetScanChangesAsync)
|
||||
.WithName("scanner.smartdiff.scan-changes")
|
||||
.WithTags("SmartDiff")
|
||||
.Produces<MaterialChangesResponse>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status404NotFound)
|
||||
.RequireAuthorization(ScannerPolicies.ScansRead);
|
||||
|
||||
// VEX candidate endpoints
|
||||
group.MapGet("/images/{digest}/candidates", HandleGetCandidatesAsync)
|
||||
.WithName("scanner.smartdiff.candidates")
|
||||
.WithTags("SmartDiff")
|
||||
.Produces<VexCandidatesResponse>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status404NotFound)
|
||||
.RequireAuthorization(ScannerPolicies.ScansRead);
|
||||
|
||||
group.MapGet("/candidates/{candidateId}", HandleGetCandidateAsync)
|
||||
.WithName("scanner.smartdiff.candidate")
|
||||
.WithTags("SmartDiff")
|
||||
.Produces<VexCandidateResponse>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status404NotFound)
|
||||
.RequireAuthorization(ScannerPolicies.ScansRead);
|
||||
|
||||
group.MapPost("/candidates/{candidateId}/review", HandleReviewCandidateAsync)
|
||||
.WithName("scanner.smartdiff.review")
|
||||
.WithTags("SmartDiff")
|
||||
.Produces<ReviewResponse>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status400BadRequest)
|
||||
.Produces(StatusCodes.Status404NotFound)
|
||||
.RequireAuthorization(ScannerPolicies.ScansWrite);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// GET /smart-diff/scans/{scanId}/changes - Get material risk changes for a scan.
|
||||
/// </summary>
|
||||
private static async Task<IResult> HandleGetScanChangesAsync(
|
||||
string scanId,
|
||||
IMaterialRiskChangeRepository repository,
|
||||
double? minPriority = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var changes = await repository.GetChangesForScanAsync(scanId, ct);
|
||||
|
||||
if (minPriority.HasValue)
|
||||
{
|
||||
changes = changes.Where(c => c.PriorityScore >= minPriority.Value).ToList();
|
||||
}
|
||||
|
||||
var response = new MaterialChangesResponse
|
||||
{
|
||||
ScanId = scanId,
|
||||
TotalChanges = changes.Count,
|
||||
Changes = changes.Select(ToChangeDto).ToImmutableArray()
|
||||
};
|
||||
|
||||
return Results.Ok(response);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// GET /smart-diff/images/{digest}/candidates - Get VEX candidates for an image.
|
||||
/// </summary>
|
||||
private static async Task<IResult> HandleGetCandidatesAsync(
|
||||
string digest,
|
||||
IVexCandidateStore store,
|
||||
double? minConfidence = null,
|
||||
bool? pendingOnly = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var normalizedDigest = NormalizeDigest(digest);
|
||||
var candidates = await store.GetCandidatesAsync(normalizedDigest, ct);
|
||||
|
||||
if (minConfidence.HasValue)
|
||||
{
|
||||
candidates = candidates.Where(c => c.Confidence >= minConfidence.Value).ToList();
|
||||
}
|
||||
|
||||
if (pendingOnly == true)
|
||||
{
|
||||
candidates = candidates.Where(c => c.RequiresReview).ToList();
|
||||
}
|
||||
|
||||
var response = new VexCandidatesResponse
|
||||
{
|
||||
ImageDigest = normalizedDigest,
|
||||
TotalCandidates = candidates.Count,
|
||||
Candidates = candidates.Select(ToCandidateDto).ToImmutableArray()
|
||||
};
|
||||
|
||||
return Results.Ok(response);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// GET /smart-diff/candidates/{candidateId} - Get a specific VEX candidate.
|
||||
/// </summary>
|
||||
private static async Task<IResult> HandleGetCandidateAsync(
|
||||
string candidateId,
|
||||
IVexCandidateStore store,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var candidate = await store.GetCandidateAsync(candidateId, ct);
|
||||
|
||||
if (candidate is null)
|
||||
{
|
||||
return Results.NotFound(new { error = "Candidate not found", candidateId });
|
||||
}
|
||||
|
||||
var response = new VexCandidateResponse
|
||||
{
|
||||
Candidate = ToCandidateDto(candidate)
|
||||
};
|
||||
|
||||
return Results.Ok(response);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// POST /smart-diff/candidates/{candidateId}/review - Review a VEX candidate.
|
||||
/// </summary>
|
||||
private static async Task<IResult> HandleReviewCandidateAsync(
|
||||
string candidateId,
|
||||
ReviewRequest request,
|
||||
IVexCandidateStore store,
|
||||
HttpContext httpContext,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (!Enum.TryParse<VexReviewAction>(request.Action, true, out var action))
|
||||
{
|
||||
return Results.BadRequest(new { error = "Invalid action", validActions = new[] { "accept", "reject", "defer" } });
|
||||
}
|
||||
|
||||
var reviewer = httpContext.User.Identity?.Name ?? "anonymous";
|
||||
var review = new VexCandidateReview(
|
||||
Action: action,
|
||||
Reviewer: reviewer,
|
||||
ReviewedAt: DateTimeOffset.UtcNow,
|
||||
Comment: request.Comment);
|
||||
|
||||
var success = await store.ReviewCandidateAsync(candidateId, review, ct);
|
||||
|
||||
if (!success)
|
||||
{
|
||||
return Results.NotFound(new { error = "Candidate not found", candidateId });
|
||||
}
|
||||
|
||||
return Results.Ok(new ReviewResponse
|
||||
{
|
||||
CandidateId = candidateId,
|
||||
Action = action.ToString().ToLowerInvariant(),
|
||||
ReviewedBy = reviewer,
|
||||
ReviewedAt = review.ReviewedAt
|
||||
});
|
||||
}
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
private static string NormalizeDigest(string digest)
|
||||
{
|
||||
// Handle URL-encoded colons
|
||||
return digest.Replace("%3A", ":", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static MaterialChangeDto ToChangeDto(MaterialRiskChangeResult change)
|
||||
{
|
||||
return new MaterialChangeDto
|
||||
{
|
||||
VulnId = change.FindingKey.VulnId,
|
||||
Purl = change.FindingKey.Purl,
|
||||
HasMaterialChange = change.HasMaterialChange,
|
||||
PriorityScore = change.PriorityScore,
|
||||
PreviousStateHash = change.PreviousStateHash,
|
||||
CurrentStateHash = change.CurrentStateHash,
|
||||
Changes = change.Changes.Select(c => new DetectedChangeDto
|
||||
{
|
||||
Rule = c.Rule.ToString(),
|
||||
ChangeType = c.ChangeType.ToString(),
|
||||
Direction = c.Direction.ToString().ToLowerInvariant(),
|
||||
Reason = c.Reason,
|
||||
PreviousValue = c.PreviousValue,
|
||||
CurrentValue = c.CurrentValue,
|
||||
Weight = c.Weight,
|
||||
SubType = c.SubType
|
||||
}).ToImmutableArray()
|
||||
};
|
||||
}
|
||||
|
||||
private static VexCandidateDto ToCandidateDto(VexCandidate candidate)
|
||||
{
|
||||
return new VexCandidateDto
|
||||
{
|
||||
CandidateId = candidate.CandidateId,
|
||||
VulnId = candidate.FindingKey.VulnId,
|
||||
Purl = candidate.FindingKey.Purl,
|
||||
ImageDigest = candidate.ImageDigest,
|
||||
SuggestedStatus = candidate.SuggestedStatus.ToString().ToLowerInvariant(),
|
||||
Justification = MapJustificationToString(candidate.Justification),
|
||||
Rationale = candidate.Rationale,
|
||||
EvidenceLinks = candidate.EvidenceLinks.Select(e => new EvidenceLinkDto
|
||||
{
|
||||
Type = e.Type,
|
||||
Uri = e.Uri,
|
||||
Digest = e.Digest
|
||||
}).ToImmutableArray(),
|
||||
Confidence = candidate.Confidence,
|
||||
GeneratedAt = candidate.GeneratedAt,
|
||||
ExpiresAt = candidate.ExpiresAt,
|
||||
RequiresReview = candidate.RequiresReview
|
||||
};
|
||||
}
|
||||
|
||||
private static string MapJustificationToString(VexJustification justification)
|
||||
{
|
||||
return justification switch
|
||||
{
|
||||
VexJustification.ComponentNotPresent => "component_not_present",
|
||||
VexJustification.VulnerableCodeNotPresent => "vulnerable_code_not_present",
|
||||
VexJustification.VulnerableCodeNotInExecutePath => "vulnerable_code_not_in_execute_path",
|
||||
VexJustification.VulnerableCodeCannotBeControlledByAdversary => "vulnerable_code_cannot_be_controlled_by_adversary",
|
||||
VexJustification.InlineMitigationsAlreadyExist => "inline_mitigations_already_exist",
|
||||
_ => "unknown"
|
||||
};
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
#region DTOs
|
||||
|
||||
/// <summary>Response for GET /scans/{id}/changes</summary>
|
||||
public sealed class MaterialChangesResponse
|
||||
{
|
||||
public required string ScanId { get; init; }
|
||||
public int TotalChanges { get; init; }
|
||||
public required ImmutableArray<MaterialChangeDto> Changes { get; init; }
|
||||
}
|
||||
|
||||
public sealed class MaterialChangeDto
|
||||
{
|
||||
public required string VulnId { get; init; }
|
||||
public required string Purl { get; init; }
|
||||
public bool HasMaterialChange { get; init; }
|
||||
public int PriorityScore { get; init; }
|
||||
public required string PreviousStateHash { get; init; }
|
||||
public required string CurrentStateHash { get; init; }
|
||||
public required ImmutableArray<DetectedChangeDto> Changes { get; init; }
|
||||
}
|
||||
|
||||
public sealed class DetectedChangeDto
|
||||
{
|
||||
public required string Rule { get; init; }
|
||||
public required string ChangeType { get; init; }
|
||||
public required string Direction { get; init; }
|
||||
public required string Reason { get; init; }
|
||||
public required string PreviousValue { get; init; }
|
||||
public required string CurrentValue { get; init; }
|
||||
public double Weight { get; init; }
|
||||
public string? SubType { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Response for GET /images/{digest}/candidates</summary>
|
||||
public sealed class VexCandidatesResponse
|
||||
{
|
||||
public required string ImageDigest { get; init; }
|
||||
public int TotalCandidates { get; init; }
|
||||
public required ImmutableArray<VexCandidateDto> Candidates { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Response for GET /candidates/{id}</summary>
|
||||
public sealed class VexCandidateResponse
|
||||
{
|
||||
public required VexCandidateDto Candidate { get; init; }
|
||||
}
|
||||
|
||||
public sealed class VexCandidateDto
|
||||
{
|
||||
public required string CandidateId { get; init; }
|
||||
public required string VulnId { get; init; }
|
||||
public required string Purl { get; init; }
|
||||
public required string ImageDigest { get; init; }
|
||||
public required string SuggestedStatus { get; init; }
|
||||
public required string Justification { get; init; }
|
||||
public required string Rationale { get; init; }
|
||||
public required ImmutableArray<EvidenceLinkDto> EvidenceLinks { get; init; }
|
||||
public double Confidence { get; init; }
|
||||
public DateTimeOffset GeneratedAt { get; init; }
|
||||
public DateTimeOffset ExpiresAt { get; init; }
|
||||
public bool RequiresReview { get; init; }
|
||||
}
|
||||
|
||||
public sealed class EvidenceLinkDto
|
||||
{
|
||||
public required string Type { get; init; }
|
||||
public required string Uri { get; init; }
|
||||
public string? Digest { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Request for POST /candidates/{id}/review</summary>
|
||||
public sealed class ReviewRequest
|
||||
{
|
||||
public required string Action { get; init; }
|
||||
public string? Comment { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Response for POST /candidates/{id}/review</summary>
|
||||
public sealed class ReviewResponse
|
||||
{
|
||||
public required string CandidateId { get; init; }
|
||||
public required string Action { get; init; }
|
||||
public required string ReviewedBy { get; init; }
|
||||
public DateTimeOffset ReviewedAt { get; init; }
|
||||
}
|
||||
|
||||
#endregion
|
||||
Reference in New Issue
Block a user