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:
master
2026-02-23 15:30:50 +02:00
parent bd8fee6ed8
commit e746577380
1424 changed files with 81225 additions and 25251 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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&amp;order=desc&amp;artifact=sha256:...&amp;reason=missing_vex&amp;page=1&amp;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; }
}

View File

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

View File

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