wip: doctor/cli/docs/api to vector db consolidation; api hardening for descriptions, tenant, and scopes; migrations and conversions of all DALs to EF v10
This commit is contained in:
@@ -106,7 +106,7 @@ internal static class CallGraphEndpoints
|
||||
}
|
||||
|
||||
// Check for duplicate submission (idempotency)
|
||||
var existing = await ingestionService.FindByDigestAsync(parsed, contentDigest, cancellationToken)
|
||||
var existing = await ingestionService.FindByDigestAsync(parsed, snapshot.TenantId, contentDigest, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (existing is not null)
|
||||
@@ -127,7 +127,7 @@ internal static class CallGraphEndpoints
|
||||
}
|
||||
|
||||
// Ingest the call graph
|
||||
var result = await ingestionService.IngestAsync(parsed, request, contentDigest, cancellationToken)
|
||||
var result = await ingestionService.IngestAsync(parsed, snapshot.TenantId, request, contentDigest, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var response = new CallGraphAcceptedResponseDto(
|
||||
|
||||
@@ -11,6 +11,7 @@ using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using StellaOps.Scanner.Core.Epss;
|
||||
using StellaOps.Scanner.WebService.Security;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Endpoints;
|
||||
@@ -29,7 +30,8 @@ public static class EpssEndpoints
|
||||
#pragma warning disable ASPDEPR002 // WithOpenApi is deprecated - migration pending
|
||||
var group = endpoints.MapGroup("/epss")
|
||||
.WithTags("EPSS")
|
||||
.WithOpenApi();
|
||||
.WithOpenApi()
|
||||
.RequireAuthorization(ScannerPolicies.ScansRead);
|
||||
#pragma warning restore ASPDEPR002
|
||||
|
||||
group.MapPost("/current", GetCurrentBatch)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Scanner.Orchestration.Fidelity;
|
||||
using StellaOps.Scanner.WebService.Security;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Endpoints;
|
||||
|
||||
@@ -9,7 +10,7 @@ public static class FidelityEndpoints
|
||||
{
|
||||
var group = app.MapGroup("/api/v1/scan")
|
||||
.WithTags("Fidelity")
|
||||
.RequireAuthorization();
|
||||
.RequireAuthorization(ScannerPolicies.ScansWrite);
|
||||
|
||||
// POST /api/v1/scan/analyze?fidelity={level}
|
||||
group.MapPost("/analyze", async (
|
||||
|
||||
@@ -12,7 +12,9 @@ internal static class ObservabilityEndpoints
|
||||
|
||||
endpoints.MapGet("/metrics", HandleMetricsAsync)
|
||||
.WithName("scanner.metrics")
|
||||
.Produces(StatusCodes.Status200OK);
|
||||
.WithDescription("Exposes scanner service metrics in Prometheus text format (text/plain 0.0.4). Scraped by Prometheus without authentication.")
|
||||
.Produces(StatusCodes.Status200OK)
|
||||
.AllowAnonymous();
|
||||
}
|
||||
|
||||
private static IResult HandleMetricsAsync(OfflineKitMetricsStore metricsStore)
|
||||
|
||||
@@ -3,14 +3,13 @@ using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Scanner.Core.Configuration;
|
||||
using StellaOps.Scanner.WebService.Constants;
|
||||
using StellaOps.Scanner.WebService.Infrastructure;
|
||||
using StellaOps.Scanner.WebService.Security;
|
||||
using StellaOps.Scanner.WebService.Services;
|
||||
using StellaOps.Scanner.WebService.Tenancy;
|
||||
using System.Linq;
|
||||
using System.Security.Claims;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Endpoints;
|
||||
@@ -221,39 +220,12 @@ internal static class OfflineKitEndpoints
|
||||
|
||||
private static string ResolveTenant(HttpContext context)
|
||||
{
|
||||
var tenant = context.User?.FindFirstValue(StellaOpsClaimTypes.Tenant);
|
||||
if (!string.IsNullOrWhiteSpace(tenant))
|
||||
{
|
||||
return tenant.Trim();
|
||||
}
|
||||
|
||||
if (context.Request.Headers.TryGetValue("X-Stella-Tenant", out var headerTenant))
|
||||
{
|
||||
var headerValue = headerTenant.ToString();
|
||||
if (!string.IsNullOrWhiteSpace(headerValue))
|
||||
{
|
||||
return headerValue.Trim();
|
||||
}
|
||||
}
|
||||
|
||||
return "default";
|
||||
return ScannerRequestContextResolver.ResolveTenantOrDefault(context);
|
||||
}
|
||||
|
||||
private static string ResolveActor(HttpContext context)
|
||||
{
|
||||
var subject = context.User?.FindFirstValue(StellaOpsClaimTypes.Subject);
|
||||
if (!string.IsNullOrWhiteSpace(subject))
|
||||
{
|
||||
return subject.Trim();
|
||||
}
|
||||
|
||||
var clientId = context.User?.FindFirstValue(StellaOpsClaimTypes.ClientId);
|
||||
if (!string.IsNullOrWhiteSpace(clientId))
|
||||
{
|
||||
return clientId.Trim();
|
||||
}
|
||||
|
||||
return "anonymous";
|
||||
return ScannerRequestContextResolver.ResolveActor(context, fallback: "anonymous");
|
||||
}
|
||||
|
||||
// Sprint 026: OFFLINE-011 - Manifest retrieval handler
|
||||
@@ -339,4 +311,3 @@ internal static class OfflineKitEndpoints
|
||||
return Results.Ok(result);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ using StellaOps.Scanner.WebService.Domain;
|
||||
using StellaOps.Scanner.WebService.Infrastructure;
|
||||
using StellaOps.Scanner.WebService.Security;
|
||||
using StellaOps.Scanner.WebService.Services;
|
||||
using StellaOps.Scanner.WebService.Tenancy;
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
@@ -73,6 +74,10 @@ internal static class ReachabilityDriftEndpoints
|
||||
ArgumentNullException.ThrowIfNull(codeChangeRepository);
|
||||
ArgumentNullException.ThrowIfNull(driftDetector);
|
||||
ArgumentNullException.ThrowIfNull(driftRepository);
|
||||
if (!TryResolveTenant(context, out var tenantId, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
if (!ScanId.TryParse(scanId, out var headScan))
|
||||
{
|
||||
@@ -99,7 +104,11 @@ internal static class ReachabilityDriftEndpoints
|
||||
|
||||
if (string.IsNullOrWhiteSpace(baseScanId))
|
||||
{
|
||||
var existing = await driftRepository.TryGetLatestForHeadAsync(headScan.Value, resolvedLanguage, cancellationToken)
|
||||
var existing = await driftRepository.TryGetLatestForHeadAsync(
|
||||
headScan.Value,
|
||||
resolvedLanguage,
|
||||
cancellationToken,
|
||||
tenantId: tenantId)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (existing is null)
|
||||
@@ -136,7 +145,11 @@ internal static class ReachabilityDriftEndpoints
|
||||
detail: "Base scan could not be located.");
|
||||
}
|
||||
|
||||
var baseGraph = await callGraphSnapshots.TryGetLatestAsync(baseScan.Value, resolvedLanguage, cancellationToken)
|
||||
var baseGraph = await callGraphSnapshots.TryGetLatestAsync(
|
||||
baseScan.Value,
|
||||
resolvedLanguage,
|
||||
cancellationToken,
|
||||
tenantId: tenantId)
|
||||
.ConfigureAwait(false);
|
||||
if (baseGraph is null)
|
||||
{
|
||||
@@ -148,7 +161,11 @@ internal static class ReachabilityDriftEndpoints
|
||||
detail: $"No call graph snapshot found for base scan {baseScan.Value} (language={resolvedLanguage}).");
|
||||
}
|
||||
|
||||
var headGraph = await callGraphSnapshots.TryGetLatestAsync(headScan.Value, resolvedLanguage, cancellationToken)
|
||||
var headGraph = await callGraphSnapshots.TryGetLatestAsync(
|
||||
headScan.Value,
|
||||
resolvedLanguage,
|
||||
cancellationToken,
|
||||
tenantId: tenantId)
|
||||
.ConfigureAwait(false);
|
||||
if (headGraph is null)
|
||||
{
|
||||
@@ -163,7 +180,7 @@ internal static class ReachabilityDriftEndpoints
|
||||
try
|
||||
{
|
||||
var codeChanges = codeChangeFactExtractor.Extract(baseGraph, headGraph);
|
||||
await codeChangeRepository.StoreAsync(codeChanges, cancellationToken).ConfigureAwait(false);
|
||||
await codeChangeRepository.StoreAsync(codeChanges, cancellationToken, tenantId: tenantId).ConfigureAwait(false);
|
||||
|
||||
var drift = driftDetector.Detect(
|
||||
baseGraph,
|
||||
@@ -171,7 +188,7 @@ internal static class ReachabilityDriftEndpoints
|
||||
codeChanges,
|
||||
includeFullPath: includeFullPath == true);
|
||||
|
||||
await driftRepository.StoreAsync(drift, cancellationToken).ConfigureAwait(false);
|
||||
await driftRepository.StoreAsync(drift, cancellationToken, tenantId: tenantId).ConfigureAwait(false);
|
||||
return Json(drift, StatusCodes.Status200OK);
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
@@ -195,6 +212,10 @@ internal static class ReachabilityDriftEndpoints
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(driftRepository);
|
||||
if (!TryResolveTenant(context, out var tenantId, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
if (driftId == Guid.Empty)
|
||||
{
|
||||
@@ -238,7 +259,7 @@ internal static class ReachabilityDriftEndpoints
|
||||
detail: "limit must be between 1 and 500.");
|
||||
}
|
||||
|
||||
if (!await driftRepository.ExistsAsync(driftId, cancellationToken).ConfigureAwait(false))
|
||||
if (!await driftRepository.ExistsAsync(driftId, cancellationToken, tenantId: tenantId).ConfigureAwait(false))
|
||||
{
|
||||
return ProblemResultFactory.Create(
|
||||
context,
|
||||
@@ -253,7 +274,8 @@ internal static class ReachabilityDriftEndpoints
|
||||
parsedDirection,
|
||||
resolvedOffset,
|
||||
resolvedLimit,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
cancellationToken,
|
||||
tenantId: tenantId).ConfigureAwait(false);
|
||||
|
||||
var response = new DriftedSinksResponseDto(
|
||||
DriftId: driftId,
|
||||
@@ -297,6 +319,29 @@ internal static class ReachabilityDriftEndpoints
|
||||
var payload = JsonSerializer.Serialize(value, SerializerOptions);
|
||||
return Results.Content(payload, "application/json", System.Text.Encoding.UTF8, statusCode);
|
||||
}
|
||||
|
||||
private static bool TryResolveTenant(HttpContext context, out string tenantId, out IResult? failure)
|
||||
{
|
||||
tenantId = string.Empty;
|
||||
failure = null;
|
||||
|
||||
if (ScannerRequestContextResolver.TryResolveTenant(
|
||||
context,
|
||||
out tenantId,
|
||||
out var tenantError,
|
||||
allowDefaultTenant: true))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
failure = ProblemResultFactory.Create(
|
||||
context,
|
||||
ProblemTypes.Validation,
|
||||
"Invalid tenant context",
|
||||
StatusCodes.Status400BadRequest,
|
||||
detail: tenantError ?? "tenant_conflict");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed record DriftedSinksResponseDto(
|
||||
|
||||
@@ -9,6 +9,7 @@ using Microsoft.AspNetCore.Routing;
|
||||
using StellaOps.Scanner.Reachability.Jobs;
|
||||
using StellaOps.Scanner.Reachability.Services;
|
||||
using StellaOps.Scanner.Reachability.Vex;
|
||||
using StellaOps.Scanner.WebService.Security;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Endpoints;
|
||||
|
||||
@@ -24,7 +25,8 @@ public static class ReachabilityEvidenceEndpoints
|
||||
this IEndpointRouteBuilder routes)
|
||||
{
|
||||
var group = routes.MapGroup("/api/reachability")
|
||||
.WithTags("Reachability Evidence");
|
||||
.WithTags("Reachability Evidence")
|
||||
.RequireAuthorization(ScannerPolicies.ScansRead);
|
||||
|
||||
// Analyze reachability for a CVE
|
||||
group.MapPost("/analyze", AnalyzeAsync)
|
||||
@@ -32,7 +34,8 @@ public static class ReachabilityEvidenceEndpoints
|
||||
.WithSummary("Analyze reachability of a CVE in an image")
|
||||
.Produces<ReachabilityAnalyzeResponse>(StatusCodes.Status200OK)
|
||||
.Produces<ProblemDetails>(StatusCodes.Status400BadRequest)
|
||||
.Produces<ProblemDetails>(StatusCodes.Status404NotFound);
|
||||
.Produces<ProblemDetails>(StatusCodes.Status404NotFound)
|
||||
.RequireAuthorization(ScannerPolicies.ScansWrite);
|
||||
|
||||
// Get job result
|
||||
group.MapGet("/result/{jobId}", GetResultAsync)
|
||||
@@ -53,7 +56,8 @@ public static class ReachabilityEvidenceEndpoints
|
||||
.WithName("GenerateVexFromReachability")
|
||||
.WithSummary("Generate VEX statement from reachability analysis")
|
||||
.Produces<VexStatementResponse>(StatusCodes.Status200OK)
|
||||
.Produces<ProblemDetails>(StatusCodes.Status400BadRequest);
|
||||
.Produces<ProblemDetails>(StatusCodes.Status400BadRequest)
|
||||
.RequireAuthorization(ScannerPolicies.ScansWrite);
|
||||
|
||||
return routes;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using StellaOps.Scanner.WebService.Contracts;
|
||||
using StellaOps.Scanner.WebService.Domain;
|
||||
using StellaOps.Scanner.WebService.Security;
|
||||
using StellaOps.Scanner.WebService.Services;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Endpoints;
|
||||
@@ -10,7 +11,8 @@ internal static class ReplayEndpoints
|
||||
{
|
||||
public static void MapReplayEndpoints(this RouteGroupBuilder apiGroup)
|
||||
{
|
||||
var replay = apiGroup.MapGroup("/replay");
|
||||
var replay = apiGroup.MapGroup("/replay")
|
||||
.RequireAuthorization(ScannerPolicies.ScansWrite);
|
||||
|
||||
replay.MapPost("/{scanId}/attach", HandleAttachAsync)
|
||||
.WithName("scanner.replay.attach")
|
||||
|
||||
@@ -12,6 +12,7 @@ using StellaOps.Scanner.WebService.Infrastructure;
|
||||
using StellaOps.Scanner.WebService.Options;
|
||||
using StellaOps.Scanner.WebService.Security;
|
||||
using StellaOps.Scanner.WebService.Services;
|
||||
using StellaOps.Scanner.WebService.Tenancy;
|
||||
using System.Collections.Generic;
|
||||
using System.IO.Pipelines;
|
||||
using System.Linq;
|
||||
@@ -151,11 +152,26 @@ internal static class ScanEndpoints
|
||||
metadata["determinism.policy"] = determinism.PolicySnapshotId;
|
||||
}
|
||||
|
||||
if (!ScannerRequestContextResolver.TryResolveTenant(
|
||||
context,
|
||||
out var tenantId,
|
||||
out var tenantError,
|
||||
allowDefaultTenant: true))
|
||||
{
|
||||
return ProblemResultFactory.Create(
|
||||
context,
|
||||
ProblemTypes.Validation,
|
||||
"Invalid tenant context",
|
||||
StatusCodes.Status400BadRequest,
|
||||
detail: tenantError ?? "tenant_conflict");
|
||||
}
|
||||
|
||||
var submission = new ScanSubmission(
|
||||
Target: target,
|
||||
Force: request.Force,
|
||||
ClientRequestId: request.ClientRequestId?.Trim(),
|
||||
Metadata: metadata);
|
||||
Metadata: metadata,
|
||||
TenantId: tenantId);
|
||||
|
||||
ScanSubmissionResult result;
|
||||
try
|
||||
|
||||
@@ -10,6 +10,7 @@ using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using StellaOps.Scanner.Core;
|
||||
using StellaOps.Scanner.WebService.Contracts;
|
||||
using StellaOps.Scanner.WebService.Security;
|
||||
using StellaOps.Scanner.WebService.Services;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Endpoints;
|
||||
@@ -18,7 +19,8 @@ internal static class ScoreReplayEndpoints
|
||||
{
|
||||
public static void MapScoreReplayEndpoints(this RouteGroupBuilder apiGroup)
|
||||
{
|
||||
var score = apiGroup.MapGroup("/score");
|
||||
var score = apiGroup.MapGroup("/score")
|
||||
.RequireAuthorization(ScannerPolicies.ScansRead);
|
||||
|
||||
score.MapPost("/{scanId}/replay", HandleReplayAsync)
|
||||
.WithName("scanner.score.replay")
|
||||
@@ -26,7 +28,8 @@ internal static class ScoreReplayEndpoints
|
||||
.Produces<ProblemDetails>(StatusCodes.Status404NotFound)
|
||||
.Produces<ProblemDetails>(StatusCodes.Status400BadRequest)
|
||||
.Produces<ProblemDetails>(StatusCodes.Status422UnprocessableEntity)
|
||||
.WithDescription("Replay scoring for a previous scan using frozen inputs");
|
||||
.WithDescription("Replay scoring for a previous scan using frozen inputs")
|
||||
.RequireAuthorization(ScannerPolicies.ScansWrite);
|
||||
|
||||
score.MapGet("/{scanId}/bundle", HandleGetBundleAsync)
|
||||
.WithName("scanner.score.bundle")
|
||||
@@ -39,7 +42,8 @@ internal static class ScoreReplayEndpoints
|
||||
.Produces<ScoreVerifyResponse>(StatusCodes.Status200OK)
|
||||
.Produces<ProblemDetails>(StatusCodes.Status404NotFound)
|
||||
.Produces<ProblemDetails>(StatusCodes.Status422UnprocessableEntity)
|
||||
.WithDescription("Verify a proof bundle against expected root hash");
|
||||
.WithDescription("Verify a proof bundle against expected root hash")
|
||||
.RequireAuthorization(ScannerPolicies.ScansWrite);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -237,9 +237,8 @@ internal static class SecretDetectionSettingsEndpoints
|
||||
ISecretExceptionPatternService service,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var pattern = await service.GetPatternAsync(exceptionId, cancellationToken);
|
||||
|
||||
if (pattern is null || pattern.TenantId != tenantId)
|
||||
var pattern = await service.GetPatternAsync(tenantId, exceptionId, cancellationToken);
|
||||
if (pattern is null)
|
||||
{
|
||||
return Results.NotFound(new
|
||||
{
|
||||
@@ -284,9 +283,8 @@ internal static class SecretDetectionSettingsEndpoints
|
||||
HttpContext context,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// Verify pattern belongs to tenant
|
||||
var existing = await service.GetPatternAsync(exceptionId, cancellationToken);
|
||||
if (existing is null || existing.TenantId != tenantId)
|
||||
var existing = await service.GetPatternAsync(tenantId, exceptionId, cancellationToken);
|
||||
if (existing is null)
|
||||
{
|
||||
return Results.NotFound(new
|
||||
{
|
||||
@@ -298,6 +296,7 @@ internal static class SecretDetectionSettingsEndpoints
|
||||
|
||||
var username = context.User.Identity?.Name ?? "system";
|
||||
var (success, pattern, errors) = await service.UpdatePatternAsync(
|
||||
tenantId,
|
||||
exceptionId,
|
||||
request,
|
||||
username,
|
||||
@@ -333,9 +332,8 @@ internal static class SecretDetectionSettingsEndpoints
|
||||
ISecretExceptionPatternService service,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// Verify pattern belongs to tenant
|
||||
var existing = await service.GetPatternAsync(exceptionId, cancellationToken);
|
||||
if (existing is null || existing.TenantId != tenantId)
|
||||
var existing = await service.GetPatternAsync(tenantId, exceptionId, cancellationToken);
|
||||
if (existing is null)
|
||||
{
|
||||
return Results.NotFound(new
|
||||
{
|
||||
@@ -345,7 +343,7 @@ internal static class SecretDetectionSettingsEndpoints
|
||||
});
|
||||
}
|
||||
|
||||
var deleted = await service.DeletePatternAsync(exceptionId, cancellationToken);
|
||||
var deleted = await service.DeletePatternAsync(tenantId, exceptionId, cancellationToken);
|
||||
if (!deleted)
|
||||
{
|
||||
return Results.NotFound(new
|
||||
|
||||
@@ -5,6 +5,7 @@ using StellaOps.Scanner.SmartDiff.Detection;
|
||||
using StellaOps.Scanner.SmartDiff.Output;
|
||||
using StellaOps.Scanner.WebService.Security;
|
||||
using StellaOps.Scanner.WebService.Services;
|
||||
using StellaOps.Scanner.WebService.Tenancy;
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Endpoints;
|
||||
@@ -87,10 +88,16 @@ internal static class SmartDiffEndpoints
|
||||
IVexCandidateStore candidateStore,
|
||||
IScanMetadataRepository? metadataRepo = null,
|
||||
bool? pretty = null,
|
||||
HttpContext? context = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (!TryResolveTenant(context, out var tenantId, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
// Gather all data for the scan
|
||||
var changes = await changeRepo.GetChangesForScanAsync(scanId, ct);
|
||||
var changes = await changeRepo.GetChangesForScanAsync(scanId, ct, tenantId: tenantId);
|
||||
|
||||
// Get scan metadata if available
|
||||
string? baseDigest = null;
|
||||
@@ -111,7 +118,7 @@ internal static class SmartDiffEndpoints
|
||||
IReadOnlyList<StellaOps.Scanner.SmartDiff.Output.VexCandidate> vexCandidates = [];
|
||||
if (!string.IsNullOrWhiteSpace(targetDigest))
|
||||
{
|
||||
var candidates = await candidateStore.GetCandidatesAsync(targetDigest, ct).ConfigureAwait(false);
|
||||
var candidates = await candidateStore.GetCandidatesAsync(targetDigest, ct, tenantId: tenantId).ConfigureAwait(false);
|
||||
vexCandidates = candidates.Select(ToSarifVexCandidate).ToList();
|
||||
}
|
||||
|
||||
@@ -164,8 +171,14 @@ internal static class SmartDiffEndpoints
|
||||
IVexCandidateStore store,
|
||||
double? minConfidence = null,
|
||||
bool? pendingOnly = null,
|
||||
HttpContext? context = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (!TryResolveTenant(context, out var tenantId, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
var metadata = await metadataRepository.GetScanMetadataAsync(scanId, ct).ConfigureAwait(false);
|
||||
var targetDigest = NormalizeDigest(metadata?.TargetDigest);
|
||||
if (string.IsNullOrWhiteSpace(targetDigest))
|
||||
@@ -173,7 +186,7 @@ internal static class SmartDiffEndpoints
|
||||
return Results.NotFound(new { error = "Scan metadata not found", scanId });
|
||||
}
|
||||
|
||||
return await HandleGetCandidatesAsync(targetDigest, store, minConfidence, pendingOnly, ct).ConfigureAwait(false);
|
||||
return await HandleGetCandidatesAsync(targetDigest, store, minConfidence, pendingOnly, context, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static StellaOps.Scanner.SmartDiff.Output.RiskDirection ToSarifRiskDirection(MaterialRiskChangeResult change)
|
||||
@@ -230,9 +243,15 @@ internal static class SmartDiffEndpoints
|
||||
string scanId,
|
||||
IMaterialRiskChangeRepository repository,
|
||||
double? minPriority = null,
|
||||
HttpContext? context = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var changes = await repository.GetChangesForScanAsync(scanId, ct);
|
||||
if (!TryResolveTenant(context, out var tenantId, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
var changes = await repository.GetChangesForScanAsync(scanId, ct, tenantId: tenantId);
|
||||
|
||||
if (minPriority.HasValue)
|
||||
{
|
||||
@@ -257,15 +276,21 @@ internal static class SmartDiffEndpoints
|
||||
IVexCandidateStore store,
|
||||
double? minConfidence = null,
|
||||
bool? pendingOnly = null,
|
||||
HttpContext? context = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (!TryResolveTenant(context, out var tenantId, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
var normalizedDigest = NormalizeDigest(digest);
|
||||
if (string.IsNullOrWhiteSpace(normalizedDigest))
|
||||
{
|
||||
return Results.BadRequest(new { error = "Invalid image digest" });
|
||||
}
|
||||
|
||||
var candidates = await store.GetCandidatesAsync(normalizedDigest, ct);
|
||||
var candidates = await store.GetCandidatesAsync(normalizedDigest, ct, tenantId: tenantId);
|
||||
|
||||
if (minConfidence.HasValue)
|
||||
{
|
||||
@@ -293,9 +318,15 @@ internal static class SmartDiffEndpoints
|
||||
private static async Task<IResult> HandleGetCandidateAsync(
|
||||
string candidateId,
|
||||
IVexCandidateStore store,
|
||||
HttpContext? context = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var candidate = await store.GetCandidateAsync(candidateId, ct);
|
||||
if (!TryResolveTenant(context, out var tenantId, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
var candidate = await store.GetCandidateAsync(candidateId, ct, tenantId: tenantId);
|
||||
|
||||
if (candidate is null)
|
||||
{
|
||||
@@ -325,6 +356,10 @@ internal static class SmartDiffEndpoints
|
||||
{
|
||||
return Results.BadRequest(new { error = "Invalid action", validActions = new[] { "accept", "reject", "defer" } });
|
||||
}
|
||||
if (!TryResolveTenant(httpContext, out var tenantId, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
var reviewer = httpContext.User.Identity?.Name ?? "anonymous";
|
||||
var review = new VexCandidateReview(
|
||||
@@ -333,7 +368,7 @@ internal static class SmartDiffEndpoints
|
||||
ReviewedAt: timeProvider.GetUtcNow(),
|
||||
Comment: request.Comment);
|
||||
|
||||
var success = await store.ReviewCandidateAsync(candidateId, review, ct);
|
||||
var success = await store.ReviewCandidateAsync(candidateId, review, ct, tenantId: tenantId);
|
||||
|
||||
if (!success)
|
||||
{
|
||||
@@ -365,6 +400,10 @@ internal static class SmartDiffEndpoints
|
||||
{
|
||||
return Results.BadRequest(new { error = "CandidateId is required" });
|
||||
}
|
||||
if (!TryResolveTenant(httpContext, out var tenantId, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
var metadata = await metadataRepository.GetScanMetadataAsync(scanId, ct).ConfigureAwait(false);
|
||||
var targetDigest = NormalizeDigest(metadata?.TargetDigest);
|
||||
@@ -373,7 +412,7 @@ internal static class SmartDiffEndpoints
|
||||
return Results.NotFound(new { error = "Scan metadata not found", scanId });
|
||||
}
|
||||
|
||||
var candidate = await store.GetCandidateAsync(request.CandidateId, ct).ConfigureAwait(false);
|
||||
var candidate = await store.GetCandidateAsync(request.CandidateId, ct, tenantId: tenantId).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 });
|
||||
@@ -482,6 +521,40 @@ internal static class SmartDiffEndpoints
|
||||
};
|
||||
}
|
||||
|
||||
private static bool TryResolveTenant(HttpContext? context, out string tenantId, out IResult? failure)
|
||||
{
|
||||
tenantId = string.Empty;
|
||||
failure = null;
|
||||
|
||||
if (context is null)
|
||||
{
|
||||
failure = Results.BadRequest(new
|
||||
{
|
||||
type = "validation-error",
|
||||
title = "Invalid tenant context",
|
||||
detail = "tenant_missing"
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
if (ScannerRequestContextResolver.TryResolveTenant(
|
||||
context,
|
||||
out tenantId,
|
||||
out var tenantError,
|
||||
allowDefaultTenant: true))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
failure = Results.BadRequest(new
|
||||
{
|
||||
type = "validation-error",
|
||||
title = "Invalid tenant context",
|
||||
detail = tenantError ?? "tenant_conflict"
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Scanner.Sources.Configuration;
|
||||
using StellaOps.Scanner.Sources.Contracts;
|
||||
using StellaOps.Scanner.Sources.Domain;
|
||||
@@ -10,7 +9,6 @@ using StellaOps.Scanner.WebService.Constants;
|
||||
using StellaOps.Scanner.WebService.Infrastructure;
|
||||
using StellaOps.Scanner.WebService.Security;
|
||||
using StellaOps.Scanner.WebService.Tenancy;
|
||||
using System.Security.Claims;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
@@ -660,53 +658,16 @@ internal static class SourcesEndpoints
|
||||
|
||||
private static bool TryResolveTenant(HttpContext context, out string tenantId)
|
||||
{
|
||||
tenantId = string.Empty;
|
||||
|
||||
var tenant = context.User?.FindFirstValue(StellaOpsClaimTypes.Tenant);
|
||||
if (!string.IsNullOrWhiteSpace(tenant))
|
||||
{
|
||||
tenantId = tenant.Trim();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (context.Request.Headers.TryGetValue("X-Stella-Tenant", out var headerTenant))
|
||||
{
|
||||
var headerValue = headerTenant.ToString();
|
||||
if (!string.IsNullOrWhiteSpace(headerValue))
|
||||
{
|
||||
tenantId = headerValue.Trim();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if (context.Request.Headers.TryGetValue("X-Tenant-Id", out var legacyTenant))
|
||||
{
|
||||
var headerValue = legacyTenant.ToString();
|
||||
if (!string.IsNullOrWhiteSpace(headerValue))
|
||||
{
|
||||
tenantId = headerValue.Trim();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
return ScannerRequestContextResolver.TryResolveTenant(
|
||||
context,
|
||||
out tenantId,
|
||||
out _,
|
||||
allowDefaultTenant: false);
|
||||
}
|
||||
|
||||
private static string ResolveActor(HttpContext context)
|
||||
{
|
||||
var subject = context.User?.FindFirstValue(StellaOpsClaimTypes.Subject);
|
||||
if (!string.IsNullOrWhiteSpace(subject))
|
||||
{
|
||||
return subject.Trim();
|
||||
}
|
||||
|
||||
var clientId = context.User?.FindFirstValue(StellaOpsClaimTypes.ClientId);
|
||||
if (!string.IsNullOrWhiteSpace(clientId))
|
||||
{
|
||||
return clientId.Trim();
|
||||
}
|
||||
|
||||
return "system";
|
||||
return ScannerRequestContextResolver.ResolveActor(context);
|
||||
}
|
||||
|
||||
private static IResult Json<T>(T value, int statusCode)
|
||||
|
||||
@@ -8,6 +8,7 @@ using StellaOps.Scanner.Triage.Models;
|
||||
using StellaOps.Scanner.Triage.Services;
|
||||
using StellaOps.Scanner.WebService.Contracts;
|
||||
using StellaOps.Scanner.WebService.Security;
|
||||
using StellaOps.Scanner.WebService.Tenancy;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Endpoints.Triage;
|
||||
|
||||
@@ -46,6 +47,7 @@ internal static class BatchTriageEndpoints
|
||||
[FromServices] IFindingQueryService findingService,
|
||||
[FromServices] IExploitPathGroupingService groupingService,
|
||||
[FromServices] TimeProvider timeProvider,
|
||||
HttpContext context,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(artifactDigest))
|
||||
@@ -58,7 +60,8 @@ internal static class BatchTriageEndpoints
|
||||
});
|
||||
}
|
||||
|
||||
var findings = await findingService.GetFindingsForArtifactAsync(artifactDigest, cancellationToken).ConfigureAwait(false);
|
||||
var tenantId = ScannerRequestContextResolver.ResolveTenantOrDefault(context);
|
||||
var findings = await findingService.GetFindingsForArtifactAsync(tenantId, artifactDigest, cancellationToken).ConfigureAwait(false);
|
||||
var clusters = similarityThreshold.HasValue
|
||||
? await groupingService.GroupFindingsAsync(artifactDigest, findings, similarityThreshold.Value, cancellationToken).ConfigureAwait(false)
|
||||
: await groupingService.GroupFindingsAsync(artifactDigest, findings, cancellationToken).ConfigureAwait(false);
|
||||
@@ -86,6 +89,7 @@ internal static class BatchTriageEndpoints
|
||||
[FromServices] IExploitPathGroupingService groupingService,
|
||||
[FromServices] ITriageStatusService triageStatusService,
|
||||
[FromServices] TimeProvider timeProvider,
|
||||
HttpContext context,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.ArtifactDigest))
|
||||
@@ -108,7 +112,8 @@ internal static class BatchTriageEndpoints
|
||||
});
|
||||
}
|
||||
|
||||
var findings = await findingService.GetFindingsForArtifactAsync(request.ArtifactDigest, cancellationToken).ConfigureAwait(false);
|
||||
var tenantId = ScannerRequestContextResolver.ResolveTenantOrDefault(context);
|
||||
var findings = await findingService.GetFindingsForArtifactAsync(tenantId, request.ArtifactDigest, cancellationToken).ConfigureAwait(false);
|
||||
var clusters = request.SimilarityThreshold.HasValue
|
||||
? await groupingService.GroupFindingsAsync(request.ArtifactDigest, findings, request.SimilarityThreshold.Value, cancellationToken).ConfigureAwait(false)
|
||||
: await groupingService.GroupFindingsAsync(request.ArtifactDigest, findings, cancellationToken).ConfigureAwait(false);
|
||||
@@ -138,7 +143,7 @@ internal static class BatchTriageEndpoints
|
||||
Actor = actor
|
||||
};
|
||||
|
||||
var result = await triageStatusService.UpdateStatusAsync(findingId, updateRequest, actor, cancellationToken).ConfigureAwait(false);
|
||||
var result = await triageStatusService.UpdateStatusAsync(tenantId, findingId, updateRequest, actor, cancellationToken).ConfigureAwait(false);
|
||||
if (result is not null)
|
||||
{
|
||||
updated.Add(findingId);
|
||||
|
||||
@@ -11,6 +11,7 @@ using Microsoft.AspNetCore.Routing;
|
||||
using StellaOps.Scanner.Triage.Models;
|
||||
using StellaOps.Scanner.Triage.Services;
|
||||
using StellaOps.Scanner.WebService.Security;
|
||||
using StellaOps.Scanner.WebService.Tenancy;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
@@ -55,6 +56,7 @@ internal static class TriageInboxEndpoints
|
||||
[FromServices] IExploitPathGroupingService groupingService,
|
||||
[FromServices] IFindingQueryService findingService,
|
||||
[FromServices] TimeProvider timeProvider,
|
||||
HttpContext context,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(groupingService);
|
||||
@@ -70,7 +72,9 @@ internal static class TriageInboxEndpoints
|
||||
});
|
||||
}
|
||||
|
||||
var findings = await findingService.GetFindingsForArtifactAsync(artifactDigest, cancellationToken);
|
||||
var tenantId = ScannerRequestContextResolver.ResolveTenantOrDefault(context);
|
||||
|
||||
var findings = await findingService.GetFindingsForArtifactAsync(tenantId, artifactDigest, cancellationToken);
|
||||
var paths = similarityThreshold.HasValue
|
||||
? await groupingService.GroupFindingsAsync(artifactDigest, findings, similarityThreshold.Value, cancellationToken)
|
||||
: await groupingService.GroupFindingsAsync(artifactDigest, findings, cancellationToken);
|
||||
@@ -150,5 +154,5 @@ public sealed record TriageInboxResponse
|
||||
|
||||
public interface IFindingQueryService
|
||||
{
|
||||
Task<IReadOnlyList<Finding>> GetFindingsForArtifactAsync(string artifactDigest, CancellationToken ct);
|
||||
Task<IReadOnlyList<Finding>> GetFindingsForArtifactAsync(string tenantId, string artifactDigest, CancellationToken ct);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ using Microsoft.AspNetCore.Routing;
|
||||
using StellaOps.Scanner.WebService.Contracts;
|
||||
using StellaOps.Scanner.WebService.Security;
|
||||
using StellaOps.Scanner.WebService.Services;
|
||||
using StellaOps.Scanner.WebService.Tenancy;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
@@ -98,7 +99,21 @@ internal static class TriageStatusEndpoints
|
||||
});
|
||||
}
|
||||
|
||||
var status = await triageService.GetFindingStatusAsync(findingId, cancellationToken);
|
||||
if (!ScannerRequestContextResolver.TryResolveTenant(
|
||||
context,
|
||||
out var tenantId,
|
||||
out var tenantError,
|
||||
allowDefaultTenant: true))
|
||||
{
|
||||
return Results.BadRequest(new
|
||||
{
|
||||
type = "validation-error",
|
||||
title = "Invalid tenant context",
|
||||
detail = tenantError ?? "tenant_conflict"
|
||||
});
|
||||
}
|
||||
|
||||
var status = await triageService.GetFindingStatusAsync(tenantId, findingId, cancellationToken);
|
||||
if (status is null)
|
||||
{
|
||||
return Results.NotFound(new
|
||||
@@ -135,7 +150,21 @@ internal static class TriageStatusEndpoints
|
||||
// Get actor from context or request
|
||||
var actor = request.Actor ?? context.User?.Identity?.Name ?? "anonymous";
|
||||
|
||||
var result = await triageService.UpdateStatusAsync(findingId, request, actor, cancellationToken);
|
||||
if (!ScannerRequestContextResolver.TryResolveTenant(
|
||||
context,
|
||||
out var tenantId,
|
||||
out var tenantError,
|
||||
allowDefaultTenant: true))
|
||||
{
|
||||
return Results.BadRequest(new
|
||||
{
|
||||
type = "validation-error",
|
||||
title = "Invalid tenant context",
|
||||
detail = tenantError ?? "tenant_conflict"
|
||||
});
|
||||
}
|
||||
|
||||
var result = await triageService.UpdateStatusAsync(tenantId, findingId, request, actor, cancellationToken);
|
||||
if (result is null)
|
||||
{
|
||||
return Results.NotFound(new
|
||||
@@ -204,7 +233,21 @@ internal static class TriageStatusEndpoints
|
||||
}
|
||||
|
||||
var actor = request.IssuedBy ?? context.User?.Identity?.Name ?? "anonymous";
|
||||
var result = await triageService.SubmitVexStatementAsync(findingId, request, actor, cancellationToken);
|
||||
if (!ScannerRequestContextResolver.TryResolveTenant(
|
||||
context,
|
||||
out var tenantId,
|
||||
out var tenantError,
|
||||
allowDefaultTenant: true))
|
||||
{
|
||||
return Results.BadRequest(new
|
||||
{
|
||||
type = "validation-error",
|
||||
title = "Invalid tenant context",
|
||||
detail = tenantError ?? "tenant_conflict"
|
||||
});
|
||||
}
|
||||
|
||||
var result = await triageService.SubmitVexStatementAsync(tenantId, findingId, request, actor, cancellationToken);
|
||||
|
||||
if (result is null)
|
||||
{
|
||||
@@ -231,7 +274,21 @@ internal static class TriageStatusEndpoints
|
||||
// Apply reasonable defaults
|
||||
var limit = Math.Min(request.Limit ?? 100, 1000);
|
||||
|
||||
var result = await triageService.QueryFindingsAsync(request, limit, cancellationToken);
|
||||
if (!ScannerRequestContextResolver.TryResolveTenant(
|
||||
context,
|
||||
out var tenantId,
|
||||
out var tenantError,
|
||||
allowDefaultTenant: true))
|
||||
{
|
||||
return Results.BadRequest(new
|
||||
{
|
||||
type = "validation-error",
|
||||
title = "Invalid tenant context",
|
||||
detail = tenantError ?? "tenant_conflict"
|
||||
});
|
||||
}
|
||||
|
||||
var result = await triageService.QueryFindingsAsync(tenantId, request, limit, cancellationToken);
|
||||
return Results.Ok(result);
|
||||
}
|
||||
|
||||
@@ -253,7 +310,21 @@ internal static class TriageStatusEndpoints
|
||||
});
|
||||
}
|
||||
|
||||
var summary = await triageService.GetSummaryAsync(artifactDigest, cancellationToken);
|
||||
if (!ScannerRequestContextResolver.TryResolveTenant(
|
||||
context,
|
||||
out var tenantId,
|
||||
out var tenantError,
|
||||
allowDefaultTenant: true))
|
||||
{
|
||||
return Results.BadRequest(new
|
||||
{
|
||||
type = "validation-error",
|
||||
title = "Invalid tenant context",
|
||||
detail = tenantError ?? "tenant_conflict"
|
||||
});
|
||||
}
|
||||
|
||||
var summary = await triageService.GetSummaryAsync(tenantId, artifactDigest, cancellationToken);
|
||||
return Results.Ok(summary);
|
||||
}
|
||||
}
|
||||
@@ -267,12 +338,13 @@ public interface ITriageStatusService
|
||||
/// <summary>
|
||||
/// Gets triage status for a finding.
|
||||
/// </summary>
|
||||
Task<FindingTriageStatusDto?> GetFindingStatusAsync(string findingId, CancellationToken ct = default);
|
||||
Task<FindingTriageStatusDto?> GetFindingStatusAsync(string tenantId, string findingId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Updates triage status for a finding.
|
||||
/// </summary>
|
||||
Task<UpdateTriageStatusResponseDto?> UpdateStatusAsync(
|
||||
string tenantId,
|
||||
string findingId,
|
||||
UpdateTriageStatusRequestDto request,
|
||||
string actor,
|
||||
@@ -282,6 +354,7 @@ public interface ITriageStatusService
|
||||
/// Submits a VEX statement for a finding.
|
||||
/// </summary>
|
||||
Task<SubmitVexStatementResponseDto?> SubmitVexStatementAsync(
|
||||
string tenantId,
|
||||
string findingId,
|
||||
SubmitVexStatementRequestDto request,
|
||||
string actor,
|
||||
@@ -291,6 +364,7 @@ public interface ITriageStatusService
|
||||
/// Queries findings with filtering.
|
||||
/// </summary>
|
||||
Task<BulkTriageQueryResponseDto> QueryFindingsAsync(
|
||||
string tenantId,
|
||||
BulkTriageQueryRequestDto request,
|
||||
int limit,
|
||||
CancellationToken ct = default);
|
||||
@@ -298,5 +372,5 @@ public interface ITriageStatusService
|
||||
/// <summary>
|
||||
/// Gets triage summary for an artifact.
|
||||
/// </summary>
|
||||
Task<TriageSummaryDto> GetSummaryAsync(string artifactDigest, CancellationToken ct = default);
|
||||
Task<TriageSummaryDto> GetSummaryAsync(string tenantId, string artifactDigest, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
@@ -1,323 +1,460 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// UnknownsEndpoints.cs
|
||||
// Sprint: SPRINT_3600_0002_0001_unknowns_ranking_containment
|
||||
// Task: UNK-RANK-007, UNK-RANK-008 - Implement GET /unknowns API with sorting/pagination
|
||||
// Description: REST API for querying and filtering unknowns
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using StellaOps.Unknowns.Core.Models;
|
||||
using StellaOps.Unknowns.Core.Repositories;
|
||||
using StellaOps.Unknowns.Core.Services;
|
||||
using StellaOps.Scanner.WebService.Security;
|
||||
using StellaOps.Scanner.WebService.Services;
|
||||
using StellaOps.Scanner.WebService.Tenancy;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Endpoints;
|
||||
|
||||
internal static class UnknownsEndpoints
|
||||
{
|
||||
private const double HotBandThreshold = 0.70;
|
||||
private const double WarmBandThreshold = 0.40;
|
||||
private const string ExternalUnknownIdPrefix = "unk-";
|
||||
|
||||
public static void MapUnknownsEndpoints(this RouteGroupBuilder apiGroup)
|
||||
{
|
||||
var unknowns = apiGroup.MapGroup("/unknowns");
|
||||
ArgumentNullException.ThrowIfNull(apiGroup);
|
||||
|
||||
var unknowns = apiGroup.MapGroup("/unknowns")
|
||||
.WithTags("Unknowns")
|
||||
.RequireAuthorization(ScannerPolicies.ScansRead);
|
||||
|
||||
unknowns.MapGet("/", HandleListAsync)
|
||||
.WithName("scanner.unknowns.list")
|
||||
.Produces<UnknownsListResponse>(StatusCodes.Status200OK)
|
||||
.Produces<ProblemDetails>(StatusCodes.Status400BadRequest)
|
||||
.WithDescription("List unknowns with optional sorting and filtering");
|
||||
.Produces(StatusCodes.Status400BadRequest)
|
||||
.WithDescription("Lists unknown entries with tenant-scoped filtering.");
|
||||
|
||||
unknowns.MapGet("/stats", HandleGetStatsAsync)
|
||||
.WithName("scanner.unknowns.stats")
|
||||
.Produces<UnknownsStatsResponse>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status400BadRequest)
|
||||
.WithDescription("Returns tenant-scoped unknown summary statistics.");
|
||||
|
||||
unknowns.MapGet("/bands", HandleGetBandsAsync)
|
||||
.WithName("scanner.unknowns.bands")
|
||||
.Produces<UnknownsBandsResponse>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status400BadRequest)
|
||||
.WithDescription("Returns tenant-scoped unknown distribution by triage band.");
|
||||
|
||||
unknowns.MapGet("/{id}/evidence", HandleGetEvidenceAsync)
|
||||
.WithName("scanner.unknowns.evidence")
|
||||
.Produces<UnknownEvidenceResponse>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status404NotFound)
|
||||
.Produces(StatusCodes.Status400BadRequest)
|
||||
.WithDescription("Returns tenant-scoped unknown evidence metadata.");
|
||||
|
||||
unknowns.MapGet("/{id}/history", HandleGetHistoryAsync)
|
||||
.WithName("scanner.unknowns.history")
|
||||
.Produces<UnknownHistoryResponse>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status404NotFound)
|
||||
.Produces(StatusCodes.Status400BadRequest)
|
||||
.WithDescription("Returns tenant-scoped unknown history.");
|
||||
|
||||
unknowns.MapGet("/{id}", HandleGetByIdAsync)
|
||||
.WithName("scanner.unknowns.get")
|
||||
.Produces<UnknownDetailResponse>(StatusCodes.Status200OK)
|
||||
.Produces<ProblemDetails>(StatusCodes.Status404NotFound)
|
||||
.WithDescription("Get details of a specific unknown");
|
||||
|
||||
unknowns.MapGet("/{id}/proof", HandleGetProofAsync)
|
||||
.WithName("scanner.unknowns.proof")
|
||||
.Produces<UnknownProofResponse>(StatusCodes.Status200OK)
|
||||
.Produces<ProblemDetails>(StatusCodes.Status404NotFound)
|
||||
.WithDescription("Get the proof trail for an unknown ranking");
|
||||
.Produces(StatusCodes.Status404NotFound)
|
||||
.Produces(StatusCodes.Status400BadRequest)
|
||||
.WithDescription("Returns tenant-scoped unknown detail.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// GET /unknowns?sort=score&order=desc&artifact=sha256:...&reason=missing_vex&page=1&limit=50
|
||||
/// </summary>
|
||||
private static async Task<IResult> HandleListAsync(
|
||||
[FromQuery] string? sort,
|
||||
[FromQuery] string? order,
|
||||
[FromQuery] string? artifact,
|
||||
[FromQuery] string? reason,
|
||||
[FromQuery] string? kind,
|
||||
[FromQuery] string? severity,
|
||||
[FromQuery] double? minScore,
|
||||
[FromQuery] double? maxScore,
|
||||
[FromQuery] int? page,
|
||||
[FromQuery] string? artifactDigest,
|
||||
[FromQuery] string? vulnId,
|
||||
[FromQuery] string? band,
|
||||
[FromQuery] string? sortBy,
|
||||
[FromQuery] string? sortOrder,
|
||||
[FromQuery] int? limit,
|
||||
IUnknownRepository repository,
|
||||
IUnknownRanker ranker,
|
||||
TimeProvider timeProvider,
|
||||
[FromQuery] int? offset,
|
||||
IUnknownsQueryService queryService,
|
||||
HttpContext context,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// Validate and default pagination
|
||||
var pageNum = Math.Max(1, page ?? 1);
|
||||
var pageSize = Math.Clamp(limit ?? 50, 1, 200);
|
||||
|
||||
// Parse sort field
|
||||
var sortField = (sort?.ToLowerInvariant()) switch
|
||||
if (!TryResolveTenant(context, out var tenantId, out var failure))
|
||||
{
|
||||
"score" => UnknownSortField.Score,
|
||||
"created" => UnknownSortField.Created,
|
||||
"updated" => UnknownSortField.Updated,
|
||||
"severity" => UnknownSortField.Severity,
|
||||
"popularity" => UnknownSortField.Popularity,
|
||||
_ => UnknownSortField.Score // Default to score
|
||||
return failure!;
|
||||
}
|
||||
|
||||
if (!TryMapBand(band, out var mappedBand))
|
||||
{
|
||||
return Results.BadRequest(new
|
||||
{
|
||||
type = "validation-error",
|
||||
title = "Invalid band",
|
||||
detail = "Band must be one of HOT, WARM, or COLD."
|
||||
});
|
||||
}
|
||||
|
||||
var query = new UnknownsListQuery
|
||||
{
|
||||
ArtifactDigest = string.IsNullOrWhiteSpace(artifactDigest) ? null : artifactDigest.Trim(),
|
||||
VulnerabilityId = string.IsNullOrWhiteSpace(vulnId) ? null : vulnId.Trim(),
|
||||
Band = mappedBand,
|
||||
SortBy = MapSortField(sortBy),
|
||||
SortOrder = MapSortOrder(sortOrder),
|
||||
Limit = Math.Clamp(limit ?? 50, 1, 500),
|
||||
Offset = Math.Max(offset ?? 0, 0)
|
||||
};
|
||||
|
||||
var sortOrder = (order?.ToLowerInvariant()) switch
|
||||
var result = await queryService.ListAsync(tenantId, query, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.Ok(new UnknownsListResponse
|
||||
{
|
||||
"asc" => SortOrder.Ascending,
|
||||
_ => SortOrder.Descending // Default to descending (highest first)
|
||||
};
|
||||
|
||||
// Parse filters
|
||||
UnknownKind? kindFilter = kind != null && Enum.TryParse<UnknownKind>(kind, true, out var k) ? k : null;
|
||||
UnknownSeverity? severityFilter = severity != null && Enum.TryParse<UnknownSeverity>(severity, true, out var s) ? s : null;
|
||||
|
||||
var query = new UnknownListQuery(
|
||||
ArtifactDigest: artifact,
|
||||
Reason: reason,
|
||||
Kind: kindFilter,
|
||||
Severity: severityFilter,
|
||||
MinScore: minScore,
|
||||
MaxScore: maxScore,
|
||||
SortField: sortField,
|
||||
SortOrder: sortOrder,
|
||||
Page: pageNum,
|
||||
PageSize: pageSize);
|
||||
|
||||
var result = await repository.ListUnknownsAsync(query, cancellationToken);
|
||||
var now = timeProvider.GetUtcNow();
|
||||
|
||||
return Results.Ok(new UnknownsListResponse(
|
||||
Items: result.Items.Select(item => UnknownItemResponse.FromUnknownItem(item, now)).ToList(),
|
||||
TotalCount: result.TotalCount,
|
||||
Page: pageNum,
|
||||
PageSize: pageSize,
|
||||
TotalPages: (int)Math.Ceiling((double)result.TotalCount / pageSize),
|
||||
HasNextPage: pageNum * pageSize < result.TotalCount,
|
||||
HasPreviousPage: pageNum > 1));
|
||||
Items = result.Items
|
||||
.Select(MapItem)
|
||||
.ToArray(),
|
||||
TotalCount = result.TotalCount,
|
||||
Limit = query.Limit,
|
||||
Offset = query.Offset
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// GET /unknowns/{id}
|
||||
/// </summary>
|
||||
private static async Task<IResult> HandleGetByIdAsync(
|
||||
Guid id,
|
||||
IUnknownRepository repository,
|
||||
string id,
|
||||
IUnknownsQueryService queryService,
|
||||
HttpContext context,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var unknown = await repository.GetByIdAsync(id, cancellationToken);
|
||||
|
||||
if (unknown is null)
|
||||
if (!TryResolveTenant(context, out var tenantId, out var failure))
|
||||
{
|
||||
return Results.NotFound(new ProblemDetails
|
||||
{
|
||||
Title = "Unknown not found",
|
||||
Detail = $"No unknown found with ID: {id}",
|
||||
Status = StatusCodes.Status404NotFound
|
||||
});
|
||||
return failure!;
|
||||
}
|
||||
|
||||
return Results.Ok(UnknownDetailResponse.FromUnknown(unknown));
|
||||
if (!TryParseUnknownId(id, out var unknownId))
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
var detail = await queryService.GetByIdAsync(tenantId, unknownId, cancellationToken).ConfigureAwait(false);
|
||||
if (detail is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
return Results.Ok(MapDetail(detail));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// GET /unknowns/{id}/proof
|
||||
/// </summary>
|
||||
private static async Task<IResult> HandleGetProofAsync(
|
||||
Guid id,
|
||||
IUnknownRepository repository,
|
||||
private static async Task<IResult> HandleGetEvidenceAsync(
|
||||
string id,
|
||||
IUnknownsQueryService queryService,
|
||||
HttpContext context,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var unknown = await repository.GetByIdAsync(id, cancellationToken);
|
||||
|
||||
if (unknown is null)
|
||||
if (!TryResolveTenant(context, out var tenantId, out var failure))
|
||||
{
|
||||
return Results.NotFound(new ProblemDetails
|
||||
{
|
||||
Title = "Unknown not found",
|
||||
Detail = $"No unknown found with ID: {id}",
|
||||
Status = StatusCodes.Status404NotFound
|
||||
});
|
||||
return failure!;
|
||||
}
|
||||
|
||||
var proofRef = unknown.ProofRef;
|
||||
if (string.IsNullOrEmpty(proofRef))
|
||||
if (!TryParseUnknownId(id, out var unknownId))
|
||||
{
|
||||
return Results.NotFound(new ProblemDetails
|
||||
{
|
||||
Title = "Proof not available",
|
||||
Detail = $"No proof trail available for unknown: {id}",
|
||||
Status = StatusCodes.Status404NotFound
|
||||
});
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
// In a real implementation, read proof from storage
|
||||
return Results.Ok(new UnknownProofResponse(
|
||||
UnknownId: id,
|
||||
ProofRef: proofRef,
|
||||
CreatedAt: unknown.SysFrom));
|
||||
var detail = await queryService.GetByIdAsync(tenantId, unknownId, cancellationToken).ConfigureAwait(false);
|
||||
if (detail is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
return Results.Ok(new UnknownEvidenceResponse
|
||||
{
|
||||
Id = ToExternalUnknownId(detail.UnknownId),
|
||||
ProofRef = detail.ProofRef,
|
||||
LastUpdatedAtUtc = detail.UpdatedAtUtc
|
||||
});
|
||||
}
|
||||
|
||||
private static async Task<IResult> HandleGetHistoryAsync(
|
||||
string id,
|
||||
IUnknownsQueryService queryService,
|
||||
HttpContext context,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!TryResolveTenant(context, out var tenantId, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
if (!TryParseUnknownId(id, out var unknownId))
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
var detail = await queryService.GetByIdAsync(tenantId, unknownId, cancellationToken).ConfigureAwait(false);
|
||||
if (detail is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
return Results.Ok(new UnknownHistoryResponse
|
||||
{
|
||||
Id = ToExternalUnknownId(detail.UnknownId),
|
||||
History = new[]
|
||||
{
|
||||
new UnknownHistoryEntryResponse
|
||||
{
|
||||
CapturedAtUtc = detail.UpdatedAtUtc,
|
||||
Score = detail.Score,
|
||||
Band = DetermineBand(detail.Score)
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static async Task<IResult> HandleGetStatsAsync(
|
||||
IUnknownsQueryService queryService,
|
||||
HttpContext context,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!TryResolveTenant(context, out var tenantId, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
var stats = await queryService.GetStatsAsync(tenantId, cancellationToken).ConfigureAwait(false);
|
||||
return Results.Ok(new UnknownsStatsResponse
|
||||
{
|
||||
Total = stats.Total,
|
||||
Hot = stats.Hot,
|
||||
Warm = stats.Warm,
|
||||
Cold = stats.Cold
|
||||
});
|
||||
}
|
||||
|
||||
private static async Task<IResult> HandleGetBandsAsync(
|
||||
IUnknownsQueryService queryService,
|
||||
HttpContext context,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!TryResolveTenant(context, out var tenantId, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
var distribution = await queryService.GetBandDistributionAsync(tenantId, cancellationToken).ConfigureAwait(false);
|
||||
return Results.Ok(new UnknownsBandsResponse
|
||||
{
|
||||
Bands = distribution
|
||||
});
|
||||
}
|
||||
|
||||
private static bool TryResolveTenant(HttpContext context, out string tenantId, out IResult? failure)
|
||||
{
|
||||
tenantId = string.Empty;
|
||||
failure = null;
|
||||
|
||||
if (ScannerRequestContextResolver.TryResolveTenant(
|
||||
context,
|
||||
out tenantId,
|
||||
out var tenantError,
|
||||
allowDefaultTenant: true))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
failure = Results.BadRequest(new
|
||||
{
|
||||
type = "validation-error",
|
||||
title = "Invalid tenant context",
|
||||
detail = tenantError ?? "tenant_conflict"
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
private static UnknownsListItemResponse MapItem(UnknownsListItem item)
|
||||
{
|
||||
return new UnknownsListItemResponse
|
||||
{
|
||||
Id = ToExternalUnknownId(item.UnknownId),
|
||||
ArtifactDigest = item.ArtifactDigest,
|
||||
VulnerabilityId = item.VulnerabilityId,
|
||||
PackagePurl = item.PackagePurl,
|
||||
Score = item.Score,
|
||||
Band = DetermineBand(item.Score),
|
||||
CreatedAtUtc = item.CreatedAtUtc,
|
||||
UpdatedAtUtc = item.UpdatedAtUtc
|
||||
};
|
||||
}
|
||||
|
||||
private static UnknownDetailResponse MapDetail(UnknownsDetail detail)
|
||||
{
|
||||
return new UnknownDetailResponse
|
||||
{
|
||||
Id = ToExternalUnknownId(detail.UnknownId),
|
||||
ArtifactDigest = detail.ArtifactDigest,
|
||||
VulnerabilityId = detail.VulnerabilityId,
|
||||
PackagePurl = detail.PackagePurl,
|
||||
Score = detail.Score,
|
||||
Band = DetermineBand(detail.Score),
|
||||
ProofRef = detail.ProofRef,
|
||||
CreatedAtUtc = detail.CreatedAtUtc,
|
||||
UpdatedAtUtc = detail.UpdatedAtUtc
|
||||
};
|
||||
}
|
||||
|
||||
private static UnknownsSortField MapSortField(string? rawValue)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(rawValue))
|
||||
{
|
||||
return UnknownsSortField.Score;
|
||||
}
|
||||
|
||||
return rawValue.Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"score" => UnknownsSortField.Score,
|
||||
"created" => UnknownsSortField.CreatedAt,
|
||||
"createdat" => UnknownsSortField.CreatedAt,
|
||||
"updated" => UnknownsSortField.UpdatedAt,
|
||||
"updatedat" => UnknownsSortField.UpdatedAt,
|
||||
"lastseen" => UnknownsSortField.UpdatedAt,
|
||||
_ => UnknownsSortField.Score
|
||||
};
|
||||
}
|
||||
|
||||
private static UnknownsSortOrder MapSortOrder(string? rawValue)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(rawValue))
|
||||
{
|
||||
return UnknownsSortOrder.Descending;
|
||||
}
|
||||
|
||||
return rawValue.Trim().Equals("asc", StringComparison.OrdinalIgnoreCase)
|
||||
? UnknownsSortOrder.Ascending
|
||||
: UnknownsSortOrder.Descending;
|
||||
}
|
||||
|
||||
private static bool TryMapBand(string? rawValue, out UnknownsBand? band)
|
||||
{
|
||||
band = null;
|
||||
if (string.IsNullOrWhiteSpace(rawValue))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
switch (rawValue.Trim().ToUpperInvariant())
|
||||
{
|
||||
case "HOT":
|
||||
band = UnknownsBand.Hot;
|
||||
return true;
|
||||
case "WARM":
|
||||
band = UnknownsBand.Warm;
|
||||
return true;
|
||||
case "COLD":
|
||||
band = UnknownsBand.Cold;
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static string DetermineBand(double score)
|
||||
{
|
||||
if (score >= HotBandThreshold)
|
||||
{
|
||||
return "HOT";
|
||||
}
|
||||
|
||||
if (score >= WarmBandThreshold)
|
||||
{
|
||||
return "WARM";
|
||||
}
|
||||
|
||||
return "COLD";
|
||||
}
|
||||
|
||||
private static string ToExternalUnknownId(Guid unknownId)
|
||||
=> $"{ExternalUnknownIdPrefix}{unknownId:N}";
|
||||
|
||||
private static bool TryParseUnknownId(string rawValue, out Guid unknownId)
|
||||
{
|
||||
unknownId = Guid.Empty;
|
||||
if (string.IsNullOrWhiteSpace(rawValue))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var trimmed = rawValue.Trim();
|
||||
if (Guid.TryParse(trimmed, out unknownId))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!trimmed.StartsWith(ExternalUnknownIdPrefix, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var guidPart = trimmed[ExternalUnknownIdPrefix.Length..];
|
||||
return Guid.TryParseExact(guidPart, "N", out unknownId)
|
||||
|| Guid.TryParse(guidPart, out unknownId);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response model for unknowns list.
|
||||
/// </summary>
|
||||
public sealed record UnknownsListResponse(
|
||||
IReadOnlyList<UnknownItemResponse> Items,
|
||||
int TotalCount,
|
||||
int Page,
|
||||
int PageSize,
|
||||
int TotalPages,
|
||||
bool HasNextPage,
|
||||
bool HasPreviousPage);
|
||||
|
||||
/// <summary>
|
||||
/// Compact unknown item for list response.
|
||||
/// </summary>
|
||||
public sealed record UnknownItemResponse(
|
||||
Guid Id,
|
||||
string SubjectRef,
|
||||
string Kind,
|
||||
string? Severity,
|
||||
double Score,
|
||||
string TriageBand,
|
||||
string Priority,
|
||||
BlastRadiusResponse? BlastRadius,
|
||||
ContainmentResponse? Containment,
|
||||
DateTimeOffset CreatedAt)
|
||||
public sealed record UnknownsListResponse
|
||||
{
|
||||
public static UnknownItemResponse FromUnknownItem(UnknownItem item, DateTimeOffset now) => new(
|
||||
Id: Guid.TryParse(item.Id, out var id) ? id : Guid.Empty,
|
||||
SubjectRef: item.ArtifactPurl ?? item.ArtifactDigest,
|
||||
Kind: string.Join(",", item.Reasons),
|
||||
Severity: null, // Would come from full Unknown
|
||||
Score: item.Score,
|
||||
TriageBand: item.Score.ToTriageBand().ToString(),
|
||||
Priority: item.Score.ToPriorityLabel(),
|
||||
BlastRadius: item.BlastRadius != null
|
||||
? new BlastRadiusResponse(item.BlastRadius.Dependents, item.BlastRadius.NetFacing, item.BlastRadius.Privilege)
|
||||
: null,
|
||||
Containment: item.Containment != null
|
||||
? new ContainmentResponse(item.Containment.Seccomp, item.Containment.Fs)
|
||||
: null,
|
||||
CreatedAt: now); // Would come from Unknown.SysFrom
|
||||
public required IReadOnlyList<UnknownsListItemResponse> Items { get; init; }
|
||||
public required int TotalCount { get; init; }
|
||||
public required int Limit { get; init; }
|
||||
public required int Offset { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Blast radius in API response.
|
||||
/// </summary>
|
||||
public sealed record BlastRadiusResponse(int Dependents, bool NetFacing, string Privilege);
|
||||
|
||||
/// <summary>
|
||||
/// Containment signals in API response.
|
||||
/// </summary>
|
||||
public sealed record ContainmentResponse(string Seccomp, string Fs);
|
||||
|
||||
/// <summary>
|
||||
/// Detailed unknown response.
|
||||
/// </summary>
|
||||
public sealed record UnknownDetailResponse(
|
||||
Guid Id,
|
||||
string TenantId,
|
||||
string SubjectHash,
|
||||
string SubjectType,
|
||||
string SubjectRef,
|
||||
string Kind,
|
||||
string? Severity,
|
||||
double Score,
|
||||
string TriageBand,
|
||||
double PopularityScore,
|
||||
int DeploymentCount,
|
||||
double UncertaintyScore,
|
||||
BlastRadiusResponse? BlastRadius,
|
||||
ContainmentResponse? Containment,
|
||||
string? ProofRef,
|
||||
DateTimeOffset ValidFrom,
|
||||
DateTimeOffset? ValidTo,
|
||||
DateTimeOffset SysFrom,
|
||||
DateTimeOffset? ResolvedAt,
|
||||
string? ResolutionType,
|
||||
string? ResolutionRef)
|
||||
public sealed record UnknownsListItemResponse
|
||||
{
|
||||
public static UnknownDetailResponse FromUnknown(Unknown u) => new(
|
||||
Id: u.Id,
|
||||
TenantId: u.TenantId,
|
||||
SubjectHash: u.SubjectHash,
|
||||
SubjectType: u.SubjectType.ToString(),
|
||||
SubjectRef: u.SubjectRef,
|
||||
Kind: u.Kind.ToString(),
|
||||
Severity: u.Severity?.ToString(),
|
||||
Score: u.TriageScore,
|
||||
TriageBand: u.TriageScore.ToTriageBand().ToString(),
|
||||
PopularityScore: u.PopularityScore,
|
||||
DeploymentCount: u.DeploymentCount,
|
||||
UncertaintyScore: u.UncertaintyScore,
|
||||
BlastRadius: u.BlastDependents.HasValue
|
||||
? new BlastRadiusResponse(u.BlastDependents.Value, u.BlastNetFacing ?? false, u.BlastPrivilege ?? "user")
|
||||
: null,
|
||||
Containment: !string.IsNullOrEmpty(u.ContainmentSeccomp) || !string.IsNullOrEmpty(u.ContainmentFs)
|
||||
? new ContainmentResponse(u.ContainmentSeccomp ?? "unknown", u.ContainmentFs ?? "unknown")
|
||||
: null,
|
||||
ProofRef: u.ProofRef,
|
||||
ValidFrom: u.ValidFrom,
|
||||
ValidTo: u.ValidTo,
|
||||
SysFrom: u.SysFrom,
|
||||
ResolvedAt: u.ResolvedAt,
|
||||
ResolutionType: u.ResolutionType?.ToString(),
|
||||
ResolutionRef: u.ResolutionRef);
|
||||
public required string Id { get; init; }
|
||||
public required string ArtifactDigest { get; init; }
|
||||
public required string VulnerabilityId { get; init; }
|
||||
public required string PackagePurl { get; init; }
|
||||
public required double Score { get; init; }
|
||||
public required string Band { get; init; }
|
||||
public required DateTimeOffset CreatedAtUtc { get; init; }
|
||||
public required DateTimeOffset UpdatedAtUtc { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Proof trail response.
|
||||
/// </summary>
|
||||
public sealed record UnknownProofResponse(
|
||||
Guid UnknownId,
|
||||
string ProofRef,
|
||||
DateTimeOffset CreatedAt);
|
||||
|
||||
/// <summary>
|
||||
/// Sort fields for unknowns query.
|
||||
/// </summary>
|
||||
public enum UnknownSortField
|
||||
public sealed record UnknownDetailResponse
|
||||
{
|
||||
Score,
|
||||
Created,
|
||||
Updated,
|
||||
Severity,
|
||||
Popularity
|
||||
public required string Id { get; init; }
|
||||
public required string ArtifactDigest { get; init; }
|
||||
public required string VulnerabilityId { get; init; }
|
||||
public required string PackagePurl { get; init; }
|
||||
public required double Score { get; init; }
|
||||
public required string Band { get; init; }
|
||||
public string? ProofRef { get; init; }
|
||||
public required DateTimeOffset CreatedAtUtc { get; init; }
|
||||
public required DateTimeOffset UpdatedAtUtc { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sort order.
|
||||
/// </summary>
|
||||
public enum SortOrder
|
||||
public sealed record UnknownEvidenceResponse
|
||||
{
|
||||
Ascending,
|
||||
Descending
|
||||
public required string Id { get; init; }
|
||||
public string? ProofRef { get; init; }
|
||||
public required DateTimeOffset LastUpdatedAtUtc { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Query parameters for listing unknowns.
|
||||
/// </summary>
|
||||
public sealed record UnknownListQuery(
|
||||
string? ArtifactDigest,
|
||||
string? Reason,
|
||||
UnknownKind? Kind,
|
||||
UnknownSeverity? Severity,
|
||||
double? MinScore,
|
||||
double? MaxScore,
|
||||
UnknownSortField SortField,
|
||||
SortOrder SortOrder,
|
||||
int Page,
|
||||
int PageSize);
|
||||
public sealed record UnknownHistoryResponse
|
||||
{
|
||||
public required string Id { get; init; }
|
||||
public required IReadOnlyList<UnknownHistoryEntryResponse> History { get; init; }
|
||||
}
|
||||
|
||||
public sealed record UnknownHistoryEntryResponse
|
||||
{
|
||||
public required DateTimeOffset CapturedAtUtc { get; init; }
|
||||
public required double Score { get; init; }
|
||||
public required string Band { get; init; }
|
||||
}
|
||||
|
||||
public sealed record UnknownsStatsResponse
|
||||
{
|
||||
public required long Total { get; init; }
|
||||
public required long Hot { get; init; }
|
||||
public required long Warm { get; init; }
|
||||
public required long Cold { get; init; }
|
||||
}
|
||||
|
||||
public sealed record UnknownsBandsResponse
|
||||
{
|
||||
public required IReadOnlyDictionary<string, long> Bands { get; init; }
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ internal static class ValidationEndpoints
|
||||
|
||||
var group = app.MapGroup("/api/v1/sbom")
|
||||
.WithTags("Validation")
|
||||
.RequireAuthorization();
|
||||
.RequireAuthorization(ScannerPolicies.ScansRead);
|
||||
|
||||
// POST /api/v1/sbom/validate
|
||||
group.MapPost("/validate", ValidateSbomAsync)
|
||||
|
||||
@@ -11,7 +11,7 @@ using StellaOps.Scanner.Sources.Triggers;
|
||||
using StellaOps.Scanner.WebService.Constants;
|
||||
using StellaOps.Scanner.WebService.Infrastructure;
|
||||
using StellaOps.Scanner.WebService.Services;
|
||||
using System.Security.Cryptography;
|
||||
using StellaOps.Scanner.WebService.Tenancy;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
@@ -109,6 +109,22 @@ internal static class WebhookEndpoints
|
||||
HttpContext context,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var hasTenantContext = ScannerRequestContextResolver.TryResolveTenant(
|
||||
context,
|
||||
out var tenantId,
|
||||
out var tenantError,
|
||||
allowDefaultTenant: false);
|
||||
|
||||
if (!hasTenantContext && string.Equals(tenantError, "tenant_conflict", StringComparison.Ordinal))
|
||||
{
|
||||
return ProblemResultFactory.Create(
|
||||
context,
|
||||
ProblemTypes.Validation,
|
||||
"Invalid tenant context",
|
||||
StatusCodes.Status400BadRequest,
|
||||
detail: tenantError);
|
||||
}
|
||||
|
||||
// Read the raw payload
|
||||
using var reader = new StreamReader(context.Request.Body);
|
||||
var payloadString = await reader.ReadToEndAsync(ct);
|
||||
@@ -125,6 +141,15 @@ internal static class WebhookEndpoints
|
||||
StatusCodes.Status404NotFound);
|
||||
}
|
||||
|
||||
if (hasTenantContext && !string.Equals(source.TenantId, tenantId, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return ProblemResultFactory.Create(
|
||||
context,
|
||||
ProblemTypes.NotFound,
|
||||
"Source not found",
|
||||
StatusCodes.Status404NotFound);
|
||||
}
|
||||
|
||||
// Get the handler
|
||||
var handler = handlers.FirstOrDefault(h => h.SourceType == source.SourceType);
|
||||
if (handler == null || handler is not IWebhookCapableHandler webhookHandler)
|
||||
@@ -269,10 +294,24 @@ internal static class WebhookEndpoints
|
||||
HttpContext context,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (!ScannerRequestContextResolver.TryResolveTenant(
|
||||
context,
|
||||
out var tenantId,
|
||||
out var tenantError,
|
||||
allowDefaultTenant: false))
|
||||
{
|
||||
return ProblemResultFactory.Create(
|
||||
context,
|
||||
ProblemTypes.Validation,
|
||||
"Invalid tenant context",
|
||||
StatusCodes.Status400BadRequest,
|
||||
detail: tenantError ?? "tenant_missing");
|
||||
}
|
||||
|
||||
// Docker Hub uses callback_url for validation
|
||||
// and sends signature in body.callback_url when configured
|
||||
|
||||
var source = await FindSourceByNameAsync(sourceRepository, sourceName, SbomSourceType.Zastava, ct);
|
||||
var source = await FindSourceByNameAsync(sourceRepository, tenantId, sourceName, SbomSourceType.Zastava, ct);
|
||||
if (source == null)
|
||||
{
|
||||
return ProblemResultFactory.Create(
|
||||
@@ -308,6 +347,20 @@ internal static class WebhookEndpoints
|
||||
HttpContext context,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (!ScannerRequestContextResolver.TryResolveTenant(
|
||||
context,
|
||||
out var tenantId,
|
||||
out var tenantError,
|
||||
allowDefaultTenant: false))
|
||||
{
|
||||
return ProblemResultFactory.Create(
|
||||
context,
|
||||
ProblemTypes.Validation,
|
||||
"Invalid tenant context",
|
||||
StatusCodes.Status400BadRequest,
|
||||
detail: tenantError ?? "tenant_missing");
|
||||
}
|
||||
|
||||
// GitHub can send ping events for webhook validation
|
||||
if (eventType == "ping")
|
||||
{
|
||||
@@ -320,7 +373,7 @@ internal static class WebhookEndpoints
|
||||
return Results.Ok(new { message = $"Event type '{eventType}' ignored" });
|
||||
}
|
||||
|
||||
var source = await FindSourceByNameAsync(sourceRepository, sourceName, SbomSourceType.Git, ct);
|
||||
var source = await FindSourceByNameAsync(sourceRepository, tenantId, sourceName, SbomSourceType.Git, ct);
|
||||
if (source == null)
|
||||
{
|
||||
return ProblemResultFactory.Create(
|
||||
@@ -358,13 +411,27 @@ internal static class WebhookEndpoints
|
||||
HttpContext context,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (!ScannerRequestContextResolver.TryResolveTenant(
|
||||
context,
|
||||
out var tenantId,
|
||||
out var tenantError,
|
||||
allowDefaultTenant: false))
|
||||
{
|
||||
return ProblemResultFactory.Create(
|
||||
context,
|
||||
ProblemTypes.Validation,
|
||||
"Invalid tenant context",
|
||||
StatusCodes.Status400BadRequest,
|
||||
detail: tenantError ?? "tenant_missing");
|
||||
}
|
||||
|
||||
// Only process push and merge request events
|
||||
if (eventType != "Push Hook" && eventType != "Merge Request Hook" && eventType != "Tag Push Hook")
|
||||
{
|
||||
return Results.Ok(new { message = $"Event type '{eventType}' ignored" });
|
||||
}
|
||||
|
||||
var source = await FindSourceByNameAsync(sourceRepository, sourceName, SbomSourceType.Git, ct);
|
||||
var source = await FindSourceByNameAsync(sourceRepository, tenantId, sourceName, SbomSourceType.Git, ct);
|
||||
if (source == null)
|
||||
{
|
||||
return ProblemResultFactory.Create(
|
||||
@@ -400,7 +467,21 @@ internal static class WebhookEndpoints
|
||||
HttpContext context,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var source = await FindSourceByNameAsync(sourceRepository, sourceName, SbomSourceType.Zastava, ct);
|
||||
if (!ScannerRequestContextResolver.TryResolveTenant(
|
||||
context,
|
||||
out var tenantId,
|
||||
out var tenantError,
|
||||
allowDefaultTenant: false))
|
||||
{
|
||||
return ProblemResultFactory.Create(
|
||||
context,
|
||||
ProblemTypes.Validation,
|
||||
"Invalid tenant context",
|
||||
StatusCodes.Status400BadRequest,
|
||||
detail: tenantError ?? "tenant_missing");
|
||||
}
|
||||
|
||||
var source = await FindSourceByNameAsync(sourceRepository, tenantId, sourceName, SbomSourceType.Zastava, ct);
|
||||
if (source == null)
|
||||
{
|
||||
return ProblemResultFactory.Create(
|
||||
@@ -421,17 +502,17 @@ internal static class WebhookEndpoints
|
||||
ct);
|
||||
}
|
||||
|
||||
private static async Task<SbomSource?> FindSourceByNameAsync(
|
||||
internal static async Task<SbomSource?> FindSourceByNameAsync(
|
||||
ISbomSourceRepository repository,
|
||||
string tenantId,
|
||||
string name,
|
||||
SbomSourceType expectedType,
|
||||
CancellationToken ct)
|
||||
{
|
||||
// Search across all tenants for the source by name
|
||||
// Note: In production, this should be scoped to a specific tenant
|
||||
// extracted from the webhook URL or a custom header
|
||||
var sources = await repository.SearchByNameAsync(name, ct);
|
||||
return sources.FirstOrDefault(s => s.SourceType == expectedType);
|
||||
var source = await repository.GetByNameAsync(tenantId, name, ct).ConfigureAwait(false);
|
||||
return source is not null && source.SourceType == expectedType
|
||||
? source
|
||||
: null;
|
||||
}
|
||||
|
||||
private static async Task<IResult> ProcessWebhookAsync(
|
||||
|
||||
Reference in New Issue
Block a user