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:
master
2025-12-16 18:44:25 +02:00
parent 2170a58734
commit 3a2100aa78
126 changed files with 15776 additions and 542 deletions

View File

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