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:
@@ -1,6 +1,7 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Scanner.WebService.Contracts;
|
||||
using StellaOps.Scanner.WebService.Services;
|
||||
using StellaOps.Scanner.WebService.Tenancy;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Controllers;
|
||||
|
||||
@@ -47,7 +48,8 @@ public sealed class FindingsEvidenceController : ControllerBase
|
||||
return Forbid("Requires evidence:raw scope for raw source access");
|
||||
}
|
||||
|
||||
var finding = await _triageService.GetFindingAsync(findingId, ct).ConfigureAwait(false);
|
||||
var tenantId = ScannerRequestContextResolver.ResolveTenantOrDefault(HttpContext);
|
||||
var finding = await _triageService.GetFindingAsync(tenantId, findingId, ct).ConfigureAwait(false);
|
||||
if (finding is null)
|
||||
{
|
||||
return NotFound(new { error = "Finding not found", findingId });
|
||||
@@ -72,9 +74,10 @@ public sealed class FindingsEvidenceController : ControllerBase
|
||||
}
|
||||
|
||||
var results = new List<FindingEvidenceResponse>();
|
||||
var tenantId = ScannerRequestContextResolver.ResolveTenantOrDefault(HttpContext);
|
||||
foreach (var findingId in request.FindingIds)
|
||||
{
|
||||
var finding = await _triageService.GetFindingAsync(findingId, ct).ConfigureAwait(false);
|
||||
var finding = await _triageService.GetFindingAsync(tenantId, findingId, ct).ConfigureAwait(false);
|
||||
if (finding is null)
|
||||
{
|
||||
continue;
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Scanner.WebService.Contracts;
|
||||
using StellaOps.Scanner.WebService.Services;
|
||||
using StellaOps.Scanner.WebService.Tenancy;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Controllers;
|
||||
|
||||
@@ -61,7 +62,8 @@ public sealed class TriageController : ControllerBase
|
||||
{
|
||||
_logger.LogDebug("Getting gating status for finding {FindingId}", findingId);
|
||||
|
||||
var status = await _gatingService.GetGatingStatusAsync(findingId, ct)
|
||||
var tenantId = ScannerRequestContextResolver.ResolveTenantOrDefault(HttpContext);
|
||||
var status = await _gatingService.GetGatingStatusAsync(tenantId, findingId, ct)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (status is null)
|
||||
@@ -97,7 +99,8 @@ public sealed class TriageController : ControllerBase
|
||||
|
||||
_logger.LogDebug("Getting bulk gating status for {Count} findings", request.FindingIds.Count);
|
||||
|
||||
var statuses = await _gatingService.GetBulkGatingStatusAsync(request.FindingIds, ct)
|
||||
var tenantId = ScannerRequestContextResolver.ResolveTenantOrDefault(HttpContext);
|
||||
var statuses = await _gatingService.GetBulkGatingStatusAsync(tenantId, request.FindingIds, ct)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return Ok(statuses);
|
||||
@@ -123,7 +126,8 @@ public sealed class TriageController : ControllerBase
|
||||
{
|
||||
_logger.LogDebug("Getting gated buckets summary for scan {ScanId}", scanId);
|
||||
|
||||
var summary = await _gatingService.GetGatedBucketsSummaryAsync(scanId, ct)
|
||||
var tenantId = ScannerRequestContextResolver.ResolveTenantOrDefault(HttpContext);
|
||||
var summary = await _gatingService.GetGatedBucketsSummaryAsync(tenantId, scanId, ct)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (summary is null)
|
||||
@@ -182,7 +186,8 @@ public sealed class TriageController : ControllerBase
|
||||
IncludeReplayCommand = includeReplayCommand
|
||||
};
|
||||
|
||||
var evidence = await _evidenceService.GetUnifiedEvidenceAsync(findingId, options, ct)
|
||||
var tenantId = ScannerRequestContextResolver.ResolveTenantOrDefault(HttpContext);
|
||||
var evidence = await _evidenceService.GetUnifiedEvidenceAsync(tenantId, findingId, options, ct)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (evidence is null)
|
||||
@@ -261,7 +266,8 @@ public sealed class TriageController : ControllerBase
|
||||
IncludeReplayCommand = true
|
||||
};
|
||||
|
||||
var evidence = await _evidenceService.GetUnifiedEvidenceAsync(findingId, options, ct)
|
||||
var tenantId = ScannerRequestContextResolver.ResolveTenantOrDefault(HttpContext);
|
||||
var evidence = await _evidenceService.GetUnifiedEvidenceAsync(tenantId, findingId, options, ct)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (evidence is null)
|
||||
@@ -317,7 +323,8 @@ public sealed class TriageController : ControllerBase
|
||||
GenerateBundle = generateBundle
|
||||
};
|
||||
|
||||
var result = await _replayService.GenerateForFindingAsync(request, ct)
|
||||
var tenantId = ScannerRequestContextResolver.ResolveTenantOrDefault(HttpContext);
|
||||
var result = await _replayService.GenerateForFindingAsync(tenantId, request, ct)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (result is null)
|
||||
@@ -358,7 +365,8 @@ public sealed class TriageController : ControllerBase
|
||||
GenerateBundle = generateBundle
|
||||
};
|
||||
|
||||
var result = await _replayService.GenerateForScanAsync(request, ct)
|
||||
var tenantId = ScannerRequestContextResolver.ResolveTenantOrDefault(HttpContext);
|
||||
var result = await _replayService.GenerateForScanAsync(tenantId, request, ct)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (result is null)
|
||||
@@ -393,12 +401,13 @@ public sealed class TriageController : ControllerBase
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
_logger.LogDebug("Getting rationale for finding {FindingId} in format {Format}", findingId, format);
|
||||
var tenantId = ScannerRequestContextResolver.ResolveTenantOrDefault(HttpContext);
|
||||
|
||||
switch (format.ToLowerInvariant())
|
||||
{
|
||||
case "plaintext":
|
||||
case "text":
|
||||
var plainText = await _rationaleService.GetRationalePlainTextAsync(findingId, ct)
|
||||
var plainText = await _rationaleService.GetRationalePlainTextAsync(tenantId, findingId, ct)
|
||||
.ConfigureAwait(false);
|
||||
if (plainText is null)
|
||||
{
|
||||
@@ -408,7 +417,7 @@ public sealed class TriageController : ControllerBase
|
||||
|
||||
case "markdown":
|
||||
case "md":
|
||||
var markdown = await _rationaleService.GetRationaleMarkdownAsync(findingId, ct)
|
||||
var markdown = await _rationaleService.GetRationaleMarkdownAsync(tenantId, findingId, ct)
|
||||
.ConfigureAwait(false);
|
||||
if (markdown is null)
|
||||
{
|
||||
@@ -418,7 +427,7 @@ public sealed class TriageController : ControllerBase
|
||||
|
||||
case "json":
|
||||
default:
|
||||
var rationale = await _rationaleService.GetRationaleAsync(findingId, ct)
|
||||
var rationale = await _rationaleService.GetRationaleAsync(tenantId, findingId, ct)
|
||||
.ConfigureAwait(false);
|
||||
if (rationale is null)
|
||||
{
|
||||
|
||||
@@ -8,7 +8,8 @@ public sealed record ScanSnapshot(
|
||||
DateTimeOffset UpdatedAt,
|
||||
string? FailureReason,
|
||||
EntropySnapshot? Entropy,
|
||||
ReplayArtifacts? Replay);
|
||||
ReplayArtifacts? Replay,
|
||||
string TenantId = "default");
|
||||
|
||||
public sealed record ReplayArtifacts(
|
||||
string ManifestHash,
|
||||
|
||||
@@ -6,7 +6,8 @@ public sealed record ScanSubmission(
|
||||
ScanTarget Target,
|
||||
bool Force,
|
||||
string? ClientRequestId,
|
||||
IReadOnlyDictionary<string, string> Metadata);
|
||||
IReadOnlyDictionary<string, string> Metadata,
|
||||
string TenantId = "default");
|
||||
|
||||
public sealed record ScanSubmissionResult(
|
||||
ScanSnapshot Snapshot,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -11,6 +11,7 @@ using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Scanner.WebService.Security;
|
||||
using StellaOps.Scanner.WebService.Tenancy;
|
||||
using System.Threading.RateLimiting;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Extensions;
|
||||
@@ -42,7 +43,7 @@ public static class RateLimitingExtensions
|
||||
// Proof replay: 100 requests per hour per tenant
|
||||
options.AddPolicy(ProofReplayPolicy, context =>
|
||||
{
|
||||
var tenantId = GetTenantId(context);
|
||||
var tenantId = GetPartitionKey(context);
|
||||
return RateLimitPartition.GetFixedWindowLimiter(
|
||||
partitionKey: $"proof-replay:{tenantId}",
|
||||
factory: _ => new FixedWindowRateLimiterOptions
|
||||
@@ -57,7 +58,7 @@ public static class RateLimitingExtensions
|
||||
// Manifest: 100 requests per hour per tenant
|
||||
options.AddPolicy(ManifestPolicy, context =>
|
||||
{
|
||||
var tenantId = GetTenantId(context);
|
||||
var tenantId = GetPartitionKey(context);
|
||||
return RateLimitPartition.GetFixedWindowLimiter(
|
||||
partitionKey: $"manifest:{tenantId}",
|
||||
factory: _ => new FixedWindowRateLimiterOptions
|
||||
@@ -98,31 +99,8 @@ public static class RateLimitingExtensions
|
||||
/// <summary>
|
||||
/// Extract tenant ID from the HTTP context for rate limiting partitioning.
|
||||
/// </summary>
|
||||
private static string GetTenantId(HttpContext context)
|
||||
private static string GetPartitionKey(HttpContext context)
|
||||
{
|
||||
// Try to get tenant from claims
|
||||
var tenantClaim = context.User?.FindFirst(ScannerClaims.TenantId);
|
||||
if (tenantClaim is not null && !string.IsNullOrWhiteSpace(tenantClaim.Value))
|
||||
{
|
||||
return tenantClaim.Value;
|
||||
}
|
||||
|
||||
// Fallback to tenant header
|
||||
if (context.Request.Headers.TryGetValue("X-Tenant-Id", out var headerValue) &&
|
||||
!string.IsNullOrWhiteSpace(headerValue))
|
||||
{
|
||||
return headerValue.ToString();
|
||||
}
|
||||
|
||||
// Fallback to IP address for unauthenticated requests
|
||||
return context.Connection.RemoteIpAddress?.ToString() ?? "unknown";
|
||||
return ScannerRequestContextResolver.ResolveTenantPartitionKey(context);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Scanner claims constants.
|
||||
/// </summary>
|
||||
public static class ScannerClaims
|
||||
{
|
||||
public const string TenantId = "tenant_id";
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ using Microsoft.Extensions.Options;
|
||||
using StellaOps.Scanner.Storage.Entities;
|
||||
using StellaOps.Scanner.Storage.Repositories;
|
||||
using StellaOps.Scanner.WebService.Options;
|
||||
using StellaOps.Scanner.WebService.Tenancy;
|
||||
using System.IO;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
@@ -79,8 +80,7 @@ public sealed class IdempotencyMiddleware
|
||||
return;
|
||||
}
|
||||
|
||||
// Get tenant ID from claims or use default
|
||||
var tenantId = GetTenantId(context);
|
||||
var tenantId = ScannerRequestContextResolver.ResolveTenantPartitionKey(context);
|
||||
|
||||
// Check for existing idempotency key
|
||||
var existingKey = await repository.TryGetAsync(tenantId, contentDigest, path, context.RequestAborted)
|
||||
@@ -188,20 +188,6 @@ public sealed class IdempotencyMiddleware
|
||||
return $"sha-256=:{base64Hash}:";
|
||||
}
|
||||
|
||||
private static string GetTenantId(HttpContext context)
|
||||
{
|
||||
// Try to get tenant from claims
|
||||
var tenantClaim = context.User?.FindFirst("tenant_id")?.Value;
|
||||
if (!string.IsNullOrEmpty(tenantClaim))
|
||||
{
|
||||
return tenantClaim;
|
||||
}
|
||||
|
||||
// Fall back to client IP or default
|
||||
var clientIp = context.Connection.RemoteIpAddress?.ToString();
|
||||
return !string.IsNullOrEmpty(clientIp) ? $"ip:{clientIp}" : "default";
|
||||
}
|
||||
|
||||
private static async Task WriteCachedResponseAsync(HttpContext context, IdempotencyKeyRow key)
|
||||
{
|
||||
context.Response.StatusCode = key.ResponseStatus;
|
||||
|
||||
@@ -18,6 +18,7 @@ using StellaOps.Configuration;
|
||||
using StellaOps.Cryptography.DependencyInjection;
|
||||
using StellaOps.Cryptography.Plugin.BouncyCastle;
|
||||
using StellaOps.Determinism;
|
||||
using StellaOps.Infrastructure.Postgres.Options;
|
||||
using StellaOps.Plugin.DependencyInjection;
|
||||
using StellaOps.Policy;
|
||||
using StellaOps.Policy.Explainability;
|
||||
@@ -31,6 +32,8 @@ using StellaOps.Scanner.Emit.Composition;
|
||||
using StellaOps.Scanner.Gate;
|
||||
using StellaOps.Scanner.ReachabilityDrift.DependencyInjection;
|
||||
using StellaOps.Scanner.SmartDiff.Detection;
|
||||
using StellaOps.Scanner.Sources.DependencyInjection;
|
||||
using StellaOps.Scanner.Sources.Persistence;
|
||||
using StellaOps.Scanner.Storage;
|
||||
using StellaOps.Scanner.Storage.Extensions;
|
||||
using StellaOps.Scanner.Storage.Postgres;
|
||||
@@ -206,6 +209,7 @@ builder.Services.AddScoped<ITriageQueryService, TriageQueryService>();
|
||||
builder.Services.AddScoped<ITriageStatusService, TriageStatusService>();
|
||||
builder.Services.TryAddScoped<IFindingQueryService, FindingQueryService>();
|
||||
builder.Services.TryAddSingleton<IExploitPathGroupingService, ExploitPathGroupingService>();
|
||||
builder.Services.AddScoped<IUnknownsQueryService, UnknownsQueryService>();
|
||||
|
||||
// Verdict rationale rendering (Sprint: SPRINT_20260106_001_001_LB_verdict_rationale_renderer)
|
||||
builder.Services.AddVerdictExplainability();
|
||||
@@ -329,6 +333,20 @@ builder.Services.AddScannerStorage(storageOptions =>
|
||||
storageOptions.ObjectStore.RustFs.BaseUrl = string.Empty;
|
||||
}
|
||||
});
|
||||
builder.Services.AddOptions<PostgresOptions>()
|
||||
.Configure(options =>
|
||||
{
|
||||
options.ConnectionString = bootstrapOptions.Storage.Dsn;
|
||||
options.CommandTimeoutSeconds = bootstrapOptions.Storage.CommandTimeoutSeconds;
|
||||
options.SchemaName = string.IsNullOrWhiteSpace(bootstrapOptions.Storage.Database)
|
||||
? ScannerStorageDefaults.DefaultSchemaName
|
||||
: bootstrapOptions.Storage.Database!.Trim();
|
||||
options.AutoMigrate = false;
|
||||
options.MigrationsPath = null;
|
||||
});
|
||||
builder.Services.TryAddSingleton<ScannerSourcesDataSource>();
|
||||
builder.Services.AddSbomSources();
|
||||
builder.Services.AddSbomSourceCredentialResolver<NullCredentialResolver>();
|
||||
builder.Services.AddSingleton<IPostConfigureOptions<ScannerStorageOptions>, ScannerStorageOptionsPostConfigurator>();
|
||||
builder.Services.AddOptions<StellaOps.Scanner.ProofSpine.Options.ProofSpineDsseSigningOptions>()
|
||||
.Bind(builder.Configuration.GetSection(StellaOps.Scanner.ProofSpine.Options.ProofSpineDsseSigningOptions.SectionName));
|
||||
@@ -633,6 +651,8 @@ if (app.Environment.IsEnvironment("Testing"))
|
||||
}
|
||||
|
||||
apiGroup.MapScanEndpoints(resolvedOptions.Api.ScansSegment);
|
||||
apiGroup.MapSourcesEndpoints();
|
||||
apiGroup.MapWebhookEndpoints();
|
||||
apiGroup.MapSbomUploadEndpoints();
|
||||
apiGroup.MapReachabilityDriftRootEndpoints();
|
||||
apiGroup.MapDeltaCompareEndpoints();
|
||||
@@ -652,6 +672,7 @@ apiGroup.MapTriageStatusEndpoints();
|
||||
apiGroup.MapTriageInboxEndpoints();
|
||||
apiGroup.MapBatchTriageEndpoints();
|
||||
apiGroup.MapProofBundleEndpoints();
|
||||
apiGroup.MapUnknownsEndpoints();
|
||||
apiGroup.MapSecretDetectionSettingsEndpoints(); // Sprint: SPRINT_20260104_006_BE
|
||||
apiGroup.MapSecurityAdapterEndpoints(); // Pack v2 security adapter routes
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using NpgsqlTypes;
|
||||
using StellaOps.Scanner.Core.Utility;
|
||||
using StellaOps.Scanner.Storage.Postgres;
|
||||
using StellaOps.Scanner.WebService.Contracts;
|
||||
using StellaOps.Scanner.WebService.Domain;
|
||||
@@ -14,8 +15,7 @@ namespace StellaOps.Scanner.WebService.Services;
|
||||
|
||||
internal sealed class CallGraphIngestionService : ICallGraphIngestionService
|
||||
{
|
||||
private const string TenantContext = "00000000-0000-0000-0000-000000000001";
|
||||
private static readonly Guid TenantId = Guid.Parse(TenantContext);
|
||||
private static readonly Guid TenantNamespace = new("ac8f2b54-72ea-43fa-9c3b-6a87ebd2d48a");
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
@@ -81,6 +81,7 @@ internal sealed class CallGraphIngestionService : ICallGraphIngestionService
|
||||
|
||||
public async Task<ExistingCallGraphDto?> FindByDigestAsync(
|
||||
ScanId scanId,
|
||||
string tenantId,
|
||||
string contentDigest,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
@@ -94,6 +95,8 @@ internal sealed class CallGraphIngestionService : ICallGraphIngestionService
|
||||
return null;
|
||||
}
|
||||
|
||||
var (tenantContext, tenantGuid) = ResolveTenantKey(tenantId);
|
||||
|
||||
var sql = $"""
|
||||
SELECT id, content_digest, created_at_utc
|
||||
FROM {CallGraphIngestionsTable}
|
||||
@@ -103,10 +106,10 @@ internal sealed class CallGraphIngestionService : ICallGraphIngestionService
|
||||
LIMIT 1
|
||||
""";
|
||||
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(TenantContext, "reader", cancellationToken)
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(tenantContext, "reader", cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
await using var command = new NpgsqlCommand(sql, connection);
|
||||
command.Parameters.AddWithValue("tenant_id", TenantId);
|
||||
command.Parameters.AddWithValue("tenant_id", tenantGuid);
|
||||
command.Parameters.AddWithValue("scan_id", scanId.Value.Trim());
|
||||
command.Parameters.AddWithValue("content_digest", contentDigest.Trim());
|
||||
|
||||
@@ -124,14 +127,17 @@ internal sealed class CallGraphIngestionService : ICallGraphIngestionService
|
||||
|
||||
public async Task<CallGraphIngestionResult> IngestAsync(
|
||||
ScanId scanId,
|
||||
string tenantId,
|
||||
CallGraphV1Dto callGraph,
|
||||
string contentDigest,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(callGraph);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(scanId.Value);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(contentDigest);
|
||||
|
||||
var (tenantContext, tenantGuid) = ResolveTenantKey(tenantId);
|
||||
var normalizedDigest = contentDigest.Trim();
|
||||
var callgraphId = CreateCallGraphId(scanId, normalizedDigest);
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
@@ -174,13 +180,13 @@ internal sealed class CallGraphIngestionService : ICallGraphIngestionService
|
||||
LIMIT 1
|
||||
""";
|
||||
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(TenantContext, "writer", cancellationToken)
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(tenantContext, "writer", cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
await using (var insert = new NpgsqlCommand(insertSql, connection))
|
||||
{
|
||||
insert.Parameters.AddWithValue("id", callgraphId);
|
||||
insert.Parameters.AddWithValue("tenant_id", TenantId);
|
||||
insert.Parameters.AddWithValue("tenant_id", tenantGuid);
|
||||
insert.Parameters.AddWithValue("scan_id", scanId.Value.Trim());
|
||||
insert.Parameters.AddWithValue("content_digest", normalizedDigest);
|
||||
insert.Parameters.AddWithValue("language", language);
|
||||
@@ -193,7 +199,7 @@ internal sealed class CallGraphIngestionService : ICallGraphIngestionService
|
||||
}
|
||||
|
||||
await using var select = new NpgsqlCommand(selectSql, connection);
|
||||
select.Parameters.AddWithValue("tenant_id", TenantId);
|
||||
select.Parameters.AddWithValue("tenant_id", tenantGuid);
|
||||
select.Parameters.AddWithValue("scan_id", scanId.Value.Trim());
|
||||
select.Parameters.AddWithValue("content_digest", normalizedDigest);
|
||||
|
||||
@@ -229,5 +235,21 @@ internal sealed class CallGraphIngestionService : ICallGraphIngestionService
|
||||
var hash = SHA256.HashData(bytes);
|
||||
return $"cg_{Convert.ToHexString(hash).ToLowerInvariant()}";
|
||||
}
|
||||
}
|
||||
|
||||
private static (string TenantContext, Guid TenantId) ResolveTenantKey(string tenantId)
|
||||
{
|
||||
var normalizedTenant = string.IsNullOrWhiteSpace(tenantId)
|
||||
? "default"
|
||||
: tenantId.Trim().ToLowerInvariant();
|
||||
|
||||
if (Guid.TryParse(normalizedTenant, out var parsed))
|
||||
{
|
||||
return (parsed.ToString("D"), parsed);
|
||||
}
|
||||
|
||||
var deterministic = ScannerIdentifiers.CreateDeterministicGuid(
|
||||
TenantNamespace,
|
||||
Encoding.UTF8.GetBytes(normalizedTenant));
|
||||
return (deterministic.ToString("D"), deterministic);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -171,11 +171,16 @@ internal sealed class DeltaScanRequestHandler : IDeltaScanRequestHandler
|
||||
metadata["stellaops:drift.newBinariesCount"] = newBinaries.Count.ToString();
|
||||
}
|
||||
|
||||
var tenantId = string.IsNullOrWhiteSpace(runtimeEvent.Tenant)
|
||||
? "default"
|
||||
: runtimeEvent.Tenant.Trim().ToLowerInvariant();
|
||||
|
||||
var submission = new ScanSubmission(
|
||||
scanTarget.Normalize(),
|
||||
Force: false,
|
||||
ClientRequestId: $"drift:{runtimeEvent.EventId}",
|
||||
Metadata: metadata);
|
||||
Metadata: metadata,
|
||||
TenantId: tenantId);
|
||||
|
||||
try
|
||||
{
|
||||
|
||||
@@ -21,18 +21,25 @@ public sealed class FindingQueryService : IFindingQueryService
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<Finding>> GetFindingsForArtifactAsync(string artifactDigest, CancellationToken ct)
|
||||
public async Task<IReadOnlyList<Finding>> GetFindingsForArtifactAsync(
|
||||
string tenantId,
|
||||
string artifactDigest,
|
||||
CancellationToken ct)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(artifactDigest))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var normalizedTenantId = tenantId.Trim().ToLowerInvariant();
|
||||
|
||||
var findings = await _dbContext.Findings
|
||||
.Include(static f => f.RiskResults)
|
||||
.Include(static f => f.ReachabilityResults)
|
||||
.AsNoTracking()
|
||||
.Where(f => f.ArtifactDigest == artifactDigest)
|
||||
.Where(f => f.TenantId == normalizedTenantId && f.ArtifactDigest == artifactDigest)
|
||||
.OrderBy(f => f.Id)
|
||||
.ToListAsync(ct)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
@@ -32,11 +32,15 @@ internal sealed class FindingRationaleService : IFindingRationaleService
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<VerdictRationaleResponseDto?> GetRationaleAsync(string findingId, CancellationToken ct = default)
|
||||
public async Task<VerdictRationaleResponseDto?> GetRationaleAsync(
|
||||
string tenantId,
|
||||
string findingId,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(findingId);
|
||||
|
||||
var finding = await _triageQueryService.GetFindingAsync(findingId, ct).ConfigureAwait(false);
|
||||
var finding = await _triageQueryService.GetFindingAsync(tenantId, findingId, ct).ConfigureAwait(false);
|
||||
if (finding is null)
|
||||
{
|
||||
_logger.LogDebug("Finding {FindingId} not found", findingId);
|
||||
@@ -52,11 +56,15 @@ internal sealed class FindingRationaleService : IFindingRationaleService
|
||||
return MapToDto(findingId, rationale);
|
||||
}
|
||||
|
||||
public async Task<RationalePlainTextResponseDto?> GetRationalePlainTextAsync(string findingId, CancellationToken ct = default)
|
||||
public async Task<RationalePlainTextResponseDto?> GetRationalePlainTextAsync(
|
||||
string tenantId,
|
||||
string findingId,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(findingId);
|
||||
|
||||
var finding = await _triageQueryService.GetFindingAsync(findingId, ct).ConfigureAwait(false);
|
||||
var finding = await _triageQueryService.GetFindingAsync(tenantId, findingId, ct).ConfigureAwait(false);
|
||||
if (finding is null)
|
||||
{
|
||||
return null;
|
||||
@@ -75,11 +83,15 @@ internal sealed class FindingRationaleService : IFindingRationaleService
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<RationalePlainTextResponseDto?> GetRationaleMarkdownAsync(string findingId, CancellationToken ct = default)
|
||||
public async Task<RationalePlainTextResponseDto?> GetRationaleMarkdownAsync(
|
||||
string tenantId,
|
||||
string findingId,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(findingId);
|
||||
|
||||
var finding = await _triageQueryService.GetFindingAsync(findingId, ct).ConfigureAwait(false);
|
||||
var finding = await _triageQueryService.GetFindingAsync(tenantId, findingId, ct).ConfigureAwait(false);
|
||||
if (finding is null)
|
||||
{
|
||||
return null;
|
||||
|
||||
@@ -35,21 +35,28 @@ public sealed class GatingReasonService : IGatingReasonService
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<FindingGatingStatusDto?> GetGatingStatusAsync(
|
||||
string tenantId,
|
||||
string findingId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
|
||||
if (!Guid.TryParse(findingId, out var id))
|
||||
{
|
||||
_logger.LogWarning("Invalid finding id format: {FindingId}", findingId);
|
||||
return null;
|
||||
}
|
||||
|
||||
var normalizedTenantId = tenantId.Trim().ToLowerInvariant();
|
||||
|
||||
var finding = await _dbContext.Findings
|
||||
.Include(f => f.ReachabilityResults)
|
||||
.Include(f => f.EffectiveVexRecords)
|
||||
.Include(f => f.PolicyDecisions)
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(f => f.Id == id, cancellationToken)
|
||||
.FirstOrDefaultAsync(
|
||||
f => f.Id == id && f.TenantId == normalizedTenantId,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (finding is null)
|
||||
@@ -63,9 +70,12 @@ public sealed class GatingReasonService : IGatingReasonService
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<FindingGatingStatusDto>> GetBulkGatingStatusAsync(
|
||||
string tenantId,
|
||||
IReadOnlyList<string> findingIds,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
|
||||
var validIds = findingIds
|
||||
.Where(id => Guid.TryParse(id, out _))
|
||||
.Select(Guid.Parse)
|
||||
@@ -76,12 +86,14 @@ public sealed class GatingReasonService : IGatingReasonService
|
||||
return Array.Empty<FindingGatingStatusDto>();
|
||||
}
|
||||
|
||||
var normalizedTenantId = tenantId.Trim().ToLowerInvariant();
|
||||
|
||||
var findings = await _dbContext.Findings
|
||||
.Include(f => f.ReachabilityResults)
|
||||
.Include(f => f.EffectiveVexRecords)
|
||||
.Include(f => f.PolicyDecisions)
|
||||
.AsNoTracking()
|
||||
.Where(f => validIds.Contains(f.Id))
|
||||
.Where(f => f.TenantId == normalizedTenantId && validIds.Contains(f.Id))
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
@@ -92,21 +104,26 @@ public sealed class GatingReasonService : IGatingReasonService
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<GatedBucketsSummaryDto?> GetGatedBucketsSummaryAsync(
|
||||
string tenantId,
|
||||
string scanId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
|
||||
if (!Guid.TryParse(scanId, out var id))
|
||||
{
|
||||
_logger.LogWarning("Invalid scan id format: {ScanId}", scanId);
|
||||
return null;
|
||||
}
|
||||
|
||||
var normalizedTenantId = tenantId.Trim().ToLowerInvariant();
|
||||
|
||||
var findings = await _dbContext.Findings
|
||||
.Include(f => f.ReachabilityResults)
|
||||
.Include(f => f.EffectiveVexRecords)
|
||||
.Include(f => f.PolicyDecisions)
|
||||
.AsNoTracking()
|
||||
.Where(f => f.ScanId == id)
|
||||
.Where(f => f.TenantId == normalizedTenantId && f.ScanId == id)
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ public interface ICallGraphIngestionService
|
||||
/// </summary>
|
||||
Task<ExistingCallGraphDto?> FindByDigestAsync(
|
||||
ScanId scanId,
|
||||
string tenantId,
|
||||
string contentDigest,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
@@ -30,6 +31,7 @@ public interface ICallGraphIngestionService
|
||||
/// </summary>
|
||||
Task<CallGraphIngestionResult> IngestAsync(
|
||||
ScanId scanId,
|
||||
string tenantId,
|
||||
CallGraphV1Dto callGraph,
|
||||
string contentDigest,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
@@ -20,7 +20,7 @@ public interface IFindingRationaleService
|
||||
/// <param name="findingId">Finding identifier.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Rationale response or null if finding not found.</returns>
|
||||
Task<VerdictRationaleResponseDto?> GetRationaleAsync(string findingId, CancellationToken ct = default);
|
||||
Task<VerdictRationaleResponseDto?> GetRationaleAsync(string tenantId, string findingId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Get the rationale as plain text (4-line format).
|
||||
@@ -28,7 +28,7 @@ public interface IFindingRationaleService
|
||||
/// <param name="findingId">Finding identifier.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Plain text response or null if finding not found.</returns>
|
||||
Task<RationalePlainTextResponseDto?> GetRationalePlainTextAsync(string findingId, CancellationToken ct = default);
|
||||
Task<RationalePlainTextResponseDto?> GetRationalePlainTextAsync(string tenantId, string findingId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Get the rationale as Markdown.
|
||||
@@ -36,5 +36,5 @@ public interface IFindingRationaleService
|
||||
/// <param name="findingId">Finding identifier.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Markdown response or null if finding not found.</returns>
|
||||
Task<RationalePlainTextResponseDto?> GetRationaleMarkdownAsync(string findingId, CancellationToken ct = default);
|
||||
Task<RationalePlainTextResponseDto?> GetRationaleMarkdownAsync(string tenantId, string findingId, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
@@ -16,30 +16,36 @@ public interface IGatingReasonService
|
||||
/// <summary>
|
||||
/// Computes the gating status for a single finding.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">Tenant identifier.</param>
|
||||
/// <param name="findingId">Finding identifier.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Gating status or null if finding not found.</returns>
|
||||
Task<FindingGatingStatusDto?> GetGatingStatusAsync(
|
||||
string tenantId,
|
||||
string findingId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Computes gating status for multiple findings.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">Tenant identifier.</param>
|
||||
/// <param name="findingIds">Finding identifiers.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Gating status for each finding.</returns>
|
||||
Task<IReadOnlyList<FindingGatingStatusDto>> GetBulkGatingStatusAsync(
|
||||
string tenantId,
|
||||
IReadOnlyList<string> findingIds,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Computes the gated buckets summary for a scan.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">Tenant identifier.</param>
|
||||
/// <param name="scanId">Scan identifier.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Summary of gated buckets or null if scan not found.</returns>
|
||||
Task<GatedBucketsSummaryDto?> GetGatedBucketsSummaryAsync(
|
||||
string tenantId,
|
||||
string scanId,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
@@ -16,20 +16,24 @@ public interface IReplayCommandService
|
||||
/// <summary>
|
||||
/// Generates replay commands for a finding.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">Tenant identifier.</param>
|
||||
/// <param name="request">Request parameters.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Replay command response or null if finding not found.</returns>
|
||||
Task<ReplayCommandResponseDto?> GenerateForFindingAsync(
|
||||
string tenantId,
|
||||
GenerateReplayCommandRequestDto request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Generates replay commands for an entire scan.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">Tenant identifier.</param>
|
||||
/// <param name="request">Request parameters.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Replay command response or null if scan not found.</returns>
|
||||
Task<ScanReplayCommandResponseDto?> GenerateForScanAsync(
|
||||
string tenantId,
|
||||
GenerateScanReplayCommandRequestDto request,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
@@ -4,5 +4,5 @@ namespace StellaOps.Scanner.WebService.Services;
|
||||
|
||||
public interface ITriageQueryService
|
||||
{
|
||||
Task<TriageFinding?> GetFindingAsync(string findingId, CancellationToken cancellationToken = default);
|
||||
Task<TriageFinding?> GetFindingAsync(string tenantId, string findingId, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
@@ -16,11 +16,13 @@ public interface IUnifiedEvidenceService
|
||||
/// <summary>
|
||||
/// Gets the complete unified evidence package for a finding.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">Tenant identifier.</param>
|
||||
/// <param name="findingId">Finding identifier.</param>
|
||||
/// <param name="options">Options controlling what evidence to include.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Unified evidence package or null if finding not found.</returns>
|
||||
Task<UnifiedEvidenceResponseDto?> GetUnifiedEvidenceAsync(
|
||||
string tenantId,
|
||||
string findingId,
|
||||
UnifiedEvidenceOptions? options = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
namespace StellaOps.Scanner.WebService.Services;
|
||||
|
||||
public interface IUnknownsQueryService
|
||||
{
|
||||
Task<UnknownsListResult> ListAsync(string tenantId, UnknownsListQuery query, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<UnknownsDetail?> GetByIdAsync(string tenantId, Guid unknownId, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<UnknownsStats> GetStatsAsync(string tenantId, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<IReadOnlyDictionary<string, long>> GetBandDistributionAsync(
|
||||
string tenantId,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public sealed record UnknownsListResult
|
||||
{
|
||||
public required IReadOnlyList<UnknownsListItem> Items { get; init; }
|
||||
public required int TotalCount { get; init; }
|
||||
}
|
||||
|
||||
public sealed record UnknownsListItem
|
||||
{
|
||||
public required Guid UnknownId { 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 DateTimeOffset CreatedAtUtc { get; init; }
|
||||
public required DateTimeOffset UpdatedAtUtc { get; init; }
|
||||
}
|
||||
|
||||
public sealed record UnknownsDetail
|
||||
{
|
||||
public required Guid UnknownId { 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 string? ProofRef { get; init; }
|
||||
public required DateTimeOffset CreatedAtUtc { get; init; }
|
||||
public required DateTimeOffset UpdatedAtUtc { get; init; }
|
||||
}
|
||||
|
||||
public sealed record UnknownsStats
|
||||
{
|
||||
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 UnknownsListQuery
|
||||
{
|
||||
public string? ArtifactDigest { get; init; }
|
||||
public string? VulnerabilityId { get; init; }
|
||||
public UnknownsBand? Band { get; init; }
|
||||
public UnknownsSortField SortBy { get; init; } = UnknownsSortField.Score;
|
||||
public UnknownsSortOrder SortOrder { get; init; } = UnknownsSortOrder.Descending;
|
||||
public int Limit { get; init; } = 50;
|
||||
public int Offset { get; init; } = 0;
|
||||
}
|
||||
|
||||
public enum UnknownsBand
|
||||
{
|
||||
Hot,
|
||||
Warm,
|
||||
Cold
|
||||
}
|
||||
|
||||
public enum UnknownsSortField
|
||||
{
|
||||
Score,
|
||||
CreatedAt,
|
||||
UpdatedAt
|
||||
}
|
||||
|
||||
public enum UnknownsSortOrder
|
||||
{
|
||||
Ascending,
|
||||
Descending
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using StellaOps.Scanner.WebService.Domain;
|
||||
using StellaOps.Scanner.WebService.Tenancy;
|
||||
using StellaOps.Scanner.WebService.Utilities;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
@@ -8,6 +10,8 @@ namespace StellaOps.Scanner.WebService.Services;
|
||||
|
||||
public sealed class InMemoryScanCoordinator : IScanCoordinator
|
||||
{
|
||||
private const string DefaultTenant = "default";
|
||||
|
||||
private sealed record ScanEntry(ScanSnapshot Snapshot);
|
||||
|
||||
private readonly ConcurrentDictionary<string, ScanEntry> scans = new(StringComparer.OrdinalIgnoreCase);
|
||||
@@ -15,11 +19,21 @@ public sealed class InMemoryScanCoordinator : IScanCoordinator
|
||||
private readonly ConcurrentDictionary<string, string> scansByReference = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly TimeProvider timeProvider;
|
||||
private readonly IScanProgressPublisher progressPublisher;
|
||||
private readonly IHttpContextAccessor? httpContextAccessor;
|
||||
|
||||
public InMemoryScanCoordinator(TimeProvider timeProvider, IScanProgressPublisher progressPublisher)
|
||||
: this(timeProvider, progressPublisher, null)
|
||||
{
|
||||
}
|
||||
|
||||
public InMemoryScanCoordinator(
|
||||
TimeProvider timeProvider,
|
||||
IScanProgressPublisher progressPublisher,
|
||||
IHttpContextAccessor? httpContextAccessor)
|
||||
{
|
||||
this.timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
this.progressPublisher = progressPublisher ?? throw new ArgumentNullException(nameof(progressPublisher));
|
||||
this.httpContextAccessor = httpContextAccessor;
|
||||
}
|
||||
|
||||
public ValueTask<ScanSubmissionResult> SubmitAsync(ScanSubmission submission, CancellationToken cancellationToken)
|
||||
@@ -28,12 +42,19 @@ public sealed class InMemoryScanCoordinator : IScanCoordinator
|
||||
|
||||
var normalizedTarget = submission.Target.Normalize();
|
||||
var metadata = submission.Metadata ?? new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
var scanId = ScanIdGenerator.Create(normalizedTarget, submission.Force, submission.ClientRequestId, metadata);
|
||||
var normalizedTenant = ResolveSubmissionTenant(submission.TenantId);
|
||||
var scanId = ScanIdGenerator.Create(
|
||||
normalizedTarget,
|
||||
submission.Force,
|
||||
submission.ClientRequestId,
|
||||
metadata,
|
||||
normalizedTenant);
|
||||
var now = timeProvider.GetUtcNow();
|
||||
|
||||
var eventData = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["force"] = submission.Force,
|
||||
["tenant"] = normalizedTenant,
|
||||
};
|
||||
foreach (var pair in metadata)
|
||||
{
|
||||
@@ -50,7 +71,8 @@ public sealed class InMemoryScanCoordinator : IScanCoordinator
|
||||
now,
|
||||
null,
|
||||
null,
|
||||
null)),
|
||||
null,
|
||||
normalizedTenant)),
|
||||
(_, existing) =>
|
||||
{
|
||||
if (submission.Force)
|
||||
@@ -67,7 +89,7 @@ public sealed class InMemoryScanCoordinator : IScanCoordinator
|
||||
return existing;
|
||||
});
|
||||
|
||||
IndexTarget(scanId.Value, normalizedTarget);
|
||||
IndexTarget(scanId.Value, normalizedTarget, normalizedTenant);
|
||||
|
||||
var created = entry.Snapshot.CreatedAt == now;
|
||||
var state = entry.Snapshot.Status.ToString();
|
||||
@@ -79,6 +101,11 @@ public sealed class InMemoryScanCoordinator : IScanCoordinator
|
||||
{
|
||||
if (scans.TryGetValue(scanId.Value, out var entry))
|
||||
{
|
||||
if (!IsTenantAuthorized(entry.Snapshot.TenantId))
|
||||
{
|
||||
return ValueTask.FromResult<ScanSnapshot?>(null);
|
||||
}
|
||||
|
||||
return ValueTask.FromResult<ScanSnapshot?>(entry.Snapshot);
|
||||
}
|
||||
|
||||
@@ -87,13 +114,20 @@ public sealed class InMemoryScanCoordinator : IScanCoordinator
|
||||
|
||||
public ValueTask<ScanSnapshot?> TryFindByTargetAsync(string? reference, string? digest, CancellationToken cancellationToken)
|
||||
{
|
||||
var requestTenant = ResolveRequestTenantOrDefault();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(digest))
|
||||
{
|
||||
var normalizedDigest = NormalizeDigest(digest);
|
||||
if (normalizedDigest is not null &&
|
||||
scansByDigest.TryGetValue(normalizedDigest, out var digestScanId) &&
|
||||
scansByDigest.TryGetValue(BuildTargetKey(requestTenant, normalizedDigest), out var digestScanId) &&
|
||||
scans.TryGetValue(digestScanId, out var digestEntry))
|
||||
{
|
||||
if (!IsTenantAuthorized(digestEntry.Snapshot.TenantId))
|
||||
{
|
||||
return ValueTask.FromResult<ScanSnapshot?>(null);
|
||||
}
|
||||
|
||||
return ValueTask.FromResult<ScanSnapshot?>(digestEntry.Snapshot);
|
||||
}
|
||||
}
|
||||
@@ -102,9 +136,14 @@ public sealed class InMemoryScanCoordinator : IScanCoordinator
|
||||
{
|
||||
var normalizedReference = NormalizeReference(reference);
|
||||
if (normalizedReference is not null &&
|
||||
scansByReference.TryGetValue(normalizedReference, out var referenceScanId) &&
|
||||
scansByReference.TryGetValue(BuildTargetKey(requestTenant, normalizedReference), out var referenceScanId) &&
|
||||
scans.TryGetValue(referenceScanId, out var referenceEntry))
|
||||
{
|
||||
if (!IsTenantAuthorized(referenceEntry.Snapshot.TenantId))
|
||||
{
|
||||
return ValueTask.FromResult<ScanSnapshot?>(null);
|
||||
}
|
||||
|
||||
return ValueTask.FromResult<ScanSnapshot?>(referenceEntry.Snapshot);
|
||||
}
|
||||
}
|
||||
@@ -121,6 +160,11 @@ public sealed class InMemoryScanCoordinator : IScanCoordinator
|
||||
return ValueTask.FromResult(false);
|
||||
}
|
||||
|
||||
if (!IsTenantAuthorized(existing.Snapshot.TenantId))
|
||||
{
|
||||
return ValueTask.FromResult(false);
|
||||
}
|
||||
|
||||
var updated = existing.Snapshot with
|
||||
{
|
||||
Replay = replay,
|
||||
@@ -145,6 +189,11 @@ public sealed class InMemoryScanCoordinator : IScanCoordinator
|
||||
return ValueTask.FromResult(false);
|
||||
}
|
||||
|
||||
if (!IsTenantAuthorized(existing.Snapshot.TenantId))
|
||||
{
|
||||
return ValueTask.FromResult(false);
|
||||
}
|
||||
|
||||
var updated = existing.Snapshot with
|
||||
{
|
||||
Entropy = entropy,
|
||||
@@ -160,19 +209,88 @@ public sealed class InMemoryScanCoordinator : IScanCoordinator
|
||||
return ValueTask.FromResult(true);
|
||||
}
|
||||
|
||||
private void IndexTarget(string scanId, ScanTarget target)
|
||||
private void IndexTarget(string scanId, ScanTarget target, string tenantId)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(target.Digest))
|
||||
{
|
||||
scansByDigest[target.Digest!] = scanId;
|
||||
scansByDigest[BuildTargetKey(tenantId, target.Digest!)] = scanId;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(target.Reference))
|
||||
{
|
||||
scansByReference[target.Reference!] = scanId;
|
||||
scansByReference[BuildTargetKey(tenantId, target.Reference!)] = scanId;
|
||||
}
|
||||
}
|
||||
|
||||
private string ResolveSubmissionTenant(string? submissionTenant)
|
||||
{
|
||||
var normalizedTenant = NormalizeTenant(submissionTenant);
|
||||
if (!string.Equals(normalizedTenant, DefaultTenant, StringComparison.Ordinal))
|
||||
{
|
||||
return normalizedTenant;
|
||||
}
|
||||
|
||||
if (TryResolveRequestTenant(out var requestTenant))
|
||||
{
|
||||
return requestTenant;
|
||||
}
|
||||
|
||||
return normalizedTenant;
|
||||
}
|
||||
|
||||
private string ResolveRequestTenantOrDefault()
|
||||
{
|
||||
if (TryResolveRequestTenant(out var requestTenant))
|
||||
{
|
||||
return requestTenant;
|
||||
}
|
||||
|
||||
return DefaultTenant;
|
||||
}
|
||||
|
||||
private bool IsTenantAuthorized(string snapshotTenant)
|
||||
{
|
||||
var context = httpContextAccessor?.HttpContext;
|
||||
if (context is null)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!ScannerRequestContextResolver.TryResolveTenant(
|
||||
context,
|
||||
out var requestTenant,
|
||||
out _,
|
||||
allowDefaultTenant: true))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return string.Equals(
|
||||
requestTenant,
|
||||
NormalizeTenant(snapshotTenant),
|
||||
StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private bool TryResolveRequestTenant(out string tenantId)
|
||||
{
|
||||
tenantId = string.Empty;
|
||||
|
||||
var context = httpContextAccessor?.HttpContext;
|
||||
if (context is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return ScannerRequestContextResolver.TryResolveTenant(
|
||||
context,
|
||||
out tenantId,
|
||||
out _,
|
||||
allowDefaultTenant: true);
|
||||
}
|
||||
|
||||
private static string BuildTargetKey(string tenantId, string targetValue)
|
||||
=> $"{NormalizeTenant(tenantId)}|{targetValue}";
|
||||
|
||||
private static string? NormalizeDigest(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
@@ -195,4 +313,11 @@ public sealed class InMemoryScanCoordinator : IScanCoordinator
|
||||
|
||||
return value.Trim();
|
||||
}
|
||||
|
||||
private static string NormalizeTenant(string? value)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(value)
|
||||
? DefaultTenant
|
||||
: value.Trim().ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
using StellaOps.Scanner.Sources.Services;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Safe default credential resolver for source and webhook endpoints.
|
||||
/// Deployments can replace this via DI with a vault-backed implementation.
|
||||
/// </summary>
|
||||
internal sealed class NullCredentialResolver : ICredentialResolver
|
||||
{
|
||||
public Task<ResolvedCredential?> ResolveAsync(string authRef, CancellationToken ct = default)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
return Task.FromResult<ResolvedCredential?>(null);
|
||||
}
|
||||
|
||||
public Task<bool> ValidateRefAsync(string authRef, CancellationToken ct = default)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
}
|
||||
@@ -38,19 +38,26 @@ public sealed class ReplayCommandService : IReplayCommandService
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ReplayCommandResponseDto?> GenerateForFindingAsync(
|
||||
string tenantId,
|
||||
GenerateReplayCommandRequestDto request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
|
||||
if (!Guid.TryParse(request.FindingId, out var id))
|
||||
{
|
||||
_logger.LogWarning("Invalid finding id format: {FindingId}", request.FindingId);
|
||||
return null;
|
||||
}
|
||||
|
||||
var normalizedTenantId = tenantId.Trim().ToLowerInvariant();
|
||||
|
||||
var finding = await _dbContext.Findings
|
||||
.Include(f => f.Scan)
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(f => f.Id == id, cancellationToken)
|
||||
.FirstOrDefaultAsync(
|
||||
f => f.Id == id && f.TenantId == normalizedTenantId,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (finding is null)
|
||||
@@ -102,18 +109,25 @@ public sealed class ReplayCommandService : IReplayCommandService
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ScanReplayCommandResponseDto?> GenerateForScanAsync(
|
||||
string tenantId,
|
||||
GenerateScanReplayCommandRequestDto request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
|
||||
if (!Guid.TryParse(request.ScanId, out var id))
|
||||
{
|
||||
_logger.LogWarning("Invalid scan id format: {ScanId}", request.ScanId);
|
||||
return null;
|
||||
}
|
||||
|
||||
var normalizedTenantId = tenantId.Trim().ToLowerInvariant();
|
||||
|
||||
var scan = await _dbContext.Scans
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(s => s.Id == id, cancellationToken)
|
||||
.FirstOrDefaultAsync(
|
||||
s => s.Id == id && s.TenantId == normalizedTenantId,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (scan is null)
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Determinism;
|
||||
using StellaOps.Policy;
|
||||
using StellaOps.Scanner.Core.Utility;
|
||||
@@ -10,12 +9,12 @@ using StellaOps.Scanner.Storage.Models;
|
||||
using StellaOps.Scanner.Storage.Services;
|
||||
using StellaOps.Scanner.WebService.Contracts;
|
||||
using StellaOps.Scanner.WebService.Options;
|
||||
using StellaOps.Scanner.WebService.Tenancy;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Diagnostics;
|
||||
using System.Linq;
|
||||
using System.Security.Claims;
|
||||
using System.Text;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Services;
|
||||
@@ -364,22 +363,7 @@ internal sealed class ReportEventDispatcher : IReportEventDispatcher
|
||||
|
||||
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 DefaultTenant;
|
||||
return ScannerRequestContextResolver.ResolveTenantOrDefault(context, DefaultTenant);
|
||||
}
|
||||
|
||||
private static OrchestratorEventScope BuildScope(ReportRequestDto request, ReportDocumentDto document)
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Scanner.Storage.Catalog;
|
||||
using StellaOps.Scanner.WebService.Contracts;
|
||||
using StellaOps.Scanner.WebService.Domain;
|
||||
using StellaOps.Scanner.WebService.Tenancy;
|
||||
using StellaOps.Scanner.WebService.Utilities;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
@@ -30,19 +32,22 @@ internal sealed class SbomByosUploadService : ISbomByosUploadService
|
||||
private readonly ISbomUploadStore _uploadStore;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<SbomByosUploadService> _logger;
|
||||
private readonly IHttpContextAccessor _httpContextAccessor;
|
||||
|
||||
public SbomByosUploadService(
|
||||
IScanCoordinator scanCoordinator,
|
||||
ISbomIngestionService ingestionService,
|
||||
ISbomUploadStore uploadStore,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<SbomByosUploadService> logger)
|
||||
ILogger<SbomByosUploadService> logger,
|
||||
IHttpContextAccessor httpContextAccessor)
|
||||
{
|
||||
_scanCoordinator = scanCoordinator ?? throw new ArgumentNullException(nameof(scanCoordinator));
|
||||
_ingestionService = ingestionService ?? throw new ArgumentNullException(nameof(ingestionService));
|
||||
_uploadStore = uploadStore ?? throw new ArgumentNullException(nameof(uploadStore));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_httpContextAccessor = httpContextAccessor ?? throw new ArgumentNullException(nameof(httpContextAccessor));
|
||||
}
|
||||
|
||||
public async Task<(SbomUploadResponseDto Response, SbomValidationSummaryDto Validation)> UploadAsync(
|
||||
@@ -118,7 +123,8 @@ internal sealed class SbomByosUploadService : ISbomByosUploadService
|
||||
|
||||
var metadata = BuildMetadata(request, format, formatVersion, digest, sbomId);
|
||||
var target = new ScanTarget(request.ArtifactRef.Trim(), request.ArtifactDigest?.Trim()).Normalize();
|
||||
var scanId = ScanIdGenerator.Create(target, force: false, clientRequestId: null, metadata);
|
||||
var tenantId = ResolveTenantId();
|
||||
var scanId = ScanIdGenerator.Create(target, force: false, clientRequestId: null, metadata, tenantId);
|
||||
|
||||
var ingestion = await _ingestionService
|
||||
.IngestAsync(
|
||||
@@ -131,7 +137,7 @@ internal sealed class SbomByosUploadService : ISbomByosUploadService
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var submission = new ScanSubmission(target, false, null, metadata);
|
||||
var submission = new ScanSubmission(target, false, null, metadata, tenantId);
|
||||
var scanResult = await _scanCoordinator.SubmitAsync(submission, cancellationToken).ConfigureAwait(false);
|
||||
if (!string.Equals(scanResult.Snapshot.ScanId.Value, scanId.Value, StringComparison.Ordinal))
|
||||
{
|
||||
@@ -241,6 +247,14 @@ internal sealed class SbomByosUploadService : ISbomByosUploadService
|
||||
return null;
|
||||
}
|
||||
|
||||
private string ResolveTenantId()
|
||||
{
|
||||
var context = _httpContextAccessor.HttpContext;
|
||||
return context is null
|
||||
? "default"
|
||||
: ScannerRequestContextResolver.ResolveTenantOrDefault(context);
|
||||
}
|
||||
|
||||
private static (string Format, string FormatVersion) ResolveFormat(JsonElement root, string? requestedFormat)
|
||||
{
|
||||
var format = string.IsNullOrWhiteSpace(requestedFormat)
|
||||
|
||||
@@ -56,6 +56,7 @@ public interface ISecretExceptionPatternService
|
||||
|
||||
/// <summary>Gets a specific pattern by ID.</summary>
|
||||
Task<SecretExceptionPatternResponseDto?> GetPatternAsync(
|
||||
Guid tenantId,
|
||||
Guid patternId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
@@ -68,6 +69,7 @@ public interface ISecretExceptionPatternService
|
||||
|
||||
/// <summary>Updates an exception pattern.</summary>
|
||||
Task<(bool Success, SecretExceptionPatternResponseDto? Pattern, IReadOnlyList<string> Errors)> UpdatePatternAsync(
|
||||
Guid tenantId,
|
||||
Guid patternId,
|
||||
SecretExceptionPatternDto pattern,
|
||||
string updatedBy,
|
||||
@@ -75,6 +77,7 @@ public interface ISecretExceptionPatternService
|
||||
|
||||
/// <summary>Deletes an exception pattern.</summary>
|
||||
Task<bool> DeletePatternAsync(
|
||||
Guid tenantId,
|
||||
Guid patternId,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -346,10 +349,11 @@ public sealed class SecretExceptionPatternService : ISecretExceptionPatternServi
|
||||
}
|
||||
|
||||
public async Task<SecretExceptionPatternResponseDto?> GetPatternAsync(
|
||||
Guid tenantId,
|
||||
Guid patternId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var pattern = await _repository.GetByIdAsync(patternId, cancellationToken).ConfigureAwait(false);
|
||||
var pattern = await _repository.GetByIdAsync(tenantId, patternId, cancellationToken).ConfigureAwait(false);
|
||||
return pattern is null ? null : MapToDto(pattern);
|
||||
}
|
||||
|
||||
@@ -384,12 +388,13 @@ public sealed class SecretExceptionPatternService : ISecretExceptionPatternServi
|
||||
}
|
||||
|
||||
public async Task<(bool Success, SecretExceptionPatternResponseDto? Pattern, IReadOnlyList<string> Errors)> UpdatePatternAsync(
|
||||
Guid tenantId,
|
||||
Guid patternId,
|
||||
SecretExceptionPatternDto pattern,
|
||||
string updatedBy,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var existing = await _repository.GetByIdAsync(patternId, cancellationToken).ConfigureAwait(false);
|
||||
var existing = await _repository.GetByIdAsync(tenantId, patternId, cancellationToken).ConfigureAwait(false);
|
||||
if (existing is null)
|
||||
{
|
||||
return (false, null, ["Pattern not found"]);
|
||||
@@ -412,21 +417,22 @@ public sealed class SecretExceptionPatternService : ISecretExceptionPatternServi
|
||||
existing.UpdatedBy = updatedBy;
|
||||
existing.UpdatedAt = _timeProvider.GetUtcNow();
|
||||
|
||||
var success = await _repository.UpdateAsync(existing, cancellationToken).ConfigureAwait(false);
|
||||
var success = await _repository.UpdateAsync(tenantId, existing, cancellationToken).ConfigureAwait(false);
|
||||
if (!success)
|
||||
{
|
||||
return (false, null, ["Failed to update pattern"]);
|
||||
}
|
||||
|
||||
var updated = await _repository.GetByIdAsync(patternId, cancellationToken).ConfigureAwait(false);
|
||||
var updated = await _repository.GetByIdAsync(tenantId, patternId, cancellationToken).ConfigureAwait(false);
|
||||
return (true, updated is null ? null : MapToDto(updated), []);
|
||||
}
|
||||
|
||||
public async Task<bool> DeletePatternAsync(
|
||||
Guid tenantId,
|
||||
Guid patternId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _repository.DeleteAsync(patternId, cancellationToken).ConfigureAwait(false);
|
||||
return await _repository.DeleteAsync(tenantId, patternId, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> ValidatePattern(SecretExceptionPatternDto pattern)
|
||||
|
||||
@@ -15,21 +15,30 @@ public sealed class TriageQueryService : ITriageQueryService
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<TriageFinding?> GetFindingAsync(string findingId, CancellationToken cancellationToken = default)
|
||||
public async Task<TriageFinding?> GetFindingAsync(
|
||||
string tenantId,
|
||||
string findingId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
|
||||
if (!Guid.TryParse(findingId, out var id))
|
||||
{
|
||||
_logger.LogWarning("Invalid finding id: {FindingId}", findingId);
|
||||
return null;
|
||||
}
|
||||
|
||||
var normalizedTenantId = tenantId.Trim().ToLowerInvariant();
|
||||
|
||||
return await _dbContext.Findings
|
||||
.Include(f => f.ReachabilityResults)
|
||||
.Include(f => f.RiskResults)
|
||||
.Include(f => f.EffectiveVexRecords)
|
||||
.Include(f => f.EvidenceArtifacts)
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(f => f.Id == id, cancellationToken)
|
||||
.FirstOrDefaultAsync(
|
||||
f => f.Id == id && f.TenantId == normalizedTenantId,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,12 +39,14 @@ public sealed class TriageStatusService : ITriageStatusService
|
||||
}
|
||||
|
||||
public async Task<FindingTriageStatusDto?> GetFindingStatusAsync(
|
||||
string tenantId,
|
||||
string findingId,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
_logger.LogDebug("Getting triage status for finding {FindingId}", findingId);
|
||||
|
||||
var finding = await _queryService.GetFindingAsync(findingId, ct);
|
||||
var finding = await _queryService.GetFindingAsync(tenantId, findingId, ct);
|
||||
if (finding is null)
|
||||
{
|
||||
return null;
|
||||
@@ -54,14 +56,16 @@ public sealed class TriageStatusService : ITriageStatusService
|
||||
}
|
||||
|
||||
public async Task<UpdateTriageStatusResponseDto?> UpdateStatusAsync(
|
||||
string tenantId,
|
||||
string findingId,
|
||||
UpdateTriageStatusRequestDto request,
|
||||
string actor,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
_logger.LogDebug("Updating triage status for finding {FindingId} by {Actor}", findingId, actor);
|
||||
|
||||
var finding = await _queryService.GetFindingAsync(findingId, ct);
|
||||
var finding = await _queryService.GetFindingAsync(tenantId, findingId, ct);
|
||||
if (finding is null)
|
||||
{
|
||||
return null;
|
||||
@@ -95,14 +99,16 @@ public sealed class TriageStatusService : ITriageStatusService
|
||||
}
|
||||
|
||||
public async Task<SubmitVexStatementResponseDto?> SubmitVexStatementAsync(
|
||||
string tenantId,
|
||||
string findingId,
|
||||
SubmitVexStatementRequestDto request,
|
||||
string actor,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
_logger.LogDebug("Submitting VEX statement for finding {FindingId} by {Actor}", findingId, actor);
|
||||
|
||||
var finding = await _queryService.GetFindingAsync(findingId, ct);
|
||||
var finding = await _queryService.GetFindingAsync(tenantId, findingId, ct);
|
||||
if (finding is null)
|
||||
{
|
||||
return null;
|
||||
@@ -137,10 +143,12 @@ public sealed class TriageStatusService : ITriageStatusService
|
||||
}
|
||||
|
||||
public Task<BulkTriageQueryResponseDto> QueryFindingsAsync(
|
||||
string tenantId,
|
||||
BulkTriageQueryRequestDto request,
|
||||
int limit,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
_logger.LogDebug("Querying findings with limit {Limit}", limit);
|
||||
|
||||
// In a full implementation, this would query the database
|
||||
@@ -162,8 +170,9 @@ public sealed class TriageStatusService : ITriageStatusService
|
||||
return Task.FromResult(response);
|
||||
}
|
||||
|
||||
public Task<TriageSummaryDto> GetSummaryAsync(string artifactDigest, CancellationToken ct = default)
|
||||
public Task<TriageSummaryDto> GetSummaryAsync(string tenantId, string artifactDigest, CancellationToken ct = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
_logger.LogDebug("Getting triage summary for artifact {ArtifactDigest}", artifactDigest);
|
||||
|
||||
// In a full implementation, this would aggregate data from the database
|
||||
|
||||
@@ -45,10 +45,12 @@ public sealed class UnifiedEvidenceService : IUnifiedEvidenceService
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<UnifiedEvidenceResponseDto?> GetUnifiedEvidenceAsync(
|
||||
string tenantId,
|
||||
string findingId,
|
||||
UnifiedEvidenceOptions? options = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
options ??= new UnifiedEvidenceOptions();
|
||||
|
||||
if (!Guid.TryParse(findingId, out var id))
|
||||
@@ -57,6 +59,8 @@ public sealed class UnifiedEvidenceService : IUnifiedEvidenceService
|
||||
return null;
|
||||
}
|
||||
|
||||
var normalizedTenantId = tenantId.Trim().ToLowerInvariant();
|
||||
|
||||
var finding = await _dbContext.Findings
|
||||
.Include(f => f.ReachabilityResults)
|
||||
.Include(f => f.EffectiveVexRecords)
|
||||
@@ -64,7 +68,9 @@ public sealed class UnifiedEvidenceService : IUnifiedEvidenceService
|
||||
.Include(f => f.EvidenceArtifacts)
|
||||
.Include(f => f.Attestations)
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(f => f.Id == id, cancellationToken)
|
||||
.FirstOrDefaultAsync(
|
||||
f => f.Id == id && f.TenantId == normalizedTenantId,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (finding is null)
|
||||
@@ -83,6 +89,7 @@ public sealed class UnifiedEvidenceService : IUnifiedEvidenceService
|
||||
|
||||
// Get replay commands
|
||||
var replayResponse = await _replayService.GenerateForFindingAsync(
|
||||
normalizedTenantId,
|
||||
new GenerateReplayCommandRequestDto { FindingId = findingId },
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
|
||||
@@ -0,0 +1,322 @@
|
||||
using StellaOps.Scanner.Storage.Postgres;
|
||||
using System.Data.Common;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Services;
|
||||
|
||||
internal sealed class UnknownsQueryService : IUnknownsQueryService
|
||||
{
|
||||
private const double HotBandThreshold = 0.70;
|
||||
private const double WarmBandThreshold = 0.40;
|
||||
|
||||
private readonly ScannerDataSource _dataSource;
|
||||
private readonly ILogger<UnknownsQueryService> _logger;
|
||||
|
||||
public UnknownsQueryService(ScannerDataSource dataSource, ILogger<UnknownsQueryService> logger)
|
||||
{
|
||||
_dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<UnknownsListResult> ListAsync(
|
||||
string tenantId,
|
||||
UnknownsListQuery query,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentNullException.ThrowIfNull(query);
|
||||
|
||||
try
|
||||
{
|
||||
await using var connection = await _dataSource
|
||||
.OpenConnectionAsync(tenantId, "reader", cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var whereClause = BuildWhereClause(query);
|
||||
var orderByClause = BuildOrderByClause(query.SortBy, query.SortOrder);
|
||||
|
||||
var countSql = $"""
|
||||
SELECT COUNT(*)
|
||||
FROM unknowns
|
||||
WHERE {whereClause}
|
||||
""";
|
||||
|
||||
var selectSql = $"""
|
||||
SELECT
|
||||
unknown_id,
|
||||
artifact_digest,
|
||||
vuln_id,
|
||||
package_purl,
|
||||
score,
|
||||
created_at_utc,
|
||||
updated_at_utc
|
||||
FROM unknowns
|
||||
WHERE {whereClause}
|
||||
ORDER BY {orderByClause}
|
||||
LIMIT @limit
|
||||
OFFSET @offset
|
||||
""";
|
||||
|
||||
await using var countCommand = connection.CreateCommand();
|
||||
countCommand.CommandText = countSql;
|
||||
countCommand.CommandTimeout = _dataSource.CommandTimeoutSeconds;
|
||||
BindCommonParameters(countCommand, tenantId, query);
|
||||
|
||||
var totalCountObject = await countCommand.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
|
||||
var totalCount = Convert.ToInt32(totalCountObject ?? 0, System.Globalization.CultureInfo.InvariantCulture);
|
||||
|
||||
await using var selectCommand = connection.CreateCommand();
|
||||
selectCommand.CommandText = selectSql;
|
||||
selectCommand.CommandTimeout = _dataSource.CommandTimeoutSeconds;
|
||||
BindCommonParameters(selectCommand, tenantId, query);
|
||||
AddParameter(selectCommand, "limit", query.Limit);
|
||||
AddParameter(selectCommand, "offset", query.Offset);
|
||||
|
||||
var items = new List<UnknownsListItem>();
|
||||
await using var reader = await selectCommand.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
items.Add(new UnknownsListItem
|
||||
{
|
||||
UnknownId = reader.GetGuid(0),
|
||||
ArtifactDigest = reader.GetString(1),
|
||||
VulnerabilityId = reader.GetString(2),
|
||||
PackagePurl = reader.GetString(3),
|
||||
Score = reader.IsDBNull(4) ? 0 : reader.GetDouble(4),
|
||||
CreatedAtUtc = reader.GetFieldValue<DateTimeOffset>(5),
|
||||
UpdatedAtUtc = reader.GetFieldValue<DateTimeOffset>(6)
|
||||
});
|
||||
}
|
||||
|
||||
return new UnknownsListResult
|
||||
{
|
||||
Items = items,
|
||||
TotalCount = totalCount
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
ex,
|
||||
"Failed to query unknowns list for tenant {TenantId}. Returning empty result.",
|
||||
tenantId);
|
||||
|
||||
return new UnknownsListResult
|
||||
{
|
||||
Items = [],
|
||||
TotalCount = 0
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<UnknownsDetail?> GetByIdAsync(
|
||||
string tenantId,
|
||||
Guid unknownId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
|
||||
try
|
||||
{
|
||||
await using var connection = await _dataSource
|
||||
.OpenConnectionAsync(tenantId, "reader", cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
const string sql = """
|
||||
SELECT
|
||||
unknown_id,
|
||||
artifact_digest,
|
||||
vuln_id,
|
||||
package_purl,
|
||||
score,
|
||||
proof_ref,
|
||||
created_at_utc,
|
||||
updated_at_utc
|
||||
FROM unknowns
|
||||
WHERE tenant_id::text = @tenantId
|
||||
AND unknown_id = @unknownId
|
||||
""";
|
||||
|
||||
await using var command = connection.CreateCommand();
|
||||
command.CommandText = sql;
|
||||
command.CommandTimeout = _dataSource.CommandTimeoutSeconds;
|
||||
AddParameter(command, "tenantId", tenantId);
|
||||
AddParameter(command, "unknownId", unknownId);
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (!await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new UnknownsDetail
|
||||
{
|
||||
UnknownId = reader.GetGuid(0),
|
||||
ArtifactDigest = reader.GetString(1),
|
||||
VulnerabilityId = reader.GetString(2),
|
||||
PackagePurl = reader.GetString(3),
|
||||
Score = reader.IsDBNull(4) ? 0 : reader.GetDouble(4),
|
||||
ProofRef = reader.IsDBNull(5) ? null : reader.GetString(5),
|
||||
CreatedAtUtc = reader.GetFieldValue<DateTimeOffset>(6),
|
||||
UpdatedAtUtc = reader.GetFieldValue<DateTimeOffset>(7)
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
ex,
|
||||
"Failed to query unknown {UnknownId} for tenant {TenantId}. Returning no result.",
|
||||
unknownId,
|
||||
tenantId);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<UnknownsStats> GetStatsAsync(string tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
|
||||
try
|
||||
{
|
||||
await using var connection = await _dataSource
|
||||
.OpenConnectionAsync(tenantId, "reader", cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
const string sql = """
|
||||
SELECT
|
||||
COUNT(*) AS total,
|
||||
COUNT(*) FILTER (WHERE score >= @hotThreshold) AS hot,
|
||||
COUNT(*) FILTER (WHERE score >= @warmThreshold AND score < @hotThreshold) AS warm,
|
||||
COUNT(*) FILTER (WHERE score < @warmThreshold) AS cold
|
||||
FROM unknowns
|
||||
WHERE tenant_id::text = @tenantId
|
||||
""";
|
||||
|
||||
await using var command = connection.CreateCommand();
|
||||
command.CommandText = sql;
|
||||
command.CommandTimeout = _dataSource.CommandTimeoutSeconds;
|
||||
AddParameter(command, "tenantId", tenantId);
|
||||
AddParameter(command, "hotThreshold", HotBandThreshold);
|
||||
AddParameter(command, "warmThreshold", WarmBandThreshold);
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (!await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
return EmptyStats();
|
||||
}
|
||||
|
||||
return new UnknownsStats
|
||||
{
|
||||
Total = reader.IsDBNull(0) ? 0 : reader.GetInt64(0),
|
||||
Hot = reader.IsDBNull(1) ? 0 : reader.GetInt64(1),
|
||||
Warm = reader.IsDBNull(2) ? 0 : reader.GetInt64(2),
|
||||
Cold = reader.IsDBNull(3) ? 0 : reader.GetInt64(3)
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
ex,
|
||||
"Failed to query unknown stats for tenant {TenantId}. Returning empty stats.",
|
||||
tenantId);
|
||||
return EmptyStats();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyDictionary<string, long>> GetBandDistributionAsync(
|
||||
string tenantId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var stats = await GetStatsAsync(tenantId, cancellationToken).ConfigureAwait(false);
|
||||
return new Dictionary<string, long>(StringComparer.Ordinal)
|
||||
{
|
||||
["HOT"] = stats.Hot,
|
||||
["WARM"] = stats.Warm,
|
||||
["COLD"] = stats.Cold
|
||||
};
|
||||
}
|
||||
|
||||
private static UnknownsStats EmptyStats()
|
||||
=> new()
|
||||
{
|
||||
Total = 0,
|
||||
Hot = 0,
|
||||
Warm = 0,
|
||||
Cold = 0
|
||||
};
|
||||
|
||||
private static string BuildWhereClause(UnknownsListQuery query)
|
||||
{
|
||||
var predicates = new List<string>
|
||||
{
|
||||
"tenant_id::text = @tenantId"
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(query.ArtifactDigest))
|
||||
{
|
||||
predicates.Add("artifact_digest = @artifactDigest");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(query.VulnerabilityId))
|
||||
{
|
||||
predicates.Add("vuln_id = @vulnerabilityId");
|
||||
}
|
||||
|
||||
if (query.Band.HasValue)
|
||||
{
|
||||
switch (query.Band.Value)
|
||||
{
|
||||
case UnknownsBand.Hot:
|
||||
predicates.Add($"score >= {HotBandThreshold.ToString(System.Globalization.CultureInfo.InvariantCulture)}");
|
||||
break;
|
||||
case UnknownsBand.Warm:
|
||||
predicates.Add(
|
||||
$"score >= {WarmBandThreshold.ToString(System.Globalization.CultureInfo.InvariantCulture)} AND " +
|
||||
$"score < {HotBandThreshold.ToString(System.Globalization.CultureInfo.InvariantCulture)}");
|
||||
break;
|
||||
case UnknownsBand.Cold:
|
||||
predicates.Add($"score < {WarmBandThreshold.ToString(System.Globalization.CultureInfo.InvariantCulture)}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return string.Join(" AND ", predicates);
|
||||
}
|
||||
|
||||
private static string BuildOrderByClause(UnknownsSortField sortField, UnknownsSortOrder sortOrder)
|
||||
{
|
||||
var column = sortField switch
|
||||
{
|
||||
UnknownsSortField.Score => "score",
|
||||
UnknownsSortField.CreatedAt => "created_at_utc",
|
||||
UnknownsSortField.UpdatedAt => "updated_at_utc",
|
||||
_ => "score"
|
||||
};
|
||||
|
||||
var direction = sortOrder == UnknownsSortOrder.Ascending ? "ASC" : "DESC";
|
||||
return $"{column} {direction}, unknown_id ASC";
|
||||
}
|
||||
|
||||
private static void BindCommonParameters(DbCommand command, string tenantId, UnknownsListQuery query)
|
||||
{
|
||||
AddParameter(command, "tenantId", tenantId);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(query.ArtifactDigest))
|
||||
{
|
||||
AddParameter(command, "artifactDigest", query.ArtifactDigest);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(query.VulnerabilityId))
|
||||
{
|
||||
AddParameter(command, "vulnerabilityId", query.VulnerabilityId);
|
||||
}
|
||||
}
|
||||
|
||||
private static void AddParameter(DbCommand command, string parameterName, object value)
|
||||
{
|
||||
var parameter = command.CreateParameter();
|
||||
parameter.ParameterName = parameterName;
|
||||
parameter.Value = value;
|
||||
_ = command.Parameters.Add(parameter);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,9 +61,6 @@
|
||||
<ProjectReference Include="../../Router/__Libraries/StellaOps.Router.AspNet/StellaOps.Router.AspNet.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Compile Remove="Endpoints\\UnknownsEndpoints.cs" />
|
||||
</ItemGroup>
|
||||
<PropertyGroup Label="StellaOpsReleaseVersion">
|
||||
<Version>1.0.0-alpha1</Version>
|
||||
<InformationalVersion>1.0.0-alpha1</InformationalVersion>
|
||||
|
||||
@@ -16,3 +16,7 @@ Source of truth: `docs/implplan/SPRINT_20260112_003_BE_csproj_audit_pending_appl
|
||||
| HOT-003 | DONE | `SPRINT_20260210_001_DOCS_sbom_attestation_hot_lookup_contract.md`: wired SBOM ingestion projection writes into Scanner WebService pipeline. |
|
||||
| HOT-004 | DONE | `SPRINT_20260210_001_DOCS_sbom_attestation_hot_lookup_contract.md`: added SBOM hot-lookup read endpoints with bounded pagination. |
|
||||
| SPRINT-20260212-002-SMARTDIFF-001 | DONE | `SPRINT_20260212_002_Scanner_unchecked_feature_verification_batch1.md`: wired SmartDiff endpoints into Program, added scan-scoped VEX candidate/review API compatibility, and embedded VEX candidates in SARIF output (2026-02-12). |
|
||||
| SPRINT-20260222-057-SCAN-TEN | DONE | `SPRINT_20260222_057_Scanner_tenant_isolation_for_scans_triage_webhooks.md`: enforced tenant-scoped triage/finding service contracts and endpoint propagation for SCAN-TEN-04/08 (2026-02-22). |
|
||||
| SPRINT-20260222-057-SCAN-TEN-10 | DONE | `SPRINT_20260222_057_Scanner_tenant_isolation_for_scans_triage_webhooks.md`: activated `/api/v1/unknowns` endpoint map with tenant-aware resolver + query service wiring (2026-02-22). |
|
||||
| SPRINT-20260222-057-SCAN-TEN-11 | DONE | `SPRINT_20260222_057_Scanner_tenant_isolation_for_scans_triage_webhooks.md`: propagated resolved tenant context through SmartDiff/Reachability endpoints into tenant-partitioned repository queries (2026-02-23). |
|
||||
| SPRINT-20260222-057-SCAN-TEN-13 | DONE | `SPRINT_20260222_057_Scanner_tenant_isolation_for_scans_triage_webhooks.md`: updated source-run and secret-exception service/endpoints to require tenant-scoped repository lookups for API-backed tenant tables (2026-02-23). |
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using System.Security.Claims;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Tenancy;
|
||||
|
||||
internal static class ScannerRequestContextResolver
|
||||
{
|
||||
private const string DefaultTenant = "default";
|
||||
private const string LegacyTenantClaim = "tid";
|
||||
private const string LegacyTenantIdClaim = "tenant_id";
|
||||
private const string LegacyTenantHeader = "X-Stella-Tenant";
|
||||
private const string AlternateTenantHeader = "X-Tenant-Id";
|
||||
private const string ActorHeader = "X-StellaOps-Actor";
|
||||
|
||||
public static bool TryResolveTenant(
|
||||
HttpContext context,
|
||||
out string tenantId,
|
||||
out string? error,
|
||||
bool allowDefaultTenant = false,
|
||||
string defaultTenant = DefaultTenant)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
|
||||
tenantId = string.Empty;
|
||||
error = null;
|
||||
|
||||
var claimTenant = NormalizeTenant(ResolveTenantClaim(context.User));
|
||||
var canonicalHeaderTenant = ReadTenantHeader(context, StellaOpsHttpHeaderNames.Tenant);
|
||||
var legacyHeaderTenant = ReadTenantHeader(context, LegacyTenantHeader);
|
||||
var alternateHeaderTenant = ReadTenantHeader(context, AlternateTenantHeader);
|
||||
|
||||
if (HasConflictingTenants(canonicalHeaderTenant, legacyHeaderTenant, alternateHeaderTenant))
|
||||
{
|
||||
error = "tenant_conflict";
|
||||
return false;
|
||||
}
|
||||
|
||||
var headerTenant = canonicalHeaderTenant ?? legacyHeaderTenant ?? alternateHeaderTenant;
|
||||
if (!string.IsNullOrWhiteSpace(claimTenant))
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(headerTenant)
|
||||
&& !string.Equals(claimTenant, headerTenant, StringComparison.Ordinal))
|
||||
{
|
||||
error = "tenant_conflict";
|
||||
return false;
|
||||
}
|
||||
|
||||
tenantId = claimTenant;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(headerTenant))
|
||||
{
|
||||
tenantId = headerTenant;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (allowDefaultTenant)
|
||||
{
|
||||
tenantId = NormalizeTenant(defaultTenant) ?? DefaultTenant;
|
||||
return true;
|
||||
}
|
||||
|
||||
error = "tenant_missing";
|
||||
return false;
|
||||
}
|
||||
|
||||
public static string ResolveTenantOrDefault(HttpContext context, string defaultTenant = DefaultTenant)
|
||||
{
|
||||
if (TryResolveTenant(context, out var tenantId, out _, allowDefaultTenant: true, defaultTenant))
|
||||
{
|
||||
return tenantId;
|
||||
}
|
||||
|
||||
return NormalizeTenant(defaultTenant) ?? DefaultTenant;
|
||||
}
|
||||
|
||||
public static string ResolveTenantPartitionKey(HttpContext context)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
|
||||
if (TryResolveTenant(context, out var tenantId, out _, allowDefaultTenant: false))
|
||||
{
|
||||
return tenantId;
|
||||
}
|
||||
|
||||
var remoteIp = context.Connection.RemoteIpAddress?.ToString();
|
||||
if (!string.IsNullOrWhiteSpace(remoteIp))
|
||||
{
|
||||
return $"ip:{remoteIp.Trim()}";
|
||||
}
|
||||
|
||||
return "anonymous";
|
||||
}
|
||||
|
||||
public static string ResolveActor(HttpContext context, string fallback = "system")
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(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();
|
||||
}
|
||||
|
||||
if (TryResolveHeader(context, ActorHeader, out var actorHeaderValue))
|
||||
{
|
||||
return actorHeaderValue;
|
||||
}
|
||||
|
||||
var identityName = context.User.Identity?.Name;
|
||||
if (!string.IsNullOrWhiteSpace(identityName))
|
||||
{
|
||||
return identityName.Trim();
|
||||
}
|
||||
|
||||
return fallback;
|
||||
}
|
||||
|
||||
private static bool HasConflictingTenants(params string?[] tenantCandidates)
|
||||
{
|
||||
string? baseline = null;
|
||||
foreach (var candidate in tenantCandidates)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(candidate))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (baseline is null)
|
||||
{
|
||||
baseline = candidate;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!string.Equals(baseline, candidate, StringComparison.Ordinal))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static string? ResolveTenantClaim(ClaimsPrincipal principal)
|
||||
{
|
||||
return principal.FindFirstValue(StellaOpsClaimTypes.Tenant)
|
||||
?? principal.FindFirstValue(LegacyTenantClaim)
|
||||
?? principal.FindFirstValue(LegacyTenantIdClaim);
|
||||
}
|
||||
|
||||
private static string? ReadTenantHeader(HttpContext context, string headerName)
|
||||
{
|
||||
return TryResolveHeader(context, headerName, out var value)
|
||||
? NormalizeTenant(value)
|
||||
: null;
|
||||
}
|
||||
|
||||
private static bool TryResolveHeader(HttpContext context, string headerName, out string value)
|
||||
{
|
||||
value = string.Empty;
|
||||
|
||||
if (!context.Request.Headers.TryGetValue(headerName, out var values))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var raw = values.ToString();
|
||||
if (string.IsNullOrWhiteSpace(raw))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
value = raw.Trim();
|
||||
return true;
|
||||
}
|
||||
|
||||
private static string? NormalizeTenant(string? value)
|
||||
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim().ToLowerInvariant();
|
||||
}
|
||||
@@ -13,11 +13,18 @@ internal static class ScanIdGenerator
|
||||
ScanTarget target,
|
||||
bool force,
|
||||
string? clientRequestId,
|
||||
IReadOnlyDictionary<string, string>? metadata)
|
||||
IReadOnlyDictionary<string, string>? metadata,
|
||||
string? tenantId = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(target);
|
||||
|
||||
var normalizedTenant = string.IsNullOrWhiteSpace(tenantId)
|
||||
? "default"
|
||||
: tenantId.Trim().ToLowerInvariant();
|
||||
|
||||
var builder = new StringBuilder();
|
||||
builder.Append("tenant:");
|
||||
builder.Append(normalizedTenant);
|
||||
builder.Append('|');
|
||||
builder.Append(target.Reference?.Trim().ToLowerInvariant() ?? string.Empty);
|
||||
builder.Append('|');
|
||||
|
||||
Reference in New Issue
Block a user