save checkpoint: save features
This commit is contained in:
@@ -3,11 +3,9 @@ using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using StellaOps.Scanner.SmartDiff.Detection;
|
||||
using StellaOps.Scanner.SmartDiff.Output;
|
||||
using StellaOps.Scanner.Storage.Postgres;
|
||||
using StellaOps.Scanner.WebService.Security;
|
||||
using StellaOps.Scanner.WebService.Services;
|
||||
using System.Collections.Immutable;
|
||||
using System.Text;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Endpoints;
|
||||
|
||||
@@ -41,6 +39,21 @@ internal static class SmartDiffEndpoints
|
||||
.RequireAuthorization(ScannerPolicies.ScansRead);
|
||||
|
||||
// VEX candidate endpoints
|
||||
group.MapGet("/{scanId}/vex-candidates", HandleGetScanCandidatesAsync)
|
||||
.WithName("scanner.smartdiff.scan-candidates")
|
||||
.WithTags("SmartDiff")
|
||||
.Produces<VexCandidatesResponse>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status404NotFound)
|
||||
.RequireAuthorization(ScannerPolicies.ScansRead);
|
||||
|
||||
group.MapPost("/{scanId}/vex-candidates/review", HandleReviewScanCandidateAsync)
|
||||
.WithName("scanner.smartdiff.scan-review")
|
||||
.WithTags("SmartDiff")
|
||||
.Produces<ReviewResponse>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status400BadRequest)
|
||||
.Produces(StatusCodes.Status404NotFound)
|
||||
.RequireAuthorization(ScannerPolicies.ScansWrite);
|
||||
|
||||
group.MapGet("/images/{digest}/candidates", HandleGetCandidatesAsync)
|
||||
.WithName("scanner.smartdiff.candidates")
|
||||
.WithTags("SmartDiff")
|
||||
@@ -89,12 +102,19 @@ internal static class SmartDiffEndpoints
|
||||
var metadata = await metadataRepo.GetScanMetadataAsync(scanId, ct);
|
||||
if (metadata is not null)
|
||||
{
|
||||
baseDigest = metadata.BaseDigest;
|
||||
targetDigest = metadata.TargetDigest;
|
||||
baseDigest = NormalizeDigest(metadata.BaseDigest);
|
||||
targetDigest = NormalizeDigest(metadata.TargetDigest);
|
||||
scanTime = metadata.ScanTime;
|
||||
}
|
||||
}
|
||||
|
||||
IReadOnlyList<StellaOps.Scanner.SmartDiff.Output.VexCandidate> vexCandidates = [];
|
||||
if (!string.IsNullOrWhiteSpace(targetDigest))
|
||||
{
|
||||
var candidates = await candidateStore.GetCandidatesAsync(targetDigest, ct).ConfigureAwait(false);
|
||||
vexCandidates = candidates.Select(ToSarifVexCandidate).ToList();
|
||||
}
|
||||
|
||||
// Convert to SARIF input format
|
||||
var sarifInput = new SmartDiffSarifInput(
|
||||
ScannerVersion: GetScannerVersion(),
|
||||
@@ -112,7 +132,7 @@ internal static class SmartDiffEndpoints
|
||||
))
|
||||
.ToList(),
|
||||
HardeningRegressions: [],
|
||||
VexCandidates: [],
|
||||
VexCandidates: vexCandidates,
|
||||
ReachabilityChanges: []);
|
||||
|
||||
// Generate SARIF
|
||||
@@ -135,6 +155,27 @@ internal static class SmartDiffEndpoints
|
||||
statusCode: StatusCodes.Status200OK);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// GET /smart-diff/{scanId}/vex-candidates - Get VEX candidates using scan metadata.
|
||||
/// </summary>
|
||||
private static async Task<IResult> HandleGetScanCandidatesAsync(
|
||||
string scanId,
|
||||
IScanMetadataRepository metadataRepository,
|
||||
IVexCandidateStore store,
|
||||
double? minConfidence = null,
|
||||
bool? pendingOnly = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var metadata = await metadataRepository.GetScanMetadataAsync(scanId, ct).ConfigureAwait(false);
|
||||
var targetDigest = NormalizeDigest(metadata?.TargetDigest);
|
||||
if (string.IsNullOrWhiteSpace(targetDigest))
|
||||
{
|
||||
return Results.NotFound(new { error = "Scan metadata not found", scanId });
|
||||
}
|
||||
|
||||
return await HandleGetCandidatesAsync(targetDigest, store, minConfidence, pendingOnly, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static StellaOps.Scanner.SmartDiff.Output.RiskDirection ToSarifRiskDirection(MaterialRiskChangeResult change)
|
||||
{
|
||||
if (change.Changes.IsDefaultOrEmpty)
|
||||
@@ -219,6 +260,11 @@ internal static class SmartDiffEndpoints
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var normalizedDigest = NormalizeDigest(digest);
|
||||
if (string.IsNullOrWhiteSpace(normalizedDigest))
|
||||
{
|
||||
return Results.BadRequest(new { error = "Invalid image digest" });
|
||||
}
|
||||
|
||||
var candidates = await store.GetCandidatesAsync(normalizedDigest, ct);
|
||||
|
||||
if (minConfidence.HasValue)
|
||||
@@ -303,12 +349,76 @@ internal static class SmartDiffEndpoints
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// POST /smart-diff/{scanId}/vex-candidates/review - Review a scan-scoped VEX candidate.
|
||||
/// </summary>
|
||||
private static async Task<IResult> HandleReviewScanCandidateAsync(
|
||||
string scanId,
|
||||
ScanReviewRequest request,
|
||||
IScanMetadataRepository metadataRepository,
|
||||
IVexCandidateStore store,
|
||||
TimeProvider timeProvider,
|
||||
HttpContext httpContext,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.CandidateId))
|
||||
{
|
||||
return Results.BadRequest(new { error = "CandidateId is required" });
|
||||
}
|
||||
|
||||
var metadata = await metadataRepository.GetScanMetadataAsync(scanId, ct).ConfigureAwait(false);
|
||||
var targetDigest = NormalizeDigest(metadata?.TargetDigest);
|
||||
if (string.IsNullOrWhiteSpace(targetDigest))
|
||||
{
|
||||
return Results.NotFound(new { error = "Scan metadata not found", scanId });
|
||||
}
|
||||
|
||||
var candidate = await store.GetCandidateAsync(request.CandidateId, ct).ConfigureAwait(false);
|
||||
if (candidate is null || !string.Equals(candidate.ImageDigest, targetDigest, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return Results.NotFound(new { error = "Candidate not found for scan", scanId, candidateId = request.CandidateId });
|
||||
}
|
||||
|
||||
return await HandleReviewCandidateAsync(
|
||||
request.CandidateId,
|
||||
new ReviewRequest
|
||||
{
|
||||
Action = request.Action,
|
||||
Comment = request.Comment
|
||||
},
|
||||
store,
|
||||
timeProvider,
|
||||
httpContext,
|
||||
ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
private static string NormalizeDigest(string digest)
|
||||
private static string? NormalizeDigest(string? digest)
|
||||
{
|
||||
// Handle URL-encoded colons
|
||||
return digest.Replace("%3A", ":", StringComparison.OrdinalIgnoreCase);
|
||||
if (string.IsNullOrWhiteSpace(digest))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// Handle URL-encoded colons.
|
||||
var normalized = digest.Trim().Replace("%3A", ":", StringComparison.OrdinalIgnoreCase);
|
||||
if (!normalized.Contains(':', StringComparison.Ordinal))
|
||||
{
|
||||
normalized = $"sha256:{normalized}";
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private static StellaOps.Scanner.SmartDiff.Output.VexCandidate ToSarifVexCandidate(
|
||||
StellaOps.Scanner.SmartDiff.Detection.VexCandidate candidate)
|
||||
{
|
||||
return new StellaOps.Scanner.SmartDiff.Output.VexCandidate(
|
||||
VulnId: candidate.FindingKey.VulnId,
|
||||
ComponentPurl: candidate.FindingKey.ComponentPurl,
|
||||
Justification: MapJustificationToString(candidate.Justification),
|
||||
ImpactStatement: candidate.Rationale);
|
||||
}
|
||||
|
||||
private static MaterialChangeDto ToChangeDto(MaterialRiskChangeResult change)
|
||||
@@ -452,6 +562,14 @@ public sealed class ReviewRequest
|
||||
public string? Comment { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Request for POST /{scanId}/vex-candidates/review</summary>
|
||||
public sealed class ScanReviewRequest
|
||||
{
|
||||
public required string CandidateId { get; init; }
|
||||
public required string Action { get; init; }
|
||||
public string? Comment { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Response for POST /candidates/{id}/review</summary>
|
||||
public sealed class ReviewResponse
|
||||
{
|
||||
|
||||
@@ -30,8 +30,10 @@ using StellaOps.Scanner.Core.TrustAnchors;
|
||||
using StellaOps.Scanner.Emit.Composition;
|
||||
using StellaOps.Scanner.Gate;
|
||||
using StellaOps.Scanner.ReachabilityDrift.DependencyInjection;
|
||||
using StellaOps.Scanner.SmartDiff.Detection;
|
||||
using StellaOps.Scanner.Storage;
|
||||
using StellaOps.Scanner.Storage.Extensions;
|
||||
using StellaOps.Scanner.Storage.Postgres;
|
||||
using StellaOps.Scanner.Surface.Env;
|
||||
using StellaOps.Scanner.Surface.FS;
|
||||
using StellaOps.Scanner.Surface.Secrets;
|
||||
@@ -180,6 +182,9 @@ builder.Services.AddSingleton<ICounterfactualApiService, CounterfactualApiServic
|
||||
builder.Services.TryAddSingleton<IVexGateResultsStore, InMemoryVexGateResultsStore>();
|
||||
builder.Services.TryAddSingleton<IVexGateQueryService, VexGateQueryService>();
|
||||
builder.Services.TryAddSingleton<IVexReachabilityDecisionFilter, VexReachabilityDecisionFilter>();
|
||||
builder.Services.TryAddSingleton<IMaterialRiskChangeRepository, PostgresMaterialRiskChangeRepository>();
|
||||
builder.Services.TryAddSingleton<IVexCandidateStore, PostgresVexCandidateStore>();
|
||||
builder.Services.TryAddSingleton<IScanMetadataRepository, InMemoryScanMetadataRepository>();
|
||||
|
||||
// Secret Detection Settings (Sprint: SPRINT_20260104_006_BE)
|
||||
builder.Services.AddScoped<ISecretDetectionSettingsService, SecretDetectionSettingsService>();
|
||||
@@ -607,6 +612,7 @@ apiGroup.MapScanEndpoints(resolvedOptions.Api.ScansSegment);
|
||||
apiGroup.MapSbomUploadEndpoints();
|
||||
apiGroup.MapReachabilityDriftRootEndpoints();
|
||||
apiGroup.MapDeltaCompareEndpoints();
|
||||
apiGroup.MapSmartDiffEndpoints();
|
||||
apiGroup.MapBaselineEndpoints();
|
||||
apiGroup.MapActionablesEndpoints();
|
||||
apiGroup.MapCounterfactualEndpoints();
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
using StellaOps.Scanner.Core;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Resolves scan metadata from stored scan manifests.
|
||||
/// </summary>
|
||||
public sealed class InMemoryScanMetadataRepository : IScanMetadataRepository
|
||||
{
|
||||
private readonly IScanManifestRepository _manifestRepository;
|
||||
|
||||
public InMemoryScanMetadataRepository(IScanManifestRepository manifestRepository)
|
||||
{
|
||||
_manifestRepository = manifestRepository ?? throw new ArgumentNullException(nameof(manifestRepository));
|
||||
}
|
||||
|
||||
public async Task<ScanMetadata?> GetScanMetadataAsync(string scanId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(scanId))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var manifest = await _manifestRepository
|
||||
.GetManifestAsync(scanId.Trim(), cancellationToken: cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (manifest is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new ScanMetadata(
|
||||
BaseDigest: null,
|
||||
TargetDigest: NormalizeDigest(manifest.Manifest.ArtifactDigest),
|
||||
ScanTime: manifest.Manifest.CreatedAtUtc);
|
||||
}
|
||||
|
||||
private static string? NormalizeDigest(string? digest)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(digest))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var normalized = digest.Trim();
|
||||
if (!normalized.Contains(':', StringComparison.Ordinal))
|
||||
{
|
||||
normalized = $"sha256:{normalized}";
|
||||
}
|
||||
|
||||
return normalized.ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
@@ -14,3 +14,4 @@ Source of truth: `docs/implplan/SPRINT_20260112_003_BE_csproj_audit_pending_appl
|
||||
| SPRINT-20260208-063-TRIAGE-001 | DONE | Implement triage cluster batch action and cluster statistics endpoints for sprint 063 (2026-02-08). |
|
||||
| HOT-003 | DONE | `SPRINT_20260210_001_DOCS_sbom_attestation_hot_lookup_contract.md`: wired SBOM ingestion projection writes into Scanner WebService pipeline. |
|
||||
| HOT-004 | DONE | `SPRINT_20260210_001_DOCS_sbom_attestation_hot_lookup_contract.md`: added SBOM hot-lookup read endpoints with bounded pagination. |
|
||||
| SPRINT-20260212-002-SMARTDIFF-001 | DONE | `SPRINT_20260212_002_Scanner_unchecked_feature_verification_batch1.md`: wired SmartDiff endpoints into Program, added scan-scoped VEX candidate/review API compatibility, and embedded VEX candidates in SARIF output (2026-02-12). |
|
||||
|
||||
Reference in New Issue
Block a user