wip: doctor/cli/docs/api to vector db consolidation; api hardening for descriptions, tenant, and scopes; migrations and conversions of all DALs to EF v10

This commit is contained in:
master
2026-02-23 15:30:50 +02:00
parent bd8fee6ed8
commit e746577380
1424 changed files with 81225 additions and 25251 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -106,7 +106,7 @@ internal static class CallGraphEndpoints
}
// Check for duplicate submission (idempotency)
var existing = await ingestionService.FindByDigestAsync(parsed, contentDigest, cancellationToken)
var existing = await ingestionService.FindByDigestAsync(parsed, snapshot.TenantId, contentDigest, cancellationToken)
.ConfigureAwait(false);
if (existing is not null)
@@ -127,7 +127,7 @@ internal static class CallGraphEndpoints
}
// Ingest the call graph
var result = await ingestionService.IngestAsync(parsed, request, contentDigest, cancellationToken)
var result = await ingestionService.IngestAsync(parsed, snapshot.TenantId, request, contentDigest, cancellationToken)
.ConfigureAwait(false);
var response = new CallGraphAcceptedResponseDto(

View File

@@ -11,6 +11,7 @@ using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Routing;
using StellaOps.Scanner.Core.Epss;
using StellaOps.Scanner.WebService.Security;
using System.ComponentModel.DataAnnotations;
namespace StellaOps.Scanner.WebService.Endpoints;
@@ -29,7 +30,8 @@ public static class EpssEndpoints
#pragma warning disable ASPDEPR002 // WithOpenApi is deprecated - migration pending
var group = endpoints.MapGroup("/epss")
.WithTags("EPSS")
.WithOpenApi();
.WithOpenApi()
.RequireAuthorization(ScannerPolicies.ScansRead);
#pragma warning restore ASPDEPR002
group.MapPost("/current", GetCurrentBatch)

View File

@@ -1,5 +1,6 @@
using Microsoft.AspNetCore.Mvc;
using StellaOps.Scanner.Orchestration.Fidelity;
using StellaOps.Scanner.WebService.Security;
namespace StellaOps.Scanner.WebService.Endpoints;
@@ -9,7 +10,7 @@ public static class FidelityEndpoints
{
var group = app.MapGroup("/api/v1/scan")
.WithTags("Fidelity")
.RequireAuthorization();
.RequireAuthorization(ScannerPolicies.ScansWrite);
// POST /api/v1/scan/analyze?fidelity={level}
group.MapPost("/analyze", async (

View File

@@ -12,7 +12,9 @@ internal static class ObservabilityEndpoints
endpoints.MapGet("/metrics", HandleMetricsAsync)
.WithName("scanner.metrics")
.Produces(StatusCodes.Status200OK);
.WithDescription("Exposes scanner service metrics in Prometheus text format (text/plain 0.0.4). Scraped by Prometheus without authentication.")
.Produces(StatusCodes.Status200OK)
.AllowAnonymous();
}
private static IResult HandleMetricsAsync(OfflineKitMetricsStore metricsStore)

View File

@@ -3,14 +3,13 @@ using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.Options;
using StellaOps.Auth.Abstractions;
using StellaOps.Scanner.Core.Configuration;
using StellaOps.Scanner.WebService.Constants;
using StellaOps.Scanner.WebService.Infrastructure;
using StellaOps.Scanner.WebService.Security;
using StellaOps.Scanner.WebService.Services;
using StellaOps.Scanner.WebService.Tenancy;
using System.Linq;
using System.Security.Claims;
using System.Text.Json;
namespace StellaOps.Scanner.WebService.Endpoints;
@@ -221,39 +220,12 @@ internal static class OfflineKitEndpoints
private static string ResolveTenant(HttpContext context)
{
var tenant = context.User?.FindFirstValue(StellaOpsClaimTypes.Tenant);
if (!string.IsNullOrWhiteSpace(tenant))
{
return tenant.Trim();
}
if (context.Request.Headers.TryGetValue("X-Stella-Tenant", out var headerTenant))
{
var headerValue = headerTenant.ToString();
if (!string.IsNullOrWhiteSpace(headerValue))
{
return headerValue.Trim();
}
}
return "default";
return ScannerRequestContextResolver.ResolveTenantOrDefault(context);
}
private static string ResolveActor(HttpContext context)
{
var subject = context.User?.FindFirstValue(StellaOpsClaimTypes.Subject);
if (!string.IsNullOrWhiteSpace(subject))
{
return subject.Trim();
}
var clientId = context.User?.FindFirstValue(StellaOpsClaimTypes.ClientId);
if (!string.IsNullOrWhiteSpace(clientId))
{
return clientId.Trim();
}
return "anonymous";
return ScannerRequestContextResolver.ResolveActor(context, fallback: "anonymous");
}
// Sprint 026: OFFLINE-011 - Manifest retrieval handler
@@ -339,4 +311,3 @@ internal static class OfflineKitEndpoints
return Results.Ok(result);
}
}

View File

@@ -9,6 +9,7 @@ using StellaOps.Scanner.WebService.Domain;
using StellaOps.Scanner.WebService.Infrastructure;
using StellaOps.Scanner.WebService.Security;
using StellaOps.Scanner.WebService.Services;
using StellaOps.Scanner.WebService.Tenancy;
using System.Collections.Immutable;
using System.Text.Json;
using System.Text.Json.Serialization;
@@ -73,6 +74,10 @@ internal static class ReachabilityDriftEndpoints
ArgumentNullException.ThrowIfNull(codeChangeRepository);
ArgumentNullException.ThrowIfNull(driftDetector);
ArgumentNullException.ThrowIfNull(driftRepository);
if (!TryResolveTenant(context, out var tenantId, out var failure))
{
return failure!;
}
if (!ScanId.TryParse(scanId, out var headScan))
{
@@ -99,7 +104,11 @@ internal static class ReachabilityDriftEndpoints
if (string.IsNullOrWhiteSpace(baseScanId))
{
var existing = await driftRepository.TryGetLatestForHeadAsync(headScan.Value, resolvedLanguage, cancellationToken)
var existing = await driftRepository.TryGetLatestForHeadAsync(
headScan.Value,
resolvedLanguage,
cancellationToken,
tenantId: tenantId)
.ConfigureAwait(false);
if (existing is null)
@@ -136,7 +145,11 @@ internal static class ReachabilityDriftEndpoints
detail: "Base scan could not be located.");
}
var baseGraph = await callGraphSnapshots.TryGetLatestAsync(baseScan.Value, resolvedLanguage, cancellationToken)
var baseGraph = await callGraphSnapshots.TryGetLatestAsync(
baseScan.Value,
resolvedLanguage,
cancellationToken,
tenantId: tenantId)
.ConfigureAwait(false);
if (baseGraph is null)
{
@@ -148,7 +161,11 @@ internal static class ReachabilityDriftEndpoints
detail: $"No call graph snapshot found for base scan {baseScan.Value} (language={resolvedLanguage}).");
}
var headGraph = await callGraphSnapshots.TryGetLatestAsync(headScan.Value, resolvedLanguage, cancellationToken)
var headGraph = await callGraphSnapshots.TryGetLatestAsync(
headScan.Value,
resolvedLanguage,
cancellationToken,
tenantId: tenantId)
.ConfigureAwait(false);
if (headGraph is null)
{
@@ -163,7 +180,7 @@ internal static class ReachabilityDriftEndpoints
try
{
var codeChanges = codeChangeFactExtractor.Extract(baseGraph, headGraph);
await codeChangeRepository.StoreAsync(codeChanges, cancellationToken).ConfigureAwait(false);
await codeChangeRepository.StoreAsync(codeChanges, cancellationToken, tenantId: tenantId).ConfigureAwait(false);
var drift = driftDetector.Detect(
baseGraph,
@@ -171,7 +188,7 @@ internal static class ReachabilityDriftEndpoints
codeChanges,
includeFullPath: includeFullPath == true);
await driftRepository.StoreAsync(drift, cancellationToken).ConfigureAwait(false);
await driftRepository.StoreAsync(drift, cancellationToken, tenantId: tenantId).ConfigureAwait(false);
return Json(drift, StatusCodes.Status200OK);
}
catch (ArgumentException ex)
@@ -195,6 +212,10 @@ internal static class ReachabilityDriftEndpoints
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(driftRepository);
if (!TryResolveTenant(context, out var tenantId, out var failure))
{
return failure!;
}
if (driftId == Guid.Empty)
{
@@ -238,7 +259,7 @@ internal static class ReachabilityDriftEndpoints
detail: "limit must be between 1 and 500.");
}
if (!await driftRepository.ExistsAsync(driftId, cancellationToken).ConfigureAwait(false))
if (!await driftRepository.ExistsAsync(driftId, cancellationToken, tenantId: tenantId).ConfigureAwait(false))
{
return ProblemResultFactory.Create(
context,
@@ -253,7 +274,8 @@ internal static class ReachabilityDriftEndpoints
parsedDirection,
resolvedOffset,
resolvedLimit,
cancellationToken).ConfigureAwait(false);
cancellationToken,
tenantId: tenantId).ConfigureAwait(false);
var response = new DriftedSinksResponseDto(
DriftId: driftId,
@@ -297,6 +319,29 @@ internal static class ReachabilityDriftEndpoints
var payload = JsonSerializer.Serialize(value, SerializerOptions);
return Results.Content(payload, "application/json", System.Text.Encoding.UTF8, statusCode);
}
private static bool TryResolveTenant(HttpContext context, out string tenantId, out IResult? failure)
{
tenantId = string.Empty;
failure = null;
if (ScannerRequestContextResolver.TryResolveTenant(
context,
out tenantId,
out var tenantError,
allowDefaultTenant: true))
{
return true;
}
failure = ProblemResultFactory.Create(
context,
ProblemTypes.Validation,
"Invalid tenant context",
StatusCodes.Status400BadRequest,
detail: tenantError ?? "tenant_conflict");
return false;
}
}
internal sealed record DriftedSinksResponseDto(

View File

@@ -9,6 +9,7 @@ using Microsoft.AspNetCore.Routing;
using StellaOps.Scanner.Reachability.Jobs;
using StellaOps.Scanner.Reachability.Services;
using StellaOps.Scanner.Reachability.Vex;
using StellaOps.Scanner.WebService.Security;
namespace StellaOps.Scanner.WebService.Endpoints;
@@ -24,7 +25,8 @@ public static class ReachabilityEvidenceEndpoints
this IEndpointRouteBuilder routes)
{
var group = routes.MapGroup("/api/reachability")
.WithTags("Reachability Evidence");
.WithTags("Reachability Evidence")
.RequireAuthorization(ScannerPolicies.ScansRead);
// Analyze reachability for a CVE
group.MapPost("/analyze", AnalyzeAsync)
@@ -32,7 +34,8 @@ public static class ReachabilityEvidenceEndpoints
.WithSummary("Analyze reachability of a CVE in an image")
.Produces<ReachabilityAnalyzeResponse>(StatusCodes.Status200OK)
.Produces<ProblemDetails>(StatusCodes.Status400BadRequest)
.Produces<ProblemDetails>(StatusCodes.Status404NotFound);
.Produces<ProblemDetails>(StatusCodes.Status404NotFound)
.RequireAuthorization(ScannerPolicies.ScansWrite);
// Get job result
group.MapGet("/result/{jobId}", GetResultAsync)
@@ -53,7 +56,8 @@ public static class ReachabilityEvidenceEndpoints
.WithName("GenerateVexFromReachability")
.WithSummary("Generate VEX statement from reachability analysis")
.Produces<VexStatementResponse>(StatusCodes.Status200OK)
.Produces<ProblemDetails>(StatusCodes.Status400BadRequest);
.Produces<ProblemDetails>(StatusCodes.Status400BadRequest)
.RequireAuthorization(ScannerPolicies.ScansWrite);
return routes;
}

View File

@@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using StellaOps.Scanner.WebService.Contracts;
using StellaOps.Scanner.WebService.Domain;
using StellaOps.Scanner.WebService.Security;
using StellaOps.Scanner.WebService.Services;
namespace StellaOps.Scanner.WebService.Endpoints;
@@ -10,7 +11,8 @@ internal static class ReplayEndpoints
{
public static void MapReplayEndpoints(this RouteGroupBuilder apiGroup)
{
var replay = apiGroup.MapGroup("/replay");
var replay = apiGroup.MapGroup("/replay")
.RequireAuthorization(ScannerPolicies.ScansWrite);
replay.MapPost("/{scanId}/attach", HandleAttachAsync)
.WithName("scanner.replay.attach")

View File

@@ -12,6 +12,7 @@ using StellaOps.Scanner.WebService.Infrastructure;
using StellaOps.Scanner.WebService.Options;
using StellaOps.Scanner.WebService.Security;
using StellaOps.Scanner.WebService.Services;
using StellaOps.Scanner.WebService.Tenancy;
using System.Collections.Generic;
using System.IO.Pipelines;
using System.Linq;
@@ -151,11 +152,26 @@ internal static class ScanEndpoints
metadata["determinism.policy"] = determinism.PolicySnapshotId;
}
if (!ScannerRequestContextResolver.TryResolveTenant(
context,
out var tenantId,
out var tenantError,
allowDefaultTenant: true))
{
return ProblemResultFactory.Create(
context,
ProblemTypes.Validation,
"Invalid tenant context",
StatusCodes.Status400BadRequest,
detail: tenantError ?? "tenant_conflict");
}
var submission = new ScanSubmission(
Target: target,
Force: request.Force,
ClientRequestId: request.ClientRequestId?.Trim(),
Metadata: metadata);
Metadata: metadata,
TenantId: tenantId);
ScanSubmissionResult result;
try

View File

@@ -10,6 +10,7 @@ using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Routing;
using StellaOps.Scanner.Core;
using StellaOps.Scanner.WebService.Contracts;
using StellaOps.Scanner.WebService.Security;
using StellaOps.Scanner.WebService.Services;
namespace StellaOps.Scanner.WebService.Endpoints;
@@ -18,7 +19,8 @@ internal static class ScoreReplayEndpoints
{
public static void MapScoreReplayEndpoints(this RouteGroupBuilder apiGroup)
{
var score = apiGroup.MapGroup("/score");
var score = apiGroup.MapGroup("/score")
.RequireAuthorization(ScannerPolicies.ScansRead);
score.MapPost("/{scanId}/replay", HandleReplayAsync)
.WithName("scanner.score.replay")
@@ -26,7 +28,8 @@ internal static class ScoreReplayEndpoints
.Produces<ProblemDetails>(StatusCodes.Status404NotFound)
.Produces<ProblemDetails>(StatusCodes.Status400BadRequest)
.Produces<ProblemDetails>(StatusCodes.Status422UnprocessableEntity)
.WithDescription("Replay scoring for a previous scan using frozen inputs");
.WithDescription("Replay scoring for a previous scan using frozen inputs")
.RequireAuthorization(ScannerPolicies.ScansWrite);
score.MapGet("/{scanId}/bundle", HandleGetBundleAsync)
.WithName("scanner.score.bundle")
@@ -39,7 +42,8 @@ internal static class ScoreReplayEndpoints
.Produces<ScoreVerifyResponse>(StatusCodes.Status200OK)
.Produces<ProblemDetails>(StatusCodes.Status404NotFound)
.Produces<ProblemDetails>(StatusCodes.Status422UnprocessableEntity)
.WithDescription("Verify a proof bundle against expected root hash");
.WithDescription("Verify a proof bundle against expected root hash")
.RequireAuthorization(ScannerPolicies.ScansWrite);
}
/// <summary>

View File

@@ -237,9 +237,8 @@ internal static class SecretDetectionSettingsEndpoints
ISecretExceptionPatternService service,
CancellationToken cancellationToken)
{
var pattern = await service.GetPatternAsync(exceptionId, cancellationToken);
if (pattern is null || pattern.TenantId != tenantId)
var pattern = await service.GetPatternAsync(tenantId, exceptionId, cancellationToken);
if (pattern is null)
{
return Results.NotFound(new
{
@@ -284,9 +283,8 @@ internal static class SecretDetectionSettingsEndpoints
HttpContext context,
CancellationToken cancellationToken)
{
// Verify pattern belongs to tenant
var existing = await service.GetPatternAsync(exceptionId, cancellationToken);
if (existing is null || existing.TenantId != tenantId)
var existing = await service.GetPatternAsync(tenantId, exceptionId, cancellationToken);
if (existing is null)
{
return Results.NotFound(new
{
@@ -298,6 +296,7 @@ internal static class SecretDetectionSettingsEndpoints
var username = context.User.Identity?.Name ?? "system";
var (success, pattern, errors) = await service.UpdatePatternAsync(
tenantId,
exceptionId,
request,
username,
@@ -333,9 +332,8 @@ internal static class SecretDetectionSettingsEndpoints
ISecretExceptionPatternService service,
CancellationToken cancellationToken)
{
// Verify pattern belongs to tenant
var existing = await service.GetPatternAsync(exceptionId, cancellationToken);
if (existing is null || existing.TenantId != tenantId)
var existing = await service.GetPatternAsync(tenantId, exceptionId, cancellationToken);
if (existing is null)
{
return Results.NotFound(new
{
@@ -345,7 +343,7 @@ internal static class SecretDetectionSettingsEndpoints
});
}
var deleted = await service.DeletePatternAsync(exceptionId, cancellationToken);
var deleted = await service.DeletePatternAsync(tenantId, exceptionId, cancellationToken);
if (!deleted)
{
return Results.NotFound(new

View File

@@ -5,6 +5,7 @@ using StellaOps.Scanner.SmartDiff.Detection;
using StellaOps.Scanner.SmartDiff.Output;
using StellaOps.Scanner.WebService.Security;
using StellaOps.Scanner.WebService.Services;
using StellaOps.Scanner.WebService.Tenancy;
using System.Collections.Immutable;
namespace StellaOps.Scanner.WebService.Endpoints;
@@ -87,10 +88,16 @@ internal static class SmartDiffEndpoints
IVexCandidateStore candidateStore,
IScanMetadataRepository? metadataRepo = null,
bool? pretty = null,
HttpContext? context = null,
CancellationToken ct = default)
{
if (!TryResolveTenant(context, out var tenantId, out var failure))
{
return failure!;
}
// Gather all data for the scan
var changes = await changeRepo.GetChangesForScanAsync(scanId, ct);
var changes = await changeRepo.GetChangesForScanAsync(scanId, ct, tenantId: tenantId);
// Get scan metadata if available
string? baseDigest = null;
@@ -111,7 +118,7 @@ internal static class SmartDiffEndpoints
IReadOnlyList<StellaOps.Scanner.SmartDiff.Output.VexCandidate> vexCandidates = [];
if (!string.IsNullOrWhiteSpace(targetDigest))
{
var candidates = await candidateStore.GetCandidatesAsync(targetDigest, ct).ConfigureAwait(false);
var candidates = await candidateStore.GetCandidatesAsync(targetDigest, ct, tenantId: tenantId).ConfigureAwait(false);
vexCandidates = candidates.Select(ToSarifVexCandidate).ToList();
}
@@ -164,8 +171,14 @@ internal static class SmartDiffEndpoints
IVexCandidateStore store,
double? minConfidence = null,
bool? pendingOnly = null,
HttpContext? context = null,
CancellationToken ct = default)
{
if (!TryResolveTenant(context, out var tenantId, out var failure))
{
return failure!;
}
var metadata = await metadataRepository.GetScanMetadataAsync(scanId, ct).ConfigureAwait(false);
var targetDigest = NormalizeDigest(metadata?.TargetDigest);
if (string.IsNullOrWhiteSpace(targetDigest))
@@ -173,7 +186,7 @@ internal static class SmartDiffEndpoints
return Results.NotFound(new { error = "Scan metadata not found", scanId });
}
return await HandleGetCandidatesAsync(targetDigest, store, minConfidence, pendingOnly, ct).ConfigureAwait(false);
return await HandleGetCandidatesAsync(targetDigest, store, minConfidence, pendingOnly, context, ct).ConfigureAwait(false);
}
private static StellaOps.Scanner.SmartDiff.Output.RiskDirection ToSarifRiskDirection(MaterialRiskChangeResult change)
@@ -230,9 +243,15 @@ internal static class SmartDiffEndpoints
string scanId,
IMaterialRiskChangeRepository repository,
double? minPriority = null,
HttpContext? context = null,
CancellationToken ct = default)
{
var changes = await repository.GetChangesForScanAsync(scanId, ct);
if (!TryResolveTenant(context, out var tenantId, out var failure))
{
return failure!;
}
var changes = await repository.GetChangesForScanAsync(scanId, ct, tenantId: tenantId);
if (minPriority.HasValue)
{
@@ -257,15 +276,21 @@ internal static class SmartDiffEndpoints
IVexCandidateStore store,
double? minConfidence = null,
bool? pendingOnly = null,
HttpContext? context = null,
CancellationToken ct = default)
{
if (!TryResolveTenant(context, out var tenantId, out var failure))
{
return failure!;
}
var normalizedDigest = NormalizeDigest(digest);
if (string.IsNullOrWhiteSpace(normalizedDigest))
{
return Results.BadRequest(new { error = "Invalid image digest" });
}
var candidates = await store.GetCandidatesAsync(normalizedDigest, ct);
var candidates = await store.GetCandidatesAsync(normalizedDigest, ct, tenantId: tenantId);
if (minConfidence.HasValue)
{
@@ -293,9 +318,15 @@ internal static class SmartDiffEndpoints
private static async Task<IResult> HandleGetCandidateAsync(
string candidateId,
IVexCandidateStore store,
HttpContext? context = null,
CancellationToken ct = default)
{
var candidate = await store.GetCandidateAsync(candidateId, ct);
if (!TryResolveTenant(context, out var tenantId, out var failure))
{
return failure!;
}
var candidate = await store.GetCandidateAsync(candidateId, ct, tenantId: tenantId);
if (candidate is null)
{
@@ -325,6 +356,10 @@ internal static class SmartDiffEndpoints
{
return Results.BadRequest(new { error = "Invalid action", validActions = new[] { "accept", "reject", "defer" } });
}
if (!TryResolveTenant(httpContext, out var tenantId, out var failure))
{
return failure!;
}
var reviewer = httpContext.User.Identity?.Name ?? "anonymous";
var review = new VexCandidateReview(
@@ -333,7 +368,7 @@ internal static class SmartDiffEndpoints
ReviewedAt: timeProvider.GetUtcNow(),
Comment: request.Comment);
var success = await store.ReviewCandidateAsync(candidateId, review, ct);
var success = await store.ReviewCandidateAsync(candidateId, review, ct, tenantId: tenantId);
if (!success)
{
@@ -365,6 +400,10 @@ internal static class SmartDiffEndpoints
{
return Results.BadRequest(new { error = "CandidateId is required" });
}
if (!TryResolveTenant(httpContext, out var tenantId, out var failure))
{
return failure!;
}
var metadata = await metadataRepository.GetScanMetadataAsync(scanId, ct).ConfigureAwait(false);
var targetDigest = NormalizeDigest(metadata?.TargetDigest);
@@ -373,7 +412,7 @@ internal static class SmartDiffEndpoints
return Results.NotFound(new { error = "Scan metadata not found", scanId });
}
var candidate = await store.GetCandidateAsync(request.CandidateId, ct).ConfigureAwait(false);
var candidate = await store.GetCandidateAsync(request.CandidateId, ct, tenantId: tenantId).ConfigureAwait(false);
if (candidate is null || !string.Equals(candidate.ImageDigest, targetDigest, StringComparison.OrdinalIgnoreCase))
{
return Results.NotFound(new { error = "Candidate not found for scan", scanId, candidateId = request.CandidateId });
@@ -482,6 +521,40 @@ internal static class SmartDiffEndpoints
};
}
private static bool TryResolveTenant(HttpContext? context, out string tenantId, out IResult? failure)
{
tenantId = string.Empty;
failure = null;
if (context is null)
{
failure = Results.BadRequest(new
{
type = "validation-error",
title = "Invalid tenant context",
detail = "tenant_missing"
});
return false;
}
if (ScannerRequestContextResolver.TryResolveTenant(
context,
out tenantId,
out var tenantError,
allowDefaultTenant: true))
{
return true;
}
failure = Results.BadRequest(new
{
type = "validation-error",
title = "Invalid tenant context",
detail = tenantError ?? "tenant_conflict"
});
return false;
}
#endregion
}

View File

@@ -1,7 +1,6 @@
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using StellaOps.Auth.Abstractions;
using StellaOps.Scanner.Sources.Configuration;
using StellaOps.Scanner.Sources.Contracts;
using StellaOps.Scanner.Sources.Domain;
@@ -10,7 +9,6 @@ using StellaOps.Scanner.WebService.Constants;
using StellaOps.Scanner.WebService.Infrastructure;
using StellaOps.Scanner.WebService.Security;
using StellaOps.Scanner.WebService.Tenancy;
using System.Security.Claims;
using System.Text.Json;
using System.Text.Json.Serialization;
@@ -660,53 +658,16 @@ internal static class SourcesEndpoints
private static bool TryResolveTenant(HttpContext context, out string tenantId)
{
tenantId = string.Empty;
var tenant = context.User?.FindFirstValue(StellaOpsClaimTypes.Tenant);
if (!string.IsNullOrWhiteSpace(tenant))
{
tenantId = tenant.Trim();
return true;
}
if (context.Request.Headers.TryGetValue("X-Stella-Tenant", out var headerTenant))
{
var headerValue = headerTenant.ToString();
if (!string.IsNullOrWhiteSpace(headerValue))
{
tenantId = headerValue.Trim();
return true;
}
}
if (context.Request.Headers.TryGetValue("X-Tenant-Id", out var legacyTenant))
{
var headerValue = legacyTenant.ToString();
if (!string.IsNullOrWhiteSpace(headerValue))
{
tenantId = headerValue.Trim();
return true;
}
}
return false;
return ScannerRequestContextResolver.TryResolveTenant(
context,
out tenantId,
out _,
allowDefaultTenant: false);
}
private static string ResolveActor(HttpContext context)
{
var subject = context.User?.FindFirstValue(StellaOpsClaimTypes.Subject);
if (!string.IsNullOrWhiteSpace(subject))
{
return subject.Trim();
}
var clientId = context.User?.FindFirstValue(StellaOpsClaimTypes.ClientId);
if (!string.IsNullOrWhiteSpace(clientId))
{
return clientId.Trim();
}
return "system";
return ScannerRequestContextResolver.ResolveActor(context);
}
private static IResult Json<T>(T value, int statusCode)

View File

@@ -8,6 +8,7 @@ using StellaOps.Scanner.Triage.Models;
using StellaOps.Scanner.Triage.Services;
using StellaOps.Scanner.WebService.Contracts;
using StellaOps.Scanner.WebService.Security;
using StellaOps.Scanner.WebService.Tenancy;
namespace StellaOps.Scanner.WebService.Endpoints.Triage;
@@ -46,6 +47,7 @@ internal static class BatchTriageEndpoints
[FromServices] IFindingQueryService findingService,
[FromServices] IExploitPathGroupingService groupingService,
[FromServices] TimeProvider timeProvider,
HttpContext context,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(artifactDigest))
@@ -58,7 +60,8 @@ internal static class BatchTriageEndpoints
});
}
var findings = await findingService.GetFindingsForArtifactAsync(artifactDigest, cancellationToken).ConfigureAwait(false);
var tenantId = ScannerRequestContextResolver.ResolveTenantOrDefault(context);
var findings = await findingService.GetFindingsForArtifactAsync(tenantId, artifactDigest, cancellationToken).ConfigureAwait(false);
var clusters = similarityThreshold.HasValue
? await groupingService.GroupFindingsAsync(artifactDigest, findings, similarityThreshold.Value, cancellationToken).ConfigureAwait(false)
: await groupingService.GroupFindingsAsync(artifactDigest, findings, cancellationToken).ConfigureAwait(false);
@@ -86,6 +89,7 @@ internal static class BatchTriageEndpoints
[FromServices] IExploitPathGroupingService groupingService,
[FromServices] ITriageStatusService triageStatusService,
[FromServices] TimeProvider timeProvider,
HttpContext context,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(request.ArtifactDigest))
@@ -108,7 +112,8 @@ internal static class BatchTriageEndpoints
});
}
var findings = await findingService.GetFindingsForArtifactAsync(request.ArtifactDigest, cancellationToken).ConfigureAwait(false);
var tenantId = ScannerRequestContextResolver.ResolveTenantOrDefault(context);
var findings = await findingService.GetFindingsForArtifactAsync(tenantId, request.ArtifactDigest, cancellationToken).ConfigureAwait(false);
var clusters = request.SimilarityThreshold.HasValue
? await groupingService.GroupFindingsAsync(request.ArtifactDigest, findings, request.SimilarityThreshold.Value, cancellationToken).ConfigureAwait(false)
: await groupingService.GroupFindingsAsync(request.ArtifactDigest, findings, cancellationToken).ConfigureAwait(false);
@@ -138,7 +143,7 @@ internal static class BatchTriageEndpoints
Actor = actor
};
var result = await triageStatusService.UpdateStatusAsync(findingId, updateRequest, actor, cancellationToken).ConfigureAwait(false);
var result = await triageStatusService.UpdateStatusAsync(tenantId, findingId, updateRequest, actor, cancellationToken).ConfigureAwait(false);
if (result is not null)
{
updated.Add(findingId);

View File

@@ -11,6 +11,7 @@ using Microsoft.AspNetCore.Routing;
using StellaOps.Scanner.Triage.Models;
using StellaOps.Scanner.Triage.Services;
using StellaOps.Scanner.WebService.Security;
using StellaOps.Scanner.WebService.Tenancy;
using System.Text.Json;
using System.Text.Json.Serialization;
@@ -55,6 +56,7 @@ internal static class TriageInboxEndpoints
[FromServices] IExploitPathGroupingService groupingService,
[FromServices] IFindingQueryService findingService,
[FromServices] TimeProvider timeProvider,
HttpContext context,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(groupingService);
@@ -70,7 +72,9 @@ internal static class TriageInboxEndpoints
});
}
var findings = await findingService.GetFindingsForArtifactAsync(artifactDigest, cancellationToken);
var tenantId = ScannerRequestContextResolver.ResolveTenantOrDefault(context);
var findings = await findingService.GetFindingsForArtifactAsync(tenantId, artifactDigest, cancellationToken);
var paths = similarityThreshold.HasValue
? await groupingService.GroupFindingsAsync(artifactDigest, findings, similarityThreshold.Value, cancellationToken)
: await groupingService.GroupFindingsAsync(artifactDigest, findings, cancellationToken);
@@ -150,5 +154,5 @@ public sealed record TriageInboxResponse
public interface IFindingQueryService
{
Task<IReadOnlyList<Finding>> GetFindingsForArtifactAsync(string artifactDigest, CancellationToken ct);
Task<IReadOnlyList<Finding>> GetFindingsForArtifactAsync(string tenantId, string artifactDigest, CancellationToken ct);
}

View File

@@ -10,6 +10,7 @@ using Microsoft.AspNetCore.Routing;
using StellaOps.Scanner.WebService.Contracts;
using StellaOps.Scanner.WebService.Security;
using StellaOps.Scanner.WebService.Services;
using StellaOps.Scanner.WebService.Tenancy;
using System.Text.Json;
using System.Text.Json.Serialization;
@@ -98,7 +99,21 @@ internal static class TriageStatusEndpoints
});
}
var status = await triageService.GetFindingStatusAsync(findingId, cancellationToken);
if (!ScannerRequestContextResolver.TryResolveTenant(
context,
out var tenantId,
out var tenantError,
allowDefaultTenant: true))
{
return Results.BadRequest(new
{
type = "validation-error",
title = "Invalid tenant context",
detail = tenantError ?? "tenant_conflict"
});
}
var status = await triageService.GetFindingStatusAsync(tenantId, findingId, cancellationToken);
if (status is null)
{
return Results.NotFound(new
@@ -135,7 +150,21 @@ internal static class TriageStatusEndpoints
// Get actor from context or request
var actor = request.Actor ?? context.User?.Identity?.Name ?? "anonymous";
var result = await triageService.UpdateStatusAsync(findingId, request, actor, cancellationToken);
if (!ScannerRequestContextResolver.TryResolveTenant(
context,
out var tenantId,
out var tenantError,
allowDefaultTenant: true))
{
return Results.BadRequest(new
{
type = "validation-error",
title = "Invalid tenant context",
detail = tenantError ?? "tenant_conflict"
});
}
var result = await triageService.UpdateStatusAsync(tenantId, findingId, request, actor, cancellationToken);
if (result is null)
{
return Results.NotFound(new
@@ -204,7 +233,21 @@ internal static class TriageStatusEndpoints
}
var actor = request.IssuedBy ?? context.User?.Identity?.Name ?? "anonymous";
var result = await triageService.SubmitVexStatementAsync(findingId, request, actor, cancellationToken);
if (!ScannerRequestContextResolver.TryResolveTenant(
context,
out var tenantId,
out var tenantError,
allowDefaultTenant: true))
{
return Results.BadRequest(new
{
type = "validation-error",
title = "Invalid tenant context",
detail = tenantError ?? "tenant_conflict"
});
}
var result = await triageService.SubmitVexStatementAsync(tenantId, findingId, request, actor, cancellationToken);
if (result is null)
{
@@ -231,7 +274,21 @@ internal static class TriageStatusEndpoints
// Apply reasonable defaults
var limit = Math.Min(request.Limit ?? 100, 1000);
var result = await triageService.QueryFindingsAsync(request, limit, cancellationToken);
if (!ScannerRequestContextResolver.TryResolveTenant(
context,
out var tenantId,
out var tenantError,
allowDefaultTenant: true))
{
return Results.BadRequest(new
{
type = "validation-error",
title = "Invalid tenant context",
detail = tenantError ?? "tenant_conflict"
});
}
var result = await triageService.QueryFindingsAsync(tenantId, request, limit, cancellationToken);
return Results.Ok(result);
}
@@ -253,7 +310,21 @@ internal static class TriageStatusEndpoints
});
}
var summary = await triageService.GetSummaryAsync(artifactDigest, cancellationToken);
if (!ScannerRequestContextResolver.TryResolveTenant(
context,
out var tenantId,
out var tenantError,
allowDefaultTenant: true))
{
return Results.BadRequest(new
{
type = "validation-error",
title = "Invalid tenant context",
detail = tenantError ?? "tenant_conflict"
});
}
var summary = await triageService.GetSummaryAsync(tenantId, artifactDigest, cancellationToken);
return Results.Ok(summary);
}
}
@@ -267,12 +338,13 @@ public interface ITriageStatusService
/// <summary>
/// Gets triage status for a finding.
/// </summary>
Task<FindingTriageStatusDto?> GetFindingStatusAsync(string findingId, CancellationToken ct = default);
Task<FindingTriageStatusDto?> GetFindingStatusAsync(string tenantId, string findingId, CancellationToken ct = default);
/// <summary>
/// Updates triage status for a finding.
/// </summary>
Task<UpdateTriageStatusResponseDto?> UpdateStatusAsync(
string tenantId,
string findingId,
UpdateTriageStatusRequestDto request,
string actor,
@@ -282,6 +354,7 @@ public interface ITriageStatusService
/// Submits a VEX statement for a finding.
/// </summary>
Task<SubmitVexStatementResponseDto?> SubmitVexStatementAsync(
string tenantId,
string findingId,
SubmitVexStatementRequestDto request,
string actor,
@@ -291,6 +364,7 @@ public interface ITriageStatusService
/// Queries findings with filtering.
/// </summary>
Task<BulkTriageQueryResponseDto> QueryFindingsAsync(
string tenantId,
BulkTriageQueryRequestDto request,
int limit,
CancellationToken ct = default);
@@ -298,5 +372,5 @@ public interface ITriageStatusService
/// <summary>
/// Gets triage summary for an artifact.
/// </summary>
Task<TriageSummaryDto> GetSummaryAsync(string artifactDigest, CancellationToken ct = default);
Task<TriageSummaryDto> GetSummaryAsync(string tenantId, string artifactDigest, CancellationToken ct = default);
}

View File

@@ -1,323 +1,460 @@
// -----------------------------------------------------------------------------
// UnknownsEndpoints.cs
// Sprint: SPRINT_3600_0002_0001_unknowns_ranking_containment
// Task: UNK-RANK-007, UNK-RANK-008 - Implement GET /unknowns API with sorting/pagination
// Description: REST API for querying and filtering unknowns
// -----------------------------------------------------------------------------
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Routing;
using StellaOps.Unknowns.Core.Models;
using StellaOps.Unknowns.Core.Repositories;
using StellaOps.Unknowns.Core.Services;
using StellaOps.Scanner.WebService.Security;
using StellaOps.Scanner.WebService.Services;
using StellaOps.Scanner.WebService.Tenancy;
namespace StellaOps.Scanner.WebService.Endpoints;
internal static class UnknownsEndpoints
{
private const double HotBandThreshold = 0.70;
private const double WarmBandThreshold = 0.40;
private const string ExternalUnknownIdPrefix = "unk-";
public static void MapUnknownsEndpoints(this RouteGroupBuilder apiGroup)
{
var unknowns = apiGroup.MapGroup("/unknowns");
ArgumentNullException.ThrowIfNull(apiGroup);
var unknowns = apiGroup.MapGroup("/unknowns")
.WithTags("Unknowns")
.RequireAuthorization(ScannerPolicies.ScansRead);
unknowns.MapGet("/", HandleListAsync)
.WithName("scanner.unknowns.list")
.Produces<UnknownsListResponse>(StatusCodes.Status200OK)
.Produces<ProblemDetails>(StatusCodes.Status400BadRequest)
.WithDescription("List unknowns with optional sorting and filtering");
.Produces(StatusCodes.Status400BadRequest)
.WithDescription("Lists unknown entries with tenant-scoped filtering.");
unknowns.MapGet("/stats", HandleGetStatsAsync)
.WithName("scanner.unknowns.stats")
.Produces<UnknownsStatsResponse>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status400BadRequest)
.WithDescription("Returns tenant-scoped unknown summary statistics.");
unknowns.MapGet("/bands", HandleGetBandsAsync)
.WithName("scanner.unknowns.bands")
.Produces<UnknownsBandsResponse>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status400BadRequest)
.WithDescription("Returns tenant-scoped unknown distribution by triage band.");
unknowns.MapGet("/{id}/evidence", HandleGetEvidenceAsync)
.WithName("scanner.unknowns.evidence")
.Produces<UnknownEvidenceResponse>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound)
.Produces(StatusCodes.Status400BadRequest)
.WithDescription("Returns tenant-scoped unknown evidence metadata.");
unknowns.MapGet("/{id}/history", HandleGetHistoryAsync)
.WithName("scanner.unknowns.history")
.Produces<UnknownHistoryResponse>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound)
.Produces(StatusCodes.Status400BadRequest)
.WithDescription("Returns tenant-scoped unknown history.");
unknowns.MapGet("/{id}", HandleGetByIdAsync)
.WithName("scanner.unknowns.get")
.Produces<UnknownDetailResponse>(StatusCodes.Status200OK)
.Produces<ProblemDetails>(StatusCodes.Status404NotFound)
.WithDescription("Get details of a specific unknown");
unknowns.MapGet("/{id}/proof", HandleGetProofAsync)
.WithName("scanner.unknowns.proof")
.Produces<UnknownProofResponse>(StatusCodes.Status200OK)
.Produces<ProblemDetails>(StatusCodes.Status404NotFound)
.WithDescription("Get the proof trail for an unknown ranking");
.Produces(StatusCodes.Status404NotFound)
.Produces(StatusCodes.Status400BadRequest)
.WithDescription("Returns tenant-scoped unknown detail.");
}
/// <summary>
/// GET /unknowns?sort=score&amp;order=desc&amp;artifact=sha256:...&amp;reason=missing_vex&amp;page=1&amp;limit=50
/// </summary>
private static async Task<IResult> HandleListAsync(
[FromQuery] string? sort,
[FromQuery] string? order,
[FromQuery] string? artifact,
[FromQuery] string? reason,
[FromQuery] string? kind,
[FromQuery] string? severity,
[FromQuery] double? minScore,
[FromQuery] double? maxScore,
[FromQuery] int? page,
[FromQuery] string? artifactDigest,
[FromQuery] string? vulnId,
[FromQuery] string? band,
[FromQuery] string? sortBy,
[FromQuery] string? sortOrder,
[FromQuery] int? limit,
IUnknownRepository repository,
IUnknownRanker ranker,
TimeProvider timeProvider,
[FromQuery] int? offset,
IUnknownsQueryService queryService,
HttpContext context,
CancellationToken cancellationToken)
{
// Validate and default pagination
var pageNum = Math.Max(1, page ?? 1);
var pageSize = Math.Clamp(limit ?? 50, 1, 200);
// Parse sort field
var sortField = (sort?.ToLowerInvariant()) switch
if (!TryResolveTenant(context, out var tenantId, out var failure))
{
"score" => UnknownSortField.Score,
"created" => UnknownSortField.Created,
"updated" => UnknownSortField.Updated,
"severity" => UnknownSortField.Severity,
"popularity" => UnknownSortField.Popularity,
_ => UnknownSortField.Score // Default to score
return failure!;
}
if (!TryMapBand(band, out var mappedBand))
{
return Results.BadRequest(new
{
type = "validation-error",
title = "Invalid band",
detail = "Band must be one of HOT, WARM, or COLD."
});
}
var query = new UnknownsListQuery
{
ArtifactDigest = string.IsNullOrWhiteSpace(artifactDigest) ? null : artifactDigest.Trim(),
VulnerabilityId = string.IsNullOrWhiteSpace(vulnId) ? null : vulnId.Trim(),
Band = mappedBand,
SortBy = MapSortField(sortBy),
SortOrder = MapSortOrder(sortOrder),
Limit = Math.Clamp(limit ?? 50, 1, 500),
Offset = Math.Max(offset ?? 0, 0)
};
var sortOrder = (order?.ToLowerInvariant()) switch
var result = await queryService.ListAsync(tenantId, query, cancellationToken).ConfigureAwait(false);
return Results.Ok(new UnknownsListResponse
{
"asc" => SortOrder.Ascending,
_ => SortOrder.Descending // Default to descending (highest first)
};
// Parse filters
UnknownKind? kindFilter = kind != null && Enum.TryParse<UnknownKind>(kind, true, out var k) ? k : null;
UnknownSeverity? severityFilter = severity != null && Enum.TryParse<UnknownSeverity>(severity, true, out var s) ? s : null;
var query = new UnknownListQuery(
ArtifactDigest: artifact,
Reason: reason,
Kind: kindFilter,
Severity: severityFilter,
MinScore: minScore,
MaxScore: maxScore,
SortField: sortField,
SortOrder: sortOrder,
Page: pageNum,
PageSize: pageSize);
var result = await repository.ListUnknownsAsync(query, cancellationToken);
var now = timeProvider.GetUtcNow();
return Results.Ok(new UnknownsListResponse(
Items: result.Items.Select(item => UnknownItemResponse.FromUnknownItem(item, now)).ToList(),
TotalCount: result.TotalCount,
Page: pageNum,
PageSize: pageSize,
TotalPages: (int)Math.Ceiling((double)result.TotalCount / pageSize),
HasNextPage: pageNum * pageSize < result.TotalCount,
HasPreviousPage: pageNum > 1));
Items = result.Items
.Select(MapItem)
.ToArray(),
TotalCount = result.TotalCount,
Limit = query.Limit,
Offset = query.Offset
});
}
/// <summary>
/// GET /unknowns/{id}
/// </summary>
private static async Task<IResult> HandleGetByIdAsync(
Guid id,
IUnknownRepository repository,
string id,
IUnknownsQueryService queryService,
HttpContext context,
CancellationToken cancellationToken)
{
var unknown = await repository.GetByIdAsync(id, cancellationToken);
if (unknown is null)
if (!TryResolveTenant(context, out var tenantId, out var failure))
{
return Results.NotFound(new ProblemDetails
{
Title = "Unknown not found",
Detail = $"No unknown found with ID: {id}",
Status = StatusCodes.Status404NotFound
});
return failure!;
}
return Results.Ok(UnknownDetailResponse.FromUnknown(unknown));
if (!TryParseUnknownId(id, out var unknownId))
{
return Results.NotFound();
}
var detail = await queryService.GetByIdAsync(tenantId, unknownId, cancellationToken).ConfigureAwait(false);
if (detail is null)
{
return Results.NotFound();
}
return Results.Ok(MapDetail(detail));
}
/// <summary>
/// GET /unknowns/{id}/proof
/// </summary>
private static async Task<IResult> HandleGetProofAsync(
Guid id,
IUnknownRepository repository,
private static async Task<IResult> HandleGetEvidenceAsync(
string id,
IUnknownsQueryService queryService,
HttpContext context,
CancellationToken cancellationToken)
{
var unknown = await repository.GetByIdAsync(id, cancellationToken);
if (unknown is null)
if (!TryResolveTenant(context, out var tenantId, out var failure))
{
return Results.NotFound(new ProblemDetails
{
Title = "Unknown not found",
Detail = $"No unknown found with ID: {id}",
Status = StatusCodes.Status404NotFound
});
return failure!;
}
var proofRef = unknown.ProofRef;
if (string.IsNullOrEmpty(proofRef))
if (!TryParseUnknownId(id, out var unknownId))
{
return Results.NotFound(new ProblemDetails
{
Title = "Proof not available",
Detail = $"No proof trail available for unknown: {id}",
Status = StatusCodes.Status404NotFound
});
return Results.NotFound();
}
// In a real implementation, read proof from storage
return Results.Ok(new UnknownProofResponse(
UnknownId: id,
ProofRef: proofRef,
CreatedAt: unknown.SysFrom));
var detail = await queryService.GetByIdAsync(tenantId, unknownId, cancellationToken).ConfigureAwait(false);
if (detail is null)
{
return Results.NotFound();
}
return Results.Ok(new UnknownEvidenceResponse
{
Id = ToExternalUnknownId(detail.UnknownId),
ProofRef = detail.ProofRef,
LastUpdatedAtUtc = detail.UpdatedAtUtc
});
}
private static async Task<IResult> HandleGetHistoryAsync(
string id,
IUnknownsQueryService queryService,
HttpContext context,
CancellationToken cancellationToken)
{
if (!TryResolveTenant(context, out var tenantId, out var failure))
{
return failure!;
}
if (!TryParseUnknownId(id, out var unknownId))
{
return Results.NotFound();
}
var detail = await queryService.GetByIdAsync(tenantId, unknownId, cancellationToken).ConfigureAwait(false);
if (detail is null)
{
return Results.NotFound();
}
return Results.Ok(new UnknownHistoryResponse
{
Id = ToExternalUnknownId(detail.UnknownId),
History = new[]
{
new UnknownHistoryEntryResponse
{
CapturedAtUtc = detail.UpdatedAtUtc,
Score = detail.Score,
Band = DetermineBand(detail.Score)
}
}
});
}
private static async Task<IResult> HandleGetStatsAsync(
IUnknownsQueryService queryService,
HttpContext context,
CancellationToken cancellationToken)
{
if (!TryResolveTenant(context, out var tenantId, out var failure))
{
return failure!;
}
var stats = await queryService.GetStatsAsync(tenantId, cancellationToken).ConfigureAwait(false);
return Results.Ok(new UnknownsStatsResponse
{
Total = stats.Total,
Hot = stats.Hot,
Warm = stats.Warm,
Cold = stats.Cold
});
}
private static async Task<IResult> HandleGetBandsAsync(
IUnknownsQueryService queryService,
HttpContext context,
CancellationToken cancellationToken)
{
if (!TryResolveTenant(context, out var tenantId, out var failure))
{
return failure!;
}
var distribution = await queryService.GetBandDistributionAsync(tenantId, cancellationToken).ConfigureAwait(false);
return Results.Ok(new UnknownsBandsResponse
{
Bands = distribution
});
}
private static bool TryResolveTenant(HttpContext context, out string tenantId, out IResult? failure)
{
tenantId = string.Empty;
failure = null;
if (ScannerRequestContextResolver.TryResolveTenant(
context,
out tenantId,
out var tenantError,
allowDefaultTenant: true))
{
return true;
}
failure = Results.BadRequest(new
{
type = "validation-error",
title = "Invalid tenant context",
detail = tenantError ?? "tenant_conflict"
});
return false;
}
private static UnknownsListItemResponse MapItem(UnknownsListItem item)
{
return new UnknownsListItemResponse
{
Id = ToExternalUnknownId(item.UnknownId),
ArtifactDigest = item.ArtifactDigest,
VulnerabilityId = item.VulnerabilityId,
PackagePurl = item.PackagePurl,
Score = item.Score,
Band = DetermineBand(item.Score),
CreatedAtUtc = item.CreatedAtUtc,
UpdatedAtUtc = item.UpdatedAtUtc
};
}
private static UnknownDetailResponse MapDetail(UnknownsDetail detail)
{
return new UnknownDetailResponse
{
Id = ToExternalUnknownId(detail.UnknownId),
ArtifactDigest = detail.ArtifactDigest,
VulnerabilityId = detail.VulnerabilityId,
PackagePurl = detail.PackagePurl,
Score = detail.Score,
Band = DetermineBand(detail.Score),
ProofRef = detail.ProofRef,
CreatedAtUtc = detail.CreatedAtUtc,
UpdatedAtUtc = detail.UpdatedAtUtc
};
}
private static UnknownsSortField MapSortField(string? rawValue)
{
if (string.IsNullOrWhiteSpace(rawValue))
{
return UnknownsSortField.Score;
}
return rawValue.Trim().ToLowerInvariant() switch
{
"score" => UnknownsSortField.Score,
"created" => UnknownsSortField.CreatedAt,
"createdat" => UnknownsSortField.CreatedAt,
"updated" => UnknownsSortField.UpdatedAt,
"updatedat" => UnknownsSortField.UpdatedAt,
"lastseen" => UnknownsSortField.UpdatedAt,
_ => UnknownsSortField.Score
};
}
private static UnknownsSortOrder MapSortOrder(string? rawValue)
{
if (string.IsNullOrWhiteSpace(rawValue))
{
return UnknownsSortOrder.Descending;
}
return rawValue.Trim().Equals("asc", StringComparison.OrdinalIgnoreCase)
? UnknownsSortOrder.Ascending
: UnknownsSortOrder.Descending;
}
private static bool TryMapBand(string? rawValue, out UnknownsBand? band)
{
band = null;
if (string.IsNullOrWhiteSpace(rawValue))
{
return true;
}
switch (rawValue.Trim().ToUpperInvariant())
{
case "HOT":
band = UnknownsBand.Hot;
return true;
case "WARM":
band = UnknownsBand.Warm;
return true;
case "COLD":
band = UnknownsBand.Cold;
return true;
default:
return false;
}
}
private static string DetermineBand(double score)
{
if (score >= HotBandThreshold)
{
return "HOT";
}
if (score >= WarmBandThreshold)
{
return "WARM";
}
return "COLD";
}
private static string ToExternalUnknownId(Guid unknownId)
=> $"{ExternalUnknownIdPrefix}{unknownId:N}";
private static bool TryParseUnknownId(string rawValue, out Guid unknownId)
{
unknownId = Guid.Empty;
if (string.IsNullOrWhiteSpace(rawValue))
{
return false;
}
var trimmed = rawValue.Trim();
if (Guid.TryParse(trimmed, out unknownId))
{
return true;
}
if (!trimmed.StartsWith(ExternalUnknownIdPrefix, StringComparison.OrdinalIgnoreCase))
{
return false;
}
var guidPart = trimmed[ExternalUnknownIdPrefix.Length..];
return Guid.TryParseExact(guidPart, "N", out unknownId)
|| Guid.TryParse(guidPart, out unknownId);
}
}
/// <summary>
/// Response model for unknowns list.
/// </summary>
public sealed record UnknownsListResponse(
IReadOnlyList<UnknownItemResponse> Items,
int TotalCount,
int Page,
int PageSize,
int TotalPages,
bool HasNextPage,
bool HasPreviousPage);
/// <summary>
/// Compact unknown item for list response.
/// </summary>
public sealed record UnknownItemResponse(
Guid Id,
string SubjectRef,
string Kind,
string? Severity,
double Score,
string TriageBand,
string Priority,
BlastRadiusResponse? BlastRadius,
ContainmentResponse? Containment,
DateTimeOffset CreatedAt)
public sealed record UnknownsListResponse
{
public static UnknownItemResponse FromUnknownItem(UnknownItem item, DateTimeOffset now) => new(
Id: Guid.TryParse(item.Id, out var id) ? id : Guid.Empty,
SubjectRef: item.ArtifactPurl ?? item.ArtifactDigest,
Kind: string.Join(",", item.Reasons),
Severity: null, // Would come from full Unknown
Score: item.Score,
TriageBand: item.Score.ToTriageBand().ToString(),
Priority: item.Score.ToPriorityLabel(),
BlastRadius: item.BlastRadius != null
? new BlastRadiusResponse(item.BlastRadius.Dependents, item.BlastRadius.NetFacing, item.BlastRadius.Privilege)
: null,
Containment: item.Containment != null
? new ContainmentResponse(item.Containment.Seccomp, item.Containment.Fs)
: null,
CreatedAt: now); // Would come from Unknown.SysFrom
public required IReadOnlyList<UnknownsListItemResponse> Items { get; init; }
public required int TotalCount { get; init; }
public required int Limit { get; init; }
public required int Offset { get; init; }
}
/// <summary>
/// Blast radius in API response.
/// </summary>
public sealed record BlastRadiusResponse(int Dependents, bool NetFacing, string Privilege);
/// <summary>
/// Containment signals in API response.
/// </summary>
public sealed record ContainmentResponse(string Seccomp, string Fs);
/// <summary>
/// Detailed unknown response.
/// </summary>
public sealed record UnknownDetailResponse(
Guid Id,
string TenantId,
string SubjectHash,
string SubjectType,
string SubjectRef,
string Kind,
string? Severity,
double Score,
string TriageBand,
double PopularityScore,
int DeploymentCount,
double UncertaintyScore,
BlastRadiusResponse? BlastRadius,
ContainmentResponse? Containment,
string? ProofRef,
DateTimeOffset ValidFrom,
DateTimeOffset? ValidTo,
DateTimeOffset SysFrom,
DateTimeOffset? ResolvedAt,
string? ResolutionType,
string? ResolutionRef)
public sealed record UnknownsListItemResponse
{
public static UnknownDetailResponse FromUnknown(Unknown u) => new(
Id: u.Id,
TenantId: u.TenantId,
SubjectHash: u.SubjectHash,
SubjectType: u.SubjectType.ToString(),
SubjectRef: u.SubjectRef,
Kind: u.Kind.ToString(),
Severity: u.Severity?.ToString(),
Score: u.TriageScore,
TriageBand: u.TriageScore.ToTriageBand().ToString(),
PopularityScore: u.PopularityScore,
DeploymentCount: u.DeploymentCount,
UncertaintyScore: u.UncertaintyScore,
BlastRadius: u.BlastDependents.HasValue
? new BlastRadiusResponse(u.BlastDependents.Value, u.BlastNetFacing ?? false, u.BlastPrivilege ?? "user")
: null,
Containment: !string.IsNullOrEmpty(u.ContainmentSeccomp) || !string.IsNullOrEmpty(u.ContainmentFs)
? new ContainmentResponse(u.ContainmentSeccomp ?? "unknown", u.ContainmentFs ?? "unknown")
: null,
ProofRef: u.ProofRef,
ValidFrom: u.ValidFrom,
ValidTo: u.ValidTo,
SysFrom: u.SysFrom,
ResolvedAt: u.ResolvedAt,
ResolutionType: u.ResolutionType?.ToString(),
ResolutionRef: u.ResolutionRef);
public required string Id { get; init; }
public required string ArtifactDigest { get; init; }
public required string VulnerabilityId { get; init; }
public required string PackagePurl { get; init; }
public required double Score { get; init; }
public required string Band { get; init; }
public required DateTimeOffset CreatedAtUtc { get; init; }
public required DateTimeOffset UpdatedAtUtc { get; init; }
}
/// <summary>
/// Proof trail response.
/// </summary>
public sealed record UnknownProofResponse(
Guid UnknownId,
string ProofRef,
DateTimeOffset CreatedAt);
/// <summary>
/// Sort fields for unknowns query.
/// </summary>
public enum UnknownSortField
public sealed record UnknownDetailResponse
{
Score,
Created,
Updated,
Severity,
Popularity
public required string Id { get; init; }
public required string ArtifactDigest { get; init; }
public required string VulnerabilityId { get; init; }
public required string PackagePurl { get; init; }
public required double Score { get; init; }
public required string Band { get; init; }
public string? ProofRef { get; init; }
public required DateTimeOffset CreatedAtUtc { get; init; }
public required DateTimeOffset UpdatedAtUtc { get; init; }
}
/// <summary>
/// Sort order.
/// </summary>
public enum SortOrder
public sealed record UnknownEvidenceResponse
{
Ascending,
Descending
public required string Id { get; init; }
public string? ProofRef { get; init; }
public required DateTimeOffset LastUpdatedAtUtc { get; init; }
}
/// <summary>
/// Query parameters for listing unknowns.
/// </summary>
public sealed record UnknownListQuery(
string? ArtifactDigest,
string? Reason,
UnknownKind? Kind,
UnknownSeverity? Severity,
double? MinScore,
double? MaxScore,
UnknownSortField SortField,
SortOrder SortOrder,
int Page,
int PageSize);
public sealed record UnknownHistoryResponse
{
public required string Id { get; init; }
public required IReadOnlyList<UnknownHistoryEntryResponse> History { get; init; }
}
public sealed record UnknownHistoryEntryResponse
{
public required DateTimeOffset CapturedAtUtc { get; init; }
public required double Score { get; init; }
public required string Band { get; init; }
}
public sealed record UnknownsStatsResponse
{
public required long Total { get; init; }
public required long Hot { get; init; }
public required long Warm { get; init; }
public required long Cold { get; init; }
}
public sealed record UnknownsBandsResponse
{
public required IReadOnlyDictionary<string, long> Bands { get; init; }
}

View File

@@ -29,7 +29,7 @@ internal static class ValidationEndpoints
var group = app.MapGroup("/api/v1/sbom")
.WithTags("Validation")
.RequireAuthorization();
.RequireAuthorization(ScannerPolicies.ScansRead);
// POST /api/v1/sbom/validate
group.MapPost("/validate", ValidateSbomAsync)

View File

@@ -11,7 +11,7 @@ using StellaOps.Scanner.Sources.Triggers;
using StellaOps.Scanner.WebService.Constants;
using StellaOps.Scanner.WebService.Infrastructure;
using StellaOps.Scanner.WebService.Services;
using System.Security.Cryptography;
using StellaOps.Scanner.WebService.Tenancy;
using System.Text;
using System.Text.Json;
@@ -109,6 +109,22 @@ internal static class WebhookEndpoints
HttpContext context,
CancellationToken ct)
{
var hasTenantContext = ScannerRequestContextResolver.TryResolveTenant(
context,
out var tenantId,
out var tenantError,
allowDefaultTenant: false);
if (!hasTenantContext && string.Equals(tenantError, "tenant_conflict", StringComparison.Ordinal))
{
return ProblemResultFactory.Create(
context,
ProblemTypes.Validation,
"Invalid tenant context",
StatusCodes.Status400BadRequest,
detail: tenantError);
}
// Read the raw payload
using var reader = new StreamReader(context.Request.Body);
var payloadString = await reader.ReadToEndAsync(ct);
@@ -125,6 +141,15 @@ internal static class WebhookEndpoints
StatusCodes.Status404NotFound);
}
if (hasTenantContext && !string.Equals(source.TenantId, tenantId, StringComparison.OrdinalIgnoreCase))
{
return ProblemResultFactory.Create(
context,
ProblemTypes.NotFound,
"Source not found",
StatusCodes.Status404NotFound);
}
// Get the handler
var handler = handlers.FirstOrDefault(h => h.SourceType == source.SourceType);
if (handler == null || handler is not IWebhookCapableHandler webhookHandler)
@@ -269,10 +294,24 @@ internal static class WebhookEndpoints
HttpContext context,
CancellationToken ct)
{
if (!ScannerRequestContextResolver.TryResolveTenant(
context,
out var tenantId,
out var tenantError,
allowDefaultTenant: false))
{
return ProblemResultFactory.Create(
context,
ProblemTypes.Validation,
"Invalid tenant context",
StatusCodes.Status400BadRequest,
detail: tenantError ?? "tenant_missing");
}
// Docker Hub uses callback_url for validation
// and sends signature in body.callback_url when configured
var source = await FindSourceByNameAsync(sourceRepository, sourceName, SbomSourceType.Zastava, ct);
var source = await FindSourceByNameAsync(sourceRepository, tenantId, sourceName, SbomSourceType.Zastava, ct);
if (source == null)
{
return ProblemResultFactory.Create(
@@ -308,6 +347,20 @@ internal static class WebhookEndpoints
HttpContext context,
CancellationToken ct)
{
if (!ScannerRequestContextResolver.TryResolveTenant(
context,
out var tenantId,
out var tenantError,
allowDefaultTenant: false))
{
return ProblemResultFactory.Create(
context,
ProblemTypes.Validation,
"Invalid tenant context",
StatusCodes.Status400BadRequest,
detail: tenantError ?? "tenant_missing");
}
// GitHub can send ping events for webhook validation
if (eventType == "ping")
{
@@ -320,7 +373,7 @@ internal static class WebhookEndpoints
return Results.Ok(new { message = $"Event type '{eventType}' ignored" });
}
var source = await FindSourceByNameAsync(sourceRepository, sourceName, SbomSourceType.Git, ct);
var source = await FindSourceByNameAsync(sourceRepository, tenantId, sourceName, SbomSourceType.Git, ct);
if (source == null)
{
return ProblemResultFactory.Create(
@@ -358,13 +411,27 @@ internal static class WebhookEndpoints
HttpContext context,
CancellationToken ct)
{
if (!ScannerRequestContextResolver.TryResolveTenant(
context,
out var tenantId,
out var tenantError,
allowDefaultTenant: false))
{
return ProblemResultFactory.Create(
context,
ProblemTypes.Validation,
"Invalid tenant context",
StatusCodes.Status400BadRequest,
detail: tenantError ?? "tenant_missing");
}
// Only process push and merge request events
if (eventType != "Push Hook" && eventType != "Merge Request Hook" && eventType != "Tag Push Hook")
{
return Results.Ok(new { message = $"Event type '{eventType}' ignored" });
}
var source = await FindSourceByNameAsync(sourceRepository, sourceName, SbomSourceType.Git, ct);
var source = await FindSourceByNameAsync(sourceRepository, tenantId, sourceName, SbomSourceType.Git, ct);
if (source == null)
{
return ProblemResultFactory.Create(
@@ -400,7 +467,21 @@ internal static class WebhookEndpoints
HttpContext context,
CancellationToken ct)
{
var source = await FindSourceByNameAsync(sourceRepository, sourceName, SbomSourceType.Zastava, ct);
if (!ScannerRequestContextResolver.TryResolveTenant(
context,
out var tenantId,
out var tenantError,
allowDefaultTenant: false))
{
return ProblemResultFactory.Create(
context,
ProblemTypes.Validation,
"Invalid tenant context",
StatusCodes.Status400BadRequest,
detail: tenantError ?? "tenant_missing");
}
var source = await FindSourceByNameAsync(sourceRepository, tenantId, sourceName, SbomSourceType.Zastava, ct);
if (source == null)
{
return ProblemResultFactory.Create(
@@ -421,17 +502,17 @@ internal static class WebhookEndpoints
ct);
}
private static async Task<SbomSource?> FindSourceByNameAsync(
internal static async Task<SbomSource?> FindSourceByNameAsync(
ISbomSourceRepository repository,
string tenantId,
string name,
SbomSourceType expectedType,
CancellationToken ct)
{
// Search across all tenants for the source by name
// Note: In production, this should be scoped to a specific tenant
// extracted from the webhook URL or a custom header
var sources = await repository.SearchByNameAsync(name, ct);
return sources.FirstOrDefault(s => s.SourceType == expectedType);
var source = await repository.GetByNameAsync(tenantId, name, ct).ConfigureAwait(false);
return source is not null && source.SourceType == expectedType
? source
: null;
}
private static async Task<IResult> ProcessWebhookAsync(

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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