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

View File

@@ -11,22 +11,22 @@ public interface IRiskStateRepository
/// <summary>
/// Store a risk state snapshot.
/// </summary>
Task StoreSnapshotAsync(RiskStateSnapshot snapshot, CancellationToken ct = default);
Task StoreSnapshotAsync(RiskStateSnapshot snapshot, CancellationToken ct = default, string? tenantId = null);
/// <summary>
/// Store multiple risk state snapshots.
/// </summary>
Task StoreSnapshotsAsync(IReadOnlyList<RiskStateSnapshot> snapshots, CancellationToken ct = default);
Task StoreSnapshotsAsync(IReadOnlyList<RiskStateSnapshot> snapshots, CancellationToken ct = default, string? tenantId = null);
/// <summary>
/// Get the latest snapshot for a finding.
/// </summary>
Task<RiskStateSnapshot?> GetLatestSnapshotAsync(FindingKey findingKey, CancellationToken ct = default);
Task<RiskStateSnapshot?> GetLatestSnapshotAsync(FindingKey findingKey, CancellationToken ct = default, string? tenantId = null);
/// <summary>
/// Get snapshots for a scan.
/// </summary>
Task<IReadOnlyList<RiskStateSnapshot>> GetSnapshotsForScanAsync(string scanId, CancellationToken ct = default);
Task<IReadOnlyList<RiskStateSnapshot>> GetSnapshotsForScanAsync(string scanId, CancellationToken ct = default, string? tenantId = null);
/// <summary>
/// Get snapshot history for a finding.
@@ -34,12 +34,13 @@ public interface IRiskStateRepository
Task<IReadOnlyList<RiskStateSnapshot>> GetSnapshotHistoryAsync(
FindingKey findingKey,
int limit = 10,
CancellationToken ct = default);
CancellationToken ct = default,
string? tenantId = null);
/// <summary>
/// Get snapshots by state hash.
/// </summary>
Task<IReadOnlyList<RiskStateSnapshot>> GetSnapshotsByHashAsync(string stateHash, CancellationToken ct = default);
Task<IReadOnlyList<RiskStateSnapshot>> GetSnapshotsByHashAsync(string stateHash, CancellationToken ct = default, string? tenantId = null);
}
/// <summary>
@@ -50,17 +51,17 @@ public interface IMaterialRiskChangeRepository
/// <summary>
/// Store a material risk change result.
/// </summary>
Task StoreChangeAsync(MaterialRiskChangeResult change, string scanId, CancellationToken ct = default);
Task StoreChangeAsync(MaterialRiskChangeResult change, string scanId, CancellationToken ct = default, string? tenantId = null);
/// <summary>
/// Store multiple material risk change results.
/// </summary>
Task StoreChangesAsync(IReadOnlyList<MaterialRiskChangeResult> changes, string scanId, CancellationToken ct = default);
Task StoreChangesAsync(IReadOnlyList<MaterialRiskChangeResult> changes, string scanId, CancellationToken ct = default, string? tenantId = null);
/// <summary>
/// Get material changes for a scan.
/// </summary>
Task<IReadOnlyList<MaterialRiskChangeResult>> GetChangesForScanAsync(string scanId, CancellationToken ct = default);
Task<IReadOnlyList<MaterialRiskChangeResult>> GetChangesForScanAsync(string scanId, CancellationToken ct = default, string? tenantId = null);
/// <summary>
/// Get material changes for a finding.
@@ -68,14 +69,16 @@ public interface IMaterialRiskChangeRepository
Task<IReadOnlyList<MaterialRiskChangeResult>> GetChangesForFindingAsync(
FindingKey findingKey,
int limit = 10,
CancellationToken ct = default);
CancellationToken ct = default,
string? tenantId = null);
/// <summary>
/// Query material changes with filters.
/// </summary>
Task<MaterialRiskChangeQueryResult> QueryChangesAsync(
MaterialRiskChangeQuery query,
CancellationToken ct = default);
CancellationToken ct = default,
string? tenantId = null);
}
/// <summary>
@@ -105,32 +108,40 @@ public sealed record MaterialRiskChangeQueryResult(
/// </summary>
public sealed class InMemoryRiskStateRepository : IRiskStateRepository
{
private readonly List<RiskStateSnapshot> _snapshots = [];
private readonly List<(string TenantId, RiskStateSnapshot Snapshot)> _snapshots = [];
private readonly object _lock = new();
public Task StoreSnapshotAsync(RiskStateSnapshot snapshot, CancellationToken ct = default)
public Task StoreSnapshotAsync(RiskStateSnapshot snapshot, CancellationToken ct = default, string? tenantId = null)
{
var normalizedTenant = NormalizeTenant(tenantId);
lock (_lock)
{
_snapshots.Add(snapshot);
_snapshots.Add((normalizedTenant, snapshot));
}
return Task.CompletedTask;
}
public Task StoreSnapshotsAsync(IReadOnlyList<RiskStateSnapshot> snapshots, CancellationToken ct = default)
public Task StoreSnapshotsAsync(IReadOnlyList<RiskStateSnapshot> snapshots, CancellationToken ct = default, string? tenantId = null)
{
var normalizedTenant = NormalizeTenant(tenantId);
lock (_lock)
{
_snapshots.AddRange(snapshots);
foreach (var snapshot in snapshots)
{
_snapshots.Add((normalizedTenant, snapshot));
}
}
return Task.CompletedTask;
}
public Task<RiskStateSnapshot?> GetLatestSnapshotAsync(FindingKey findingKey, CancellationToken ct = default)
public Task<RiskStateSnapshot?> GetLatestSnapshotAsync(FindingKey findingKey, CancellationToken ct = default, string? tenantId = null)
{
var normalizedTenant = NormalizeTenant(tenantId);
lock (_lock)
{
var snapshot = _snapshots
.Where(entry => string.Equals(entry.TenantId, normalizedTenant, StringComparison.Ordinal))
.Select(entry => entry.Snapshot)
.Where(s => s.FindingKey == findingKey)
.OrderByDescending(s => s.CapturedAt)
.FirstOrDefault();
@@ -138,11 +149,14 @@ public sealed class InMemoryRiskStateRepository : IRiskStateRepository
}
}
public Task<IReadOnlyList<RiskStateSnapshot>> GetSnapshotsForScanAsync(string scanId, CancellationToken ct = default)
public Task<IReadOnlyList<RiskStateSnapshot>> GetSnapshotsForScanAsync(string scanId, CancellationToken ct = default, string? tenantId = null)
{
var normalizedTenant = NormalizeTenant(tenantId);
lock (_lock)
{
var snapshots = _snapshots
.Where(entry => string.Equals(entry.TenantId, normalizedTenant, StringComparison.Ordinal))
.Select(entry => entry.Snapshot)
.Where(s => s.ScanId == scanId)
.ToList();
return Task.FromResult<IReadOnlyList<RiskStateSnapshot>>(snapshots);
@@ -152,11 +166,15 @@ public sealed class InMemoryRiskStateRepository : IRiskStateRepository
public Task<IReadOnlyList<RiskStateSnapshot>> GetSnapshotHistoryAsync(
FindingKey findingKey,
int limit = 10,
CancellationToken ct = default)
CancellationToken ct = default,
string? tenantId = null)
{
var normalizedTenant = NormalizeTenant(tenantId);
lock (_lock)
{
var snapshots = _snapshots
.Where(entry => string.Equals(entry.TenantId, normalizedTenant, StringComparison.Ordinal))
.Select(entry => entry.Snapshot)
.Where(s => s.FindingKey == findingKey)
.OrderByDescending(s => s.CapturedAt)
.Take(limit)
@@ -165,16 +183,24 @@ public sealed class InMemoryRiskStateRepository : IRiskStateRepository
}
}
public Task<IReadOnlyList<RiskStateSnapshot>> GetSnapshotsByHashAsync(string stateHash, CancellationToken ct = default)
public Task<IReadOnlyList<RiskStateSnapshot>> GetSnapshotsByHashAsync(string stateHash, CancellationToken ct = default, string? tenantId = null)
{
var normalizedTenant = NormalizeTenant(tenantId);
lock (_lock)
{
var snapshots = _snapshots
.Where(entry => string.Equals(entry.TenantId, normalizedTenant, StringComparison.Ordinal))
.Select(entry => entry.Snapshot)
.Where(s => s.ComputeStateHash() == stateHash)
.ToList();
return Task.FromResult<IReadOnlyList<RiskStateSnapshot>>(snapshots);
}
}
private static string NormalizeTenant(string? tenantId)
=> string.IsNullOrWhiteSpace(tenantId)
? "default"
: tenantId.Trim().ToLowerInvariant();
}
/// <summary>
@@ -186,54 +212,70 @@ public sealed class InMemoryVexCandidateStore : IVexCandidateStore
private readonly Dictionary<string, VexCandidateReview> _reviews = [];
private readonly object _lock = new();
public Task StoreCandidatesAsync(IReadOnlyList<VexCandidate> candidates, CancellationToken ct = default)
public Task StoreCandidatesAsync(IReadOnlyList<VexCandidate> candidates, CancellationToken ct = default, string? tenantId = null)
{
var normalizedTenant = NormalizeTenant(tenantId);
lock (_lock)
{
foreach (var candidate in candidates)
{
_candidates[candidate.CandidateId] = candidate;
_candidates[BuildCandidateKey(normalizedTenant, candidate.CandidateId)] = candidate;
}
}
return Task.CompletedTask;
}
public Task<IReadOnlyList<VexCandidate>> GetCandidatesAsync(string imageDigest, CancellationToken ct = default)
public Task<IReadOnlyList<VexCandidate>> GetCandidatesAsync(string imageDigest, CancellationToken ct = default, string? tenantId = null)
{
var normalizedTenant = NormalizeTenant(tenantId);
var tenantPrefix = $"{normalizedTenant}:";
lock (_lock)
{
var candidates = _candidates.Values
.Where(c => c.ImageDigest == imageDigest)
var candidates = _candidates
.Where(entry => entry.Key.StartsWith(tenantPrefix, StringComparison.Ordinal))
.Select(entry => entry.Value)
.Where(candidate => candidate.ImageDigest == imageDigest)
.ToList();
return Task.FromResult<IReadOnlyList<VexCandidate>>(candidates);
}
}
public Task<VexCandidate?> GetCandidateAsync(string candidateId, CancellationToken ct = default)
public Task<VexCandidate?> GetCandidateAsync(string candidateId, CancellationToken ct = default, string? tenantId = null)
{
var normalizedTenant = NormalizeTenant(tenantId);
lock (_lock)
{
_candidates.TryGetValue(candidateId, out var candidate);
_candidates.TryGetValue(BuildCandidateKey(normalizedTenant, candidateId), out var candidate);
return Task.FromResult(candidate);
}
}
public Task<bool> ReviewCandidateAsync(string candidateId, VexCandidateReview review, CancellationToken ct = default)
public Task<bool> ReviewCandidateAsync(string candidateId, VexCandidateReview review, CancellationToken ct = default, string? tenantId = null)
{
var normalizedTenant = NormalizeTenant(tenantId);
var candidateKey = BuildCandidateKey(normalizedTenant, candidateId);
lock (_lock)
{
if (!_candidates.ContainsKey(candidateId))
if (!_candidates.ContainsKey(candidateKey))
return Task.FromResult(false);
_reviews[candidateId] = review;
_reviews[candidateKey] = review;
// Update candidate to mark as reviewed
if (_candidates.TryGetValue(candidateId, out var candidate))
if (_candidates.TryGetValue(candidateKey, out var candidate))
{
_candidates[candidateId] = candidate with { RequiresReview = false };
_candidates[candidateKey] = candidate with { RequiresReview = false };
}
return Task.FromResult(true);
}
}
private static string BuildCandidateKey(string tenantId, string candidateId)
=> $"{tenantId}:{candidateId}";
private static string NormalizeTenant(string? tenantId)
=> string.IsNullOrWhiteSpace(tenantId)
? "default"
: tenantId.Trim().ToLowerInvariant();
}

View File

@@ -136,22 +136,22 @@ public interface IVexCandidateStore
/// <summary>
/// Store candidates.
/// </summary>
Task StoreCandidatesAsync(IReadOnlyList<VexCandidate> candidates, CancellationToken ct = default);
Task StoreCandidatesAsync(IReadOnlyList<VexCandidate> candidates, CancellationToken ct = default, string? tenantId = null);
/// <summary>
/// Get candidates for an image.
/// </summary>
Task<IReadOnlyList<VexCandidate>> GetCandidatesAsync(string imageDigest, CancellationToken ct = default);
Task<IReadOnlyList<VexCandidate>> GetCandidatesAsync(string imageDigest, CancellationToken ct = default, string? tenantId = null);
/// <summary>
/// Get a specific candidate by ID.
/// </summary>
Task<VexCandidate?> GetCandidateAsync(string candidateId, CancellationToken ct = default);
Task<VexCandidate?> GetCandidateAsync(string candidateId, CancellationToken ct = default, string? tenantId = null);
/// <summary>
/// Mark a candidate as reviewed.
/// </summary>
Task<bool> ReviewCandidateAsync(string candidateId, VexCandidateReview review, CancellationToken ct = default);
Task<bool> ReviewCandidateAsync(string candidateId, VexCandidateReview review, CancellationToken ct = default, string? tenantId = null);
}
/// <summary>

View File

@@ -80,12 +80,13 @@ public interface ISbomSourceRunRepository
/// <summary>
/// Get a run by ID.
/// </summary>
Task<SbomSourceRun?> GetByIdAsync(Guid runId, CancellationToken ct = default);
Task<SbomSourceRun?> GetByIdAsync(string tenantId, Guid runId, CancellationToken ct = default);
/// <summary>
/// List runs for a source.
/// </summary>
Task<PagedResponse<SbomSourceRun>> ListForSourceAsync(
string tenantId,
Guid sourceId,
ListSourceRunsRequest request,
CancellationToken ct = default);
@@ -111,7 +112,7 @@ public interface ISbomSourceRunRepository
/// <summary>
/// Get aggregate statistics for a source.
/// </summary>
Task<SourceRunStats> GetStatsAsync(Guid sourceId, CancellationToken ct = default);
Task<SourceRunStats> GetStatsAsync(string tenantId, Guid sourceId, CancellationToken ct = default);
}
/// <summary>

View File

@@ -28,32 +28,37 @@ public sealed class SbomSourceRunRepository : RepositoryBase<ScannerSourcesDataS
_timeProvider = timeProvider ?? TimeProvider.System;
}
public async Task<SbomSourceRun?> GetByIdAsync(Guid runId, CancellationToken ct = default)
public async Task<SbomSourceRun?> GetByIdAsync(string tenantId, Guid runId, CancellationToken ct = default)
{
const string sql = $"""
SELECT * FROM {FullTable}
WHERE run_id = @runId
WHERE tenant_id = @tenantId AND run_id = @runId
""";
// Use system tenant for run queries (runs have their own tenant_id)
return await QuerySingleOrDefaultAsync(
"__system__",
tenantId,
sql,
cmd => AddParameter(cmd, "runId", runId),
cmd =>
{
AddParameter(cmd, "tenantId", tenantId);
AddParameter(cmd, "runId", runId);
},
MapRun,
ct);
}
public async Task<PagedResponse<SbomSourceRun>> ListForSourceAsync(
string tenantId,
Guid sourceId,
ListSourceRunsRequest request,
CancellationToken ct = default)
{
var sb = new StringBuilder($"SELECT * FROM {FullTable} WHERE source_id = @sourceId");
var countSb = new StringBuilder($"SELECT COUNT(*) FROM {FullTable} WHERE source_id = @sourceId");
var sb = new StringBuilder($"SELECT * FROM {FullTable} WHERE tenant_id = @tenantId AND source_id = @sourceId");
var countSb = new StringBuilder($"SELECT COUNT(*) FROM {FullTable} WHERE tenant_id = @tenantId AND source_id = @sourceId");
void AddFilters(NpgsqlCommand cmd)
{
AddParameter(cmd, "tenantId", tenantId);
AddParameter(cmd, "sourceId", sourceId);
if (request.Trigger.HasValue)
@@ -95,14 +100,14 @@ public sealed class SbomSourceRunRepository : RepositoryBase<ScannerSourcesDataS
}
var items = await QueryAsync(
"__system__",
tenantId,
sb.ToString(),
AddFilters,
MapRun,
ct);
var totalCount = await ExecuteScalarAsync<long>(
"__system__",
tenantId,
countSb.ToString(),
AddFilters,
ct);
@@ -197,7 +202,7 @@ public sealed class SbomSourceRunRepository : RepositoryBase<ScannerSourcesDataS
ct);
}
public async Task<SourceRunStats> GetStatsAsync(Guid sourceId, CancellationToken ct = default)
public async Task<SourceRunStats> GetStatsAsync(string tenantId, Guid sourceId, CancellationToken ct = default)
{
const string sql = $"""
SELECT
@@ -209,14 +214,19 @@ public sealed class SbomSourceRunRepository : RepositoryBase<ScannerSourcesDataS
MAX(completed_at) FILTER (WHERE status = 'Succeeded') as last_success_at,
MAX(completed_at) FILTER (WHERE status = 'Failed') as last_failure_at
FROM {FullTable}
WHERE source_id = @sourceId
WHERE tenant_id = @tenantId
AND source_id = @sourceId
AND completed_at IS NOT NULL
""";
var result = await QuerySingleOrDefaultAsync(
"__system__",
tenantId,
sql,
cmd => AddParameter(cmd, "sourceId", sourceId),
cmd =>
{
AddParameter(cmd, "tenantId", tenantId);
AddParameter(cmd, "sourceId", sourceId);
},
reader => new SourceRunStats
{
TotalRuns = reader.GetInt32(reader.GetOrdinal("total_runs")),

View File

@@ -379,7 +379,7 @@ public sealed class SbomSourceService : ISbomSourceService
var source = await _sourceRepository.GetByIdAsync(tenantId, sourceId, ct)
?? throw new KeyNotFoundException($"Source {sourceId} not found");
var result = await _runRepository.ListForSourceAsync(sourceId, request, ct);
var result = await _runRepository.ListForSourceAsync(tenantId, sourceId, request, ct);
return new PagedResponse<SourceRunResponse>
{
@@ -399,7 +399,7 @@ public sealed class SbomSourceService : ISbomSourceService
_ = await _sourceRepository.GetByIdAsync(tenantId, sourceId, ct)
?? throw new KeyNotFoundException($"Source {sourceId} not found");
var run = await _runRepository.GetByIdAsync(runId, ct);
var run = await _runRepository.GetByIdAsync(tenantId, runId, ct);
if (run == null || run.SourceId != sourceId)
{
return null;

View File

@@ -9,3 +9,4 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
| AUDIT-0684-T | DONE | Revalidated 2026-01-12. |
| AUDIT-0684-A | DONE | Applied 2026-01-14. |
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
| SPRINT-20260222-057-SCAN-TEN-13 | DONE | `SPRINT_20260222_057_Scanner_tenant_isolation_for_scans_triage_webhooks.md`: tenant-parameterized `ISbomSourceRunRepository` (`GetByIdAsync`, `ListForSourceAsync`, `GetStatsAsync`) and SQL predicates for `scanner.sbom_source_runs` (2026-02-23). |

View File

@@ -248,7 +248,13 @@ public sealed class SourceTriggerDispatcher : ISourceTriggerDispatcher
Guid originalRunId,
CancellationToken ct = default)
{
var originalRun = await _runRepository.GetByIdAsync(originalRunId, ct);
var source = await _sourceRepository.GetByIdAnyTenantAsync(sourceId, ct);
if (source == null)
{
throw new KeyNotFoundException($"Source {sourceId} not found");
}
var originalRun = await _runRepository.GetByIdAsync(source.TenantId, originalRunId, ct);
if (originalRun == null)
{
throw new KeyNotFoundException($"Run {originalRunId} not found");

View File

@@ -0,0 +1,9 @@
// <auto-generated />
using Microsoft.EntityFrameworkCore.Infrastructure;
using StellaOps.Scanner.Storage.EfCore.CompiledModels;
using StellaOps.Scanner.Storage.EfCore.Context;
#pragma warning disable 219, 612, 618
#nullable disable
[assembly: DbContextModel(typeof(ScannerDbContext), typeof(ScannerDbContextModel))]

View File

@@ -0,0 +1,48 @@
// <auto-generated />
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using StellaOps.Scanner.Storage.EfCore.Context;
#pragma warning disable 219, 612, 618
#nullable disable
namespace StellaOps.Scanner.Storage.EfCore.CompiledModels
{
[DbContext(typeof(ScannerDbContext))]
public partial class ScannerDbContextModel : RuntimeModel
{
private static readonly bool _useOldBehavior31751 =
System.AppContext.TryGetSwitch("Microsoft.EntityFrameworkCore.Issue31751", out var enabled31751) && enabled31751;
static ScannerDbContextModel()
{
var model = new ScannerDbContextModel();
if (_useOldBehavior31751)
{
model.Initialize();
}
else
{
var thread = new System.Threading.Thread(RunInitialization, 10 * 1024 * 1024);
thread.Start();
thread.Join();
void RunInitialization()
{
model.Initialize();
}
}
model.Customize();
_instance = (ScannerDbContextModel)model.FinalizeModel();
}
private static ScannerDbContextModel _instance;
public static IModel Instance => _instance;
partial void Initialize();
partial void Customize();
}
}

View File

@@ -0,0 +1,27 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#pragma warning disable 219, 612, 618
#nullable disable
namespace StellaOps.Scanner.Storage.EfCore.CompiledModels
{
public partial class ScannerDbContextModel
{
private ScannerDbContextModel()
: base(skipDetectChanges: false, modelId: new Guid("b2a4e1c7-8d3f-4a5b-9e6c-1f7d2e8b3c4a"), entityTypeCount: 13)
{
}
partial void Initialize()
{
// Stub: entity types will be populated by `dotnet ef dbcontext optimize`.
AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn);
AddAnnotation("ProductVersion", "10.0.0");
AddAnnotation("Relational:MaxIdentifierLength", 63);
}
}
}

View File

@@ -0,0 +1,397 @@
using Microsoft.EntityFrameworkCore;
using StellaOps.Scanner.Storage.EfCore.Models;
namespace StellaOps.Scanner.Storage.EfCore.Context;
/// <summary>
/// Entity Framework Core DbContext for the Scanner Storage schema.
/// SQL migrations remain authoritative; EF models are scaffolded FROM schema.
/// </summary>
public partial class ScannerDbContext : DbContext
{
private readonly string _schemaName;
public ScannerDbContext(DbContextOptions<ScannerDbContext> options, string? schemaName = null)
: base(options)
{
_schemaName = string.IsNullOrWhiteSpace(schemaName)
? ScannerStorageDefaults.DefaultSchemaName
: schemaName.Trim();
}
// ----- Scanner schema tables -----
public virtual DbSet<IdempotencyKeyEntity> IdempotencyKeys { get; set; }
public virtual DbSet<ScanMetricsEntity> ScanMetrics { get; set; }
public virtual DbSet<RiskStateSnapshotEntity> RiskStateSnapshots { get; set; }
public virtual DbSet<MaterialRiskChangeEntity> MaterialRiskChanges { get; set; }
public virtual DbSet<CallGraphSnapshotEntity> CallGraphSnapshots { get; set; }
public virtual DbSet<ReachabilityResultEntity> ReachabilityResults { get; set; }
// ----- Public/default schema tables -----
public virtual DbSet<ScanManifestEntity> ScanManifests { get; set; }
public virtual DbSet<ProofBundleEntity> ProofBundles { get; set; }
public virtual DbSet<BinaryIdentityEntity> BinaryIdentities { get; set; }
public virtual DbSet<BinaryPackageMapEntity> BinaryPackageMaps { get; set; }
public virtual DbSet<BinaryVulnAssertionEntity> BinaryVulnAssertions { get; set; }
public virtual DbSet<SecretDetectionSettingsEntity> SecretDetectionSettings { get; set; }
public virtual DbSet<ArtifactBomEntity> ArtifactBoms { get; set; }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
var schema = _schemaName;
// ======================================================================
// Scanner schema tables
// ======================================================================
modelBuilder.Entity<IdempotencyKeyEntity>(entity =>
{
entity.ToTable("idempotency_keys", schema);
entity.HasKey(e => e.KeyId);
entity.Property(e => e.KeyId).HasColumnName("key_id").HasDefaultValueSql("gen_random_uuid()");
entity.Property(e => e.TenantId).HasColumnName("tenant_id");
entity.Property(e => e.ContentDigest).HasColumnName("content_digest");
entity.Property(e => e.EndpointPath).HasColumnName("endpoint_path");
entity.Property(e => e.ResponseStatus).HasColumnName("response_status");
entity.Property(e => e.ResponseBody).HasColumnName("response_body").HasColumnType("jsonb");
entity.Property(e => e.ResponseHeaders).HasColumnName("response_headers").HasColumnType("jsonb");
entity.Property(e => e.CreatedAt).HasColumnName("created_at").HasDefaultValueSql("now()");
entity.Property(e => e.ExpiresAt).HasColumnName("expires_at").HasDefaultValueSql("(now() + interval '24 hours')");
entity.HasIndex(e => new { e.TenantId, e.ContentDigest, e.EndpointPath })
.IsUnique()
.HasDatabaseName("uk_idempotency_tenant_digest_path");
entity.HasIndex(e => new { e.TenantId, e.ContentDigest })
.HasDatabaseName("ix_idempotency_keys_tenant_digest");
entity.HasIndex(e => e.ExpiresAt)
.HasDatabaseName("ix_idempotency_keys_expires_at");
});
modelBuilder.Entity<ScanMetricsEntity>(entity =>
{
entity.ToTable("scan_metrics", schema);
entity.HasKey(e => e.MetricsId);
entity.Property(e => e.MetricsId).HasColumnName("metrics_id").HasDefaultValueSql("gen_random_uuid()");
entity.Property(e => e.ScanId).HasColumnName("scan_id");
entity.Property(e => e.TenantId).HasColumnName("tenant_id");
entity.Property(e => e.SurfaceId).HasColumnName("surface_id");
entity.Property(e => e.ArtifactDigest).HasColumnName("artifact_digest");
entity.Property(e => e.ArtifactType).HasColumnName("artifact_type");
entity.Property(e => e.ReplayManifestHash).HasColumnName("replay_manifest_hash");
entity.Property(e => e.FindingsSha256).HasColumnName("findings_sha256");
entity.Property(e => e.VexBundleSha256).HasColumnName("vex_bundle_sha256");
entity.Property(e => e.ProofBundleSha256).HasColumnName("proof_bundle_sha256");
entity.Property(e => e.SbomSha256).HasColumnName("sbom_sha256");
entity.Property(e => e.PolicyDigest).HasColumnName("policy_digest");
entity.Property(e => e.FeedSnapshotId).HasColumnName("feed_snapshot_id");
entity.Property(e => e.StartedAt).HasColumnName("started_at");
entity.Property(e => e.FinishedAt).HasColumnName("finished_at");
entity.Property(e => e.TIngestMs).HasColumnName("t_ingest_ms");
entity.Property(e => e.TAnalyzeMs).HasColumnName("t_analyze_ms");
entity.Property(e => e.TReachabilityMs).HasColumnName("t_reachability_ms");
entity.Property(e => e.TVexMs).HasColumnName("t_vex_ms");
entity.Property(e => e.TSignMs).HasColumnName("t_sign_ms");
entity.Property(e => e.TPublishMs).HasColumnName("t_publish_ms");
entity.Property(e => e.PackageCount).HasColumnName("package_count");
entity.Property(e => e.FindingCount).HasColumnName("finding_count");
entity.Property(e => e.VexDecisionCount).HasColumnName("vex_decision_count");
entity.Property(e => e.ScannerVersion).HasColumnName("scanner_version");
entity.Property(e => e.ScannerImageDigest).HasColumnName("scanner_image_digest");
entity.Property(e => e.IsReplay).HasColumnName("is_replay");
entity.Property(e => e.CreatedAt).HasColumnName("created_at").HasDefaultValueSql("NOW()");
entity.HasIndex(e => e.ScanId).IsUnique().HasDatabaseName("scan_metrics_scan_id_key");
entity.HasIndex(e => e.TenantId).HasDatabaseName("idx_scan_metrics_tenant");
entity.HasIndex(e => e.ArtifactDigest).HasDatabaseName("idx_scan_metrics_artifact");
entity.HasIndex(e => e.StartedAt).HasDatabaseName("idx_scan_metrics_started");
entity.HasIndex(e => new { e.TenantId, e.StartedAt }).HasDatabaseName("idx_scan_metrics_tenant_started");
});
modelBuilder.Entity<RiskStateSnapshotEntity>(entity =>
{
entity.ToTable("risk_state_snapshots", schema);
entity.HasKey(e => e.Id);
entity.Property(e => e.Id).HasColumnName("id").HasDefaultValueSql("gen_random_uuid()");
entity.Property(e => e.TenantId).HasColumnName("tenant_id");
entity.Property(e => e.VulnId).HasColumnName("vuln_id");
entity.Property(e => e.Purl).HasColumnName("purl");
entity.Property(e => e.ScanId).HasColumnName("scan_id");
entity.Property(e => e.CapturedAt).HasColumnName("captured_at").HasDefaultValueSql("NOW()");
entity.Property(e => e.Reachable).HasColumnName("reachable");
entity.Property(e => e.LatticeState).HasColumnName("lattice_state");
entity.Property(e => e.VexStatus).HasColumnName("vex_status");
entity.Property(e => e.InAffectedRange).HasColumnName("in_affected_range");
entity.Property(e => e.Kev).HasColumnName("kev");
entity.Property(e => e.EpssScore).HasColumnName("epss_score").HasColumnType("numeric(5,4)");
entity.Property(e => e.PolicyFlags).HasColumnName("policy_flags");
entity.Property(e => e.PolicyDecision).HasColumnName("policy_decision");
entity.Property(e => e.StateHash).HasColumnName("state_hash");
entity.Property(e => e.CreatedAt).HasColumnName("created_at").HasDefaultValueSql("NOW()");
entity.HasIndex(e => new { e.TenantId, e.ScanId, e.VulnId, e.Purl })
.IsUnique()
.HasDatabaseName("risk_state_unique_per_scan");
entity.HasIndex(e => new { e.TenantId, e.VulnId, e.Purl })
.HasDatabaseName("idx_risk_state_tenant_finding");
entity.HasIndex(e => e.ScanId).HasDatabaseName("idx_risk_state_scan");
entity.HasIndex(e => e.StateHash).HasDatabaseName("idx_risk_state_hash");
});
modelBuilder.Entity<MaterialRiskChangeEntity>(entity =>
{
entity.ToTable("material_risk_changes", schema);
entity.HasKey(e => e.Id);
entity.Property(e => e.Id).HasColumnName("id").HasDefaultValueSql("gen_random_uuid()");
entity.Property(e => e.TenantId).HasColumnName("tenant_id");
entity.Property(e => e.VulnId).HasColumnName("vuln_id");
entity.Property(e => e.Purl).HasColumnName("purl");
entity.Property(e => e.ScanId).HasColumnName("scan_id");
entity.Property(e => e.HasMaterialChange).HasColumnName("has_material_change");
entity.Property(e => e.PriorityScore).HasColumnName("priority_score").HasColumnType("numeric(12,4)");
entity.Property(e => e.PreviousStateHash).HasColumnName("previous_state_hash");
entity.Property(e => e.CurrentStateHash).HasColumnName("current_state_hash");
entity.Property(e => e.Changes).HasColumnName("changes").HasColumnType("jsonb");
entity.Property(e => e.DetectedAt).HasColumnName("detected_at").HasDefaultValueSql("NOW()");
entity.Property(e => e.BaseScanId).HasColumnName("base_scan_id");
entity.Property(e => e.Cause).HasColumnName("cause");
entity.Property(e => e.CauseKind).HasColumnName("cause_kind");
entity.Property(e => e.PathNodes).HasColumnName("path_nodes").HasColumnType("jsonb");
entity.Property(e => e.AssociatedVulns).HasColumnName("associated_vulns").HasColumnType("jsonb");
entity.HasIndex(e => new { e.TenantId, e.ScanId, e.VulnId, e.Purl })
.IsUnique()
.HasDatabaseName("material_change_unique_per_scan");
entity.HasIndex(e => new { e.TenantId, e.ScanId })
.HasDatabaseName("idx_material_changes_tenant_scan");
});
modelBuilder.Entity<CallGraphSnapshotEntity>(entity =>
{
entity.ToTable("call_graph_snapshots");
entity.HasKey(e => e.Id);
entity.Property(e => e.Id).HasColumnName("id").HasDefaultValueSql("gen_random_uuid()");
entity.Property(e => e.TenantId).HasColumnName("tenant_id");
entity.Property(e => e.ScanId).HasColumnName("scan_id");
entity.Property(e => e.Language).HasColumnName("language");
entity.Property(e => e.GraphDigest).HasColumnName("graph_digest");
entity.Property(e => e.ExtractedAt).HasColumnName("extracted_at").HasDefaultValueSql("NOW()");
entity.Property(e => e.NodeCount).HasColumnName("node_count");
entity.Property(e => e.EdgeCount).HasColumnName("edge_count");
entity.Property(e => e.EntrypointCount).HasColumnName("entrypoint_count");
entity.Property(e => e.SinkCount).HasColumnName("sink_count");
entity.Property(e => e.SnapshotJson).HasColumnName("snapshot_json").HasColumnType("jsonb");
entity.HasIndex(e => new { e.TenantId, e.ScanId, e.Language, e.GraphDigest })
.IsUnique()
.HasDatabaseName("call_graph_snapshot_unique_per_scan");
entity.HasIndex(e => new { e.TenantId, e.ScanId, e.Language })
.HasDatabaseName("idx_call_graph_snapshots_tenant_scan");
entity.HasIndex(e => e.GraphDigest).HasDatabaseName("idx_call_graph_snapshots_graph_digest");
});
modelBuilder.Entity<ReachabilityResultEntity>(entity =>
{
entity.ToTable("reachability_results");
entity.HasKey(e => e.Id);
entity.Property(e => e.Id).HasColumnName("id").HasDefaultValueSql("gen_random_uuid()");
entity.Property(e => e.TenantId).HasColumnName("tenant_id");
entity.Property(e => e.ScanId).HasColumnName("scan_id");
entity.Property(e => e.Language).HasColumnName("language");
entity.Property(e => e.GraphDigest).HasColumnName("graph_digest");
entity.Property(e => e.ResultDigest).HasColumnName("result_digest");
entity.Property(e => e.ComputedAt).HasColumnName("computed_at").HasDefaultValueSql("NOW()");
entity.Property(e => e.ReachableNodeCount).HasColumnName("reachable_node_count");
entity.Property(e => e.ReachableSinkCount).HasColumnName("reachable_sink_count");
entity.Property(e => e.ResultJson).HasColumnName("result_json").HasColumnType("jsonb");
entity.HasIndex(e => new { e.TenantId, e.ScanId, e.Language, e.GraphDigest, e.ResultDigest })
.IsUnique()
.HasDatabaseName("reachability_result_unique_per_scan");
entity.HasIndex(e => new { e.TenantId, e.ScanId, e.Language })
.HasDatabaseName("idx_reachability_results_tenant_scan");
});
// ======================================================================
// Public/default schema tables
// ======================================================================
modelBuilder.Entity<ScanManifestEntity>(entity =>
{
entity.ToTable("scan_manifest");
entity.HasKey(e => e.ManifestId);
entity.Property(e => e.ManifestId).HasColumnName("manifest_id").HasDefaultValueSql("gen_random_uuid()");
entity.Property(e => e.ScanId).HasColumnName("scan_id");
entity.Property(e => e.ManifestHash).HasColumnName("manifest_hash").HasMaxLength(128);
entity.Property(e => e.SbomHash).HasColumnName("sbom_hash").HasMaxLength(128);
entity.Property(e => e.RulesHash).HasColumnName("rules_hash").HasMaxLength(128);
entity.Property(e => e.FeedHash).HasColumnName("feed_hash").HasMaxLength(128);
entity.Property(e => e.PolicyHash).HasColumnName("policy_hash").HasMaxLength(128);
entity.Property(e => e.ScanStartedAt).HasColumnName("scan_started_at");
entity.Property(e => e.ScanCompletedAt).HasColumnName("scan_completed_at");
entity.Property(e => e.ManifestContent).HasColumnName("manifest_content").HasColumnType("jsonb");
entity.Property(e => e.ScannerVersion).HasColumnName("scanner_version").HasMaxLength(64);
entity.Property(e => e.CreatedAt).HasColumnName("created_at").HasDefaultValueSql("now()");
entity.HasIndex(e => e.ManifestHash).HasDatabaseName("idx_scan_manifest_hash");
entity.HasIndex(e => e.ScanId).HasDatabaseName("idx_scan_manifest_scan_id");
entity.HasIndex(e => e.CreatedAt).IsDescending().HasDatabaseName("idx_scan_manifest_created_at");
});
modelBuilder.Entity<ProofBundleEntity>(entity =>
{
entity.ToTable("proof_bundle");
entity.HasKey(e => new { e.ScanId, e.RootHash });
entity.Property(e => e.ScanId).HasColumnName("scan_id");
entity.Property(e => e.RootHash).HasColumnName("root_hash").HasMaxLength(128);
entity.Property(e => e.BundleType).HasColumnName("bundle_type").HasMaxLength(32);
entity.Property(e => e.DsseEnvelope).HasColumnName("dsse_envelope").HasColumnType("jsonb");
entity.Property(e => e.SignatureKeyId).HasColumnName("signature_keyid").HasMaxLength(256);
entity.Property(e => e.SignatureAlgorithm).HasColumnName("signature_algorithm").HasMaxLength(64);
entity.Property(e => e.BundleContent).HasColumnName("bundle_content");
entity.Property(e => e.BundleHash).HasColumnName("bundle_hash").HasMaxLength(128);
entity.Property(e => e.LedgerHash).HasColumnName("ledger_hash").HasMaxLength(128);
entity.Property(e => e.ManifestHash).HasColumnName("manifest_hash").HasMaxLength(128);
entity.Property(e => e.SbomHash).HasColumnName("sbom_hash").HasMaxLength(128);
entity.Property(e => e.VexHash).HasColumnName("vex_hash").HasMaxLength(128);
entity.Property(e => e.CreatedAt).HasColumnName("created_at").HasDefaultValueSql("now()");
entity.Property(e => e.ExpiresAt).HasColumnName("expires_at");
entity.HasIndex(e => e.RootHash).HasDatabaseName("idx_proof_bundle_root_hash");
entity.HasIndex(e => e.CreatedAt).IsDescending().HasDatabaseName("idx_proof_bundle_created_at");
});
modelBuilder.Entity<BinaryIdentityEntity>(entity =>
{
entity.ToTable("binary_identity");
entity.HasKey(e => e.Id);
entity.Property(e => e.Id).HasColumnName("id").HasDefaultValueSql("gen_random_uuid()");
entity.Property(e => e.ScanId).HasColumnName("scan_id");
entity.Property(e => e.FilePath).HasColumnName("file_path").HasMaxLength(1024);
entity.Property(e => e.FileSha256).HasColumnName("file_sha256").HasMaxLength(64);
entity.Property(e => e.TextSha256).HasColumnName("text_sha256").HasMaxLength(64);
entity.Property(e => e.BuildId).HasColumnName("build_id").HasMaxLength(128);
entity.Property(e => e.BuildIdType).HasColumnName("build_id_type").HasMaxLength(32);
entity.Property(e => e.Architecture).HasColumnName("architecture").HasMaxLength(32);
entity.Property(e => e.BinaryFormat).HasColumnName("binary_format").HasMaxLength(16);
entity.Property(e => e.FileSize).HasColumnName("file_size");
entity.Property(e => e.IsStripped).HasColumnName("is_stripped");
entity.Property(e => e.HasDebugInfo).HasColumnName("has_debug_info");
entity.Property(e => e.CreatedAtUtc).HasColumnName("created_at_utc").HasDefaultValueSql("NOW()");
entity.HasIndex(e => e.BuildId).HasDatabaseName("idx_binary_identity_build_id");
entity.HasIndex(e => e.FileSha256).HasDatabaseName("idx_binary_identity_file_sha256");
entity.HasIndex(e => e.TextSha256).HasDatabaseName("idx_binary_identity_text_sha256");
entity.HasIndex(e => e.ScanId).HasDatabaseName("idx_binary_identity_scan_id");
});
modelBuilder.Entity<BinaryPackageMapEntity>(entity =>
{
entity.ToTable("binary_package_map");
entity.HasKey(e => e.Id);
entity.Property(e => e.Id).HasColumnName("id").HasDefaultValueSql("gen_random_uuid()");
entity.Property(e => e.BinaryIdentityId).HasColumnName("binary_identity_id");
entity.Property(e => e.Purl).HasColumnName("purl").HasMaxLength(512);
entity.Property(e => e.MatchType).HasColumnName("match_type").HasMaxLength(32);
entity.Property(e => e.Confidence).HasColumnName("confidence").HasColumnType("numeric(3,2)");
entity.Property(e => e.MatchSource).HasColumnName("match_source").HasMaxLength(64);
entity.Property(e => e.EvidenceJson).HasColumnName("evidence_json").HasColumnType("jsonb");
entity.Property(e => e.CreatedAtUtc).HasColumnName("created_at_utc").HasDefaultValueSql("NOW()");
entity.HasIndex(e => new { e.BinaryIdentityId, e.Purl }).IsUnique().HasDatabaseName("uq_binary_package_map");
entity.HasIndex(e => e.Purl).HasDatabaseName("idx_binary_package_map_purl");
entity.HasIndex(e => e.BinaryIdentityId).HasDatabaseName("idx_binary_package_map_binary_id");
entity.HasOne(e => e.BinaryIdentity)
.WithMany(b => b.PackageMaps)
.HasForeignKey(e => e.BinaryIdentityId)
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity<BinaryVulnAssertionEntity>(entity =>
{
entity.ToTable("binary_vuln_assertion");
entity.HasKey(e => e.Id);
entity.Property(e => e.Id).HasColumnName("id").HasDefaultValueSql("gen_random_uuid()");
entity.Property(e => e.BinaryIdentityId).HasColumnName("binary_identity_id");
entity.Property(e => e.VulnId).HasColumnName("vuln_id").HasMaxLength(64);
entity.Property(e => e.Status).HasColumnName("status").HasMaxLength(32);
entity.Property(e => e.Source).HasColumnName("source").HasMaxLength(64);
entity.Property(e => e.AssertionType).HasColumnName("assertion_type").HasMaxLength(32);
entity.Property(e => e.Confidence).HasColumnName("confidence").HasColumnType("numeric(3,2)");
entity.Property(e => e.EvidenceJson).HasColumnName("evidence_json").HasColumnType("jsonb");
entity.Property(e => e.ValidFrom).HasColumnName("valid_from");
entity.Property(e => e.ValidUntil).HasColumnName("valid_until");
entity.Property(e => e.SignatureRef).HasColumnName("signature_ref").HasMaxLength(256);
entity.Property(e => e.CreatedAtUtc).HasColumnName("created_at_utc").HasDefaultValueSql("NOW()");
entity.HasIndex(e => e.VulnId).HasDatabaseName("idx_binary_vuln_assertion_vuln_id");
entity.HasIndex(e => e.BinaryIdentityId).HasDatabaseName("idx_binary_vuln_assertion_binary_id");
entity.HasIndex(e => e.Status).HasDatabaseName("idx_binary_vuln_assertion_status");
entity.HasOne(e => e.BinaryIdentity)
.WithMany(b => b.VulnAssertions)
.HasForeignKey(e => e.BinaryIdentityId)
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity<SecretDetectionSettingsEntity>(entity =>
{
entity.ToTable("secret_detection_settings", schema);
entity.HasKey(e => e.SettingsId);
entity.Property(e => e.SettingsId).HasColumnName("settings_id").HasDefaultValueSql("gen_random_uuid()");
entity.Property(e => e.TenantId).HasColumnName("tenant_id");
entity.Property(e => e.Enabled).HasColumnName("enabled");
entity.Property(e => e.RevelationPolicy).HasColumnName("revelation_policy").HasColumnType("jsonb");
entity.Property(e => e.EnabledRuleCategories).HasColumnName("enabled_rule_categories");
entity.Property(e => e.DisabledRuleIds).HasColumnName("disabled_rule_ids");
entity.Property(e => e.AlertSettings).HasColumnName("alert_settings").HasColumnType("jsonb");
entity.Property(e => e.MaxFileSizeBytes).HasColumnName("max_file_size_bytes");
entity.Property(e => e.ExcludedFileExtensions).HasColumnName("excluded_file_extensions");
entity.Property(e => e.ExcludedPaths).HasColumnName("excluded_paths");
entity.Property(e => e.ScanBinaryFiles).HasColumnName("scan_binary_files");
entity.Property(e => e.RequireSignedRuleBundles).HasColumnName("require_signed_rule_bundles");
entity.Property(e => e.Version).HasColumnName("version");
entity.Property(e => e.UpdatedAt).HasColumnName("updated_at").HasDefaultValueSql("NOW()");
entity.Property(e => e.UpdatedBy).HasColumnName("updated_by");
entity.Property(e => e.CreatedAt).HasColumnName("created_at").HasDefaultValueSql("NOW()");
entity.HasIndex(e => e.TenantId).IsUnique().HasDatabaseName("secret_detection_settings_tenant_id_key");
});
modelBuilder.Entity<ArtifactBomEntity>(entity =>
{
entity.ToTable("artifact_boms", schema);
entity.HasKey(e => new { e.BuildId, e.InsertedAt });
entity.Property(e => e.BuildId).HasColumnName("build_id");
entity.Property(e => e.CanonicalBomSha256).HasColumnName("canonical_bom_sha256");
entity.Property(e => e.PayloadDigest).HasColumnName("payload_digest");
entity.Property(e => e.InsertedAt).HasColumnName("inserted_at");
entity.Property(e => e.RawBomRef).HasColumnName("raw_bom_ref");
entity.Property(e => e.CanonicalBomRef).HasColumnName("canonical_bom_ref");
entity.Property(e => e.DsseEnvelopeRef).HasColumnName("dsse_envelope_ref");
entity.Property(e => e.MergedVexRef).HasColumnName("merged_vex_ref");
entity.Property(e => e.CanonicalBomJson).HasColumnName("canonical_bom").HasColumnType("jsonb");
entity.Property(e => e.MergedVexJson).HasColumnName("merged_vex").HasColumnType("jsonb");
entity.Property(e => e.AttestationsJson).HasColumnName("attestations").HasColumnType("jsonb");
entity.Property(e => e.EvidenceScore).HasColumnName("evidence_score");
entity.Property(e => e.RekorTileId).HasColumnName("rekor_tile_id");
});
OnModelCreatingPartial(modelBuilder);
}
partial void OnModelCreatingPartial(ModelBuilder modelBuilder);
}

View File

@@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;
namespace StellaOps.Scanner.Storage.EfCore.Context;
/// <summary>
/// Design-time factory for EF Core tooling (scaffold, optimize, migrations).
/// </summary>
public sealed class ScannerDesignTimeDbContextFactory : IDesignTimeDbContextFactory<ScannerDbContext>
{
private const string DefaultConnectionString = "Host=localhost;Port=55434;Database=postgres;Username=postgres;Password=postgres;Search Path=scanner,public";
private const string ConnectionStringEnvironmentVariable = "STELLAOPS_SCANNER_EF_CONNECTION";
public ScannerDbContext CreateDbContext(string[] args)
{
var connectionString = ResolveConnectionString();
var options = new DbContextOptionsBuilder<ScannerDbContext>()
.UseNpgsql(connectionString)
.Options;
return new ScannerDbContext(options);
}
private static string ResolveConnectionString()
{
var fromEnvironment = Environment.GetEnvironmentVariable(ConnectionStringEnvironmentVariable);
return string.IsNullOrWhiteSpace(fromEnvironment) ? DefaultConnectionString : fromEnvironment;
}
}

View File

@@ -0,0 +1,21 @@
namespace StellaOps.Scanner.Storage.EfCore.Models;
/// <summary>
/// EF Core entity for the artifact_boms partitioned table.
/// </summary>
public sealed class ArtifactBomEntity
{
public string BuildId { get; set; } = null!;
public string CanonicalBomSha256 { get; set; } = null!;
public string PayloadDigest { get; set; } = null!;
public DateTimeOffset InsertedAt { get; set; }
public string? RawBomRef { get; set; }
public string? CanonicalBomRef { get; set; }
public string? DsseEnvelopeRef { get; set; }
public string? MergedVexRef { get; set; }
public string? CanonicalBomJson { get; set; }
public string? MergedVexJson { get; set; }
public string? AttestationsJson { get; set; }
public double? EvidenceScore { get; set; }
public string? RekorTileId { get; set; }
}

View File

@@ -0,0 +1,24 @@
namespace StellaOps.Scanner.Storage.EfCore.Models;
/// <summary>
/// EF Core entity for the binary_identity table.
/// </summary>
public sealed class BinaryIdentityEntity
{
public Guid Id { get; set; }
public Guid ScanId { get; set; }
public string FilePath { get; set; } = null!;
public string FileSha256 { get; set; } = null!;
public string? TextSha256 { get; set; }
public string? BuildId { get; set; }
public string? BuildIdType { get; set; }
public string Architecture { get; set; } = null!;
public string BinaryFormat { get; set; } = null!;
public long FileSize { get; set; }
public bool IsStripped { get; set; }
public bool HasDebugInfo { get; set; }
public DateTimeOffset CreatedAtUtc { get; set; }
public ICollection<BinaryPackageMapEntity> PackageMaps { get; set; } = new List<BinaryPackageMapEntity>();
public ICollection<BinaryVulnAssertionEntity> VulnAssertions { get; set; } = new List<BinaryVulnAssertionEntity>();
}

View File

@@ -0,0 +1,18 @@
namespace StellaOps.Scanner.Storage.EfCore.Models;
/// <summary>
/// EF Core entity for the binary_package_map table.
/// </summary>
public sealed class BinaryPackageMapEntity
{
public Guid Id { get; set; }
public Guid BinaryIdentityId { get; set; }
public string Purl { get; set; } = null!;
public string MatchType { get; set; } = null!;
public decimal Confidence { get; set; }
public string MatchSource { get; set; } = null!;
public string? EvidenceJson { get; set; }
public DateTimeOffset CreatedAtUtc { get; set; }
public BinaryIdentityEntity? BinaryIdentity { get; set; }
}

View File

@@ -0,0 +1,22 @@
namespace StellaOps.Scanner.Storage.EfCore.Models;
/// <summary>
/// EF Core entity for the binary_vuln_assertion table.
/// </summary>
public sealed class BinaryVulnAssertionEntity
{
public Guid Id { get; set; }
public Guid BinaryIdentityId { get; set; }
public string VulnId { get; set; } = null!;
public string Status { get; set; } = null!;
public string Source { get; set; } = null!;
public string AssertionType { get; set; } = null!;
public decimal Confidence { get; set; }
public string? EvidenceJson { get; set; }
public DateTimeOffset ValidFrom { get; set; }
public DateTimeOffset? ValidUntil { get; set; }
public string? SignatureRef { get; set; }
public DateTimeOffset CreatedAtUtc { get; set; }
public BinaryIdentityEntity? BinaryIdentity { get; set; }
}

View File

@@ -0,0 +1,19 @@
namespace StellaOps.Scanner.Storage.EfCore.Models;
/// <summary>
/// EF Core entity for the call_graph_snapshots table.
/// </summary>
public sealed class CallGraphSnapshotEntity
{
public Guid Id { get; set; }
public Guid TenantId { get; set; }
public string ScanId { get; set; } = null!;
public string Language { get; set; } = null!;
public string GraphDigest { get; set; } = null!;
public DateTimeOffset ExtractedAt { get; set; }
public int NodeCount { get; set; }
public int EdgeCount { get; set; }
public int EntrypointCount { get; set; }
public int SinkCount { get; set; }
public string SnapshotJson { get; set; } = null!;
}

View File

@@ -0,0 +1,17 @@
namespace StellaOps.Scanner.Storage.EfCore.Models;
/// <summary>
/// EF Core entity for the scanner.idempotency_keys table.
/// </summary>
public sealed class IdempotencyKeyEntity
{
public Guid KeyId { get; set; }
public string TenantId { get; set; } = null!;
public string ContentDigest { get; set; } = null!;
public string EndpointPath { get; set; } = null!;
public int ResponseStatus { get; set; }
public string? ResponseBody { get; set; }
public string? ResponseHeaders { get; set; }
public DateTimeOffset CreatedAt { get; set; }
public DateTimeOffset ExpiresAt { get; set; }
}

View File

@@ -0,0 +1,24 @@
namespace StellaOps.Scanner.Storage.EfCore.Models;
/// <summary>
/// EF Core entity for the material_risk_changes table.
/// </summary>
public sealed class MaterialRiskChangeEntity
{
public Guid Id { get; set; }
public Guid TenantId { get; set; }
public string VulnId { get; set; } = null!;
public string Purl { get; set; } = null!;
public string ScanId { get; set; } = null!;
public bool HasMaterialChange { get; set; }
public decimal PriorityScore { get; set; }
public string PreviousStateHash { get; set; } = null!;
public string CurrentStateHash { get; set; } = null!;
public string Changes { get; set; } = null!;
public DateTimeOffset DetectedAt { get; set; }
public string? BaseScanId { get; set; }
public string? Cause { get; set; }
public string? CauseKind { get; set; }
public string? PathNodes { get; set; }
public string? AssociatedVulns { get; set; }
}

View File

@@ -0,0 +1,22 @@
namespace StellaOps.Scanner.Storage.EfCore.Models;
/// <summary>
/// EF Core entity for the proof_bundle table.
/// </summary>
public sealed class ProofBundleEntity
{
public Guid ScanId { get; set; }
public string RootHash { get; set; } = null!;
public string BundleType { get; set; } = null!;
public string? DsseEnvelope { get; set; }
public string? SignatureKeyId { get; set; }
public string? SignatureAlgorithm { get; set; }
public byte[]? BundleContent { get; set; }
public string BundleHash { get; set; } = null!;
public string? LedgerHash { get; set; }
public string? ManifestHash { get; set; }
public string? SbomHash { get; set; }
public string? VexHash { get; set; }
public DateTimeOffset CreatedAt { get; set; }
public DateTimeOffset? ExpiresAt { get; set; }
}

View File

@@ -0,0 +1,18 @@
namespace StellaOps.Scanner.Storage.EfCore.Models;
/// <summary>
/// EF Core entity for the reachability_results table.
/// </summary>
public sealed class ReachabilityResultEntity
{
public Guid Id { get; set; }
public Guid TenantId { get; set; }
public string ScanId { get; set; } = null!;
public string Language { get; set; } = null!;
public string GraphDigest { get; set; } = null!;
public string ResultDigest { get; set; } = null!;
public DateTimeOffset ComputedAt { get; set; }
public int ReachableNodeCount { get; set; }
public int ReachableSinkCount { get; set; }
public string ResultJson { get; set; } = null!;
}

View File

@@ -0,0 +1,24 @@
namespace StellaOps.Scanner.Storage.EfCore.Models;
/// <summary>
/// EF Core entity for the risk_state_snapshots table (both public and scanner schema).
/// </summary>
public sealed class RiskStateSnapshotEntity
{
public Guid Id { get; set; }
public Guid TenantId { get; set; }
public string VulnId { get; set; } = null!;
public string Purl { get; set; } = null!;
public string ScanId { get; set; } = null!;
public DateTimeOffset CapturedAt { get; set; }
public bool? Reachable { get; set; }
public string? LatticeState { get; set; }
public string VexStatus { get; set; } = null!;
public bool? InAffectedRange { get; set; }
public bool Kev { get; set; }
public decimal? EpssScore { get; set; }
public string[]? PolicyFlags { get; set; }
public string? PolicyDecision { get; set; }
public string StateHash { get; set; } = null!;
public DateTimeOffset CreatedAt { get; set; }
}

View File

@@ -0,0 +1,20 @@
namespace StellaOps.Scanner.Storage.EfCore.Models;
/// <summary>
/// EF Core entity for the scan_manifest table.
/// </summary>
public sealed class ScanManifestEntity
{
public Guid ManifestId { get; set; }
public Guid ScanId { get; set; }
public string ManifestHash { get; set; } = null!;
public string SbomHash { get; set; } = null!;
public string RulesHash { get; set; } = null!;
public string FeedHash { get; set; } = null!;
public string PolicyHash { get; set; } = null!;
public DateTimeOffset ScanStartedAt { get; set; }
public DateTimeOffset? ScanCompletedAt { get; set; }
public string ManifestContent { get; set; } = null!;
public string ScannerVersion { get; set; } = null!;
public DateTimeOffset CreatedAt { get; set; }
}

View File

@@ -0,0 +1,36 @@
namespace StellaOps.Scanner.Storage.EfCore.Models;
/// <summary>
/// EF Core entity for the scanner.scan_metrics table.
/// </summary>
public sealed class ScanMetricsEntity
{
public Guid MetricsId { get; set; }
public Guid ScanId { get; set; }
public Guid TenantId { get; set; }
public Guid? SurfaceId { get; set; }
public string ArtifactDigest { get; set; } = null!;
public string ArtifactType { get; set; } = null!;
public string? ReplayManifestHash { get; set; }
public string FindingsSha256 { get; set; } = null!;
public string? VexBundleSha256 { get; set; }
public string? ProofBundleSha256 { get; set; }
public string? SbomSha256 { get; set; }
public string? PolicyDigest { get; set; }
public string? FeedSnapshotId { get; set; }
public DateTimeOffset StartedAt { get; set; }
public DateTimeOffset FinishedAt { get; set; }
public int TIngestMs { get; set; }
public int TAnalyzeMs { get; set; }
public int TReachabilityMs { get; set; }
public int TVexMs { get; set; }
public int TSignMs { get; set; }
public int TPublishMs { get; set; }
public int? PackageCount { get; set; }
public int? FindingCount { get; set; }
public int? VexDecisionCount { get; set; }
public string ScannerVersion { get; set; } = null!;
public string? ScannerImageDigest { get; set; }
public bool IsReplay { get; set; }
public DateTimeOffset CreatedAt { get; set; }
}

View File

@@ -0,0 +1,24 @@
namespace StellaOps.Scanner.Storage.EfCore.Models;
/// <summary>
/// EF Core entity for the secret_detection_settings table.
/// </summary>
public sealed class SecretDetectionSettingsEntity
{
public Guid SettingsId { get; set; }
public Guid TenantId { get; set; }
public bool Enabled { get; set; }
public string? RevelationPolicy { get; set; }
public string[]? EnabledRuleCategories { get; set; }
public string[]? DisabledRuleIds { get; set; }
public string? AlertSettings { get; set; }
public long MaxFileSizeBytes { get; set; }
public string[]? ExcludedFileExtensions { get; set; }
public string[]? ExcludedPaths { get; set; }
public bool ScanBinaryFiles { get; set; }
public bool RequireSignedRuleBundles { get; set; }
public int Version { get; set; }
public DateTimeOffset UpdatedAt { get; set; }
public string? UpdatedBy { get; set; }
public DateTimeOffset CreatedAt { get; set; }
}

View File

@@ -0,0 +1,200 @@
-- Compatibility bridge between 022_reachability_evidence and 023_runtime_observations.
-- 022 creates scanner.runtime_observations in the legacy shape; 023 expects node_hash/function_map columns.
CREATE SCHEMA IF NOT EXISTS scanner;
CREATE TABLE IF NOT EXISTS scanner.runtime_observations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid()
);
ALTER TABLE scanner.runtime_observations
ADD COLUMN IF NOT EXISTS observation_id TEXT,
ADD COLUMN IF NOT EXISTS node_hash TEXT,
ADD COLUMN IF NOT EXISTS function_name TEXT,
ADD COLUMN IF NOT EXISTS pod_name TEXT,
ADD COLUMN IF NOT EXISTS namespace TEXT,
ADD COLUMN IF NOT EXISTS probe_type TEXT,
ADD COLUMN IF NOT EXISTS observation_count INTEGER DEFAULT 1,
ADD COLUMN IF NOT EXISTS duration_us BIGINT,
ADD COLUMN IF NOT EXISTS observed_at TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS created_at TIMESTAMPTZ DEFAULT now();
UPDATE scanner.runtime_observations
SET observation_id = COALESCE(observation_id, id::text)
WHERE observation_id IS NULL;
DO $$
BEGIN
IF EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = 'scanner'
AND table_name = 'runtime_observations'
AND column_name = 'symbol_name')
THEN
EXECUTE $sql$
UPDATE scanner.runtime_observations
SET function_name = COALESCE(function_name, symbol_name)
WHERE function_name IS NULL;
$sql$;
END IF;
END
$$;
UPDATE scanner.runtime_observations
SET function_name = COALESCE(function_name, 'unknown')
WHERE function_name IS NULL;
DO $$
BEGIN
IF EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = 'scanner'
AND table_name = 'runtime_observations'
AND column_name = 'observation_source')
THEN
EXECUTE $sql$
UPDATE scanner.runtime_observations
SET probe_type = COALESCE(probe_type, observation_source)
WHERE probe_type IS NULL;
$sql$;
END IF;
END
$$;
UPDATE scanner.runtime_observations
SET probe_type = COALESCE(probe_type, 'runtime')
WHERE probe_type IS NULL;
DO $$
BEGIN
IF EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = 'scanner'
AND table_name = 'runtime_observations'
AND column_name = 'last_observed_at_utc')
THEN
EXECUTE $sql$
UPDATE scanner.runtime_observations
SET observed_at = COALESCE(observed_at, last_observed_at_utc)
WHERE observed_at IS NULL;
$sql$;
END IF;
IF EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = 'scanner'
AND table_name = 'runtime_observations'
AND column_name = 'first_observed_at_utc')
THEN
EXECUTE $sql$
UPDATE scanner.runtime_observations
SET observed_at = COALESCE(observed_at, first_observed_at_utc)
WHERE observed_at IS NULL;
$sql$;
END IF;
IF EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = 'scanner'
AND table_name = 'runtime_observations'
AND column_name = 'created_at_utc')
THEN
EXECUTE $sql$
UPDATE scanner.runtime_observations
SET observed_at = COALESCE(observed_at, created_at_utc)
WHERE observed_at IS NULL;
$sql$;
EXECUTE $sql$
UPDATE scanner.runtime_observations
SET created_at = COALESCE(created_at, created_at_utc)
WHERE created_at IS NULL;
$sql$;
END IF;
END
$$;
UPDATE scanner.runtime_observations
SET observed_at = COALESCE(observed_at, now())
WHERE observed_at IS NULL;
UPDATE scanner.runtime_observations
SET created_at = COALESCE(created_at, now())
WHERE created_at IS NULL;
DO $$
BEGIN
IF EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = 'scanner'
AND table_name = 'runtime_observations'
AND column_name = 'image_digest')
AND EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = 'scanner'
AND table_name = 'runtime_observations'
AND column_name = 'symbol_name')
THEN
EXECUTE $sql$
UPDATE scanner.runtime_observations
SET node_hash = COALESCE(
node_hash,
'legacy:' || md5(
COALESCE(image_digest, '') || '|' ||
COALESCE(symbol_name, '') || '|' ||
COALESCE(observation_id, '')))
WHERE node_hash IS NULL;
$sql$;
ELSIF EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = 'scanner'
AND table_name = 'runtime_observations'
AND column_name = 'symbol_name')
THEN
EXECUTE $sql$
UPDATE scanner.runtime_observations
SET node_hash = COALESCE(
node_hash,
'legacy:' || md5(COALESCE(symbol_name, '') || '|' || COALESCE(observation_id, '')))
WHERE node_hash IS NULL;
$sql$;
END IF;
END
$$;
UPDATE scanner.runtime_observations
SET node_hash = COALESCE(node_hash, 'legacy:' || md5(COALESCE(observation_id, '')))
WHERE node_hash IS NULL;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM pg_indexes
WHERE schemaname = 'scanner'
AND tablename = 'runtime_observations'
AND indexdef ILIKE 'CREATE UNIQUE INDEX%'
AND indexdef ILIKE '%(observation_id)%')
THEN
EXECUTE 'CREATE UNIQUE INDEX uq_runtime_observations_observation_id ON scanner.runtime_observations (observation_id)';
END IF;
END
$$;
ALTER TABLE scanner.runtime_observations
ALTER COLUMN observation_id SET NOT NULL,
ALTER COLUMN node_hash SET NOT NULL,
ALTER COLUMN function_name SET NOT NULL,
ALTER COLUMN probe_type SET NOT NULL,
ALTER COLUMN observed_at SET NOT NULL,
ALTER COLUMN created_at SET NOT NULL,
ALTER COLUMN created_at SET DEFAULT now(),
ALTER COLUMN observation_count SET DEFAULT 1;

View File

@@ -1,5 +1,6 @@
using Dapper;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Npgsql;
using StellaOps.Scanner.Storage.Entities;
using StellaOps.Scanner.Storage.Repositories;
@@ -40,78 +41,55 @@ public sealed class PostgresArtifactBomRepository : IArtifactBomRepository
var monthEnd = monthStart.AddMonths(1);
var lockKey = $"{row.CanonicalBomSha256}|{row.PayloadDigest}|{monthStart:yyyy-MM}";
const string selectExistingTemplate = """
var selectExistingSql = $"""
SELECT
build_id AS BuildId,
canonical_bom_sha256 AS CanonicalBomSha256,
payload_digest AS PayloadDigest,
inserted_at AS InsertedAt,
raw_bom_ref AS RawBomRef,
canonical_bom_ref AS CanonicalBomRef,
dsse_envelope_ref AS DsseEnvelopeRef,
merged_vex_ref AS MergedVexRef,
canonical_bom::text AS CanonicalBomJson,
merged_vex::text AS MergedVexJson,
attestations::text AS AttestationsJson,
evidence_score AS EvidenceScore,
rekor_tile_id AS RekorTileId
FROM {0}
WHERE canonical_bom_sha256 = @CanonicalBomSha256
AND payload_digest = @PayloadDigest
AND inserted_at >= @MonthStart
AND inserted_at < @MonthEnd
build_id AS "BuildId",
canonical_bom_sha256 AS "CanonicalBomSha256",
payload_digest AS "PayloadDigest",
inserted_at AS "InsertedAt",
raw_bom_ref AS "RawBomRef",
canonical_bom_ref AS "CanonicalBomRef",
dsse_envelope_ref AS "DsseEnvelopeRef",
merged_vex_ref AS "MergedVexRef",
canonical_bom::text AS "CanonicalBomJson",
merged_vex::text AS "MergedVexJson",
attestations::text AS "AttestationsJson",
evidence_score AS "EvidenceScore",
rekor_tile_id AS "RekorTileId"
FROM {TableName}
WHERE canonical_bom_sha256 = $1
AND payload_digest = $2
AND inserted_at >= $3
AND inserted_at < $4
ORDER BY inserted_at DESC, build_id ASC
LIMIT 1
FOR UPDATE
""";
var selectExistingSql = string.Format(selectExistingTemplate, TableName);
var updateExistingSql = $"""
UPDATE {TableName}
SET
raw_bom_ref = @RawBomRef,
canonical_bom_ref = @CanonicalBomRef,
dsse_envelope_ref = @DsseEnvelopeRef,
merged_vex_ref = @MergedVexRef,
canonical_bom = @CanonicalBomJson::jsonb,
merged_vex = @MergedVexJson::jsonb,
attestations = @AttestationsJson::jsonb,
evidence_score = @EvidenceScore,
rekor_tile_id = @RekorTileId
WHERE build_id = @BuildId
AND inserted_at = @InsertedAt
raw_bom_ref = $1,
canonical_bom_ref = $2,
dsse_envelope_ref = $3,
merged_vex_ref = $4,
canonical_bom = $5::jsonb,
merged_vex = $6::jsonb,
attestations = $7::jsonb,
evidence_score = $8,
rekor_tile_id = $9
WHERE build_id = $10
AND inserted_at = $11
""";
var insertSql = $"""
INSERT INTO {TableName} (
build_id,
canonical_bom_sha256,
payload_digest,
inserted_at,
raw_bom_ref,
canonical_bom_ref,
dsse_envelope_ref,
merged_vex_ref,
canonical_bom,
merged_vex,
attestations,
evidence_score,
rekor_tile_id
build_id, canonical_bom_sha256, payload_digest, inserted_at,
raw_bom_ref, canonical_bom_ref, dsse_envelope_ref, merged_vex_ref,
canonical_bom, merged_vex, attestations, evidence_score, rekor_tile_id
) VALUES (
@BuildId,
@CanonicalBomSha256,
@PayloadDigest,
@InsertedAt,
@RawBomRef,
@CanonicalBomRef,
@DsseEnvelopeRef,
@MergedVexRef,
@CanonicalBomJson::jsonb,
@MergedVexJson::jsonb,
@AttestationsJson::jsonb,
@EvidenceScore,
@RekorTileId
$1, $2, $3, $4, $5, $6, $7, $8,
$9::jsonb, $10::jsonb, $11::jsonb, $12, $13
)
ON CONFLICT (build_id, inserted_at) DO UPDATE SET
canonical_bom_sha256 = EXCLUDED.canonical_bom_sha256,
@@ -130,47 +108,59 @@ public sealed class PostgresArtifactBomRepository : IArtifactBomRepository
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var transaction = await connection.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
var command = new CommandDefinition(
"SELECT pg_advisory_xact_lock(hashtext(@LockKey));",
new { LockKey = lockKey },
transaction,
cancellationToken: cancellationToken);
await connection.ExecuteAsync(command).ConfigureAwait(false);
// Advisory lock
await using (var lockCmd = new NpgsqlCommand("SELECT pg_advisory_xact_lock(hashtext($1));", connection, transaction))
{
lockCmd.Parameters.AddWithValue(lockKey);
await lockCmd.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
var existing = await connection.QuerySingleOrDefaultAsync<ArtifactBomRow>(
new CommandDefinition(
selectExistingSql,
new
// Try to find existing row with FOR UPDATE
ArtifactBomRow? existing = null;
await using (var selectCmd = new NpgsqlCommand(selectExistingSql, connection, transaction))
{
selectCmd.Parameters.AddWithValue(row.CanonicalBomSha256);
selectCmd.Parameters.AddWithValue(row.PayloadDigest);
selectCmd.Parameters.AddWithValue(monthStart);
selectCmd.Parameters.AddWithValue(monthEnd);
await using var reader = await selectCmd.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
if (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
existing = new ArtifactBomRow
{
row.CanonicalBomSha256,
row.PayloadDigest,
MonthStart = monthStart,
MonthEnd = monthEnd
},
transaction,
cancellationToken: cancellationToken)).ConfigureAwait(false);
BuildId = reader.GetString(0),
CanonicalBomSha256 = reader.GetString(1),
PayloadDigest = reader.GetString(2),
InsertedAt = reader.GetFieldValue<DateTimeOffset>(3),
RawBomRef = reader.IsDBNull(4) ? null : reader.GetString(4),
CanonicalBomRef = reader.IsDBNull(5) ? null : reader.GetString(5),
DsseEnvelopeRef = reader.IsDBNull(6) ? null : reader.GetString(6),
MergedVexRef = reader.IsDBNull(7) ? null : reader.GetString(7),
CanonicalBomJson = reader.IsDBNull(8) ? null : reader.GetString(8),
MergedVexJson = reader.IsDBNull(9) ? null : reader.GetString(9),
AttestationsJson = reader.IsDBNull(10) ? null : reader.GetString(10),
EvidenceScore = reader.IsDBNull(11) ? 0 : reader.GetInt32(11),
RekorTileId = reader.IsDBNull(12) ? null : reader.GetString(12)
};
}
}
if (existing is not null)
{
await connection.ExecuteAsync(
new CommandDefinition(
updateExistingSql,
new
{
BuildId = existing.BuildId,
InsertedAt = existing.InsertedAt,
row.RawBomRef,
row.CanonicalBomRef,
row.DsseEnvelopeRef,
row.MergedVexRef,
row.CanonicalBomJson,
row.MergedVexJson,
row.AttestationsJson,
row.EvidenceScore,
row.RekorTileId
},
transaction,
cancellationToken: cancellationToken)).ConfigureAwait(false);
await using var updateCmd = new NpgsqlCommand(updateExistingSql, connection, transaction);
updateCmd.Parameters.AddWithValue((object?)row.RawBomRef ?? DBNull.Value);
updateCmd.Parameters.AddWithValue((object?)row.CanonicalBomRef ?? DBNull.Value);
updateCmd.Parameters.AddWithValue((object?)row.DsseEnvelopeRef ?? DBNull.Value);
updateCmd.Parameters.AddWithValue((object?)row.MergedVexRef ?? DBNull.Value);
updateCmd.Parameters.AddWithValue((object?)row.CanonicalBomJson ?? DBNull.Value);
updateCmd.Parameters.AddWithValue((object?)row.MergedVexJson ?? DBNull.Value);
updateCmd.Parameters.AddWithValue((object?)row.AttestationsJson ?? DBNull.Value);
updateCmd.Parameters.AddWithValue(row.EvidenceScore);
updateCmd.Parameters.AddWithValue((object?)row.RekorTileId ?? DBNull.Value);
updateCmd.Parameters.AddWithValue(existing.BuildId);
updateCmd.Parameters.AddWithValue(existing.InsertedAt);
await updateCmd.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
@@ -186,27 +176,23 @@ public sealed class PostgresArtifactBomRepository : IArtifactBomRepository
return existing;
}
await connection.ExecuteAsync(
new CommandDefinition(
insertSql,
new
{
row.BuildId,
row.CanonicalBomSha256,
row.PayloadDigest,
InsertedAt = insertedAt,
row.RawBomRef,
row.CanonicalBomRef,
row.DsseEnvelopeRef,
row.MergedVexRef,
row.CanonicalBomJson,
row.MergedVexJson,
row.AttestationsJson,
row.EvidenceScore,
row.RekorTileId
},
transaction,
cancellationToken: cancellationToken)).ConfigureAwait(false);
await using (var insertCmd = new NpgsqlCommand(insertSql, connection, transaction))
{
insertCmd.Parameters.AddWithValue(row.BuildId);
insertCmd.Parameters.AddWithValue(row.CanonicalBomSha256);
insertCmd.Parameters.AddWithValue(row.PayloadDigest);
insertCmd.Parameters.AddWithValue(insertedAt);
insertCmd.Parameters.AddWithValue((object?)row.RawBomRef ?? DBNull.Value);
insertCmd.Parameters.AddWithValue((object?)row.CanonicalBomRef ?? DBNull.Value);
insertCmd.Parameters.AddWithValue((object?)row.DsseEnvelopeRef ?? DBNull.Value);
insertCmd.Parameters.AddWithValue((object?)row.MergedVexRef ?? DBNull.Value);
insertCmd.Parameters.AddWithValue((object?)row.CanonicalBomJson ?? DBNull.Value);
insertCmd.Parameters.AddWithValue((object?)row.MergedVexJson ?? DBNull.Value);
insertCmd.Parameters.AddWithValue((object?)row.AttestationsJson ?? DBNull.Value);
insertCmd.Parameters.AddWithValue(row.EvidenceScore);
insertCmd.Parameters.AddWithValue((object?)row.RekorTileId ?? DBNull.Value);
await insertCmd.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
@@ -222,24 +208,27 @@ public sealed class PostgresArtifactBomRepository : IArtifactBomRepository
var sql = $"""
SELECT
build_id AS BuildId,
canonical_bom_sha256 AS CanonicalBomSha256,
payload_digest AS PayloadDigest,
inserted_at AS InsertedAt,
evidence_score AS EvidenceScore,
rekor_tile_id AS RekorTileId
build_id AS "BuildId",
canonical_bom_sha256 AS "CanonicalBomSha256",
payload_digest AS "PayloadDigest",
inserted_at AS "InsertedAt",
evidence_score AS "EvidenceScore",
rekor_tile_id AS "RekorTileId"
FROM {TableName}
WHERE payload_digest = @PayloadDigest
WHERE payload_digest = @p0
ORDER BY inserted_at DESC, build_id ASC
LIMIT 1
""";
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
return await connection.QuerySingleOrDefaultAsync<ArtifactBomRow>(
new CommandDefinition(
sql,
new { PayloadDigest = payloadDigest.Trim() },
cancellationToken: cancellationToken)).ConfigureAwait(false);
await using var dbContext = ScannerDbContextFactory.Create(connection, _dataSource.CommandTimeoutSeconds, SchemaName);
var result = await dbContext.Database.SqlQueryRaw<ArtifactBomRow>(
sql, payloadDigest.Trim())
.FirstOrDefaultAsync(cancellationToken)
.ConfigureAwait(false);
return result?.BuildId is not null ? result : null;
}
public async Task<IReadOnlyList<ArtifactBomRow>> FindByComponentPurlAsync(
@@ -253,29 +242,30 @@ public sealed class PostgresArtifactBomRepository : IArtifactBomRepository
var sql = $"""
SELECT
build_id AS BuildId,
canonical_bom_sha256 AS CanonicalBomSha256,
payload_digest AS PayloadDigest,
inserted_at AS InsertedAt,
evidence_score AS EvidenceScore
build_id AS "BuildId",
canonical_bom_sha256 AS "CanonicalBomSha256",
payload_digest AS "PayloadDigest",
inserted_at AS "InsertedAt",
evidence_score AS "EvidenceScore"
FROM {TableName}
WHERE jsonb_path_exists(
canonical_bom,
'$.components[*] ? (@.purl == $purl)',
jsonb_build_object('purl', to_jsonb(@Purl::text)))
jsonb_build_object('purl', to_jsonb(@p0::text)))
ORDER BY inserted_at DESC, build_id ASC
LIMIT @Limit
OFFSET @Offset
LIMIT @p1
OFFSET @p2
""";
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
var rows = await connection.QueryAsync<ArtifactBomRow>(
new CommandDefinition(
sql,
new { Purl = purl.Trim(), Limit = limit, Offset = offset },
cancellationToken: cancellationToken)).ConfigureAwait(false);
await using var dbContext = ScannerDbContextFactory.Create(connection, _dataSource.CommandTimeoutSeconds, SchemaName);
return rows.AsList();
var rows = await dbContext.Database.SqlQueryRaw<ArtifactBomRow>(
sql, purl.Trim(), limit, offset)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
return rows;
}
public async Task<IReadOnlyList<ArtifactBomRow>> FindByComponentNameAsync(
@@ -295,38 +285,37 @@ public sealed class PostgresArtifactBomRepository : IArtifactBomRepository
var sql = $"""
SELECT
build_id AS BuildId,
canonical_bom_sha256 AS CanonicalBomSha256,
payload_digest AS PayloadDigest,
inserted_at AS InsertedAt,
evidence_score AS EvidenceScore
build_id AS "BuildId",
canonical_bom_sha256 AS "CanonicalBomSha256",
payload_digest AS "PayloadDigest",
inserted_at AS "InsertedAt",
evidence_score AS "EvidenceScore"
FROM {TableName}
WHERE jsonb_path_exists(
canonical_bom,
@JsonPath::jsonpath,
@p0::jsonpath,
jsonb_build_object(
'name', to_jsonb(@Name::text),
'minVersion', to_jsonb(@MinVersion::text)))
'name', to_jsonb(@p1::text),
'minVersion', to_jsonb(@p2::text)))
ORDER BY inserted_at DESC, build_id ASC
LIMIT @Limit
OFFSET @Offset
LIMIT @p3
OFFSET @p4
""";
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
var rows = await connection.QueryAsync<ArtifactBomRow>(
new CommandDefinition(
sql,
new
{
JsonPath = jsonPath,
Name = componentName.Trim().ToLowerInvariant(),
MinVersion = minVersion?.Trim() ?? string.Empty,
Limit = limit,
Offset = offset
},
cancellationToken: cancellationToken)).ConfigureAwait(false);
await using var dbContext = ScannerDbContextFactory.Create(connection, _dataSource.CommandTimeoutSeconds, SchemaName);
return rows.AsList();
var rows = await dbContext.Database.SqlQueryRaw<ArtifactBomRow>(
sql,
jsonPath,
componentName.Trim().ToLowerInvariant(),
minVersion?.Trim() ?? string.Empty,
limit,
offset)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
return rows;
}
public async Task<IReadOnlyList<ArtifactBomRow>> FindPendingTriageAsync(
@@ -340,27 +329,28 @@ public sealed class PostgresArtifactBomRepository : IArtifactBomRepository
var sql = $"""
SELECT
build_id AS BuildId,
canonical_bom_sha256 AS CanonicalBomSha256,
payload_digest AS PayloadDigest,
inserted_at AS InsertedAt,
evidence_score AS EvidenceScore,
jsonb_path_query_array(merged_vex, @PendingPath::jsonpath)::text AS PendingMergedVexJson
build_id AS "BuildId",
canonical_bom_sha256 AS "CanonicalBomSha256",
payload_digest AS "PayloadDigest",
inserted_at AS "InsertedAt",
evidence_score AS "EvidenceScore",
jsonb_path_query_array(merged_vex, @p0::jsonpath)::text AS "PendingMergedVexJson"
FROM {TableName}
WHERE jsonb_path_exists(merged_vex, @PendingPath::jsonpath)
WHERE jsonb_path_exists(merged_vex, @p0::jsonpath)
ORDER BY inserted_at DESC, build_id ASC
LIMIT @Limit
OFFSET @Offset
LIMIT @p1
OFFSET @p2
""";
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
var rows = await connection.QueryAsync<ArtifactBomRow>(
new CommandDefinition(
sql,
new { PendingPath, Limit = limit, Offset = offset },
cancellationToken: cancellationToken)).ConfigureAwait(false);
await using var dbContext = ScannerDbContextFactory.Create(connection, _dataSource.CommandTimeoutSeconds, SchemaName);
return rows.AsList();
var rows = await dbContext.Database.SqlQueryRaw<ArtifactBomRow>(
sql, PendingPath, limit, offset)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
return rows;
}
public async Task EnsureFuturePartitionsAsync(int monthsAhead, CancellationToken cancellationToken = default)
@@ -370,18 +360,24 @@ public sealed class PostgresArtifactBomRepository : IArtifactBomRepository
throw new ArgumentOutOfRangeException(nameof(monthsAhead), "monthsAhead must be >= 0.");
}
var sql = $"SELECT partition_name FROM {SchemaName}.ensure_artifact_boms_future_partitions(@MonthsAhead);";
var sql = $"SELECT partition_name FROM {SchemaName}.ensure_artifact_boms_future_partitions($1);";
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
var partitions = await connection.QueryAsync<string>(
new CommandDefinition(
sql,
new { MonthsAhead = monthsAhead },
cancellationToken: cancellationToken)).ConfigureAwait(false);
var partitions = new List<string>();
await using (var cmd = new NpgsqlCommand(sql, connection))
{
cmd.Parameters.AddWithValue(monthsAhead);
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
partitions.Add(reader.GetString(0));
}
}
_logger.LogInformation(
"Ensured scanner.artifact_boms partitions monthsAhead={MonthsAhead} createdOrVerified={Count}",
monthsAhead,
partitions.Count());
partitions.Count);
}
public async Task<IReadOnlyList<ArtifactBomPartitionDropRow>> DropOldPartitionsAsync(
@@ -396,19 +392,20 @@ public sealed class PostgresArtifactBomRepository : IArtifactBomRepository
var sql = $"""
SELECT
partition_name AS PartitionName,
dropped AS Dropped
FROM {SchemaName}.drop_artifact_boms_partitions_older_than(@RetainMonths, @DryRun)
partition_name AS "PartitionName",
dropped AS "Dropped"
FROM {SchemaName}.drop_artifact_boms_partitions_older_than(@p0, @p1)
""";
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
var rows = await connection.QueryAsync<ArtifactBomPartitionDropRow>(
new CommandDefinition(
sql,
new { RetainMonths = retainMonths, DryRun = dryRun },
cancellationToken: cancellationToken)).ConfigureAwait(false);
await using var dbContext = ScannerDbContextFactory.Create(connection, _dataSource.CommandTimeoutSeconds, SchemaName);
return rows.AsList();
var rows = await dbContext.Database.SqlQueryRaw<ArtifactBomPartitionDropRow>(
sql, retainMonths, dryRun)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
return rows;
}
private static void ValidatePagination(int limit, int offset)

View File

@@ -1,4 +1,5 @@
using Dapper;
using Microsoft.EntityFrameworkCore;
using StellaOps.Scanner.Storage.EfCore.Models;
using StellaOps.Scanner.Storage.Entities;
using StellaOps.Scanner.Storage.Repositories;
@@ -6,15 +7,12 @@ namespace StellaOps.Scanner.Storage.Postgres;
/// <summary>
/// PostgreSQL repository for binary evidence data.
/// Converted from Dapper to EF Core; INSERT RETURNING kept as raw SQL.
/// </summary>
public sealed class PostgresBinaryEvidenceRepository : IBinaryEvidenceRepository
{
private readonly ScannerDataSource _dataSource;
private string SchemaName => _dataSource.SchemaName ?? ScannerDataSource.DefaultSchema;
private string IdentityTable => $"{SchemaName}.binary_identity";
private string PackageMapTable => $"{SchemaName}.binary_package_map";
private string VulnAssertionTable => $"{SchemaName}.binary_vuln_assertion";
public PostgresBinaryEvidenceRepository(ScannerDataSource dataSource)
{
@@ -23,67 +21,87 @@ public sealed class PostgresBinaryEvidenceRepository : IBinaryEvidenceRepository
public async Task<BinaryIdentityRow?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default)
{
var sql = $"""
SELECT
id AS Id,
scan_id AS ScanId,
file_path AS FilePath,
file_sha256 AS FileSha256,
text_sha256 AS TextSha256,
build_id AS BuildId,
build_id_type AS BuildIdType,
architecture AS Architecture,
binary_format AS BinaryFormat,
file_size AS FileSize,
is_stripped AS IsStripped,
has_debug_info AS HasDebugInfo,
created_at_utc AS CreatedAtUtc
FROM {IdentityTable}
WHERE id = @Id
""";
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
return await connection.QuerySingleOrDefaultAsync<BinaryIdentityRow>(
new CommandDefinition(sql, new { Id = id }, cancellationToken: cancellationToken)).ConfigureAwait(false);
await using var dbContext = ScannerDbContextFactory.Create(connection, _dataSource.CommandTimeoutSeconds, SchemaName);
var entity = await dbContext.BinaryIdentities
.AsNoTracking()
.FirstOrDefaultAsync(e => e.Id == id, cancellationToken)
.ConfigureAwait(false);
return entity is null ? null : MapIdentityToRow(entity);
}
public Task<BinaryIdentityRow?> GetByBuildIdAsync(string buildId, CancellationToken cancellationToken = default)
=> GetByFieldAsync("build_id", buildId, cancellationToken);
public async Task<BinaryIdentityRow?> GetByBuildIdAsync(string buildId, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(buildId)) return null;
public Task<BinaryIdentityRow?> GetByFileSha256Async(string sha256, CancellationToken cancellationToken = default)
=> GetByFieldAsync("file_sha256", sha256, cancellationToken);
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var dbContext = ScannerDbContextFactory.Create(connection, _dataSource.CommandTimeoutSeconds, SchemaName);
public Task<BinaryIdentityRow?> GetByTextSha256Async(string sha256, CancellationToken cancellationToken = default)
=> GetByFieldAsync("text_sha256", sha256, cancellationToken);
var entity = await dbContext.BinaryIdentities
.AsNoTracking()
.Where(e => e.BuildId == buildId)
.OrderByDescending(e => e.CreatedAtUtc)
.ThenBy(e => e.Id)
.FirstOrDefaultAsync(cancellationToken)
.ConfigureAwait(false);
return entity is null ? null : MapIdentityToRow(entity);
}
public async Task<BinaryIdentityRow?> GetByFileSha256Async(string sha256, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(sha256)) return null;
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var dbContext = ScannerDbContextFactory.Create(connection, _dataSource.CommandTimeoutSeconds, SchemaName);
var entity = await dbContext.BinaryIdentities
.AsNoTracking()
.Where(e => e.FileSha256 == sha256)
.OrderByDescending(e => e.CreatedAtUtc)
.ThenBy(e => e.Id)
.FirstOrDefaultAsync(cancellationToken)
.ConfigureAwait(false);
return entity is null ? null : MapIdentityToRow(entity);
}
public async Task<BinaryIdentityRow?> GetByTextSha256Async(string sha256, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(sha256)) return null;
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var dbContext = ScannerDbContextFactory.Create(connection, _dataSource.CommandTimeoutSeconds, SchemaName);
var entity = await dbContext.BinaryIdentities
.AsNoTracking()
.Where(e => e.TextSha256 == sha256)
.OrderByDescending(e => e.CreatedAtUtc)
.ThenBy(e => e.Id)
.FirstOrDefaultAsync(cancellationToken)
.ConfigureAwait(false);
return entity is null ? null : MapIdentityToRow(entity);
}
public async Task<IReadOnlyList<BinaryIdentityRow>> GetByScanIdAsync(
Guid scanId,
CancellationToken cancellationToken = default)
{
var sql = $"""
SELECT
id AS Id,
scan_id AS ScanId,
file_path AS FilePath,
file_sha256 AS FileSha256,
text_sha256 AS TextSha256,
build_id AS BuildId,
build_id_type AS BuildIdType,
architecture AS Architecture,
binary_format AS BinaryFormat,
file_size AS FileSize,
is_stripped AS IsStripped,
has_debug_info AS HasDebugInfo,
created_at_utc AS CreatedAtUtc
FROM {IdentityTable}
WHERE scan_id = @ScanId
ORDER BY created_at_utc, id
""";
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
var results = await connection.QueryAsync<BinaryIdentityRow>(
new CommandDefinition(sql, new { ScanId = scanId }, cancellationToken: cancellationToken)).ConfigureAwait(false);
return results.ToList();
await using var dbContext = ScannerDbContextFactory.Create(connection, _dataSource.CommandTimeoutSeconds, SchemaName);
var entities = await dbContext.BinaryIdentities
.AsNoTracking()
.Where(e => e.ScanId == scanId)
.OrderBy(e => e.CreatedAtUtc)
.ThenBy(e => e.Id)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
return entities.Select(MapIdentityToRow).ToList();
}
public async Task<BinaryIdentityRow> AddIdentityAsync(
@@ -92,41 +110,29 @@ public sealed class PostgresBinaryEvidenceRepository : IBinaryEvidenceRepository
{
ArgumentNullException.ThrowIfNull(identity);
var sql = $"""
INSERT INTO {IdentityTable} (
scan_id,
file_path,
file_sha256,
text_sha256,
build_id,
build_id_type,
architecture,
binary_format,
file_size,
is_stripped,
has_debug_info
) VALUES (
@ScanId,
@FilePath,
@FileSha256,
@TextSha256,
@BuildId,
@BuildIdType,
@Architecture,
@BinaryFormat,
@FileSize,
@IsStripped,
@HasDebugInfo
)
RETURNING id AS Id, created_at_utc AS CreatedAtUtc
""";
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
var created = await connection.QuerySingleAsync<(Guid Id, DateTimeOffset CreatedAtUtc)>(
new CommandDefinition(sql, identity, cancellationToken: cancellationToken)).ConfigureAwait(false);
await using var dbContext = ScannerDbContextFactory.Create(connection, _dataSource.CommandTimeoutSeconds, SchemaName);
identity.Id = created.Id;
identity.CreatedAtUtc = created.CreatedAtUtc;
var entity = new BinaryIdentityEntity
{
ScanId = identity.ScanId,
FilePath = identity.FilePath,
FileSha256 = identity.FileSha256,
TextSha256 = identity.TextSha256,
BuildId = identity.BuildId,
BuildIdType = identity.BuildIdType,
Architecture = identity.Architecture,
BinaryFormat = identity.BinaryFormat,
FileSize = identity.FileSize,
IsStripped = identity.IsStripped,
HasDebugInfo = identity.HasDebugInfo
};
dbContext.BinaryIdentities.Add(entity);
await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
identity.Id = entity.Id;
identity.CreatedAtUtc = entity.CreatedAtUtc;
return identity;
}
@@ -134,26 +140,19 @@ public sealed class PostgresBinaryEvidenceRepository : IBinaryEvidenceRepository
Guid binaryId,
CancellationToken cancellationToken = default)
{
var sql = $"""
SELECT
id AS Id,
binary_identity_id AS BinaryIdentityId,
purl AS Purl,
match_type AS MatchType,
confidence AS Confidence,
match_source AS MatchSource,
evidence_json AS EvidenceJson,
created_at_utc AS CreatedAtUtc
FROM {PackageMapTable}
WHERE binary_identity_id = @BinaryIdentityId
ORDER BY created_at_utc, purl, id
""";
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
var results = await connection.QueryAsync<BinaryPackageMapRow>(
new CommandDefinition(sql, new { BinaryIdentityId = binaryId }, cancellationToken: cancellationToken))
await using var dbContext = ScannerDbContextFactory.Create(connection, _dataSource.CommandTimeoutSeconds, SchemaName);
var entities = await dbContext.BinaryPackageMaps
.AsNoTracking()
.Where(e => e.BinaryIdentityId == binaryId)
.OrderBy(e => e.CreatedAtUtc)
.ThenBy(e => e.Purl)
.ThenBy(e => e.Id)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
return results.ToList();
return entities.Select(MapPackageMapToRow).ToList();
}
public async Task<BinaryPackageMapRow> AddPackageMapAsync(
@@ -162,31 +161,27 @@ public sealed class PostgresBinaryEvidenceRepository : IBinaryEvidenceRepository
{
ArgumentNullException.ThrowIfNull(map);
// Keep raw SQL for jsonb cast in INSERT.
var sql = $"""
INSERT INTO {PackageMapTable} (
binary_identity_id,
purl,
match_type,
confidence,
match_source,
evidence_json
INSERT INTO binary_package_map (
binary_identity_id, purl, match_type, confidence, match_source, evidence_json
) VALUES (
@BinaryIdentityId,
@Purl,
@MatchType,
@Confidence,
@MatchSource,
@EvidenceJson::jsonb
@p0, @p1, @p2, @p3, @p4, @p5::jsonb
)
RETURNING id AS Id, created_at_utc AS CreatedAtUtc
RETURNING id, created_at_utc
""";
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
var created = await connection.QuerySingleAsync<(Guid Id, DateTimeOffset CreatedAtUtc)>(
new CommandDefinition(sql, map, cancellationToken: cancellationToken)).ConfigureAwait(false);
await using var dbContext = ScannerDbContextFactory.Create(connection, _dataSource.CommandTimeoutSeconds, SchemaName);
map.Id = created.Id;
map.CreatedAtUtc = created.CreatedAtUtc;
var result = await dbContext.Database.SqlQueryRaw<PackageMapInsertResult>(
sql, map.BinaryIdentityId, map.Purl, map.MatchType, map.Confidence,
map.MatchSource, (object?)map.EvidenceJson ?? DBNull.Value)
.FirstAsync(cancellationToken)
.ConfigureAwait(false);
map.Id = result.id;
map.CreatedAtUtc = result.created_at_utc;
return map;
}
@@ -194,60 +189,38 @@ public sealed class PostgresBinaryEvidenceRepository : IBinaryEvidenceRepository
Guid binaryId,
CancellationToken cancellationToken = default)
{
var sql = $"""
SELECT
id AS Id,
binary_identity_id AS BinaryIdentityId,
vuln_id AS VulnId,
status AS Status,
source AS Source,
assertion_type AS AssertionType,
confidence AS Confidence,
evidence_json AS EvidenceJson,
valid_from AS ValidFrom,
valid_until AS ValidUntil,
signature_ref AS SignatureRef,
created_at_utc AS CreatedAtUtc
FROM {VulnAssertionTable}
WHERE binary_identity_id = @BinaryIdentityId
ORDER BY created_at_utc, vuln_id, id
""";
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
var results = await connection.QueryAsync<BinaryVulnAssertionRow>(
new CommandDefinition(sql, new { BinaryIdentityId = binaryId }, cancellationToken: cancellationToken))
await using var dbContext = ScannerDbContextFactory.Create(connection, _dataSource.CommandTimeoutSeconds, SchemaName);
var entities = await dbContext.BinaryVulnAssertions
.AsNoTracking()
.Where(e => e.BinaryIdentityId == binaryId)
.OrderBy(e => e.CreatedAtUtc)
.ThenBy(e => e.VulnId)
.ThenBy(e => e.Id)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
return results.ToList();
return entities.Select(MapVulnAssertionToRow).ToList();
}
public async Task<IReadOnlyList<BinaryVulnAssertionRow>> GetVulnAssertionsByVulnIdAsync(
string vulnId,
CancellationToken cancellationToken = default)
{
var sql = $"""
SELECT
id AS Id,
binary_identity_id AS BinaryIdentityId,
vuln_id AS VulnId,
status AS Status,
source AS Source,
assertion_type AS AssertionType,
confidence AS Confidence,
evidence_json AS EvidenceJson,
valid_from AS ValidFrom,
valid_until AS ValidUntil,
signature_ref AS SignatureRef,
created_at_utc AS CreatedAtUtc
FROM {VulnAssertionTable}
WHERE vuln_id = @VulnId
ORDER BY created_at_utc, binary_identity_id, id
""";
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
var results = await connection.QueryAsync<BinaryVulnAssertionRow>(
new CommandDefinition(sql, new { VulnId = vulnId }, cancellationToken: cancellationToken))
await using var dbContext = ScannerDbContextFactory.Create(connection, _dataSource.CommandTimeoutSeconds, SchemaName);
var entities = await dbContext.BinaryVulnAssertions
.AsNoTracking()
.Where(e => e.VulnId == vulnId)
.OrderBy(e => e.CreatedAtUtc)
.ThenBy(e => e.BinaryIdentityId)
.ThenBy(e => e.Id)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
return results.ToList();
return entities.Select(MapVulnAssertionToRow).ToList();
}
public async Task<BinaryVulnAssertionRow> AddVulnAssertionAsync(
@@ -256,75 +229,88 @@ public sealed class PostgresBinaryEvidenceRepository : IBinaryEvidenceRepository
{
ArgumentNullException.ThrowIfNull(assertion);
// Keep raw SQL for jsonb cast in INSERT.
var sql = $"""
INSERT INTO {VulnAssertionTable} (
binary_identity_id,
vuln_id,
status,
source,
assertion_type,
confidence,
evidence_json,
valid_from,
valid_until,
signature_ref
INSERT INTO binary_vuln_assertion (
binary_identity_id, vuln_id, status, source, assertion_type,
confidence, evidence_json, valid_from, valid_until, signature_ref
) VALUES (
@BinaryIdentityId,
@VulnId,
@Status,
@Source,
@AssertionType,
@Confidence,
@EvidenceJson::jsonb,
@ValidFrom,
@ValidUntil,
@SignatureRef
@p0, @p1, @p2, @p3, @p4, @p5, @p6::jsonb, @p7, @p8, @p9
)
RETURNING id AS Id, created_at_utc AS CreatedAtUtc
RETURNING id, created_at_utc
""";
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
var created = await connection.QuerySingleAsync<(Guid Id, DateTimeOffset CreatedAtUtc)>(
new CommandDefinition(sql, assertion, cancellationToken: cancellationToken)).ConfigureAwait(false);
await using var dbContext = ScannerDbContextFactory.Create(connection, _dataSource.CommandTimeoutSeconds, SchemaName);
assertion.Id = created.Id;
assertion.CreatedAtUtc = created.CreatedAtUtc;
var result = await dbContext.Database.SqlQueryRaw<VulnAssertionInsertResult>(
sql, assertion.BinaryIdentityId, assertion.VulnId, assertion.Status,
assertion.Source, assertion.AssertionType, assertion.Confidence,
(object?)assertion.EvidenceJson ?? DBNull.Value,
assertion.ValidFrom, (object?)assertion.ValidUntil ?? DBNull.Value,
(object?)assertion.SignatureRef ?? DBNull.Value)
.FirstAsync(cancellationToken)
.ConfigureAwait(false);
assertion.Id = result.id;
assertion.CreatedAtUtc = result.created_at_utc;
return assertion;
}
private async Task<BinaryIdentityRow?> GetByFieldAsync(
string column,
string value,
CancellationToken cancellationToken)
private static BinaryIdentityRow MapIdentityToRow(BinaryIdentityEntity e) => new()
{
if (string.IsNullOrWhiteSpace(value))
{
return null;
}
Id = e.Id,
ScanId = e.ScanId,
FilePath = e.FilePath,
FileSha256 = e.FileSha256,
TextSha256 = e.TextSha256,
BuildId = e.BuildId,
BuildIdType = e.BuildIdType,
Architecture = e.Architecture,
BinaryFormat = e.BinaryFormat,
FileSize = e.FileSize,
IsStripped = e.IsStripped,
HasDebugInfo = e.HasDebugInfo,
CreatedAtUtc = e.CreatedAtUtc
};
var sql = $"""
SELECT
id AS Id,
scan_id AS ScanId,
file_path AS FilePath,
file_sha256 AS FileSha256,
text_sha256 AS TextSha256,
build_id AS BuildId,
build_id_type AS BuildIdType,
architecture AS Architecture,
binary_format AS BinaryFormat,
file_size AS FileSize,
is_stripped AS IsStripped,
has_debug_info AS HasDebugInfo,
created_at_utc AS CreatedAtUtc
FROM {IdentityTable}
WHERE {column} = @Value
ORDER BY created_at_utc DESC, id
LIMIT 1
""";
private static BinaryPackageMapRow MapPackageMapToRow(BinaryPackageMapEntity e) => new()
{
Id = e.Id,
BinaryIdentityId = e.BinaryIdentityId,
Purl = e.Purl,
MatchType = e.MatchType,
Confidence = e.Confidence,
MatchSource = e.MatchSource,
EvidenceJson = e.EvidenceJson,
CreatedAtUtc = e.CreatedAtUtc
};
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
return await connection.QuerySingleOrDefaultAsync<BinaryIdentityRow>(
new CommandDefinition(sql, new { Value = value }, cancellationToken: cancellationToken)).ConfigureAwait(false);
private static BinaryVulnAssertionRow MapVulnAssertionToRow(BinaryVulnAssertionEntity e) => new()
{
Id = e.Id,
BinaryIdentityId = e.BinaryIdentityId,
VulnId = e.VulnId,
Status = e.Status,
Source = e.Source,
AssertionType = e.AssertionType,
Confidence = e.Confidence,
EvidenceJson = e.EvidenceJson,
ValidFrom = e.ValidFrom,
ValidUntil = e.ValidUntil,
SignatureRef = e.SignatureRef,
CreatedAtUtc = e.CreatedAtUtc
};
private sealed record PackageMapInsertResult
{
public Guid id { get; init; }
public DateTimeOffset created_at_utc { get; init; }
}
private sealed record VulnAssertionInsertResult
{
public Guid id { get; init; }
public DateTimeOffset created_at_utc { get; init; }
}
}

View File

@@ -1,5 +1,5 @@
using Dapper;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using StellaOps.Scanner.Contracts;
using StellaOps.Scanner.Storage.Repositories;
@@ -7,11 +7,11 @@ using System.Text.Json;
namespace StellaOps.Scanner.Storage.Postgres;
/// <summary>
/// Converted from Dapper to EF Core raw SQL; ON CONFLICT + jsonb cast kept as raw SQL.
/// </summary>
public sealed class PostgresCallGraphSnapshotRepository : ICallGraphSnapshotRepository
{
private const string TenantContext = "00000000-0000-0000-0000-000000000001";
private static readonly Guid TenantId = Guid.Parse(TenantContext);
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
WriteIndented = false
@@ -31,34 +31,18 @@ public sealed class PostgresCallGraphSnapshotRepository : ICallGraphSnapshotRepo
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task StoreAsync(CallGraphSnapshot snapshot, CancellationToken ct = default)
public async Task StoreAsync(CallGraphSnapshot snapshot, CancellationToken ct = default, string? tenantId = null)
{
ArgumentNullException.ThrowIfNull(snapshot);
var trimmed = snapshot.Trimmed();
var tenantScope = ScannerTenantScope.Resolve(tenantId);
var sql = $"""
INSERT INTO {CallGraphSnapshotsTable} (
tenant_id,
scan_id,
language,
graph_digest,
extracted_at,
node_count,
edge_count,
entrypoint_count,
sink_count,
snapshot_json
tenant_id, scan_id, language, graph_digest, extracted_at,
node_count, edge_count, entrypoint_count, sink_count, snapshot_json
) VALUES (
@TenantId,
@ScanId,
@Language,
@GraphDigest,
@ExtractedAt,
@NodeCount,
@EdgeCount,
@EntrypointCount,
@SinkCount,
@SnapshotJson::jsonb
@p0, @p1, @p2, @p3, @p4, @p5, @p6, @p7, @p8, @p9::jsonb
)
ON CONFLICT (tenant_id, scan_id, language, graph_digest) DO UPDATE SET
extracted_at = EXCLUDED.extracted_at,
@@ -71,20 +55,19 @@ public sealed class PostgresCallGraphSnapshotRepository : ICallGraphSnapshotRepo
var json = JsonSerializer.Serialize(trimmed, JsonOptions);
await using var connection = await _dataSource.OpenConnectionAsync(TenantContext, ct).ConfigureAwait(false);
await connection.ExecuteAsync(new CommandDefinition(sql, new
{
TenantId = TenantId,
ScanId = trimmed.ScanId,
Language = trimmed.Language,
GraphDigest = trimmed.GraphDigest,
ExtractedAt = trimmed.ExtractedAt.UtcDateTime,
NodeCount = trimmed.Nodes.Length,
EdgeCount = trimmed.Edges.Length,
EntrypointCount = trimmed.EntrypointIds.Length,
SinkCount = trimmed.SinkIds.Length,
SnapshotJson = json
}, cancellationToken: ct)).ConfigureAwait(false);
await using var connection = await _dataSource.OpenConnectionAsync(tenantScope.TenantContext, ct).ConfigureAwait(false);
await using var dbContext = ScannerDbContextFactory.Create(connection, _dataSource.CommandTimeoutSeconds, SchemaName);
await dbContext.Database.ExecuteSqlRawAsync(
sql,
[
tenantScope.TenantId, trimmed.ScanId, trimmed.Language,
trimmed.GraphDigest, trimmed.ExtractedAt.UtcDateTime,
trimmed.Nodes.Length, trimmed.Edges.Length,
trimmed.EntrypointIds.Length, trimmed.SinkIds.Length,
json
],
ct).ConfigureAwait(false);
_logger.LogDebug(
"Stored call graph snapshot scan={ScanId} lang={Language} nodes={Nodes} edges={Edges}",
@@ -94,26 +77,27 @@ public sealed class PostgresCallGraphSnapshotRepository : ICallGraphSnapshotRepo
trimmed.Edges.Length);
}
public async Task<CallGraphSnapshot?> TryGetLatestAsync(string scanId, string language, CancellationToken ct = default)
public async Task<CallGraphSnapshot?> TryGetLatestAsync(string scanId, string language, CancellationToken ct = default, string? tenantId = null)
{
ArgumentException.ThrowIfNullOrWhiteSpace(scanId);
ArgumentException.ThrowIfNullOrWhiteSpace(language);
var tenantScope = ScannerTenantScope.Resolve(tenantId);
var sql = $"""
SELECT snapshot_json
FROM {CallGraphSnapshotsTable}
WHERE tenant_id = @TenantId AND scan_id = @ScanId AND language = @Language
WHERE tenant_id = @p0 AND scan_id = @p1 AND language = @p2
ORDER BY extracted_at DESC
LIMIT 1
""";
await using var connection = await _dataSource.OpenConnectionAsync(TenantContext, ct).ConfigureAwait(false);
var json = await connection.ExecuteScalarAsync<string?>(new CommandDefinition(sql, new
{
TenantId = TenantId,
ScanId = scanId,
Language = language
}, cancellationToken: ct)).ConfigureAwait(false);
await using var connection = await _dataSource.OpenConnectionAsync(tenantScope.TenantContext, ct).ConfigureAwait(false);
await using var dbContext = ScannerDbContextFactory.Create(connection, _dataSource.CommandTimeoutSeconds, SchemaName);
var json = await dbContext.Database.SqlQueryRaw<string>(
sql, tenantScope.TenantId, scanId, language)
.FirstOrDefaultAsync(ct)
.ConfigureAwait(false);
if (string.IsNullOrWhiteSpace(json))
{
@@ -123,4 +107,3 @@ public sealed class PostgresCallGraphSnapshotRepository : ICallGraphSnapshotRepo
return JsonSerializer.Deserialize<CallGraphSnapshot>(json, JsonOptions);
}
}

View File

@@ -1,6 +1,7 @@
using Dapper;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Npgsql;
using StellaOps.Scanner.ReachabilityDrift;
using StellaOps.Scanner.Storage.Repositories;
using System.Text.Json;
@@ -9,9 +10,6 @@ namespace StellaOps.Scanner.Storage.Postgres;
public sealed class PostgresCodeChangeRepository : ICodeChangeRepository
{
private const string TenantContext = "00000000-0000-0000-0000-000000000001";
private static readonly Guid TenantId = Guid.Parse(TenantContext);
private readonly ScannerDataSource _dataSource;
private readonly ILogger<PostgresCodeChangeRepository> _logger;
@@ -26,7 +24,7 @@ public sealed class PostgresCodeChangeRepository : ICodeChangeRepository
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task StoreAsync(IReadOnlyList<CodeChangeFact> changes, CancellationToken ct = default)
public async Task StoreAsync(IReadOnlyList<CodeChangeFact> changes, CancellationToken ct = default, string? tenantId = null)
{
ArgumentNullException.ThrowIfNull(changes);
@@ -34,32 +32,14 @@ public sealed class PostgresCodeChangeRepository : ICodeChangeRepository
{
return;
}
var tenantScope = ScannerTenantScope.Resolve(tenantId);
var sql = $"""
INSERT INTO {CodeChangesTable} (
id,
tenant_id,
scan_id,
base_scan_id,
language,
node_id,
file,
symbol,
change_kind,
details,
detected_at
id, tenant_id, scan_id, base_scan_id, language,
node_id, file, symbol, change_kind, details, detected_at
) VALUES (
@Id,
@TenantId,
@ScanId,
@BaseScanId,
@Language,
@NodeId,
@File,
@Symbol,
@ChangeKind,
@Details::jsonb,
@DetectedAt
@p0, @p1, @p2, @p3, @p4, @p5, @p6, @p7, @p8, @p9::jsonb, @p10
)
ON CONFLICT (tenant_id, scan_id, base_scan_id, language, symbol, change_kind) DO UPDATE SET
node_id = EXCLUDED.node_id,
@@ -68,23 +48,24 @@ public sealed class PostgresCodeChangeRepository : ICodeChangeRepository
detected_at = EXCLUDED.detected_at
""";
var rows = changes.Select(change => new
{
change.Id,
TenantId,
ScanId = change.ScanId.Trim(),
BaseScanId = change.BaseScanId.Trim(),
Language = change.Language.Trim(),
NodeId = string.IsNullOrWhiteSpace(change.NodeId) ? null : change.NodeId.Trim(),
File = change.File.Trim(),
Symbol = change.Symbol.Trim(),
ChangeKind = ToDbValue(change.Kind),
Details = SerializeDetails(change.Details),
DetectedAt = change.DetectedAt.UtcDateTime
}).ToList();
await using var connection = await _dataSource.OpenConnectionAsync(tenantScope.TenantContext, ct).ConfigureAwait(false);
await using var dbContext = ScannerDbContextFactory.Create(connection, _dataSource.CommandTimeoutSeconds, SchemaName);
await using var connection = await _dataSource.OpenConnectionAsync(TenantContext, ct).ConfigureAwait(false);
await connection.ExecuteAsync(new CommandDefinition(sql, rows, cancellationToken: ct)).ConfigureAwait(false);
foreach (var change in changes)
{
await dbContext.Database.ExecuteSqlRawAsync(
sql,
[
change.Id, tenantScope.TenantId, change.ScanId.Trim(),
change.BaseScanId.Trim(), change.Language.Trim(),
(object?)(string.IsNullOrWhiteSpace(change.NodeId) ? null : change.NodeId.Trim()) ?? DBNull.Value,
change.File.Trim(), change.Symbol.Trim(),
ToDbValue(change.Kind),
(object?)SerializeDetails(change.Details) ?? DBNull.Value,
change.DetectedAt.UtcDateTime
],
ct).ConfigureAwait(false);
}
_logger.LogDebug(
"Stored {Count} code change facts scan={ScanId} base={BaseScanId} lang={Language}",

View File

@@ -5,7 +5,8 @@
// Description: PostgreSQL implementation of IEpssRawRepository.
// -----------------------------------------------------------------------------
using Dapper;
using Microsoft.EntityFrameworkCore;
using Npgsql;
using StellaOps.Scanner.Storage.Repositories;
namespace StellaOps.Scanner.Storage.Postgres;
@@ -36,9 +37,9 @@ public sealed class PostgresEpssRawRepository : IEpssRawRepository
row_count, compressed_size, decompressed_size, import_run_id
)
VALUES (
@SourceUri, @AsOfDate, @Payload::jsonb, @PayloadSha256,
@HeaderComment, @ModelVersion, @PublishedDate,
@RowCount, @CompressedSize, @DecompressedSize, @ImportRunId
$1, $2, $3::jsonb, $4,
$5, $6, $7,
$8, $9, $10, $11
)
ON CONFLICT (source_uri, asof_date, payload_sha256) DO NOTHING
RETURNING raw_id, ingestion_ts
@@ -46,27 +47,28 @@ public sealed class PostgresEpssRawRepository : IEpssRawRepository
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken);
var result = await connection.QueryFirstOrDefaultAsync<(long raw_id, DateTimeOffset ingestion_ts)?>(sql, new
{
raw.SourceUri,
AsOfDate = raw.AsOfDate.ToDateTime(TimeOnly.MinValue),
raw.Payload,
raw.PayloadSha256,
raw.HeaderComment,
raw.ModelVersion,
PublishedDate = raw.PublishedDate?.ToDateTime(TimeOnly.MinValue),
raw.RowCount,
raw.CompressedSize,
raw.DecompressedSize,
raw.ImportRunId
});
await using var cmd = new NpgsqlCommand(sql, connection);
cmd.Parameters.AddWithValue(raw.SourceUri);
cmd.Parameters.AddWithValue(raw.AsOfDate.ToDateTime(TimeOnly.MinValue));
cmd.Parameters.AddWithValue(raw.Payload);
cmd.Parameters.AddWithValue(raw.PayloadSha256);
cmd.Parameters.AddWithValue((object?)raw.HeaderComment ?? DBNull.Value);
cmd.Parameters.AddWithValue((object?)raw.ModelVersion ?? DBNull.Value);
cmd.Parameters.AddWithValue(raw.PublishedDate.HasValue ? raw.PublishedDate.Value.ToDateTime(TimeOnly.MinValue) : DBNull.Value);
cmd.Parameters.AddWithValue(raw.RowCount);
cmd.Parameters.AddWithValue(raw.CompressedSize.HasValue ? raw.CompressedSize.Value : DBNull.Value);
cmd.Parameters.AddWithValue(raw.DecompressedSize.HasValue ? raw.DecompressedSize.Value : DBNull.Value);
cmd.Parameters.AddWithValue(raw.ImportRunId.HasValue ? raw.ImportRunId.Value : DBNull.Value);
if (result.HasValue)
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
if (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
var rawId = reader.GetInt64(0);
var ingestionTs = reader.GetFieldValue<DateTimeOffset>(1);
return raw with
{
RawId = result.Value.raw_id,
IngestionTs = result.Value.ingestion_ts
RawId = rawId,
IngestionTs = ingestionTs
};
}
@@ -83,18 +85,20 @@ public sealed class PostgresEpssRawRepository : IEpssRawRepository
payload, payload_sha256, header_comment, model_version, published_date,
row_count, compressed_size, decompressed_size, import_run_id
FROM {RawTable}
WHERE asof_date = @AsOfDate
WHERE asof_date = @p0
ORDER BY ingestion_ts DESC
LIMIT 1
""";
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken);
var row = await connection.QueryFirstOrDefaultAsync<RawRow?>(sql, new
{
AsOfDate = asOfDate.ToDateTime(TimeOnly.MinValue)
});
await using var dbContext = ScannerDbContextFactory.Create(connection, _dataSource.CommandTimeoutSeconds, SchemaName);
return row.HasValue ? MapToRaw(row.Value) : null;
var row = await dbContext.Database.SqlQueryRaw<RawRow>(
sql, asOfDate.ToDateTime(TimeOnly.MinValue))
.FirstOrDefaultAsync(cancellationToken)
.ConfigureAwait(false);
return row is not null && row.raw_id != 0 ? MapToRaw(row) : null;
}
public async Task<IReadOnlyList<EpssRaw>> GetByDateRangeAsync(
@@ -108,16 +112,17 @@ public sealed class PostgresEpssRawRepository : IEpssRawRepository
payload, payload_sha256, header_comment, model_version, published_date,
row_count, compressed_size, decompressed_size, import_run_id
FROM {RawTable}
WHERE asof_date >= @StartDate AND asof_date <= @EndDate
WHERE asof_date >= @p0 AND asof_date <= @p1
ORDER BY asof_date DESC
""";
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken);
var rows = await connection.QueryAsync<RawRow>(sql, new
{
StartDate = startDate.ToDateTime(TimeOnly.MinValue),
EndDate = endDate.ToDateTime(TimeOnly.MinValue)
});
await using var dbContext = ScannerDbContextFactory.Create(connection, _dataSource.CommandTimeoutSeconds, SchemaName);
var rows = await dbContext.Database.SqlQueryRaw<RawRow>(
sql, startDate.ToDateTime(TimeOnly.MinValue), endDate.ToDateTime(TimeOnly.MinValue))
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
return rows.Select(MapToRaw).ToList();
}
@@ -135,26 +140,30 @@ public sealed class PostgresEpssRawRepository : IEpssRawRepository
""";
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken);
var row = await connection.QueryFirstOrDefaultAsync<RawRow?>(sql);
await using var dbContext = ScannerDbContextFactory.Create(connection, _dataSource.CommandTimeoutSeconds, SchemaName);
return row.HasValue ? MapToRaw(row.Value) : null;
var row = await dbContext.Database.SqlQueryRaw<RawRow>(sql)
.FirstOrDefaultAsync(cancellationToken)
.ConfigureAwait(false);
return row is not null && row.raw_id != 0 ? MapToRaw(row) : null;
}
public async Task<bool> ExistsAsync(DateOnly asOfDate, byte[] payloadSha256, CancellationToken cancellationToken = default)
{
var sql = $"""
SELECT EXISTS (
SELECT CAST(CASE WHEN EXISTS (
SELECT 1 FROM {RawTable}
WHERE asof_date = @AsOfDate AND payload_sha256 = @PayloadSha256
)
WHERE asof_date = $1 AND payload_sha256 = $2
) THEN 1 ELSE 0 END AS integer)
""";
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken);
return await connection.ExecuteScalarAsync<bool>(sql, new
{
AsOfDate = asOfDate.ToDateTime(TimeOnly.MinValue),
PayloadSha256 = payloadSha256
});
await using var cmd = new NpgsqlCommand(sql, connection);
cmd.Parameters.AddWithValue(asOfDate.ToDateTime(TimeOnly.MinValue));
cmd.Parameters.AddWithValue(payloadSha256);
var result = await cmd.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
return Convert.ToInt32(result) == 1;
}
public async Task<IReadOnlyList<EpssRaw>> GetByModelVersionAsync(
@@ -168,27 +177,31 @@ public sealed class PostgresEpssRawRepository : IEpssRawRepository
payload, payload_sha256, header_comment, model_version, published_date,
row_count, compressed_size, decompressed_size, import_run_id
FROM {RawTable}
WHERE model_version = @ModelVersion
WHERE model_version = @p0
ORDER BY asof_date DESC
LIMIT @Limit
LIMIT @p1
""";
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken);
var rows = await connection.QueryAsync<RawRow>(sql, new
{
ModelVersion = modelVersion,
Limit = limit
});
await using var dbContext = ScannerDbContextFactory.Create(connection, _dataSource.CommandTimeoutSeconds, SchemaName);
var rows = await dbContext.Database.SqlQueryRaw<RawRow>(
sql, modelVersion, limit)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
return rows.Select(MapToRaw).ToList();
}
public async Task<int> PruneAsync(int retentionDays = 365, CancellationToken cancellationToken = default)
{
var sql = $"SELECT {SchemaName}.prune_epss_raw(@RetentionDays)";
var sql = $"SELECT {SchemaName}.prune_epss_raw($1)";
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken);
return await connection.ExecuteScalarAsync<int>(sql, new { RetentionDays = retentionDays });
await using var cmd = new NpgsqlCommand(sql, connection);
cmd.Parameters.AddWithValue(retentionDays);
var result = await cmd.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
return Convert.ToInt32(result);
}
private static EpssRaw MapToRaw(RawRow row)
@@ -211,18 +224,20 @@ public sealed class PostgresEpssRawRepository : IEpssRawRepository
};
}
private readonly record struct RawRow(
long raw_id,
string source_uri,
DateTime asof_date,
DateTimeOffset ingestion_ts,
string payload,
byte[] payload_sha256,
string? header_comment,
string? model_version,
DateTime? published_date,
int row_count,
long? compressed_size,
long? decompressed_size,
Guid? import_run_id);
private sealed class RawRow
{
public long raw_id { get; set; }
public string source_uri { get; set; } = "";
public DateTime asof_date { get; set; }
public DateTimeOffset ingestion_ts { get; set; }
public string payload { get; set; } = "";
public byte[] payload_sha256 { get; set; } = [];
public string? header_comment { get; set; }
public string? model_version { get; set; }
public DateTime? published_date { get; set; }
public int row_count { get; set; }
public long? compressed_size { get; set; }
public long? decompressed_size { get; set; }
public Guid? import_run_id { get; set; }
}
}

View File

@@ -6,20 +6,17 @@
// -----------------------------------------------------------------------------
using Dapper;
using Microsoft.EntityFrameworkCore;
using Npgsql;
using NpgsqlTypes;
using StellaOps.Scanner.Core.Epss;
using StellaOps.Scanner.Storage.Epss;
using StellaOps.Scanner.Storage.Repositories;
using System.Data;
namespace StellaOps.Scanner.Storage.Postgres;
public sealed class PostgresEpssRepository : IEpssRepository
{
private static int _typeHandlersRegistered;
private readonly ScannerDataSource _dataSource;
private string SchemaName => _dataSource.SchemaName ?? ScannerDataSource.DefaultSchema;
@@ -31,7 +28,6 @@ public sealed class PostgresEpssRepository : IEpssRepository
public PostgresEpssRepository(ScannerDataSource dataSource)
{
EnsureTypeHandlers();
_dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource));
}
@@ -52,12 +48,16 @@ public sealed class PostgresEpssRepository : IEpssRepository
error,
created_at
FROM {ImportRunsTable}
WHERE model_date = @ModelDate
WHERE model_date = @p0
""";
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
var row = await connection.QuerySingleOrDefaultAsync<ImportRunRow>(
new CommandDefinition(sql, new { ModelDate = modelDate }, cancellationToken: cancellationToken)).ConfigureAwait(false);
await using var dbContext = ScannerDbContextFactory.Create(connection, _dataSource.CommandTimeoutSeconds, SchemaName);
var row = await dbContext.Database.SqlQueryRaw<ImportRunRow>(
sql, modelDate)
.FirstOrDefaultAsync(cancellationToken)
.ConfigureAwait(false);
return row?.ToModel();
}
@@ -81,13 +81,7 @@ public sealed class PostgresEpssRepository : IEpssRepository
status,
created_at
) VALUES (
@ModelDate,
@SourceUri,
@RetrievedAtUtc,
@FileSha256,
0,
'PENDING',
@RetrievedAtUtc
@p0, @p1, @p2, @p3, 0, 'PENDING', @p2
)
ON CONFLICT (model_date) DO UPDATE SET
source_uri = EXCLUDED.source_uri,
@@ -116,16 +110,12 @@ public sealed class PostgresEpssRepository : IEpssRepository
""";
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
var row = await connection.QuerySingleOrDefaultAsync<ImportRunRow>(new CommandDefinition(
insertSql,
new
{
ModelDate = modelDate,
SourceUri = sourceUri,
RetrievedAtUtc = retrievedAtUtc,
FileSha256 = fileSha256
},
cancellationToken: cancellationToken)).ConfigureAwait(false);
await using var dbContext = ScannerDbContextFactory.Create(connection, _dataSource.CommandTimeoutSeconds, SchemaName);
var row = await dbContext.Database.SqlQueryRaw<ImportRunRow>(
insertSql, modelDate, sourceUri, retrievedAtUtc, fileSha256)
.FirstOrDefaultAsync(cancellationToken)
.ConfigureAwait(false);
if (row is not null)
{
@@ -154,25 +144,26 @@ public sealed class PostgresEpssRepository : IEpssRepository
UPDATE {ImportRunsTable}
SET status = 'SUCCEEDED',
error = NULL,
row_count = @RowCount,
decompressed_sha256 = @DecompressedSha256,
model_version_tag = @ModelVersionTag,
published_date = @PublishedDate
WHERE import_run_id = @ImportRunId
row_count = @p0,
decompressed_sha256 = @p1,
model_version_tag = @p2,
published_date = @p3
WHERE import_run_id = @p4
""";
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await connection.ExecuteAsync(new CommandDefinition(
await using var dbContext = ScannerDbContextFactory.Create(connection, _dataSource.CommandTimeoutSeconds, SchemaName);
await dbContext.Database.ExecuteSqlRawAsync(
sql,
new
{
ImportRunId = importRunId,
RowCount = rowCount,
DecompressedSha256 = decompressedSha256,
ModelVersionTag = modelVersionTag,
PublishedDate = publishedDate
},
cancellationToken: cancellationToken)).ConfigureAwait(false);
[
rowCount,
(object?)decompressedSha256 ?? DBNull.Value,
(object?)modelVersionTag ?? DBNull.Value,
publishedDate.HasValue ? publishedDate.Value : DBNull.Value,
importRunId
],
cancellationToken).ConfigureAwait(false);
}
public async Task MarkImportFailedAsync(Guid importRunId, string error, CancellationToken cancellationToken = default)
@@ -182,15 +173,15 @@ public sealed class PostgresEpssRepository : IEpssRepository
var sql = $"""
UPDATE {ImportRunsTable}
SET status = 'FAILED',
error = @Error
WHERE import_run_id = @ImportRunId
error = @p0
WHERE import_run_id = @p1
""";
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await connection.ExecuteAsync(new CommandDefinition(
sql,
new { ImportRunId = importRunId, Error = error },
cancellationToken: cancellationToken)).ConfigureAwait(false);
await using var dbContext = ScannerDbContextFactory.Create(connection, _dataSource.CommandTimeoutSeconds, SchemaName);
await dbContext.Database.ExecuteSqlRawAsync(
sql, [error, importRunId], cancellationToken).ConfigureAwait(false);
}
public async Task<EpssWriteResult> WriteSnapshotAsync(
@@ -218,10 +209,10 @@ public sealed class PostgresEpssRepository : IEpssRepository
) ON COMMIT DROP
""";
await connection.ExecuteAsync(new CommandDefinition(
createStageSql,
transaction: transaction,
cancellationToken: cancellationToken)).ConfigureAwait(false);
await using (var createCmd = new NpgsqlCommand(createStageSql, connection, transaction))
{
await createCmd.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
var (rowCount, distinctCount) = await CopyStageAsync(connection, transaction, stageTable, rows, cancellationToken).ConfigureAwait(false);
if (rowCount != distinctCount)
@@ -231,15 +222,16 @@ public sealed class PostgresEpssRepository : IEpssRepository
var insertScoresSql = $"""
INSERT INTO {ScoresTable} (model_date, cve_id, epss_score, percentile, import_run_id)
SELECT @ModelDate, cve_id, epss_score, percentile, @ImportRunId
SELECT $1, cve_id, epss_score, percentile, $2
FROM {stageTable}
""";
await connection.ExecuteAsync(new CommandDefinition(
insertScoresSql,
new { ModelDate = modelDate, ImportRunId = importRunId },
transaction: transaction,
cancellationToken: cancellationToken)).ConfigureAwait(false);
await using (var insertScoresCmd = new NpgsqlCommand(insertScoresSql, connection, transaction))
{
insertScoresCmd.Parameters.AddWithValue(NpgsqlDbType.Date, modelDate);
insertScoresCmd.Parameters.AddWithValue(importRunId);
await insertScoresCmd.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
await InsertChangesAsync(connection, transaction, stageTable, modelDate, importRunId, cancellationToken).ConfigureAwait(false);
await UpsertCurrentAsync(connection, transaction, stageTable, modelDate, importRunId, updatedAtUtc, cancellationToken).ConfigureAwait(false);
@@ -279,15 +271,17 @@ public sealed class PostgresEpssRepository : IEpssRepository
var sql = $"""
SELECT cve_id, epss_score, percentile, model_date, import_run_id
FROM {CurrentTable}
WHERE cve_id = ANY(@CveIds)
WHERE cve_id = ANY(@p0)
ORDER BY cve_id
""";
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
var rows = await connection.QueryAsync<CurrentRow>(new CommandDefinition(
sql,
new { CveIds = normalized },
cancellationToken: cancellationToken)).ConfigureAwait(false);
await using var dbContext = ScannerDbContextFactory.Create(connection, _dataSource.CommandTimeoutSeconds, SchemaName);
var rows = await dbContext.Database.SqlQueryRaw<CurrentRow>(
sql, (object)normalized)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
var result = new Dictionary<string, EpssCurrentEntry>(StringComparer.Ordinal);
foreach (var row in rows)
@@ -316,16 +310,18 @@ public sealed class PostgresEpssRepository : IEpssRepository
var sql = $"""
SELECT model_date, epss_score, percentile, import_run_id
FROM {ScoresTable}
WHERE cve_id = @CveId
WHERE cve_id = @p0
ORDER BY model_date DESC
LIMIT @Limit
LIMIT @p1
""";
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
var rows = await connection.QueryAsync<HistoryRow>(new CommandDefinition(
sql,
new { CveId = normalized, Limit = limit },
cancellationToken: cancellationToken)).ConfigureAwait(false);
await using var dbContext = ScannerDbContextFactory.Create(connection, _dataSource.CommandTimeoutSeconds, SchemaName);
var rows = await dbContext.Database.SqlQueryRaw<HistoryRow>(
sql, normalized, limit)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
return rows.Select(static row => new EpssHistoryEntry(
row.model_date,
@@ -341,12 +337,11 @@ public sealed class PostgresEpssRepository : IEpssRepository
DateOnly modelDate,
CancellationToken cancellationToken)
{
var sql = "SELECT create_epss_partition(@Year, @Month)";
await connection.ExecuteAsync(new CommandDefinition(
sql,
new { Year = modelDate.Year, Month = modelDate.Month },
transaction: transaction,
cancellationToken: cancellationToken)).ConfigureAwait(false);
var sql = "SELECT create_epss_partition($1, $2)";
await using var cmd = new NpgsqlCommand(sql, connection, transaction);
cmd.Parameters.AddWithValue(modelDate.Year);
cmd.Parameters.AddWithValue(modelDate.Month);
await cmd.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
private static async Task<(int RowCount, int DistinctCount)> CopyStageAsync(
@@ -372,17 +367,12 @@ public sealed class PostgresEpssRepository : IEpssRepository
await importer.CompleteAsync(cancellationToken).ConfigureAwait(false);
}
var countsSql = $"""
SELECT COUNT(*) AS total, COUNT(DISTINCT cve_id) AS distinct_count
FROM {stageTable}
""";
var countsSql = $"SELECT COUNT(DISTINCT cve_id) FROM {stageTable}";
await using var countCmd = new NpgsqlCommand(countsSql, connection, transaction);
var distinctObj = await countCmd.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
var distinctCount = Convert.ToInt32(distinctObj);
var counts = await connection.QuerySingleAsync<StageCounts>(new CommandDefinition(
countsSql,
transaction: transaction,
cancellationToken: cancellationToken)).ConfigureAwait(false);
return (rowCount, counts.distinct_count);
return (rowCount, distinctCount);
}
private async Task InsertChangesAsync(
@@ -407,7 +397,7 @@ public sealed class PostgresEpssRepository : IEpssRepository
import_run_id
)
SELECT
@ModelDate,
$1,
s.cve_id,
c.epss_score AS old_score,
s.epss_score AS new_score,
@@ -424,7 +414,7 @@ public sealed class PostgresEpssRepository : IEpssRepository
cfg.high_percentile,
cfg.big_jump_delta
) AS flags,
@ImportRunId
$2
FROM {stageTable} s
LEFT JOIN {CurrentTable} c ON c.cve_id = s.cve_id
CROSS JOIN (
@@ -435,11 +425,10 @@ public sealed class PostgresEpssRepository : IEpssRepository
) cfg
""";
await connection.ExecuteAsync(new CommandDefinition(
sql,
new { ModelDate = modelDate, ImportRunId = importRunId },
transaction: transaction,
cancellationToken: cancellationToken)).ConfigureAwait(false);
await using var cmd = new NpgsqlCommand(sql, connection, transaction);
cmd.Parameters.AddWithValue(NpgsqlDbType.Date, modelDate);
cmd.Parameters.AddWithValue(importRunId);
await cmd.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
private async Task UpsertCurrentAsync(
@@ -464,9 +453,9 @@ public sealed class PostgresEpssRepository : IEpssRepository
cve_id,
epss_score,
percentile,
@ModelDate,
@ImportRunId,
@UpdatedAtUtc
$1,
$2,
$3
FROM {stageTable}
ON CONFLICT (cve_id) DO UPDATE SET
epss_score = EXCLUDED.epss_score,
@@ -476,11 +465,11 @@ public sealed class PostgresEpssRepository : IEpssRepository
updated_at = EXCLUDED.updated_at
""";
await connection.ExecuteAsync(new CommandDefinition(
sql,
new { ModelDate = modelDate, ImportRunId = importRunId, UpdatedAtUtc = updatedAtUtc },
transaction: transaction,
cancellationToken: cancellationToken)).ConfigureAwait(false);
await using var cmd = new NpgsqlCommand(sql, connection, transaction);
cmd.Parameters.AddWithValue(NpgsqlDbType.Date, modelDate);
cmd.Parameters.AddWithValue(importRunId);
cmd.Parameters.AddWithValue(updatedAtUtc);
await cmd.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
@@ -490,6 +479,17 @@ public sealed class PostgresEpssRepository : IEpssRepository
int limit = 100000,
CancellationToken cancellationToken = default)
{
var paramList = new List<object> { modelDate };
var paramIndex = 1;
var flagsClause = "";
if (flags.HasValue)
{
flagsClause = $"AND (flags & @p{paramIndex}) != 0";
paramList.Add((int)flags.Value);
paramIndex++;
}
var sql = $"""
SELECT
cve_id,
@@ -500,23 +500,21 @@ public sealed class PostgresEpssRepository : IEpssRepository
new_percentile,
model_date
FROM {ChangesTable}
WHERE model_date = @ModelDate
{(flags.HasValue ? "AND (flags & @Flags) != 0" : "")}
WHERE model_date = @p0
{flagsClause}
ORDER BY new_score DESC, cve_id
LIMIT @Limit
LIMIT @p{paramIndex}
""";
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
paramList.Add(limit);
var rows = await connection.QueryAsync<ChangeRow>(new CommandDefinition(
sql,
new
{
ModelDate = modelDate,
Flags = flags.HasValue ? (int)flags.Value : 0,
Limit = limit
},
cancellationToken: cancellationToken)).ConfigureAwait(false);
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var dbContext = ScannerDbContextFactory.Create(connection, _dataSource.CommandTimeoutSeconds, SchemaName);
var rows = await dbContext.Database.SqlQueryRaw<ChangeRow>(
sql, paramList.ToArray())
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
return rows.Select(r => new EpssChangeRecord
{
@@ -569,11 +567,6 @@ public sealed class PostgresEpssRepository : IEpssRepository
return Core.Epss.EpssPriorityBand.Low;
}
private sealed class StageCounts
{
public int distinct_count { get; set; }
}
private sealed class ImportRunRow
{
public Guid import_run_id { get; set; }
@@ -621,69 +614,4 @@ public sealed class PostgresEpssRepository : IEpssRepository
public Guid import_run_id { get; set; }
}
private static void EnsureTypeHandlers()
{
if (Interlocked.Exchange(ref _typeHandlersRegistered, 1) == 1)
{
return;
}
SqlMapper.AddTypeHandler(new DateOnlyTypeHandler());
SqlMapper.AddTypeHandler(new NullableDateOnlyTypeHandler());
}
private sealed class DateOnlyTypeHandler : SqlMapper.TypeHandler<DateOnly>
{
public override void SetValue(IDbDataParameter parameter, DateOnly value)
{
parameter.Value = value;
if (parameter is NpgsqlParameter npgsqlParameter)
{
npgsqlParameter.NpgsqlDbType = NpgsqlDbType.Date;
}
}
public override DateOnly Parse(object value)
{
return value switch
{
DateOnly dateOnly => dateOnly,
DateTime dateTime => DateOnly.FromDateTime(dateTime),
_ => DateOnly.FromDateTime((DateTime)value)
};
}
}
private sealed class NullableDateOnlyTypeHandler : SqlMapper.TypeHandler<DateOnly?>
{
public override void SetValue(IDbDataParameter parameter, DateOnly? value)
{
if (value is null)
{
parameter.Value = DBNull.Value;
return;
}
parameter.Value = value.Value;
if (parameter is NpgsqlParameter npgsqlParameter)
{
npgsqlParameter.NpgsqlDbType = NpgsqlDbType.Date;
}
}
public override DateOnly? Parse(object value)
{
if (value is null || value is DBNull)
{
return null;
}
return value switch
{
DateOnly dateOnly => dateOnly,
DateTime dateTime => DateOnly.FromDateTime(dateTime),
_ => DateOnly.FromDateTime((DateTime)value)
};
}
}
}

View File

@@ -6,7 +6,8 @@
// -----------------------------------------------------------------------------
using Dapper;
using Microsoft.EntityFrameworkCore;
using Npgsql;
using StellaOps.Scanner.Storage.Repositories;
using System.Text.Json;
@@ -39,9 +40,9 @@ public sealed class PostgresEpssSignalRepository : IEpssSignalRepository
is_model_change, model_version, dedupe_key, explain_hash, payload
)
VALUES (
@TenantId, @ModelDate, @CveId, @EventType, @RiskBand,
@EpssScore, @EpssDelta, @Percentile, @PercentileDelta,
@IsModelChange, @ModelVersion, @DedupeKey, @ExplainHash, @Payload::jsonb
$1, $2, $3, $4, $5,
$6, $7, $8, $9,
$10, $11, $12, $13, $14::jsonb
)
ON CONFLICT (tenant_id, dedupe_key) DO NOTHING
RETURNING signal_id, created_at
@@ -49,30 +50,31 @@ public sealed class PostgresEpssSignalRepository : IEpssSignalRepository
await using var connection = await _dataSource.OpenConnectionAsync(signal.TenantId.ToString("D"), cancellationToken);
var result = await connection.QueryFirstOrDefaultAsync<(long signal_id, DateTimeOffset created_at)?>(sql, new
{
signal.TenantId,
ModelDate = signal.ModelDate.ToDateTime(TimeOnly.MinValue),
signal.CveId,
signal.EventType,
signal.RiskBand,
signal.EpssScore,
signal.EpssDelta,
signal.Percentile,
signal.PercentileDelta,
signal.IsModelChange,
signal.ModelVersion,
signal.DedupeKey,
signal.ExplainHash,
signal.Payload
});
await using var cmd = new NpgsqlCommand(sql, connection);
cmd.Parameters.AddWithValue(signal.TenantId);
cmd.Parameters.AddWithValue(signal.ModelDate.ToDateTime(TimeOnly.MinValue));
cmd.Parameters.AddWithValue(signal.CveId);
cmd.Parameters.AddWithValue(signal.EventType);
cmd.Parameters.AddWithValue((object?)signal.RiskBand ?? DBNull.Value);
cmd.Parameters.AddWithValue(signal.EpssScore.HasValue ? signal.EpssScore.Value : DBNull.Value);
cmd.Parameters.AddWithValue(signal.EpssDelta.HasValue ? signal.EpssDelta.Value : DBNull.Value);
cmd.Parameters.AddWithValue(signal.Percentile.HasValue ? signal.Percentile.Value : DBNull.Value);
cmd.Parameters.AddWithValue(signal.PercentileDelta.HasValue ? signal.PercentileDelta.Value : DBNull.Value);
cmd.Parameters.AddWithValue(signal.IsModelChange);
cmd.Parameters.AddWithValue((object?)signal.ModelVersion ?? DBNull.Value);
cmd.Parameters.AddWithValue(signal.DedupeKey);
cmd.Parameters.AddWithValue(signal.ExplainHash);
cmd.Parameters.AddWithValue(signal.Payload);
if (result.HasValue)
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
if (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
var signalId = reader.GetInt64(0);
var createdAt = reader.GetFieldValue<DateTimeOffset>(1);
return signal with
{
SignalId = result.Value.signal_id,
CreatedAt = result.Value.created_at
SignalId = signalId,
CreatedAt = createdAt
};
}
@@ -98,9 +100,9 @@ public sealed class PostgresEpssSignalRepository : IEpssSignalRepository
is_model_change, model_version, dedupe_key, explain_hash, payload
)
VALUES (
@TenantId, @ModelDate, @CveId, @EventType, @RiskBand,
@EpssScore, @EpssDelta, @Percentile, @PercentileDelta,
@IsModelChange, @ModelVersion, @DedupeKey, @ExplainHash, @Payload::jsonb
$1, $2, $3, $4, $5,
$6, $7, $8, $9,
$10, $11, $12, $13, $14::jsonb
)
ON CONFLICT (tenant_id, dedupe_key) DO NOTHING
""";
@@ -113,25 +115,23 @@ public sealed class PostgresEpssSignalRepository : IEpssSignalRepository
foreach (var signal in tenantGroup)
{
var affected = await connection.ExecuteAsync(sql, new
{
signal.TenantId,
ModelDate = signal.ModelDate.ToDateTime(TimeOnly.MinValue),
signal.CveId,
signal.EventType,
signal.RiskBand,
signal.EpssScore,
signal.EpssDelta,
signal.Percentile,
signal.PercentileDelta,
signal.IsModelChange,
signal.ModelVersion,
signal.DedupeKey,
signal.ExplainHash,
signal.Payload
}, transaction);
await using var cmd = new NpgsqlCommand(sql, connection, transaction);
cmd.Parameters.AddWithValue(signal.TenantId);
cmd.Parameters.AddWithValue(signal.ModelDate.ToDateTime(TimeOnly.MinValue));
cmd.Parameters.AddWithValue(signal.CveId);
cmd.Parameters.AddWithValue(signal.EventType);
cmd.Parameters.AddWithValue((object?)signal.RiskBand ?? DBNull.Value);
cmd.Parameters.AddWithValue(signal.EpssScore.HasValue ? signal.EpssScore.Value : DBNull.Value);
cmd.Parameters.AddWithValue(signal.EpssDelta.HasValue ? signal.EpssDelta.Value : DBNull.Value);
cmd.Parameters.AddWithValue(signal.Percentile.HasValue ? signal.Percentile.Value : DBNull.Value);
cmd.Parameters.AddWithValue(signal.PercentileDelta.HasValue ? signal.PercentileDelta.Value : DBNull.Value);
cmd.Parameters.AddWithValue(signal.IsModelChange);
cmd.Parameters.AddWithValue((object?)signal.ModelVersion ?? DBNull.Value);
cmd.Parameters.AddWithValue(signal.DedupeKey);
cmd.Parameters.AddWithValue(signal.ExplainHash);
cmd.Parameters.AddWithValue(signal.Payload);
inserted += affected;
inserted += await cmd.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
await transaction.CommitAsync(cancellationToken);
@@ -150,29 +150,42 @@ public sealed class PostgresEpssSignalRepository : IEpssSignalRepository
var eventTypeList = eventTypes?.ToList();
var hasEventTypeFilter = eventTypeList?.Count > 0;
var paramList = new List<object>
{
tenantId,
startDate.ToDateTime(TimeOnly.MinValue),
endDate.ToDateTime(TimeOnly.MinValue)
};
var paramIndex = 3;
var eventTypeClause = "";
if (hasEventTypeFilter)
{
eventTypeClause = $"AND event_type = ANY(@p{paramIndex})";
paramList.Add(eventTypeList!.ToArray());
}
var sql = $"""
SELECT
signal_id, tenant_id, model_date, cve_id, event_type, risk_band,
epss_score, epss_delta, percentile, percentile_delta,
is_model_change, model_version, dedupe_key, explain_hash, payload, created_at
FROM {SignalTable}
WHERE tenant_id = @TenantId
AND model_date >= @StartDate
AND model_date <= @EndDate
{(hasEventTypeFilter ? "AND event_type = ANY(@EventTypes)" : "")}
WHERE tenant_id = @p0
AND model_date >= @p1
AND model_date <= @p2
{eventTypeClause}
ORDER BY model_date DESC, created_at DESC
LIMIT 10000
""";
await using var connection = await _dataSource.OpenConnectionAsync(tenantId.ToString("D"), cancellationToken);
await using var dbContext = ScannerDbContextFactory.Create(connection, _dataSource.CommandTimeoutSeconds, SchemaName);
var rows = await connection.QueryAsync<SignalRow>(sql, new
{
TenantId = tenantId,
StartDate = startDate.ToDateTime(TimeOnly.MinValue),
EndDate = endDate.ToDateTime(TimeOnly.MinValue),
EventTypes = eventTypeList?.ToArray()
});
var rows = await dbContext.Database.SqlQueryRaw<SignalRow>(
sql, paramList.ToArray())
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
return rows.Select(MapToSignal).ToList();
}
@@ -189,20 +202,19 @@ public sealed class PostgresEpssSignalRepository : IEpssSignalRepository
epss_score, epss_delta, percentile, percentile_delta,
is_model_change, model_version, dedupe_key, explain_hash, payload, created_at
FROM {SignalTable}
WHERE tenant_id = @TenantId
AND cve_id = @CveId
WHERE tenant_id = @p0
AND cve_id = @p1
ORDER BY model_date DESC, created_at DESC
LIMIT @Limit
LIMIT @p2
""";
await using var connection = await _dataSource.OpenConnectionAsync(tenantId.ToString("D"), cancellationToken);
await using var dbContext = ScannerDbContextFactory.Create(connection, _dataSource.CommandTimeoutSeconds, SchemaName);
var rows = await connection.QueryAsync<SignalRow>(sql, new
{
TenantId = tenantId,
CveId = cveId,
Limit = limit
});
var rows = await dbContext.Database.SqlQueryRaw<SignalRow>(
sql, tenantId, cveId, limit)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
return rows.Select(MapToSignal).ToList();
}
@@ -219,22 +231,21 @@ public sealed class PostgresEpssSignalRepository : IEpssSignalRepository
epss_score, epss_delta, percentile, percentile_delta,
is_model_change, model_version, dedupe_key, explain_hash, payload, created_at
FROM {SignalTable}
WHERE tenant_id = @TenantId
AND model_date >= @StartDate
AND model_date <= @EndDate
WHERE tenant_id = @p0
AND model_date >= @p1
AND model_date <= @p2
AND risk_band IN ('CRITICAL', 'HIGH')
ORDER BY model_date DESC, created_at DESC
LIMIT 10000
""";
await using var connection = await _dataSource.OpenConnectionAsync(tenantId.ToString("D"), cancellationToken);
await using var dbContext = ScannerDbContextFactory.Create(connection, _dataSource.CommandTimeoutSeconds, SchemaName);
var rows = await connection.QueryAsync<SignalRow>(sql, new
{
TenantId = tenantId,
StartDate = startDate.ToDateTime(TimeOnly.MinValue),
EndDate = endDate.ToDateTime(TimeOnly.MinValue)
});
var rows = await dbContext.Database.SqlQueryRaw<SignalRow>(
sql, tenantId, startDate.ToDateTime(TimeOnly.MinValue), endDate.ToDateTime(TimeOnly.MinValue))
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
return rows.Select(MapToSignal).ToList();
}
@@ -248,14 +259,18 @@ public sealed class PostgresEpssSignalRepository : IEpssSignalRepository
big_jump_delta, suppress_on_model_change, enabled_event_types,
created_at, updated_at
FROM {ConfigTable}
WHERE tenant_id = @TenantId
WHERE tenant_id = @p0
""";
await using var connection = await _dataSource.OpenConnectionAsync(tenantId.ToString("D"), cancellationToken);
await using var dbContext = ScannerDbContextFactory.Create(connection, _dataSource.CommandTimeoutSeconds, SchemaName);
var row = await connection.QueryFirstOrDefaultAsync<ConfigRow?>(sql, new { TenantId = tenantId });
var row = await dbContext.Database.SqlQueryRaw<ConfigRow>(
sql, tenantId)
.FirstOrDefaultAsync(cancellationToken)
.ConfigureAwait(false);
return row.HasValue ? MapToConfig(row.Value) : null;
return row is not null && row.config_id != Guid.Empty ? MapToConfig(row) : null;
}
public async Task<EpssSignalConfig> UpsertConfigAsync(EpssSignalConfig config, CancellationToken cancellationToken = default)
@@ -268,8 +283,7 @@ public sealed class PostgresEpssSignalRepository : IEpssSignalRepository
big_jump_delta, suppress_on_model_change, enabled_event_types
)
VALUES (
@TenantId, @CriticalPercentile, @HighPercentile, @MediumPercentile,
@BigJumpDelta, @SuppressOnModelChange, @EnabledEventTypes
$1, $2, $3, $4, $5, $6, $7
)
ON CONFLICT (tenant_id) DO UPDATE SET
critical_percentile = EXCLUDED.critical_percentile,
@@ -284,31 +298,35 @@ public sealed class PostgresEpssSignalRepository : IEpssSignalRepository
await using var connection = await _dataSource.OpenConnectionAsync(config.TenantId.ToString("D"), cancellationToken);
var result = await connection.QueryFirstAsync<(Guid config_id, DateTimeOffset created_at, DateTimeOffset updated_at)>(sql, new
{
config.TenantId,
config.CriticalPercentile,
config.HighPercentile,
config.MediumPercentile,
config.BigJumpDelta,
config.SuppressOnModelChange,
EnabledEventTypes = config.EnabledEventTypes.ToArray()
});
await using var cmd = new NpgsqlCommand(sql, connection);
cmd.Parameters.AddWithValue(config.TenantId);
cmd.Parameters.AddWithValue(config.CriticalPercentile);
cmd.Parameters.AddWithValue(config.HighPercentile);
cmd.Parameters.AddWithValue(config.MediumPercentile);
cmd.Parameters.AddWithValue(config.BigJumpDelta);
cmd.Parameters.AddWithValue(config.SuppressOnModelChange);
cmd.Parameters.AddWithValue(config.EnabledEventTypes.ToArray());
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
await reader.ReadAsync(cancellationToken).ConfigureAwait(false);
return config with
{
ConfigId = result.config_id,
CreatedAt = result.created_at,
UpdatedAt = result.updated_at
ConfigId = reader.GetGuid(0),
CreatedAt = reader.GetFieldValue<DateTimeOffset>(1),
UpdatedAt = reader.GetFieldValue<DateTimeOffset>(2)
};
}
public async Task<int> PruneAsync(int retentionDays = 90, CancellationToken cancellationToken = default)
{
var sql = $"SELECT {SchemaName}.prune_epss_signals(@RetentionDays)";
var sql = $"SELECT {SchemaName}.prune_epss_signals($1)";
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken);
return await connection.ExecuteScalarAsync<int>(sql, new { RetentionDays = retentionDays });
await using var cmd = new NpgsqlCommand(sql, connection);
cmd.Parameters.AddWithValue(retentionDays);
var result = await cmd.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
return Convert.ToInt32(result);
}
private async Task<EpssSignal?> GetByDedupeKeyAsync(Guid tenantId, string dedupeKey, CancellationToken cancellationToken)
@@ -319,13 +337,18 @@ public sealed class PostgresEpssSignalRepository : IEpssSignalRepository
epss_score, epss_delta, percentile, percentile_delta,
is_model_change, model_version, dedupe_key, explain_hash, payload, created_at
FROM {SignalTable}
WHERE tenant_id = @TenantId AND dedupe_key = @DedupeKey
WHERE tenant_id = @p0 AND dedupe_key = @p1
""";
await using var connection = await _dataSource.OpenConnectionAsync(tenantId.ToString("D"), cancellationToken);
var row = await connection.QueryFirstOrDefaultAsync<SignalRow?>(sql, new { TenantId = tenantId, DedupeKey = dedupeKey });
await using var dbContext = ScannerDbContextFactory.Create(connection, _dataSource.CommandTimeoutSeconds, SchemaName);
return row.HasValue ? MapToSignal(row.Value) : null;
var row = await dbContext.Database.SqlQueryRaw<SignalRow>(
sql, tenantId, dedupeKey)
.FirstOrDefaultAsync(cancellationToken)
.ConfigureAwait(false);
return row is not null && row.signal_id != 0 ? MapToSignal(row) : null;
}
private static EpssSignal MapToSignal(SignalRow row)
@@ -368,33 +391,37 @@ public sealed class PostgresEpssSignalRepository : IEpssSignalRepository
};
}
private readonly record struct SignalRow(
long signal_id,
Guid tenant_id,
DateOnly model_date,
string cve_id,
string event_type,
string? risk_band,
double? epss_score,
double? epss_delta,
double? percentile,
double? percentile_delta,
bool is_model_change,
string? model_version,
string dedupe_key,
byte[] explain_hash,
string payload,
DateTimeOffset created_at);
private sealed class SignalRow
{
public long signal_id { get; set; }
public Guid tenant_id { get; set; }
public DateOnly model_date { get; set; }
public string cve_id { get; set; } = "";
public string event_type { get; set; } = "";
public string? risk_band { get; set; }
public double? epss_score { get; set; }
public double? epss_delta { get; set; }
public double? percentile { get; set; }
public double? percentile_delta { get; set; }
public bool is_model_change { get; set; }
public string? model_version { get; set; }
public string dedupe_key { get; set; } = "";
public byte[] explain_hash { get; set; } = [];
public string payload { get; set; } = "";
public DateTimeOffset created_at { get; set; }
}
private readonly record struct ConfigRow(
Guid config_id,
Guid tenant_id,
double critical_percentile,
double high_percentile,
double medium_percentile,
double big_jump_delta,
bool suppress_on_model_change,
string[]? enabled_event_types,
DateTimeOffset created_at,
DateTimeOffset updated_at);
private sealed class ConfigRow
{
public Guid config_id { get; set; }
public Guid tenant_id { get; set; }
public double critical_percentile { get; set; }
public double high_percentile { get; set; }
public double medium_percentile { get; set; }
public double big_jump_delta { get; set; }
public bool suppress_on_model_change { get; set; }
public string[]? enabled_event_types { get; set; }
public DateTimeOffset created_at { get; set; }
public DateTimeOffset updated_at { get; set; }
}
}

View File

@@ -3,12 +3,13 @@
// Sprint: SPRINT_3500_0002_0003_proof_replay_api
// Task: T3 - Idempotency Middleware
// Description: PostgreSQL implementation of idempotency key repository
// Converted from Dapper to EF Core; ON CONFLICT upsert and stored function kept as raw SQL.
// -----------------------------------------------------------------------------
using Dapper;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Npgsql;
using StellaOps.Determinism;
using StellaOps.Scanner.Storage.EfCore.Models;
using StellaOps.Scanner.Storage.Entities;
using StellaOps.Scanner.Storage.Repositories;
@@ -16,6 +17,7 @@ namespace StellaOps.Scanner.Storage.Postgres;
/// <summary>
/// PostgreSQL implementation of <see cref="IIdempotencyKeyRepository"/>.
/// Converted from Dapper to EF Core; ON CONFLICT upsert and stored function kept as raw SQL.
/// </summary>
public sealed class PostgresIdempotencyKeyRepository : IIdempotencyKeyRepository
{
@@ -41,28 +43,20 @@ public sealed class PostgresIdempotencyKeyRepository : IIdempotencyKeyRepository
string endpointPath,
CancellationToken cancellationToken = default)
{
var sql = $"""
SELECT
key_id AS KeyId,
tenant_id AS TenantId,
content_digest AS ContentDigest,
endpoint_path AS EndpointPath,
response_status AS ResponseStatus,
response_body AS ResponseBody,
response_headers AS ResponseHeaders,
created_at AS CreatedAt,
expires_at AS ExpiresAt
FROM {SchemaName}.idempotency_keys
WHERE tenant_id = @TenantId
AND content_digest = @ContentDigest
AND endpoint_path = @EndpointPath
AND expires_at > now()
""";
await using var conn = await _dataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
return await conn.QuerySingleOrDefaultAsync<IdempotencyKeyRow>(
new CommandDefinition(sql, new { TenantId = tenantId, ContentDigest = contentDigest, EndpointPath = endpointPath }, cancellationToken: cancellationToken))
await using var dbContext = ScannerDbContextFactory.Create(conn, _dataSource.CommandTimeoutSeconds, SchemaName);
var now = DateTimeOffset.UtcNow;
var entity = await dbContext.IdempotencyKeys
.AsNoTracking()
.Where(e => e.TenantId == tenantId
&& e.ContentDigest == contentDigest
&& e.EndpointPath == endpointPath
&& e.ExpiresAt > now)
.FirstOrDefaultAsync(cancellationToken)
.ConfigureAwait(false);
return entity is null ? null : MapToRow(entity);
}
/// <inheritdoc />
@@ -75,15 +69,16 @@ public sealed class PostgresIdempotencyKeyRepository : IIdempotencyKeyRepository
key.KeyId = _guidProvider.NewGuid();
}
// Keep raw SQL for ON CONFLICT upsert + jsonb casts.
var sql = $"""
INSERT INTO {SchemaName}.idempotency_keys
(key_id, tenant_id, content_digest, endpoint_path,
response_status, response_body, response_headers,
created_at, expires_at)
VALUES
(@KeyId, @TenantId, @ContentDigest, @EndpointPath,
@ResponseStatus, @ResponseBody::jsonb, @ResponseHeaders::jsonb,
@CreatedAt, @ExpiresAt)
(@p0, @p1, @p2, @p3,
@p4, @p5::jsonb, @p6::jsonb,
@p7, @p8)
ON CONFLICT (tenant_id, content_digest, endpoint_path) DO UPDATE
SET response_status = EXCLUDED.response_status,
response_body = EXCLUDED.response_body,
@@ -94,22 +89,19 @@ public sealed class PostgresIdempotencyKeyRepository : IIdempotencyKeyRepository
""";
await using var conn = await _dataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
var keyId = await conn.ExecuteScalarAsync<Guid>(
new CommandDefinition(sql, new
{
key.KeyId,
key.TenantId,
key.ContentDigest,
key.EndpointPath,
key.ResponseStatus,
key.ResponseBody,
key.ResponseHeaders,
key.CreatedAt,
key.ExpiresAt
}, cancellationToken: cancellationToken))
await using var dbContext = ScannerDbContextFactory.Create(conn, _dataSource.CommandTimeoutSeconds, SchemaName);
var result = await dbContext.Database.SqlQueryRaw<IdempotencyKeyInsertResult>(
sql,
key.KeyId, key.TenantId, key.ContentDigest, key.EndpointPath,
key.ResponseStatus,
(object?)key.ResponseBody ?? DBNull.Value,
(object?)key.ResponseHeaders ?? DBNull.Value,
key.CreatedAt, key.ExpiresAt)
.FirstAsync(cancellationToken)
.ConfigureAwait(false);
key.KeyId = keyId;
key.KeyId = result.key_id;
_logger.LogDebug(
"Saved idempotency key {KeyId} for tenant {TenantId}, digest {ContentDigest}",
@@ -121,11 +113,14 @@ public sealed class PostgresIdempotencyKeyRepository : IIdempotencyKeyRepository
/// <inheritdoc />
public async Task<int> DeleteExpiredAsync(CancellationToken cancellationToken = default)
{
// Keep raw SQL for stored function call.
var sql = $"SELECT {SchemaName}.cleanup_expired_idempotency_keys()";
await using var conn = await _dataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
var result = await conn.ExecuteScalarAsync<int>(
new CommandDefinition(sql, cancellationToken: cancellationToken))
await using var dbContext = ScannerDbContextFactory.Create(conn, _dataSource.CommandTimeoutSeconds, SchemaName);
var result = await dbContext.Database.SqlQueryRaw<int>(sql)
.FirstAsync(cancellationToken)
.ConfigureAwait(false);
if (result > 0)
@@ -135,4 +130,22 @@ public sealed class PostgresIdempotencyKeyRepository : IIdempotencyKeyRepository
return result;
}
private static IdempotencyKeyRow MapToRow(IdempotencyKeyEntity e) => new()
{
KeyId = e.KeyId,
TenantId = e.TenantId,
ContentDigest = e.ContentDigest,
EndpointPath = e.EndpointPath,
ResponseStatus = e.ResponseStatus,
ResponseBody = e.ResponseBody,
ResponseHeaders = e.ResponseHeaders,
CreatedAt = e.CreatedAt,
ExpiresAt = e.ExpiresAt
};
private sealed record IdempotencyKeyInsertResult
{
public Guid key_id { get; init; }
}
}

View File

@@ -1,5 +1,5 @@
using Dapper;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Npgsql;
using StellaOps.Scanner.SmartDiff.Detection;
@@ -14,9 +14,6 @@ namespace StellaOps.Scanner.Storage.Postgres;
/// </summary>
public sealed class PostgresMaterialRiskChangeRepository : IMaterialRiskChangeRepository
{
private const string TenantContext = "00000000-0000-0000-0000-000000000001";
private static readonly Guid TenantId = Guid.Parse(TenantContext);
private readonly ScannerDataSource _dataSource;
private readonly ILogger<PostgresMaterialRiskChangeRepository> _logger;
@@ -36,31 +33,41 @@ public sealed class PostgresMaterialRiskChangeRepository : IMaterialRiskChangeRe
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task StoreChangeAsync(MaterialRiskChangeResult change, string scanId, CancellationToken ct = default)
public async Task StoreChangeAsync(
MaterialRiskChangeResult change,
string scanId,
CancellationToken ct = default,
string? tenantId = null)
{
ArgumentNullException.ThrowIfNull(change);
ArgumentException.ThrowIfNullOrWhiteSpace(scanId);
var tenantScope = ScannerTenantScope.Resolve(tenantId);
await using var connection = await _dataSource.OpenConnectionAsync(TenantContext, ct).ConfigureAwait(false);
await InsertChangeAsync(connection, change, scanId.Trim(), ct).ConfigureAwait(false);
await using var connection = await _dataSource.OpenConnectionAsync(tenantScope.TenantContext, ct).ConfigureAwait(false);
await InsertChangeAsync(connection, change, scanId.Trim(), tenantScope.TenantId, ct).ConfigureAwait(false);
}
public async Task StoreChangesAsync(IReadOnlyList<MaterialRiskChangeResult> changes, string scanId, CancellationToken ct = default)
public async Task StoreChangesAsync(
IReadOnlyList<MaterialRiskChangeResult> changes,
string scanId,
CancellationToken ct = default,
string? tenantId = null)
{
ArgumentNullException.ThrowIfNull(changes);
ArgumentException.ThrowIfNullOrWhiteSpace(scanId);
var tenantScope = ScannerTenantScope.Resolve(tenantId);
if (changes.Count == 0)
return;
await using var connection = await _dataSource.OpenConnectionAsync(TenantContext, ct).ConfigureAwait(false);
await using var connection = await _dataSource.OpenConnectionAsync(tenantScope.TenantContext, ct).ConfigureAwait(false);
await using var transaction = await connection.BeginTransactionAsync(ct).ConfigureAwait(false);
try
{
foreach (var change in changes)
{
await InsertChangeAsync(connection, change, scanId.Trim(), ct, transaction).ConfigureAwait(false);
await InsertChangeAsync(connection, change, scanId.Trim(), tenantScope.TenantId, ct, transaction).ConfigureAwait(false);
}
await transaction.CommitAsync(ct).ConfigureAwait(false);
@@ -74,22 +81,31 @@ public sealed class PostgresMaterialRiskChangeRepository : IMaterialRiskChangeRe
}
}
public async Task<IReadOnlyList<MaterialRiskChangeResult>> GetChangesForScanAsync(string scanId, CancellationToken ct = default)
public async Task<IReadOnlyList<MaterialRiskChangeResult>> GetChangesForScanAsync(
string scanId,
CancellationToken ct = default,
string? tenantId = null)
{
ArgumentException.ThrowIfNullOrWhiteSpace(scanId);
var tenantScope = ScannerTenantScope.Resolve(tenantId);
var sql = $"""
SELECT
SELECT
vuln_id, purl, has_material_change, priority_score,
previous_state_hash, current_state_hash, changes
FROM {MaterialRiskChangesTable}
WHERE tenant_id = @TenantId
AND scan_id = @ScanId
WHERE tenant_id = @p0
AND scan_id = @p1
ORDER BY priority_score DESC
""";
await using var connection = await _dataSource.OpenConnectionAsync(TenantContext, ct).ConfigureAwait(false);
var rows = await connection.QueryAsync<MaterialRiskChangeRow>(sql, new { TenantId, ScanId = scanId.Trim() });
await using var connection = await _dataSource.OpenConnectionAsync(tenantScope.TenantContext, ct).ConfigureAwait(false);
await using var dbContext = ScannerDbContextFactory.Create(connection, _dataSource.CommandTimeoutSeconds, SchemaName);
var rows = await dbContext.Database.SqlQueryRaw<MaterialRiskChangeRow>(
sql, tenantScope.TenantId, scanId.Trim())
.ToListAsync(ct)
.ConfigureAwait(false);
return rows.Select(r => r.ToResult()).ToList();
}
@@ -97,94 +113,99 @@ public sealed class PostgresMaterialRiskChangeRepository : IMaterialRiskChangeRe
public async Task<IReadOnlyList<MaterialRiskChangeResult>> GetChangesForFindingAsync(
FindingKey findingKey,
int limit = 10,
CancellationToken ct = default)
CancellationToken ct = default,
string? tenantId = null)
{
ArgumentNullException.ThrowIfNull(findingKey);
ArgumentOutOfRangeException.ThrowIfLessThan(limit, 1);
var tenantScope = ScannerTenantScope.Resolve(tenantId);
var sql = $"""
SELECT
SELECT
vuln_id, purl, has_material_change, priority_score,
previous_state_hash, current_state_hash, changes
FROM {MaterialRiskChangesTable}
WHERE tenant_id = @TenantId
AND vuln_id = @VulnId
AND purl = @Purl
WHERE tenant_id = @p0
AND vuln_id = @p1
AND purl = @p2
ORDER BY detected_at DESC
LIMIT @Limit
LIMIT @p3
""";
await using var connection = await _dataSource.OpenConnectionAsync(TenantContext, ct).ConfigureAwait(false);
var rows = await connection.QueryAsync<MaterialRiskChangeRow>(sql, new
{
TenantId,
VulnId = findingKey.VulnId,
Purl = findingKey.ComponentPurl,
Limit = limit
});
await using var connection = await _dataSource.OpenConnectionAsync(tenantScope.TenantContext, ct).ConfigureAwait(false);
await using var dbContext = ScannerDbContextFactory.Create(connection, _dataSource.CommandTimeoutSeconds, SchemaName);
var rows = await dbContext.Database.SqlQueryRaw<MaterialRiskChangeRow>(
sql, tenantScope.TenantId, findingKey.VulnId, findingKey.ComponentPurl, limit)
.ToListAsync(ct)
.ConfigureAwait(false);
return rows.Select(r => r.ToResult()).ToList();
}
public async Task<MaterialRiskChangeQueryResult> QueryChangesAsync(
MaterialRiskChangeQuery query,
CancellationToken ct = default)
CancellationToken ct = default,
string? tenantId = null)
{
ArgumentNullException.ThrowIfNull(query);
var tenantScope = ScannerTenantScope.Resolve(tenantId);
var conditions = new List<string> { "has_material_change = TRUE" };
var parameters = new DynamicParameters();
if (!string.IsNullOrEmpty(query.ImageDigest))
{
// Would need a join with scan metadata for image filtering
// For now, skip this filter
}
var paramList = new List<object>();
var paramIndex = 0;
if (query.Since.HasValue)
{
conditions.Add("detected_at >= @Since");
parameters.Add("Since", query.Since.Value);
conditions.Add($"detected_at >= @p{paramIndex}");
paramList.Add(query.Since.Value);
paramIndex++;
}
if (query.Until.HasValue)
{
conditions.Add("detected_at <= @Until");
parameters.Add("Until", query.Until.Value);
conditions.Add($"detected_at <= @p{paramIndex}");
paramList.Add(query.Until.Value);
paramIndex++;
}
if (query.MinPriorityScore.HasValue)
{
conditions.Add("priority_score >= @MinPriority");
parameters.Add("MinPriority", query.MinPriorityScore.Value);
conditions.Add($"priority_score >= @p{paramIndex}");
paramList.Add(query.MinPriorityScore.Value);
paramIndex++;
}
conditions.Add("tenant_id = @TenantId");
parameters.Add("TenantId", TenantId);
conditions.Add($"tenant_id = @p{paramIndex}");
paramList.Add(tenantScope.TenantId);
paramIndex++;
var whereClause = string.Join(" AND ", conditions);
// Count query
var countSql = $"SELECT COUNT(*) FROM {MaterialRiskChangesTable} WHERE {whereClause}";
// Data query
var dataSql = $"""
SELECT
SELECT
vuln_id, purl, has_material_change, priority_score,
previous_state_hash, current_state_hash, changes
FROM {MaterialRiskChangesTable}
WHERE {whereClause}
ORDER BY priority_score DESC
OFFSET @Offset LIMIT @Limit
OFFSET @p{paramIndex} LIMIT @p{paramIndex + 1}
""";
parameters.Add("Offset", query.Offset);
parameters.Add("Limit", query.Limit);
var dataParams = new List<object>(paramList) { query.Offset, query.Limit };
await using var connection = await _dataSource.OpenConnectionAsync(TenantContext, ct).ConfigureAwait(false);
var totalCount = await connection.ExecuteScalarAsync<int>(countSql, parameters);
var rows = await connection.QueryAsync<MaterialRiskChangeRow>(dataSql, parameters);
await using var connection = await _dataSource.OpenConnectionAsync(tenantScope.TenantContext, ct).ConfigureAwait(false);
await using var dbContext = ScannerDbContextFactory.Create(connection, _dataSource.CommandTimeoutSeconds, SchemaName);
var totalCount = await dbContext.Database.SqlQueryRaw<int>(countSql, paramList.ToArray())
.FirstAsync(ct).ConfigureAwait(false);
var rows = await dbContext.Database.SqlQueryRaw<MaterialRiskChangeRow>(dataSql, dataParams.ToArray())
.ToListAsync(ct).ConfigureAwait(false);
var changes = rows.Select(r => r.ToResult()).ToImmutableArray();
@@ -199,6 +220,7 @@ public sealed class PostgresMaterialRiskChangeRepository : IMaterialRiskChangeRe
NpgsqlConnection connection,
MaterialRiskChangeResult change,
string scanId,
Guid tenantId,
CancellationToken ct,
NpgsqlTransaction? transaction = null)
{
@@ -212,9 +234,7 @@ public sealed class PostgresMaterialRiskChangeRepository : IMaterialRiskChangeRe
has_material_change, priority_score,
previous_state_hash, current_state_hash, changes
) VALUES (
@TenantId, @VulnId, @Purl, @ScanId,
@HasMaterialChange, @PriorityScore,
@PreviousStateHash, @CurrentStateHash, @Changes::jsonb
$1, $2, $3, $4, $5, $6, $7, $8, $9::jsonb
)
ON CONFLICT (tenant_id, scan_id, vuln_id, purl) DO UPDATE SET
has_material_change = EXCLUDED.has_material_change,
@@ -226,18 +246,18 @@ public sealed class PostgresMaterialRiskChangeRepository : IMaterialRiskChangeRe
var changesJson = JsonSerializer.Serialize(change.Changes, JsonOptions);
await connection.ExecuteAsync(new CommandDefinition(sql, new
{
TenantId,
VulnId = change.FindingKey.VulnId,
Purl = change.FindingKey.ComponentPurl,
ScanId = scanId,
HasMaterialChange = change.HasMaterialChange,
PriorityScore = change.PriorityScore,
PreviousStateHash = change.PreviousStateHash,
CurrentStateHash = change.CurrentStateHash,
Changes = changesJson
}, transaction: transaction, cancellationToken: ct));
await using var cmd = new NpgsqlCommand(sql, connection, transaction);
cmd.Parameters.AddWithValue(tenantId);
cmd.Parameters.AddWithValue(change.FindingKey.VulnId);
cmd.Parameters.AddWithValue(change.FindingKey.ComponentPurl);
cmd.Parameters.AddWithValue(scanId);
cmd.Parameters.AddWithValue(change.HasMaterialChange);
cmd.Parameters.AddWithValue(change.PriorityScore);
cmd.Parameters.AddWithValue(change.PreviousStateHash);
cmd.Parameters.AddWithValue(change.CurrentStateHash);
cmd.Parameters.AddWithValue(changesJson);
await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
}
/// <summary>

View File

@@ -3,9 +3,10 @@
// Sprint: SPRINT_3413_0001_0001_epss_live_enrichment
// Task: S6 - Add observed CVEs filter
// Description: PostgreSQL implementation of IObservedCveRepository.
// Converted from Dapper to EF Core raw SQL (triage table not modeled in Scanner DbContext).
// -----------------------------------------------------------------------------
using Dapper;
using Microsoft.EntityFrameworkCore;
using StellaOps.Scanner.Storage.Repositories;
namespace StellaOps.Scanner.Storage.Postgres;
@@ -13,6 +14,7 @@ namespace StellaOps.Scanner.Storage.Postgres;
/// <summary>
/// PostgreSQL implementation of <see cref="IObservedCveRepository"/>.
/// Queries vuln_instance_triage to determine which CVEs are observed per tenant.
/// Converted from Dapper to EF Core raw SQL.
/// </summary>
public sealed class PostgresObservedCveRepository : IObservedCveRepository
{
@@ -33,13 +35,17 @@ public sealed class PostgresObservedCveRepository : IObservedCveRepository
var sql = $"""
SELECT DISTINCT cve_id
FROM {TriageTable}
WHERE tenant_id = @TenantId
WHERE tenant_id = @p0
AND cve_id IS NOT NULL
AND cve_id LIKE 'CVE-%'
""";
await using var connection = await _dataSource.OpenConnectionAsync(tenantId.ToString("D"), cancellationToken);
var cves = await connection.QueryAsync<string>(sql, new { TenantId = tenantId });
await using var dbContext = ScannerDbContextFactory.Create(connection, _dataSource.CommandTimeoutSeconds, SchemaName);
var cves = await dbContext.Database.SqlQueryRaw<string>(sql, tenantId)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
return new HashSet<string>(cves, StringComparer.OrdinalIgnoreCase);
}
@@ -52,13 +58,17 @@ public sealed class PostgresObservedCveRepository : IObservedCveRepository
var sql = $"""
SELECT EXISTS (
SELECT 1 FROM {TriageTable}
WHERE tenant_id = @TenantId
AND cve_id = @CveId
WHERE tenant_id = @p0
AND cve_id = @p1
)
""";
await using var connection = await _dataSource.OpenConnectionAsync(tenantId.ToString("D"), cancellationToken);
return await connection.ExecuteScalarAsync<bool>(sql, new { TenantId = tenantId, CveId = cveId });
await using var dbContext = ScannerDbContextFactory.Create(connection, _dataSource.CommandTimeoutSeconds, SchemaName);
return await dbContext.Database.SqlQueryRaw<bool>(sql, tenantId, cveId)
.FirstAsync(cancellationToken)
.ConfigureAwait(false);
}
public async Task<IReadOnlySet<string>> FilterObservedAsync(
@@ -75,16 +85,16 @@ public sealed class PostgresObservedCveRepository : IObservedCveRepository
var sql = $"""
SELECT DISTINCT cve_id
FROM {TriageTable}
WHERE tenant_id = @TenantId
AND cve_id = ANY(@CveIds)
WHERE tenant_id = @p0
AND cve_id = ANY(@p1)
""";
await using var connection = await _dataSource.OpenConnectionAsync(tenantId.ToString("D"), cancellationToken);
var observed = await connection.QueryAsync<string>(sql, new
{
TenantId = tenantId,
CveIds = cveList.ToArray()
});
await using var dbContext = ScannerDbContextFactory.Create(connection, _dataSource.CommandTimeoutSeconds, SchemaName);
var observed = await dbContext.Database.SqlQueryRaw<string>(sql, tenantId, cveList.ToArray())
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
return new HashSet<string>(observed, StringComparer.OrdinalIgnoreCase);
}
@@ -100,9 +110,11 @@ public sealed class PostgresObservedCveRepository : IObservedCveRepository
""";
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken);
var tenants = await connection.QueryAsync<Guid>(sql);
await using var dbContext = ScannerDbContextFactory.Create(connection, _dataSource.CommandTimeoutSeconds, SchemaName);
return tenants.ToList();
return await dbContext.Database.SqlQueryRaw<Guid>(sql)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
}
public async Task<IReadOnlyDictionary<string, IReadOnlyList<Guid>>> GetTenantsObservingCvesAsync(
@@ -118,15 +130,16 @@ public sealed class PostgresObservedCveRepository : IObservedCveRepository
var sql = $"""
SELECT cve_id, tenant_id
FROM {TriageTable}
WHERE cve_id = ANY(@CveIds)
WHERE cve_id = ANY(@p0)
GROUP BY cve_id, tenant_id
""";
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken);
var rows = await connection.QueryAsync<(string cve_id, Guid tenant_id)>(sql, new
{
CveIds = cveList.ToArray()
});
await using var dbContext = ScannerDbContextFactory.Create(connection, _dataSource.CommandTimeoutSeconds, SchemaName);
var rows = await dbContext.Database.SqlQueryRaw<CveTenantRow>(sql, (object)cveList.ToArray())
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
var result = new Dictionary<string, List<Guid>>(StringComparer.OrdinalIgnoreCase);
@@ -149,4 +162,10 @@ public sealed class PostgresObservedCveRepository : IObservedCveRepository
kvp => (IReadOnlyList<Guid>)kvp.Value,
StringComparer.OrdinalIgnoreCase);
}
private sealed record CveTenantRow
{
public string cve_id { get; init; } = "";
public Guid tenant_id { get; init; }
}
}

View File

@@ -1,4 +1,5 @@
using Dapper;
using Microsoft.EntityFrameworkCore;
using StellaOps.Scanner.Storage.EfCore.Models;
using StellaOps.Scanner.Storage.Entities;
using StellaOps.Scanner.Storage.Repositories;
@@ -6,12 +7,12 @@ namespace StellaOps.Scanner.Storage.Postgres;
/// <summary>
/// PostgreSQL implementation of proof bundle repository.
/// Converted from Dapper to EF Core; ON CONFLICT upsert kept as raw SQL.
/// </summary>
public sealed class PostgresProofBundleRepository : IProofBundleRepository
{
private readonly ScannerDataSource _dataSource;
private string SchemaName => _dataSource.SchemaName ?? ScannerDataSource.DefaultSchema;
private string TableName => $"{SchemaName}.proof_bundle";
public PostgresProofBundleRepository(ScannerDataSource dataSource)
{
@@ -20,68 +21,39 @@ public sealed class PostgresProofBundleRepository : IProofBundleRepository
public async Task<ProofBundleRow?> GetByRootHashAsync(string rootHash, CancellationToken cancellationToken = default)
{
var sql = $"""
SELECT
scan_id AS ScanId,
root_hash AS RootHash,
bundle_type AS BundleType,
dsse_envelope AS DsseEnvelope,
signature_keyid AS SignatureKeyId,
signature_algorithm AS SignatureAlgorithm,
bundle_content AS BundleContent,
bundle_hash AS BundleHash,
ledger_hash AS LedgerHash,
manifest_hash AS ManifestHash,
sbom_hash AS SbomHash,
vex_hash AS VexHash,
created_at AS CreatedAt,
expires_at AS ExpiresAt
FROM {TableName}
WHERE root_hash = @RootHash
""";
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
return await connection.QuerySingleOrDefaultAsync<ProofBundleRow>(
new CommandDefinition(sql, new { RootHash = rootHash }, cancellationToken: cancellationToken))
await using var dbContext = ScannerDbContextFactory.Create(connection, _dataSource.CommandTimeoutSeconds, SchemaName);
var entity = await dbContext.ProofBundles
.AsNoTracking()
.FirstOrDefaultAsync(e => e.RootHash == rootHash, cancellationToken)
.ConfigureAwait(false);
return entity is null ? null : MapToRow(entity);
}
public async Task<IReadOnlyList<ProofBundleRow>> GetByScanIdAsync(Guid scanId, CancellationToken cancellationToken = default)
{
var sql = $"""
SELECT
scan_id AS ScanId,
root_hash AS RootHash,
bundle_type AS BundleType,
dsse_envelope AS DsseEnvelope,
signature_keyid AS SignatureKeyId,
signature_algorithm AS SignatureAlgorithm,
bundle_content AS BundleContent,
bundle_hash AS BundleHash,
ledger_hash AS LedgerHash,
manifest_hash AS ManifestHash,
sbom_hash AS SbomHash,
vex_hash AS VexHash,
created_at AS CreatedAt,
expires_at AS ExpiresAt
FROM {TableName}
WHERE scan_id = @ScanId
ORDER BY created_at DESC
""";
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
var results = await connection.QueryAsync<ProofBundleRow>(
new CommandDefinition(sql, new { ScanId = scanId }, cancellationToken: cancellationToken))
await using var dbContext = ScannerDbContextFactory.Create(connection, _dataSource.CommandTimeoutSeconds, SchemaName);
var entities = await dbContext.ProofBundles
.AsNoTracking()
.Where(e => e.ScanId == scanId)
.OrderByDescending(e => e.CreatedAt)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
return results.ToList();
return entities.Select(MapToRow).ToList();
}
public async Task<ProofBundleRow> SaveAsync(ProofBundleRow bundle, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(bundle);
// Keep raw SQL for ON CONFLICT upsert + jsonb cast.
var sql = $"""
INSERT INTO {TableName} (
INSERT INTO {SchemaName}.proof_bundle (
scan_id,
root_hash,
bundle_type,
@@ -96,47 +68,70 @@ public sealed class PostgresProofBundleRepository : IProofBundleRepository
vex_hash,
expires_at
) VALUES (
@ScanId,
@RootHash,
@BundleType,
@DsseEnvelope::jsonb,
@SignatureKeyId,
@SignatureAlgorithm,
@BundleContent,
@BundleHash,
@LedgerHash,
@ManifestHash,
@SbomHash,
@VexHash,
@ExpiresAt
@p0, @p1, @p2, @p3::jsonb, @p4, @p5, @p6, @p7, @p8, @p9, @p10, @p11, @p12
)
ON CONFLICT (scan_id, root_hash) DO UPDATE SET
dsse_envelope = EXCLUDED.dsse_envelope,
bundle_content = EXCLUDED.bundle_content,
bundle_hash = EXCLUDED.bundle_hash,
ledger_hash = EXCLUDED.ledger_hash
RETURNING created_at AS CreatedAt
RETURNING created_at
""";
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
var createdAt = await connection.QuerySingleAsync<DateTimeOffset>(
new CommandDefinition(sql, bundle, cancellationToken: cancellationToken))
await using var dbContext = ScannerDbContextFactory.Create(connection, _dataSource.CommandTimeoutSeconds, SchemaName);
var result = await dbContext.Database.SqlQueryRaw<ProofBundleInsertResult>(
sql,
bundle.ScanId, bundle.RootHash, bundle.BundleType,
(object?)bundle.DsseEnvelope ?? DBNull.Value,
(object?)bundle.SignatureKeyId ?? DBNull.Value,
(object?)bundle.SignatureAlgorithm ?? DBNull.Value,
(object?)bundle.BundleContent ?? DBNull.Value,
(object?)bundle.BundleHash ?? DBNull.Value,
(object?)bundle.LedgerHash ?? DBNull.Value,
(object?)bundle.ManifestHash ?? DBNull.Value,
(object?)bundle.SbomHash ?? DBNull.Value,
(object?)bundle.VexHash ?? DBNull.Value,
(object?)bundle.ExpiresAt ?? DBNull.Value)
.FirstAsync(cancellationToken)
.ConfigureAwait(false);
bundle.CreatedAt = createdAt;
bundle.CreatedAt = result.created_at;
return bundle;
}
public async Task<int> DeleteExpiredAsync(CancellationToken cancellationToken = default)
{
var sql = $"""
DELETE FROM {TableName}
WHERE expires_at IS NOT NULL AND expires_at < NOW()
""";
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
return await connection.ExecuteAsync(
new CommandDefinition(sql, cancellationToken: cancellationToken))
await using var dbContext = ScannerDbContextFactory.Create(connection, _dataSource.CommandTimeoutSeconds, SchemaName);
return await dbContext.ProofBundles
.Where(e => e.ExpiresAt != null && e.ExpiresAt < DateTimeOffset.UtcNow)
.ExecuteDeleteAsync(cancellationToken)
.ConfigureAwait(false);
}
private static ProofBundleRow MapToRow(ProofBundleEntity e) => new()
{
ScanId = e.ScanId,
RootHash = e.RootHash,
BundleType = e.BundleType,
DsseEnvelope = e.DsseEnvelope,
SignatureKeyId = e.SignatureKeyId,
SignatureAlgorithm = e.SignatureAlgorithm,
BundleContent = e.BundleContent,
BundleHash = e.BundleHash,
LedgerHash = e.LedgerHash,
ManifestHash = e.ManifestHash,
SbomHash = e.SbomHash,
VexHash = e.VexHash,
CreatedAt = e.CreatedAt,
ExpiresAt = e.ExpiresAt
};
private sealed record ProofBundleInsertResult
{
public DateTimeOffset created_at { get; init; }
}
}

View File

@@ -1,6 +1,7 @@
using Dapper;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Npgsql;
using StellaOps.Scanner.Contracts;
using StellaOps.Scanner.ReachabilityDrift;
using StellaOps.Scanner.Storage.Repositories;
@@ -11,9 +12,6 @@ namespace StellaOps.Scanner.Storage.Postgres;
public sealed class PostgresReachabilityDriftResultRepository : IReachabilityDriftResultRepository
{
private const string TenantContext = "00000000-0000-0000-0000-000000000001";
private static readonly Guid TenantId = Guid.Parse(TenantContext);
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
WriteIndented = false
@@ -34,31 +32,17 @@ public sealed class PostgresReachabilityDriftResultRepository : IReachabilityDri
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task StoreAsync(ReachabilityDriftResult result, CancellationToken ct = default)
public async Task StoreAsync(ReachabilityDriftResult result, CancellationToken ct = default, string? tenantId = null)
{
ArgumentNullException.ThrowIfNull(result);
var tenantScope = ScannerTenantScope.Resolve(tenantId);
var insertResultSql = $"""
INSERT INTO {DriftResultsTable} (
id,
tenant_id,
base_scan_id,
head_scan_id,
language,
newly_reachable_count,
newly_unreachable_count,
detected_at,
result_digest
id, tenant_id, base_scan_id, head_scan_id, language,
newly_reachable_count, newly_unreachable_count, detected_at, result_digest
) VALUES (
@Id,
@TenantId,
@BaseScanId,
@HeadScanId,
@Language,
@NewlyReachableCount,
@NewlyUnreachableCount,
@DetectedAt,
@ResultDigest
$1, $2, $3, $4, $5, $6, $7, $8, $9
)
ON CONFLICT (tenant_id, base_scan_id, head_scan_id, language, result_digest) DO UPDATE SET
newly_reachable_count = EXCLUDED.newly_reachable_count,
@@ -69,42 +53,17 @@ public sealed class PostgresReachabilityDriftResultRepository : IReachabilityDri
var deleteSinksSql = $"""
DELETE FROM {DriftedSinksTable}
WHERE tenant_id = @TenantId AND drift_result_id = @DriftId
WHERE tenant_id = $1 AND drift_result_id = $2
""";
var insertSinkSql = $"""
INSERT INTO {DriftedSinksTable} (
id,
tenant_id,
drift_result_id,
sink_node_id,
symbol,
sink_category,
direction,
cause_kind,
cause_description,
cause_symbol,
cause_file,
cause_line,
code_change_id,
compressed_path,
associated_vulns
id, tenant_id, drift_result_id, sink_node_id, symbol,
sink_category, direction, cause_kind, cause_description,
cause_symbol, cause_file, cause_line, code_change_id,
compressed_path, associated_vulns
) VALUES (
@Id,
@TenantId,
@DriftId,
@SinkNodeId,
@Symbol,
@SinkCategory,
@Direction,
@CauseKind,
@CauseDescription,
@CauseSymbol,
@CauseFile,
@CauseLine,
@CodeChangeId,
@CompressedPath::jsonb,
@AssociatedVulns::jsonb
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14::jsonb, $15::jsonb
)
ON CONFLICT (drift_result_id, sink_node_id) DO UPDATE SET
symbol = EXCLUDED.symbol,
@@ -120,48 +79,57 @@ public sealed class PostgresReachabilityDriftResultRepository : IReachabilityDri
associated_vulns = EXCLUDED.associated_vulns
""";
await using var connection = await _dataSource.OpenConnectionAsync(TenantContext, ct).ConfigureAwait(false);
await using var connection = await _dataSource.OpenConnectionAsync(tenantScope.TenantContext, ct).ConfigureAwait(false);
await using var transaction = await connection.BeginTransactionAsync(ct).ConfigureAwait(false);
try
{
var driftId = await connection.ExecuteScalarAsync<Guid>(new CommandDefinition(
insertResultSql,
new
{
result.Id,
TenantId,
BaseScanId = result.BaseScanId.Trim(),
HeadScanId = result.HeadScanId.Trim(),
Language = result.Language.Trim(),
NewlyReachableCount = result.NewlyReachable.Length,
NewlyUnreachableCount = result.NewlyUnreachable.Length,
DetectedAt = result.DetectedAt.UtcDateTime,
result.ResultDigest
},
transaction: transaction,
cancellationToken: ct))
.ConfigureAwait(false);
// Insert drift result header and get the returned id
await using var insertCmd = new NpgsqlCommand(insertResultSql, connection, transaction);
insertCmd.Parameters.AddWithValue(result.Id);
insertCmd.Parameters.AddWithValue(tenantScope.TenantId);
insertCmd.Parameters.AddWithValue(result.BaseScanId.Trim());
insertCmd.Parameters.AddWithValue(result.HeadScanId.Trim());
insertCmd.Parameters.AddWithValue(result.Language.Trim());
insertCmd.Parameters.AddWithValue(result.NewlyReachable.Length);
insertCmd.Parameters.AddWithValue(result.NewlyUnreachable.Length);
insertCmd.Parameters.AddWithValue(result.DetectedAt.UtcDateTime);
insertCmd.Parameters.AddWithValue(result.ResultDigest);
await connection.ExecuteAsync(new CommandDefinition(
deleteSinksSql,
new { TenantId, DriftId = driftId },
transaction: transaction,
cancellationToken: ct))
.ConfigureAwait(false);
var driftIdObj = await insertCmd.ExecuteScalarAsync(ct).ConfigureAwait(false);
var driftId = (Guid)driftIdObj!;
var sinkRows = EnumerateSinkRows(driftId, result.NewlyReachable, DriftDirection.BecameReachable)
.Concat(EnumerateSinkRows(driftId, result.NewlyUnreachable, DriftDirection.BecameUnreachable))
// Delete existing sinks for this drift result
await using var deleteCmd = new NpgsqlCommand(deleteSinksSql, connection, transaction);
deleteCmd.Parameters.AddWithValue(tenantScope.TenantId);
deleteCmd.Parameters.AddWithValue(driftId);
await deleteCmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
// Insert all sink rows
var sinks = EnumerateSinkParams(driftId, tenantScope.TenantId, result.NewlyReachable, DriftDirection.BecameReachable)
.Concat(EnumerateSinkParams(driftId, tenantScope.TenantId, result.NewlyUnreachable, DriftDirection.BecameUnreachable))
.ToList();
if (sinkRows.Count > 0)
foreach (var sink in sinks)
{
await connection.ExecuteAsync(new CommandDefinition(
insertSinkSql,
sinkRows,
transaction: transaction,
cancellationToken: ct))
.ConfigureAwait(false);
await using var sinkCmd = new NpgsqlCommand(insertSinkSql, connection, transaction);
sinkCmd.Parameters.AddWithValue(sink.Id);
sinkCmd.Parameters.AddWithValue(sink.TenantId);
sinkCmd.Parameters.AddWithValue(sink.DriftId);
sinkCmd.Parameters.AddWithValue(sink.SinkNodeId);
sinkCmd.Parameters.AddWithValue(sink.Symbol);
sinkCmd.Parameters.AddWithValue(sink.SinkCategory);
sinkCmd.Parameters.AddWithValue(sink.Direction);
sinkCmd.Parameters.AddWithValue(sink.CauseKind);
sinkCmd.Parameters.AddWithValue(sink.CauseDescription);
sinkCmd.Parameters.AddWithValue((object?)sink.CauseSymbol ?? DBNull.Value);
sinkCmd.Parameters.AddWithValue((object?)sink.CauseFile ?? DBNull.Value);
sinkCmd.Parameters.AddWithValue(sink.CauseLine.HasValue ? sink.CauseLine.Value : DBNull.Value);
sinkCmd.Parameters.AddWithValue(sink.CodeChangeId.HasValue ? sink.CodeChangeId.Value : DBNull.Value);
sinkCmd.Parameters.AddWithValue(sink.CompressedPath);
sinkCmd.Parameters.AddWithValue((object?)sink.AssociatedVulns ?? DBNull.Value);
await sinkCmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
}
await transaction.CommitAsync(ct).ConfigureAwait(false);
@@ -181,81 +149,81 @@ public sealed class PostgresReachabilityDriftResultRepository : IReachabilityDri
}
}
public async Task<ReachabilityDriftResult?> TryGetLatestForHeadAsync(string headScanId, string language, CancellationToken ct = default)
public async Task<ReachabilityDriftResult?> TryGetLatestForHeadAsync(string headScanId, string language, CancellationToken ct = default, string? tenantId = null)
{
ArgumentException.ThrowIfNullOrWhiteSpace(headScanId);
ArgumentException.ThrowIfNullOrWhiteSpace(language);
var tenantScope = ScannerTenantScope.Resolve(tenantId);
var sql = $"""
SELECT id, base_scan_id, head_scan_id, language, detected_at, result_digest
FROM {DriftResultsTable}
WHERE tenant_id = @TenantId AND head_scan_id = @HeadScanId AND language = @Language
WHERE tenant_id = @p0 AND head_scan_id = @p1 AND language = @p2
ORDER BY detected_at DESC
LIMIT 1
""";
await using var connection = await _dataSource.OpenConnectionAsync(TenantContext, ct).ConfigureAwait(false);
var header = await connection.QuerySingleOrDefaultAsync<DriftHeaderRow>(new CommandDefinition(
sql,
new
{
TenantId,
HeadScanId = headScanId.Trim(),
Language = language.Trim()
},
cancellationToken: ct)).ConfigureAwait(false);
await using var connection = await _dataSource.OpenConnectionAsync(tenantScope.TenantContext, ct).ConfigureAwait(false);
await using var dbContext = ScannerDbContextFactory.Create(connection, _dataSource.CommandTimeoutSeconds, SchemaName);
var header = await dbContext.Database.SqlQueryRaw<DriftHeaderRow>(
sql, tenantScope.TenantId, headScanId.Trim(), language.Trim())
.FirstOrDefaultAsync(ct)
.ConfigureAwait(false);
if (header is null)
{
return null;
}
return await LoadResultAsync(connection, header, ct).ConfigureAwait(false);
return await LoadResultAsync(connection, header, tenantScope.TenantId, ct).ConfigureAwait(false);
}
public async Task<ReachabilityDriftResult?> TryGetByIdAsync(Guid driftId, CancellationToken ct = default)
public async Task<ReachabilityDriftResult?> TryGetByIdAsync(Guid driftId, CancellationToken ct = default, string? tenantId = null)
{
var tenantScope = ScannerTenantScope.Resolve(tenantId);
var sql = $"""
SELECT id, base_scan_id, head_scan_id, language, detected_at, result_digest
FROM {DriftResultsTable}
WHERE tenant_id = @TenantId AND id = @DriftId
WHERE tenant_id = @p0 AND id = @p1
LIMIT 1
""";
await using var connection = await _dataSource.OpenConnectionAsync(TenantContext, ct).ConfigureAwait(false);
var header = await connection.QuerySingleOrDefaultAsync<DriftHeaderRow>(new CommandDefinition(
sql,
new
{
TenantId,
DriftId = driftId
},
cancellationToken: ct)).ConfigureAwait(false);
await using var connection = await _dataSource.OpenConnectionAsync(tenantScope.TenantContext, ct).ConfigureAwait(false);
await using var dbContext = ScannerDbContextFactory.Create(connection, _dataSource.CommandTimeoutSeconds, SchemaName);
var header = await dbContext.Database.SqlQueryRaw<DriftHeaderRow>(
sql, tenantScope.TenantId, driftId)
.FirstOrDefaultAsync(ct)
.ConfigureAwait(false);
if (header is null)
{
return null;
}
return await LoadResultAsync(connection, header, ct).ConfigureAwait(false);
return await LoadResultAsync(connection, header, tenantScope.TenantId, ct).ConfigureAwait(false);
}
public async Task<bool> ExistsAsync(Guid driftId, CancellationToken ct = default)
public async Task<bool> ExistsAsync(Guid driftId, CancellationToken ct = default, string? tenantId = null)
{
var tenantScope = ScannerTenantScope.Resolve(tenantId);
var sql = $"""
SELECT 1
SELECT CAST(1 AS integer) AS "Value"
FROM {DriftResultsTable}
WHERE tenant_id = @TenantId AND id = @DriftId
WHERE tenant_id = @p0 AND id = @p1
LIMIT 1
""";
await using var connection = await _dataSource.OpenConnectionAsync(TenantContext, ct).ConfigureAwait(false);
var result = await connection.ExecuteScalarAsync<int?>(new CommandDefinition(
sql,
new { TenantId, DriftId = driftId },
cancellationToken: ct)).ConfigureAwait(false);
await using var connection = await _dataSource.OpenConnectionAsync(tenantScope.TenantContext, ct).ConfigureAwait(false);
await using var dbContext = ScannerDbContextFactory.Create(connection, _dataSource.CommandTimeoutSeconds, SchemaName);
return result is not null;
var result = await dbContext.Database.SqlQueryRaw<int>(
sql, tenantScope.TenantId, driftId)
.FirstOrDefaultAsync(ct)
.ConfigureAwait(false);
return result != 0;
}
public async Task<IReadOnlyList<DriftedSink>> ListSinksAsync(
@@ -263,7 +231,8 @@ public sealed class PostgresReachabilityDriftResultRepository : IReachabilityDri
DriftDirection direction,
int offset,
int limit,
CancellationToken ct = default)
CancellationToken ct = default,
string? tenantId = null)
{
if (offset < 0)
{
@@ -274,6 +243,7 @@ public sealed class PostgresReachabilityDriftResultRepository : IReachabilityDri
{
throw new ArgumentOutOfRangeException(nameof(limit));
}
var tenantScope = ScannerTenantScope.Resolve(tenantId);
var sql = $"""
SELECT
@@ -291,28 +261,27 @@ public sealed class PostgresReachabilityDriftResultRepository : IReachabilityDri
compressed_path,
associated_vulns
FROM {DriftedSinksTable}
WHERE tenant_id = @TenantId AND drift_result_id = @DriftId AND direction = @Direction
WHERE tenant_id = @p0 AND drift_result_id = @p1 AND direction = @p2
ORDER BY sink_node_id ASC
OFFSET @Offset LIMIT @Limit
OFFSET @p3 LIMIT @p4
""";
await using var connection = await _dataSource.OpenConnectionAsync(TenantContext, ct).ConfigureAwait(false);
var rows = await connection.QueryAsync<DriftSinkRow>(new CommandDefinition(
sql,
new
{
TenantId,
DriftId = driftId,
Direction = ToDbValue(direction),
Offset = offset,
Limit = limit
},
cancellationToken: ct)).ConfigureAwait(false);
await using var connection = await _dataSource.OpenConnectionAsync(tenantScope.TenantContext, ct).ConfigureAwait(false);
await using var dbContext = ScannerDbContextFactory.Create(connection, _dataSource.CommandTimeoutSeconds, SchemaName);
var rows = await dbContext.Database.SqlQueryRaw<DriftSinkRow>(
sql, tenantScope.TenantId, driftId, ToDbValue(direction), offset, limit)
.ToListAsync(ct)
.ConfigureAwait(false);
return rows.Select(r => r.ToModel(direction)).ToList();
}
private static IEnumerable<object> EnumerateSinkRows(Guid driftId, ImmutableArray<DriftedSink> sinks, DriftDirection direction)
private static IEnumerable<SinkInsertParams> EnumerateSinkParams(
Guid driftId,
Guid tenantId,
ImmutableArray<DriftedSink> sinks,
DriftDirection direction)
{
foreach (var sink in sinks)
{
@@ -321,30 +290,35 @@ public sealed class PostgresReachabilityDriftResultRepository : IReachabilityDri
? null
: JsonSerializer.Serialize(sink.AssociatedVulns, JsonOptions);
yield return new
{
sink.Id,
TenantId,
DriftId = driftId,
SinkNodeId = sink.SinkNodeId,
Symbol = sink.Symbol,
SinkCategory = ToDbValue(sink.SinkCategory),
Direction = ToDbValue(direction),
CauseKind = ToDbValue(sink.Cause.Kind),
CauseDescription = sink.Cause.Description,
CauseSymbol = sink.Cause.ChangedSymbol,
CauseFile = sink.Cause.ChangedFile,
CauseLine = sink.Cause.ChangedLine,
CodeChangeId = sink.Cause.CodeChangeId,
CompressedPath = pathJson,
AssociatedVulns = vulnsJson
};
yield return new SinkInsertParams(
Id: sink.Id,
TenantId: tenantId,
DriftId: driftId,
SinkNodeId: sink.SinkNodeId,
Symbol: sink.Symbol,
SinkCategory: ToDbValue(sink.SinkCategory),
Direction: ToDbValue(direction),
CauseKind: ToDbValue(sink.Cause.Kind),
CauseDescription: sink.Cause.Description,
CauseSymbol: sink.Cause.ChangedSymbol,
CauseFile: sink.Cause.ChangedFile,
CauseLine: sink.Cause.ChangedLine,
CodeChangeId: sink.Cause.CodeChangeId,
CompressedPath: pathJson,
AssociatedVulns: vulnsJson);
}
}
private sealed record SinkInsertParams(
Guid Id, Guid TenantId, Guid DriftId,
string SinkNodeId, string Symbol, string SinkCategory, string Direction,
string CauseKind, string CauseDescription, string? CauseSymbol, string? CauseFile,
int? CauseLine, Guid? CodeChangeId, string CompressedPath, string? AssociatedVulns);
private async Task<ReachabilityDriftResult> LoadResultAsync(
System.Data.IDbConnection connection,
NpgsqlConnection connection,
DriftHeaderRow header,
Guid tenantId,
CancellationToken ct)
{
var sinksSql = $"""
@@ -363,14 +337,16 @@ public sealed class PostgresReachabilityDriftResultRepository : IReachabilityDri
compressed_path,
associated_vulns
FROM {DriftedSinksTable}
WHERE tenant_id = @TenantId AND drift_result_id = @DriftId
WHERE tenant_id = @p0 AND drift_result_id = @p1
ORDER BY direction ASC, sink_node_id ASC
""";
var rows = (await connection.QueryAsync<DriftSinkRow>(new CommandDefinition(
sinksSql,
new { TenantId, DriftId = header.id },
cancellationToken: ct)).ConfigureAwait(false)).ToList();
await using var dbContext = ScannerDbContextFactory.Create(connection, _dataSource.CommandTimeoutSeconds, SchemaName);
var rows = await dbContext.Database.SqlQueryRaw<DriftSinkRow>(
sinksSql, tenantId, header.id)
.ToListAsync(ct)
.ConfigureAwait(false);
var reachable = rows
.Where(r => string.Equals(r.direction, ToDbValue(DriftDirection.BecameReachable), StringComparison.Ordinal))

View File

@@ -1,5 +1,5 @@
using Dapper;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using StellaOps.Scanner.Contracts;
using StellaOps.Scanner.Storage.Repositories;
@@ -9,9 +9,6 @@ namespace StellaOps.Scanner.Storage.Postgres;
public sealed class PostgresReachabilityResultRepository : IReachabilityResultRepository
{
private const string TenantContext = "00000000-0000-0000-0000-000000000001";
private static readonly Guid TenantId = Guid.Parse(TenantContext);
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
WriteIndented = false
@@ -31,32 +28,18 @@ public sealed class PostgresReachabilityResultRepository : IReachabilityResultRe
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task StoreAsync(ReachabilityAnalysisResult result, CancellationToken ct = default)
public async Task StoreAsync(ReachabilityAnalysisResult result, CancellationToken ct = default, string? tenantId = null)
{
ArgumentNullException.ThrowIfNull(result);
var trimmed = result.Trimmed();
var tenantScope = ScannerTenantScope.Resolve(tenantId);
var sql = $"""
INSERT INTO {ReachabilityResultsTable} (
tenant_id,
scan_id,
language,
graph_digest,
result_digest,
computed_at,
reachable_node_count,
reachable_sink_count,
result_json
tenant_id, scan_id, language, graph_digest, result_digest,
computed_at, reachable_node_count, reachable_sink_count, result_json
) VALUES (
@TenantId,
@ScanId,
@Language,
@GraphDigest,
@ResultDigest,
@ComputedAt,
@ReachableNodeCount,
@ReachableSinkCount,
@ResultJson::jsonb
@p0, @p1, @p2, @p3, @p4, @p5, @p6, @p7, @p8::jsonb
)
ON CONFLICT (tenant_id, scan_id, language, graph_digest, result_digest) DO UPDATE SET
computed_at = EXCLUDED.computed_at,
@@ -67,19 +50,19 @@ public sealed class PostgresReachabilityResultRepository : IReachabilityResultRe
var json = JsonSerializer.Serialize(trimmed, JsonOptions);
await using var connection = await _dataSource.OpenConnectionAsync(TenantContext, ct).ConfigureAwait(false);
await connection.ExecuteAsync(new CommandDefinition(sql, new
{
TenantId = TenantId,
ScanId = trimmed.ScanId,
Language = trimmed.Language,
GraphDigest = trimmed.GraphDigest,
ResultDigest = trimmed.ResultDigest,
ComputedAt = trimmed.ComputedAt.UtcDateTime,
ReachableNodeCount = trimmed.ReachableNodeIds.Length,
ReachableSinkCount = trimmed.ReachableSinkIds.Length,
ResultJson = json
}, cancellationToken: ct)).ConfigureAwait(false);
await using var connection = await _dataSource.OpenConnectionAsync(tenantScope.TenantContext, ct).ConfigureAwait(false);
await using var dbContext = ScannerDbContextFactory.Create(connection, _dataSource.CommandTimeoutSeconds, SchemaName);
await dbContext.Database.ExecuteSqlRawAsync(
sql,
[
tenantScope.TenantId, trimmed.ScanId, trimmed.Language,
trimmed.GraphDigest, trimmed.ResultDigest,
trimmed.ComputedAt.UtcDateTime,
trimmed.ReachableNodeIds.Length, trimmed.ReachableSinkIds.Length,
json
],
ct).ConfigureAwait(false);
_logger.LogDebug(
"Stored reachability result scan={ScanId} lang={Language} sinks={Sinks}",
@@ -88,26 +71,27 @@ public sealed class PostgresReachabilityResultRepository : IReachabilityResultRe
trimmed.ReachableSinkIds.Length);
}
public async Task<ReachabilityAnalysisResult?> TryGetLatestAsync(string scanId, string language, CancellationToken ct = default)
public async Task<ReachabilityAnalysisResult?> TryGetLatestAsync(string scanId, string language, CancellationToken ct = default, string? tenantId = null)
{
ArgumentException.ThrowIfNullOrWhiteSpace(scanId);
ArgumentException.ThrowIfNullOrWhiteSpace(language);
var tenantScope = ScannerTenantScope.Resolve(tenantId);
var sql = $"""
SELECT result_json
FROM {ReachabilityResultsTable}
WHERE tenant_id = @TenantId AND scan_id = @ScanId AND language = @Language
WHERE tenant_id = @p0 AND scan_id = @p1 AND language = @p2
ORDER BY computed_at DESC
LIMIT 1
""";
await using var connection = await _dataSource.OpenConnectionAsync(TenantContext, ct).ConfigureAwait(false);
var json = await connection.ExecuteScalarAsync<string?>(new CommandDefinition(sql, new
{
TenantId = TenantId,
ScanId = scanId,
Language = language
}, cancellationToken: ct)).ConfigureAwait(false);
await using var connection = await _dataSource.OpenConnectionAsync(tenantScope.TenantContext, ct).ConfigureAwait(false);
await using var dbContext = ScannerDbContextFactory.Create(connection, _dataSource.CommandTimeoutSeconds, SchemaName);
var json = await dbContext.Database.SqlQueryRaw<string>(
sql, tenantScope.TenantId, scanId, language)
.FirstOrDefaultAsync(ct)
.ConfigureAwait(false);
if (string.IsNullOrWhiteSpace(json))
{
@@ -117,4 +101,3 @@ public sealed class PostgresReachabilityResultRepository : IReachabilityResultRe
return JsonSerializer.Deserialize<ReachabilityAnalysisResult>(json, JsonOptions);
}
}

View File

@@ -1,5 +1,5 @@
using Dapper;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Npgsql;
using StellaOps.Scanner.SmartDiff.Detection;
@@ -13,9 +13,6 @@ namespace StellaOps.Scanner.Storage.Postgres;
/// </summary>
public sealed class PostgresRiskStateRepository : IRiskStateRepository
{
private const string TenantContext = "00000000-0000-0000-0000-000000000001";
private static readonly Guid TenantId = Guid.Parse(TenantContext);
private readonly ScannerDataSource _dataSource;
private readonly ILogger<PostgresRiskStateRepository> _logger;
@@ -30,15 +27,16 @@ public sealed class PostgresRiskStateRepository : IRiskStateRepository
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task StoreSnapshotAsync(RiskStateSnapshot snapshot, CancellationToken ct = default)
public async Task StoreSnapshotAsync(RiskStateSnapshot snapshot, CancellationToken ct = default, string? tenantId = null)
{
ArgumentNullException.ThrowIfNull(snapshot);
var tenantScope = ScannerTenantScope.Resolve(tenantId);
await using var connection = await _dataSource.OpenConnectionAsync(TenantContext, ct).ConfigureAwait(false);
await InsertSnapshotAsync(connection, snapshot, ct).ConfigureAwait(false);
await using var connection = await _dataSource.OpenConnectionAsync(tenantScope.TenantContext, ct).ConfigureAwait(false);
await InsertSnapshotAsync(connection, snapshot, tenantScope.TenantId, ct).ConfigureAwait(false);
}
public async Task StoreSnapshotsAsync(IReadOnlyList<RiskStateSnapshot> snapshots, CancellationToken ct = default)
public async Task StoreSnapshotsAsync(IReadOnlyList<RiskStateSnapshot> snapshots, CancellationToken ct = default, string? tenantId = null)
{
ArgumentNullException.ThrowIfNull(snapshots);
@@ -47,14 +45,16 @@ public sealed class PostgresRiskStateRepository : IRiskStateRepository
return;
}
await using var connection = await _dataSource.OpenConnectionAsync(TenantContext, ct).ConfigureAwait(false);
var tenantScope = ScannerTenantScope.Resolve(tenantId);
await using var connection = await _dataSource.OpenConnectionAsync(tenantScope.TenantContext, ct).ConfigureAwait(false);
await using var transaction = await connection.BeginTransactionAsync(ct).ConfigureAwait(false);
try
{
foreach (var snapshot in snapshots)
{
await InsertSnapshotAsync(connection, snapshot, ct, transaction).ConfigureAwait(false);
await InsertSnapshotAsync(connection, snapshot, tenantScope.TenantId, ct, transaction).ConfigureAwait(false);
}
await transaction.CommitAsync(ct).ConfigureAwait(false);
@@ -66,51 +66,58 @@ public sealed class PostgresRiskStateRepository : IRiskStateRepository
}
}
public async Task<RiskStateSnapshot?> GetLatestSnapshotAsync(FindingKey findingKey, CancellationToken ct = default)
public async Task<RiskStateSnapshot?> GetLatestSnapshotAsync(FindingKey findingKey, CancellationToken ct = default, string? tenantId = null)
{
ArgumentNullException.ThrowIfNull(findingKey);
var tenantScope = ScannerTenantScope.Resolve(tenantId);
var sql = $"""
SELECT
vuln_id, purl, scan_id, captured_at,
reachable, lattice_state, vex_status::TEXT, in_affected_range,
kev, epss_score, policy_flags, policy_decision::TEXT, state_hash
reachable, lattice_state, vex_status::TEXT AS vex_status, in_affected_range,
kev, epss_score, policy_flags, policy_decision::TEXT AS policy_decision, state_hash
FROM {RiskStateSnapshotsTable}
WHERE tenant_id = @TenantId
AND vuln_id = @VulnId
AND purl = @Purl
WHERE tenant_id = @p0
AND vuln_id = @p1
AND purl = @p2
ORDER BY captured_at DESC
LIMIT 1
""";
await using var connection = await _dataSource.OpenConnectionAsync(TenantContext, ct).ConfigureAwait(false);
var row = await connection.QuerySingleOrDefaultAsync<RiskStateRow>(sql, new
{
TenantId,
VulnId = findingKey.VulnId,
Purl = findingKey.ComponentPurl
});
await using var connection = await _dataSource.OpenConnectionAsync(tenantScope.TenantContext, ct).ConfigureAwait(false);
await using var dbContext = ScannerDbContextFactory.Create(connection, _dataSource.CommandTimeoutSeconds, SchemaName);
var row = await dbContext.Database.SqlQueryRaw<RiskStateRow>(
sql, tenantScope.TenantId, findingKey.VulnId, findingKey.ComponentPurl)
.FirstOrDefaultAsync(ct)
.ConfigureAwait(false);
return row?.ToSnapshot();
}
public async Task<IReadOnlyList<RiskStateSnapshot>> GetSnapshotsForScanAsync(string scanId, CancellationToken ct = default)
public async Task<IReadOnlyList<RiskStateSnapshot>> GetSnapshotsForScanAsync(string scanId, CancellationToken ct = default, string? tenantId = null)
{
ArgumentException.ThrowIfNullOrWhiteSpace(scanId);
var tenantScope = ScannerTenantScope.Resolve(tenantId);
var sql = $"""
SELECT
vuln_id, purl, scan_id, captured_at,
reachable, lattice_state, vex_status::TEXT, in_affected_range,
kev, epss_score, policy_flags, policy_decision::TEXT, state_hash
reachable, lattice_state, vex_status::TEXT AS vex_status, in_affected_range,
kev, epss_score, policy_flags, policy_decision::TEXT AS policy_decision, state_hash
FROM {RiskStateSnapshotsTable}
WHERE tenant_id = @TenantId
AND scan_id = @ScanId
WHERE tenant_id = @p0
AND scan_id = @p1
ORDER BY vuln_id, purl
""";
await using var connection = await _dataSource.OpenConnectionAsync(TenantContext, ct).ConfigureAwait(false);
var rows = await connection.QueryAsync<RiskStateRow>(sql, new { TenantId, ScanId = scanId.Trim() });
await using var connection = await _dataSource.OpenConnectionAsync(tenantScope.TenantContext, ct).ConfigureAwait(false);
await using var dbContext = ScannerDbContextFactory.Create(connection, _dataSource.CommandTimeoutSeconds, SchemaName);
var rows = await dbContext.Database.SqlQueryRaw<RiskStateRow>(
sql, tenantScope.TenantId, scanId.Trim())
.ToListAsync(ct)
.ConfigureAwait(false);
return rows.Select(r => r.ToSnapshot()).ToList();
}
@@ -118,53 +125,60 @@ public sealed class PostgresRiskStateRepository : IRiskStateRepository
public async Task<IReadOnlyList<RiskStateSnapshot>> GetSnapshotHistoryAsync(
FindingKey findingKey,
int limit = 10,
CancellationToken ct = default)
CancellationToken ct = default,
string? tenantId = null)
{
ArgumentNullException.ThrowIfNull(findingKey);
ArgumentOutOfRangeException.ThrowIfLessThan(limit, 1);
var tenantScope = ScannerTenantScope.Resolve(tenantId);
var sql = $"""
SELECT
vuln_id, purl, scan_id, captured_at,
reachable, lattice_state, vex_status::TEXT, in_affected_range,
kev, epss_score, policy_flags, policy_decision::TEXT, state_hash
reachable, lattice_state, vex_status::TEXT AS vex_status, in_affected_range,
kev, epss_score, policy_flags, policy_decision::TEXT AS policy_decision, state_hash
FROM {RiskStateSnapshotsTable}
WHERE tenant_id = @TenantId
AND vuln_id = @VulnId
AND purl = @Purl
WHERE tenant_id = @p0
AND vuln_id = @p1
AND purl = @p2
ORDER BY captured_at DESC
LIMIT @Limit
LIMIT @p3
""";
await using var connection = await _dataSource.OpenConnectionAsync(TenantContext, ct).ConfigureAwait(false);
var rows = await connection.QueryAsync<RiskStateRow>(sql, new
{
TenantId,
VulnId = findingKey.VulnId,
Purl = findingKey.ComponentPurl,
Limit = limit
});
await using var connection = await _dataSource.OpenConnectionAsync(tenantScope.TenantContext, ct).ConfigureAwait(false);
await using var dbContext = ScannerDbContextFactory.Create(connection, _dataSource.CommandTimeoutSeconds, SchemaName);
var rows = await dbContext.Database.SqlQueryRaw<RiskStateRow>(
sql, tenantScope.TenantId, findingKey.VulnId, findingKey.ComponentPurl, limit)
.ToListAsync(ct)
.ConfigureAwait(false);
return rows.Select(r => r.ToSnapshot()).ToList();
}
public async Task<IReadOnlyList<RiskStateSnapshot>> GetSnapshotsByHashAsync(string stateHash, CancellationToken ct = default)
public async Task<IReadOnlyList<RiskStateSnapshot>> GetSnapshotsByHashAsync(string stateHash, CancellationToken ct = default, string? tenantId = null)
{
ArgumentException.ThrowIfNullOrWhiteSpace(stateHash);
var tenantScope = ScannerTenantScope.Resolve(tenantId);
var sql = $"""
SELECT
vuln_id, purl, scan_id, captured_at,
reachable, lattice_state, vex_status::TEXT, in_affected_range,
kev, epss_score, policy_flags, policy_decision::TEXT, state_hash
reachable, lattice_state, vex_status::TEXT AS vex_status, in_affected_range,
kev, epss_score, policy_flags, policy_decision::TEXT AS policy_decision, state_hash
FROM {RiskStateSnapshotsTable}
WHERE tenant_id = @TenantId
AND state_hash = @StateHash
WHERE tenant_id = @p0
AND state_hash = @p1
ORDER BY captured_at DESC
""";
await using var connection = await _dataSource.OpenConnectionAsync(TenantContext, ct).ConfigureAwait(false);
var rows = await connection.QueryAsync<RiskStateRow>(sql, new { TenantId, StateHash = stateHash.Trim() });
await using var connection = await _dataSource.OpenConnectionAsync(tenantScope.TenantContext, ct).ConfigureAwait(false);
await using var dbContext = ScannerDbContextFactory.Create(connection, _dataSource.CommandTimeoutSeconds, SchemaName);
var rows = await dbContext.Database.SqlQueryRaw<RiskStateRow>(
sql, tenantScope.TenantId, stateHash.Trim())
.ToListAsync(ct)
.ConfigureAwait(false);
return rows.Select(r => r.ToSnapshot()).ToList();
}
@@ -172,6 +186,7 @@ public sealed class PostgresRiskStateRepository : IRiskStateRepository
private async Task InsertSnapshotAsync(
NpgsqlConnection connection,
RiskStateSnapshot snapshot,
Guid tenantId,
CancellationToken ct,
NpgsqlTransaction? transaction = null)
{
@@ -183,9 +198,8 @@ public sealed class PostgresRiskStateRepository : IRiskStateRepository
reachable, lattice_state, vex_status, in_affected_range,
kev, epss_score, policy_flags, policy_decision, state_hash
) VALUES (
@TenantId, @VulnId, @Purl, @ScanId, @CapturedAt,
@Reachable, @LatticeState, @VexStatus::vex_status_type, @InAffectedRange,
@Kev, @EpssScore, @PolicyFlags, @PolicyDecision::policy_decision_type, @StateHash
$1, $2, $3, $4, $5, $6, $7, $8::vex_status_type, $9,
$10, $11, $12, $13::policy_decision_type, $14
)
ON CONFLICT (tenant_id, scan_id, vuln_id, purl) DO UPDATE SET
reachable = EXCLUDED.reachable,
@@ -199,27 +213,23 @@ public sealed class PostgresRiskStateRepository : IRiskStateRepository
state_hash = EXCLUDED.state_hash
""";
await connection.ExecuteAsync(new CommandDefinition(
sql,
new
{
TenantId,
VulnId = snapshot.FindingKey.VulnId,
Purl = snapshot.FindingKey.ComponentPurl,
ScanId = snapshot.ScanId,
CapturedAt = snapshot.CapturedAt,
Reachable = snapshot.Reachable,
LatticeState = snapshot.LatticeState,
VexStatus = snapshot.VexStatus.ToString().ToLowerInvariant(),
InAffectedRange = snapshot.InAffectedRange,
Kev = snapshot.Kev,
EpssScore = snapshot.EpssScore,
PolicyFlags = snapshot.PolicyFlags.ToArray(),
PolicyDecision = snapshot.PolicyDecision?.ToString().ToLowerInvariant(),
StateHash = snapshot.ComputeStateHash()
},
transaction: transaction,
cancellationToken: ct)).ConfigureAwait(false);
await using var cmd = new NpgsqlCommand(sql, connection, transaction);
cmd.Parameters.AddWithValue(tenantId);
cmd.Parameters.AddWithValue(snapshot.FindingKey.VulnId);
cmd.Parameters.AddWithValue(snapshot.FindingKey.ComponentPurl);
cmd.Parameters.AddWithValue(snapshot.ScanId);
cmd.Parameters.AddWithValue(snapshot.CapturedAt);
cmd.Parameters.AddWithValue(snapshot.Reachable.HasValue ? snapshot.Reachable.Value : DBNull.Value);
cmd.Parameters.AddWithValue(snapshot.LatticeState is null ? DBNull.Value : snapshot.LatticeState);
cmd.Parameters.AddWithValue(snapshot.VexStatus.ToString().ToLowerInvariant());
cmd.Parameters.AddWithValue(snapshot.InAffectedRange.HasValue ? snapshot.InAffectedRange.Value : DBNull.Value);
cmd.Parameters.AddWithValue(snapshot.Kev);
cmd.Parameters.AddWithValue(snapshot.EpssScore.HasValue ? snapshot.EpssScore.Value : DBNull.Value);
cmd.Parameters.AddWithValue(snapshot.PolicyFlags.ToArray());
cmd.Parameters.AddWithValue(snapshot.PolicyDecision?.ToString().ToLowerInvariant() ?? (object)DBNull.Value);
cmd.Parameters.AddWithValue(snapshot.ComputeStateHash());
await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
}
/// <summary>

View File

@@ -1,4 +1,5 @@
using Dapper;
using Microsoft.EntityFrameworkCore;
using StellaOps.Scanner.Storage.EfCore.Models;
using StellaOps.Scanner.Storage.Entities;
using StellaOps.Scanner.Storage.Repositories;
@@ -6,12 +7,12 @@ namespace StellaOps.Scanner.Storage.Postgres;
/// <summary>
/// PostgreSQL implementation of scan manifest repository.
/// Converted from Dapper to EF Core; complex SQL kept as raw where needed.
/// </summary>
public sealed class PostgresScanManifestRepository : IScanManifestRepository
{
private readonly ScannerDataSource _dataSource;
private string SchemaName => _dataSource.SchemaName ?? ScannerDataSource.DefaultSchema;
private string TableName => $"{SchemaName}.scan_manifest";
public PostgresScanManifestRepository(ScannerDataSource dataSource)
{
@@ -20,112 +21,97 @@ public sealed class PostgresScanManifestRepository : IScanManifestRepository
public async Task<ScanManifestRow?> GetByHashAsync(string manifestHash, CancellationToken cancellationToken = default)
{
var sql = $"""
SELECT
manifest_id AS ManifestId,
scan_id AS ScanId,
manifest_hash AS ManifestHash,
sbom_hash AS SbomHash,
rules_hash AS RulesHash,
feed_hash AS FeedHash,
policy_hash AS PolicyHash,
scan_started_at AS ScanStartedAt,
scan_completed_at AS ScanCompletedAt,
manifest_content AS ManifestContent,
scanner_version AS ScannerVersion,
created_at AS CreatedAt
FROM {TableName}
WHERE manifest_hash = @ManifestHash
ORDER BY created_at DESC
LIMIT 1
""";
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
return await connection.QueryFirstOrDefaultAsync<ScanManifestRow>(
new CommandDefinition(sql, new { ManifestHash = manifestHash }, cancellationToken: cancellationToken))
await using var dbContext = ScannerDbContextFactory.Create(connection, _dataSource.CommandTimeoutSeconds, SchemaName);
var entity = await dbContext.ScanManifests
.AsNoTracking()
.Where(e => e.ManifestHash == manifestHash)
.OrderByDescending(e => e.CreatedAt)
.FirstOrDefaultAsync(cancellationToken)
.ConfigureAwait(false);
return entity is null ? null : MapToRow(entity);
}
public async Task<ScanManifestRow?> GetByScanIdAsync(Guid scanId, CancellationToken cancellationToken = default)
{
var sql = $"""
SELECT
manifest_id AS ManifestId,
scan_id AS ScanId,
manifest_hash AS ManifestHash,
sbom_hash AS SbomHash,
rules_hash AS RulesHash,
feed_hash AS FeedHash,
policy_hash AS PolicyHash,
scan_started_at AS ScanStartedAt,
scan_completed_at AS ScanCompletedAt,
manifest_content AS ManifestContent,
scanner_version AS ScannerVersion,
created_at AS CreatedAt
FROM {TableName}
WHERE scan_id = @ScanId
ORDER BY created_at DESC
LIMIT 1
""";
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
return await connection.QuerySingleOrDefaultAsync<ScanManifestRow>(
new CommandDefinition(sql, new { ScanId = scanId }, cancellationToken: cancellationToken))
await using var dbContext = ScannerDbContextFactory.Create(connection, _dataSource.CommandTimeoutSeconds, SchemaName);
var entity = await dbContext.ScanManifests
.AsNoTracking()
.Where(e => e.ScanId == scanId)
.OrderByDescending(e => e.CreatedAt)
.FirstOrDefaultAsync(cancellationToken)
.ConfigureAwait(false);
return entity is null ? null : MapToRow(entity);
}
public async Task<ScanManifestRow> SaveAsync(ScanManifestRow manifest, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(manifest);
// Use raw SQL for INSERT RETURNING + jsonb cast which EF Core does not natively support.
var sql = $"""
INSERT INTO {TableName} (
scan_id,
manifest_hash,
sbom_hash,
rules_hash,
feed_hash,
policy_hash,
scan_started_at,
scan_completed_at,
manifest_content,
scanner_version
INSERT INTO {SchemaName}.scan_manifest (
scan_id, manifest_hash, sbom_hash, rules_hash, feed_hash,
policy_hash, scan_started_at, scan_completed_at, manifest_content, scanner_version
) VALUES (
@ScanId,
@ManifestHash,
@SbomHash,
@RulesHash,
@FeedHash,
@PolicyHash,
@ScanStartedAt,
@ScanCompletedAt,
@ManifestContent::jsonb,
@ScannerVersion
@p0, @p1, @p2, @p3, @p4, @p5, @p6, @p7, @p8::jsonb, @p9
)
RETURNING manifest_id AS ManifestId, created_at AS CreatedAt
RETURNING manifest_id, created_at
""";
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
var result = await connection.QuerySingleAsync<(Guid ManifestId, DateTimeOffset CreatedAt)>(
new CommandDefinition(sql, manifest, cancellationToken: cancellationToken))
await using var dbContext = ScannerDbContextFactory.Create(connection, _dataSource.CommandTimeoutSeconds, SchemaName);
var result = await dbContext.Database.SqlQueryRaw<ManifestInsertResult>(
sql,
manifest.ScanId, manifest.ManifestHash, manifest.SbomHash, manifest.RulesHash,
manifest.FeedHash, manifest.PolicyHash, manifest.ScanStartedAt,
(object?)manifest.ScanCompletedAt ?? DBNull.Value,
manifest.ManifestContent, manifest.ScannerVersion)
.FirstAsync(cancellationToken)
.ConfigureAwait(false);
manifest.ManifestId = result.ManifestId;
manifest.CreatedAt = result.CreatedAt;
manifest.ManifestId = result.manifest_id;
manifest.CreatedAt = result.created_at;
return manifest;
}
public async Task MarkCompletedAsync(Guid manifestId, DateTimeOffset completedAt, CancellationToken cancellationToken = default)
{
var sql = $"""
UPDATE {TableName}
SET scan_completed_at = @CompletedAt
WHERE manifest_id = @ManifestId
""";
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await connection.ExecuteAsync(
new CommandDefinition(sql, new { ManifestId = manifestId, CompletedAt = completedAt }, cancellationToken: cancellationToken))
await using var dbContext = ScannerDbContextFactory.Create(connection, _dataSource.CommandTimeoutSeconds, SchemaName);
await dbContext.ScanManifests
.Where(e => e.ManifestId == manifestId)
.ExecuteUpdateAsync(s => s.SetProperty(e => e.ScanCompletedAt, completedAt), cancellationToken)
.ConfigureAwait(false);
}
private static ScanManifestRow MapToRow(ScanManifestEntity entity) => new()
{
ManifestId = entity.ManifestId,
ScanId = entity.ScanId,
ManifestHash = entity.ManifestHash,
SbomHash = entity.SbomHash,
RulesHash = entity.RulesHash,
FeedHash = entity.FeedHash,
PolicyHash = entity.PolicyHash,
ScanStartedAt = entity.ScanStartedAt,
ScanCompletedAt = entity.ScanCompletedAt,
ManifestContent = entity.ManifestContent,
ScannerVersion = entity.ScannerVersion,
CreatedAt = entity.CreatedAt
};
// Internal record for raw SQL result mapping.
private sealed record ManifestInsertResult
{
public Guid manifest_id { get; init; }
public DateTimeOffset created_at { get; init; }
}
}

View File

@@ -3,9 +3,11 @@
// Sprint: SPRINT_20260104_006_BE (Secret Detection Configuration API)
// Task: SDC-004 - Add persistence
// Description: PostgreSQL implementation for secret detection settings.
// Converted from Dapper to EF Core; jsonb casts and optimistic concurrency kept as raw SQL.
// -----------------------------------------------------------------------------
using Dapper;
using Microsoft.EntityFrameworkCore;
using StellaOps.Scanner.Storage.EfCore.Models;
using StellaOps.Scanner.Storage.Entities;
using StellaOps.Scanner.Storage.Repositories;
@@ -13,12 +15,12 @@ namespace StellaOps.Scanner.Storage.Postgres;
/// <summary>
/// PostgreSQL implementation of secret detection settings repository.
/// Converted from Dapper to EF Core; jsonb casts and optimistic concurrency kept as raw SQL.
/// </summary>
public sealed class PostgresSecretDetectionSettingsRepository : ISecretDetectionSettingsRepository
{
private readonly ScannerDataSource _dataSource;
private string SchemaName => _dataSource.SchemaName ?? ScannerDataSource.DefaultSchema;
private string TableName => $"{SchemaName}.secret_detection_settings";
public PostgresSecretDetectionSettingsRepository(ScannerDataSource dataSource)
{
@@ -29,32 +31,15 @@ public sealed class PostgresSecretDetectionSettingsRepository : ISecretDetection
Guid tenantId,
CancellationToken cancellationToken = default)
{
var sql = $"""
SELECT
settings_id AS SettingsId,
tenant_id AS TenantId,
enabled AS Enabled,
revelation_policy AS RevelationPolicy,
enabled_rule_categories AS EnabledRuleCategories,
disabled_rule_ids AS DisabledRuleIds,
alert_settings AS AlertSettings,
max_file_size_bytes AS MaxFileSizeBytes,
excluded_file_extensions AS ExcludedFileExtensions,
excluded_paths AS ExcludedPaths,
scan_binary_files AS ScanBinaryFiles,
require_signed_rule_bundles AS RequireSignedRuleBundles,
version AS Version,
updated_at AS UpdatedAt,
updated_by AS UpdatedBy,
created_at AS CreatedAt
FROM {TableName}
WHERE tenant_id = @TenantId
""";
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
return await connection.QuerySingleOrDefaultAsync<SecretDetectionSettingsRow>(
new CommandDefinition(sql, new { TenantId = tenantId }, cancellationToken: cancellationToken))
await using var dbContext = ScannerDbContextFactory.Create(connection, _dataSource.CommandTimeoutSeconds, SchemaName);
var entity = await dbContext.SecretDetectionSettings
.AsNoTracking()
.FirstOrDefaultAsync(e => e.TenantId == tenantId, cancellationToken)
.ConfigureAwait(false);
return entity is null ? null : MapToRow(entity);
}
public async Task<SecretDetectionSettingsRow> CreateAsync(
@@ -63,46 +48,42 @@ public sealed class PostgresSecretDetectionSettingsRepository : ISecretDetection
{
ArgumentNullException.ThrowIfNull(settings);
// Keep raw SQL for INSERT RETURNING + jsonb casts.
var sql = $"""
INSERT INTO {TableName} (
tenant_id,
enabled,
revelation_policy,
enabled_rule_categories,
disabled_rule_ids,
alert_settings,
max_file_size_bytes,
excluded_file_extensions,
excluded_paths,
scan_binary_files,
require_signed_rule_bundles,
updated_by
INSERT INTO {SchemaName}.secret_detection_settings (
tenant_id, enabled, revelation_policy,
enabled_rule_categories, disabled_rule_ids,
alert_settings, max_file_size_bytes,
excluded_file_extensions, excluded_paths,
scan_binary_files, require_signed_rule_bundles, updated_by
) VALUES (
@TenantId,
@Enabled,
@RevelationPolicy::jsonb,
@EnabledRuleCategories,
@DisabledRuleIds,
@AlertSettings::jsonb,
@MaxFileSizeBytes,
@ExcludedFileExtensions,
@ExcludedPaths,
@ScanBinaryFiles,
@RequireSignedRuleBundles,
@UpdatedBy
@p0, @p1, @p2::jsonb, @p3, @p4, @p5::jsonb, @p6, @p7, @p8, @p9, @p10, @p11
)
RETURNING settings_id AS SettingsId, version AS Version, created_at AS CreatedAt, updated_at AS UpdatedAt
RETURNING settings_id, version, created_at, updated_at
""";
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
var result = await connection.QuerySingleAsync<(Guid SettingsId, int Version, DateTimeOffset CreatedAt, DateTimeOffset UpdatedAt)>(
new CommandDefinition(sql, settings, cancellationToken: cancellationToken))
await using var dbContext = ScannerDbContextFactory.Create(connection, _dataSource.CommandTimeoutSeconds, SchemaName);
var result = await dbContext.Database.SqlQueryRaw<SettingsInsertResult>(
sql,
settings.TenantId, settings.Enabled,
(object?)settings.RevelationPolicy ?? DBNull.Value,
(object?)settings.EnabledRuleCategories ?? DBNull.Value,
(object?)settings.DisabledRuleIds ?? DBNull.Value,
(object?)settings.AlertSettings ?? DBNull.Value,
settings.MaxFileSizeBytes,
(object?)settings.ExcludedFileExtensions ?? DBNull.Value,
(object?)settings.ExcludedPaths ?? DBNull.Value,
settings.ScanBinaryFiles, settings.RequireSignedRuleBundles,
(object?)settings.UpdatedBy ?? DBNull.Value)
.FirstAsync(cancellationToken)
.ConfigureAwait(false);
settings.SettingsId = result.SettingsId;
settings.Version = result.Version;
settings.CreatedAt = result.CreatedAt;
settings.UpdatedAt = result.UpdatedAt;
settings.SettingsId = result.settings_id;
settings.Version = result.version;
settings.CreatedAt = result.created_at;
settings.UpdatedAt = result.updated_at;
return settings;
}
@@ -113,43 +94,45 @@ public sealed class PostgresSecretDetectionSettingsRepository : ISecretDetection
{
ArgumentNullException.ThrowIfNull(settings);
// Keep raw SQL for optimistic concurrency + jsonb casts.
var sql = $"""
UPDATE {TableName}
UPDATE {SchemaName}.secret_detection_settings
SET
enabled = @Enabled,
revelation_policy = @RevelationPolicy::jsonb,
enabled_rule_categories = @EnabledRuleCategories,
disabled_rule_ids = @DisabledRuleIds,
alert_settings = @AlertSettings::jsonb,
max_file_size_bytes = @MaxFileSizeBytes,
excluded_file_extensions = @ExcludedFileExtensions,
excluded_paths = @ExcludedPaths,
scan_binary_files = @ScanBinaryFiles,
require_signed_rule_bundles = @RequireSignedRuleBundles,
enabled = @p0,
revelation_policy = @p1::jsonb,
enabled_rule_categories = @p2,
disabled_rule_ids = @p3,
alert_settings = @p4::jsonb,
max_file_size_bytes = @p5,
excluded_file_extensions = @p6,
excluded_paths = @p7,
scan_binary_files = @p8,
require_signed_rule_bundles = @p9,
version = version + 1,
updated_at = NOW(),
updated_by = @UpdatedBy
WHERE settings_id = @SettingsId AND version = @ExpectedVersion
updated_by = @p10
WHERE settings_id = @p11 AND version = @p12
""";
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
var rowsAffected = await connection.ExecuteAsync(
new CommandDefinition(sql, new
{
settings.SettingsId,
await using var dbContext = ScannerDbContextFactory.Create(connection, _dataSource.CommandTimeoutSeconds, SchemaName);
var rowsAffected = await dbContext.Database.ExecuteSqlRawAsync(
sql,
[
settings.Enabled,
settings.RevelationPolicy,
settings.EnabledRuleCategories,
settings.DisabledRuleIds,
settings.AlertSettings,
(object?)settings.RevelationPolicy ?? DBNull.Value,
(object?)settings.EnabledRuleCategories ?? DBNull.Value,
(object?)settings.DisabledRuleIds ?? DBNull.Value,
(object?)settings.AlertSettings ?? DBNull.Value,
settings.MaxFileSizeBytes,
settings.ExcludedFileExtensions,
settings.ExcludedPaths,
settings.ScanBinaryFiles,
settings.RequireSignedRuleBundles,
settings.UpdatedBy,
ExpectedVersion = expectedVersion
}, cancellationToken: cancellationToken))
(object?)settings.ExcludedFileExtensions ?? DBNull.Value,
(object?)settings.ExcludedPaths ?? DBNull.Value,
settings.ScanBinaryFiles, settings.RequireSignedRuleBundles,
(object?)settings.UpdatedBy ?? DBNull.Value,
settings.SettingsId, expectedVersion
],
cancellationToken)
.ConfigureAwait(false);
return rowsAffected > 0;
@@ -157,24 +140,50 @@ public sealed class PostgresSecretDetectionSettingsRepository : ISecretDetection
public async Task<IReadOnlyList<Guid>> GetEnabledTenantsAsync(CancellationToken cancellationToken = default)
{
var sql = $"""
SELECT tenant_id
FROM {TableName}
WHERE enabled = TRUE
ORDER BY tenant_id
""";
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
var result = await connection.QueryAsync<Guid>(
new CommandDefinition(sql, cancellationToken: cancellationToken))
.ConfigureAwait(false);
await using var dbContext = ScannerDbContextFactory.Create(connection, _dataSource.CommandTimeoutSeconds, SchemaName);
return result.ToList();
return await dbContext.SecretDetectionSettings
.AsNoTracking()
.Where(e => e.Enabled)
.OrderBy(e => e.TenantId)
.Select(e => e.TenantId)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
}
private static SecretDetectionSettingsRow MapToRow(SecretDetectionSettingsEntity e) => new()
{
SettingsId = e.SettingsId,
TenantId = e.TenantId,
Enabled = e.Enabled,
RevelationPolicy = e.RevelationPolicy ?? string.Empty,
EnabledRuleCategories = e.EnabledRuleCategories ?? [],
DisabledRuleIds = e.DisabledRuleIds ?? [],
AlertSettings = e.AlertSettings ?? string.Empty,
MaxFileSizeBytes = e.MaxFileSizeBytes,
ExcludedFileExtensions = e.ExcludedFileExtensions ?? [],
ExcludedPaths = e.ExcludedPaths ?? [],
ScanBinaryFiles = e.ScanBinaryFiles,
RequireSignedRuleBundles = e.RequireSignedRuleBundles,
Version = e.Version,
UpdatedAt = e.UpdatedAt,
UpdatedBy = e.UpdatedBy ?? string.Empty,
CreatedAt = e.CreatedAt
};
private sealed record SettingsInsertResult
{
public Guid settings_id { get; init; }
public int version { get; init; }
public DateTimeOffset created_at { get; init; }
public DateTimeOffset updated_at { get; init; }
}
}
/// <summary>
/// PostgreSQL implementation of secret exception pattern repository.
/// Converted from Dapper to EF Core raw SQL (tables not modeled in DbContext).
/// </summary>
public sealed class PostgresSecretExceptionPatternRepository : ISecretExceptionPatternRepository
{
@@ -194,66 +203,69 @@ public sealed class PostgresSecretExceptionPatternRepository : ISecretExceptionP
{
var sql = $"""
SELECT
exception_id AS ExceptionId,
tenant_id AS TenantId,
name AS Name,
description AS Description,
value_pattern AS ValuePattern,
applicable_rule_ids AS ApplicableRuleIds,
file_path_glob AS FilePathGlob,
justification AS Justification,
expires_at AS ExpiresAt,
is_active AS IsActive,
match_count AS MatchCount,
last_matched_at AS LastMatchedAt,
created_at AS CreatedAt,
created_by AS CreatedBy,
updated_at AS UpdatedAt,
updated_by AS UpdatedBy
exception_id AS "ExceptionId",
tenant_id AS "TenantId",
name AS "Name",
description AS "Description",
value_pattern AS "ValuePattern",
applicable_rule_ids AS "ApplicableRuleIds",
file_path_glob AS "FilePathGlob",
justification AS "Justification",
expires_at AS "ExpiresAt",
is_active AS "IsActive",
match_count AS "MatchCount",
last_matched_at AS "LastMatchedAt",
created_at AS "CreatedAt",
created_by AS "CreatedBy",
updated_at AS "UpdatedAt",
updated_by AS "UpdatedBy"
FROM {PatternTableName}
WHERE tenant_id = @TenantId
WHERE tenant_id = @p0
AND is_active = TRUE
AND (expires_at IS NULL OR expires_at > NOW())
ORDER BY name
""";
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
var result = await connection.QueryAsync<SecretExceptionPatternRow>(
new CommandDefinition(sql, new { TenantId = tenantId }, cancellationToken: cancellationToken))
.ConfigureAwait(false);
await using var dbContext = ScannerDbContextFactory.Create(connection, _dataSource.CommandTimeoutSeconds, SchemaName);
return result.ToList();
return await dbContext.Database.SqlQueryRaw<SecretExceptionPatternRow>(sql, tenantId)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
}
public async Task<SecretExceptionPatternRow?> GetByIdAsync(
Guid tenantId,
Guid exceptionId,
CancellationToken cancellationToken = default)
{
var sql = $"""
SELECT
exception_id AS ExceptionId,
tenant_id AS TenantId,
name AS Name,
description AS Description,
value_pattern AS ValuePattern,
applicable_rule_ids AS ApplicableRuleIds,
file_path_glob AS FilePathGlob,
justification AS Justification,
expires_at AS ExpiresAt,
is_active AS IsActive,
match_count AS MatchCount,
last_matched_at AS LastMatchedAt,
created_at AS CreatedAt,
created_by AS CreatedBy,
updated_at AS UpdatedAt,
updated_by AS UpdatedBy
exception_id AS "ExceptionId",
tenant_id AS "TenantId",
name AS "Name",
description AS "Description",
value_pattern AS "ValuePattern",
applicable_rule_ids AS "ApplicableRuleIds",
file_path_glob AS "FilePathGlob",
justification AS "Justification",
expires_at AS "ExpiresAt",
is_active AS "IsActive",
match_count AS "MatchCount",
last_matched_at AS "LastMatchedAt",
created_at AS "CreatedAt",
created_by AS "CreatedBy",
updated_at AS "UpdatedAt",
updated_by AS "UpdatedBy"
FROM {PatternTableName}
WHERE exception_id = @ExceptionId
WHERE tenant_id = @p0 AND exception_id = @p1
""";
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
return await connection.QuerySingleOrDefaultAsync<SecretExceptionPatternRow>(
new CommandDefinition(sql, new { ExceptionId = exceptionId }, cancellationToken: cancellationToken))
await using var dbContext = ScannerDbContextFactory.Create(connection, _dataSource.CommandTimeoutSeconds, SchemaName);
return await dbContext.Database.SqlQueryRaw<SecretExceptionPatternRow>(sql, tenantId, exceptionId)
.FirstOrDefaultAsync(cancellationToken)
.ConfigureAwait(false);
}
@@ -265,42 +277,39 @@ public sealed class PostgresSecretExceptionPatternRepository : ISecretExceptionP
var sql = $"""
INSERT INTO {PatternTableName} (
tenant_id,
name,
description,
value_pattern,
applicable_rule_ids,
file_path_glob,
justification,
expires_at,
is_active,
created_by
tenant_id, name, description, value_pattern,
applicable_rule_ids, file_path_glob, justification,
expires_at, is_active, created_by
) VALUES (
@TenantId,
@Name,
@Description,
@ValuePattern,
@ApplicableRuleIds,
@FilePathGlob,
@Justification,
@ExpiresAt,
@IsActive,
@CreatedBy
@p0, @p1, @p2, @p3, @p4, @p5, @p6, @p7, @p8, @p9
)
RETURNING exception_id AS ExceptionId, created_at AS CreatedAt
RETURNING exception_id, created_at
""";
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
var result = await connection.QuerySingleAsync<(Guid ExceptionId, DateTimeOffset CreatedAt)>(
new CommandDefinition(sql, pattern, cancellationToken: cancellationToken))
await using var dbContext = ScannerDbContextFactory.Create(connection, _dataSource.CommandTimeoutSeconds, SchemaName);
var result = await dbContext.Database.SqlQueryRaw<ExceptionPatternInsertResult>(
sql,
pattern.TenantId, pattern.Name,
(object?)pattern.Description ?? DBNull.Value,
pattern.ValuePattern,
(object?)pattern.ApplicableRuleIds ?? DBNull.Value,
(object?)pattern.FilePathGlob ?? DBNull.Value,
(object?)pattern.Justification ?? DBNull.Value,
(object?)pattern.ExpiresAt ?? DBNull.Value,
pattern.IsActive,
(object?)pattern.CreatedBy ?? DBNull.Value)
.FirstAsync(cancellationToken)
.ConfigureAwait(false);
pattern.ExceptionId = result.ExceptionId;
pattern.CreatedAt = result.CreatedAt;
pattern.ExceptionId = result.exception_id;
pattern.CreatedAt = result.created_at;
return pattern;
}
public async Task<bool> UpdateAsync(
Guid tenantId,
SecretExceptionPatternRow pattern,
CancellationToken cancellationToken = default)
{
@@ -309,39 +318,50 @@ public sealed class PostgresSecretExceptionPatternRepository : ISecretExceptionP
var sql = $"""
UPDATE {PatternTableName}
SET
name = @Name,
description = @Description,
value_pattern = @ValuePattern,
applicable_rule_ids = @ApplicableRuleIds,
file_path_glob = @FilePathGlob,
justification = @Justification,
expires_at = @ExpiresAt,
is_active = @IsActive,
updated_at = NOW(),
updated_by = @UpdatedBy
WHERE exception_id = @ExceptionId
name = @p0, description = @p1, value_pattern = @p2,
applicable_rule_ids = @p3, file_path_glob = @p4,
justification = @p5, expires_at = @p6, is_active = @p7,
updated_at = NOW(), updated_by = @p8
WHERE tenant_id = @p9 AND exception_id = @p10
""";
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
var rowsAffected = await connection.ExecuteAsync(
new CommandDefinition(sql, pattern, cancellationToken: cancellationToken))
await using var dbContext = ScannerDbContextFactory.Create(connection, _dataSource.CommandTimeoutSeconds, SchemaName);
var rowsAffected = await dbContext.Database.ExecuteSqlRawAsync(
sql,
[
pattern.Name,
(object?)pattern.Description ?? DBNull.Value,
pattern.ValuePattern,
(object?)pattern.ApplicableRuleIds ?? DBNull.Value,
(object?)pattern.FilePathGlob ?? DBNull.Value,
(object?)pattern.Justification ?? DBNull.Value,
(object?)pattern.ExpiresAt ?? DBNull.Value,
pattern.IsActive,
(object?)pattern.UpdatedBy ?? DBNull.Value,
tenantId, pattern.ExceptionId
],
cancellationToken)
.ConfigureAwait(false);
return rowsAffected > 0;
}
public async Task<bool> DeleteAsync(
Guid tenantId,
Guid exceptionId,
CancellationToken cancellationToken = default)
{
var sql = $"""
DELETE FROM {PatternTableName}
WHERE exception_id = @ExceptionId
WHERE tenant_id = @p0 AND exception_id = @p1
""";
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
var rowsAffected = await connection.ExecuteAsync(
new CommandDefinition(sql, new { ExceptionId = exceptionId }, cancellationToken: cancellationToken))
await using var dbContext = ScannerDbContextFactory.Create(connection, _dataSource.CommandTimeoutSeconds, SchemaName);
var rowsAffected = await dbContext.Database.ExecuteSqlRawAsync(sql, [tenantId, exceptionId], cancellationToken)
.ConfigureAwait(false);
return rowsAffected > 0;
@@ -353,33 +373,33 @@ public sealed class PostgresSecretExceptionPatternRepository : ISecretExceptionP
{
var sql = $"""
SELECT
exception_id AS ExceptionId,
tenant_id AS TenantId,
name AS Name,
description AS Description,
value_pattern AS ValuePattern,
applicable_rule_ids AS ApplicableRuleIds,
file_path_glob AS FilePathGlob,
justification AS Justification,
expires_at AS ExpiresAt,
is_active AS IsActive,
match_count AS MatchCount,
last_matched_at AS LastMatchedAt,
created_at AS CreatedAt,
created_by AS CreatedBy,
updated_at AS UpdatedAt,
updated_by AS UpdatedBy
exception_id AS "ExceptionId",
tenant_id AS "TenantId",
name AS "Name",
description AS "Description",
value_pattern AS "ValuePattern",
applicable_rule_ids AS "ApplicableRuleIds",
file_path_glob AS "FilePathGlob",
justification AS "Justification",
expires_at AS "ExpiresAt",
is_active AS "IsActive",
match_count AS "MatchCount",
last_matched_at AS "LastMatchedAt",
created_at AS "CreatedAt",
created_by AS "CreatedBy",
updated_at AS "UpdatedAt",
updated_by AS "UpdatedBy"
FROM {PatternTableName}
WHERE tenant_id = @TenantId
WHERE tenant_id = @p0
ORDER BY name
""";
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
var result = await connection.QueryAsync<SecretExceptionPatternRow>(
new CommandDefinition(sql, new { TenantId = tenantId }, cancellationToken: cancellationToken))
.ConfigureAwait(false);
await using var dbContext = ScannerDbContextFactory.Create(connection, _dataSource.CommandTimeoutSeconds, SchemaName);
return result.ToList();
return await dbContext.Database.SqlQueryRaw<SecretExceptionPatternRow>(sql, tenantId)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
}
public async Task RecordMatchAsync(
@@ -392,23 +412,20 @@ public sealed class PostgresSecretExceptionPatternRepository : ISecretExceptionP
{
var sql = $"""
INSERT INTO {MatchLogTableName} (
tenant_id,
exception_id,
scan_id,
file_path,
rule_id
) VALUES (
@TenantId,
@ExceptionId,
@ScanId,
@FilePath,
@RuleId
)
tenant_id, exception_id, scan_id, file_path, rule_id
) VALUES (@p0, @p1, @p2, @p3, @p4)
""";
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await connection.ExecuteAsync(
new CommandDefinition(sql, new { TenantId = tenantId, ExceptionId = exceptionId, ScanId = scanId, FilePath = filePath, RuleId = ruleId }, cancellationToken: cancellationToken))
await using var dbContext = ScannerDbContextFactory.Create(connection, _dataSource.CommandTimeoutSeconds, SchemaName);
await dbContext.Database.ExecuteSqlRawAsync(
sql,
[tenantId, exceptionId,
(object?)scanId ?? DBNull.Value,
(object?)filePath ?? DBNull.Value,
(object?)ruleId ?? DBNull.Value],
cancellationToken)
.ConfigureAwait(false);
}
@@ -418,34 +435,40 @@ public sealed class PostgresSecretExceptionPatternRepository : ISecretExceptionP
{
var sql = $"""
SELECT
exception_id AS ExceptionId,
tenant_id AS TenantId,
name AS Name,
description AS Description,
value_pattern AS ValuePattern,
applicable_rule_ids AS ApplicableRuleIds,
file_path_glob AS FilePathGlob,
justification AS Justification,
expires_at AS ExpiresAt,
is_active AS IsActive,
match_count AS MatchCount,
last_matched_at AS LastMatchedAt,
created_at AS CreatedAt,
created_by AS CreatedBy,
updated_at AS UpdatedAt,
updated_by AS UpdatedBy
exception_id AS "ExceptionId",
tenant_id AS "TenantId",
name AS "Name",
description AS "Description",
value_pattern AS "ValuePattern",
applicable_rule_ids AS "ApplicableRuleIds",
file_path_glob AS "FilePathGlob",
justification AS "Justification",
expires_at AS "ExpiresAt",
is_active AS "IsActive",
match_count AS "MatchCount",
last_matched_at AS "LastMatchedAt",
created_at AS "CreatedAt",
created_by AS "CreatedBy",
updated_at AS "UpdatedAt",
updated_by AS "UpdatedBy"
FROM {PatternTableName}
WHERE expires_at IS NOT NULL
AND expires_at <= @AsOf
AND expires_at <= @p0
AND is_active = TRUE
ORDER BY expires_at
""";
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
var result = await connection.QueryAsync<SecretExceptionPatternRow>(
new CommandDefinition(sql, new { AsOf = asOf }, cancellationToken: cancellationToken))
.ConfigureAwait(false);
await using var dbContext = ScannerDbContextFactory.Create(connection, _dataSource.CommandTimeoutSeconds, SchemaName);
return result.ToList();
return await dbContext.Database.SqlQueryRaw<SecretExceptionPatternRow>(sql, asOf)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
}
private sealed record ExceptionPatternInsertResult
{
public Guid exception_id { get; init; }
public DateTimeOffset created_at { get; init; }
}
}

View File

@@ -1,5 +1,5 @@
using Dapper;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Npgsql;
using StellaOps.Scanner.SmartDiff.Detection;
@@ -14,9 +14,6 @@ namespace StellaOps.Scanner.Storage.Postgres;
/// </summary>
public sealed class PostgresVexCandidateStore : IVexCandidateStore
{
private const string TenantContext = "00000000-0000-0000-0000-000000000001";
private static readonly Guid TenantId = Guid.Parse(TenantContext);
private readonly ScannerDataSource _dataSource;
private readonly ILogger<PostgresVexCandidateStore> _logger;
@@ -36,21 +33,22 @@ public sealed class PostgresVexCandidateStore : IVexCandidateStore
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task StoreCandidatesAsync(IReadOnlyList<VexCandidate> candidates, CancellationToken ct = default)
public async Task StoreCandidatesAsync(IReadOnlyList<VexCandidate> candidates, CancellationToken ct = default, string? tenantId = null)
{
ArgumentNullException.ThrowIfNull(candidates);
var tenantScope = ScannerTenantScope.Resolve(tenantId);
if (candidates.Count == 0)
return;
await using var connection = await _dataSource.OpenConnectionAsync(TenantContext, ct).ConfigureAwait(false);
await using var connection = await _dataSource.OpenConnectionAsync(tenantScope.TenantContext, ct).ConfigureAwait(false);
await using var transaction = await connection.BeginTransactionAsync(ct).ConfigureAwait(false);
try
{
foreach (var candidate in candidates)
{
await InsertCandidateAsync(connection, candidate, ct, transaction).ConfigureAwait(false);
await InsertCandidateAsync(connection, candidate, tenantScope.TenantId, ct, transaction).ConfigureAwait(false);
}
await transaction.CommitAsync(ct).ConfigureAwait(false);
@@ -64,75 +62,92 @@ public sealed class PostgresVexCandidateStore : IVexCandidateStore
}
}
public async Task<IReadOnlyList<VexCandidate>> GetCandidatesAsync(string imageDigest, CancellationToken ct = default)
public async Task<IReadOnlyList<VexCandidate>> GetCandidatesAsync(string imageDigest, CancellationToken ct = default, string? tenantId = null)
{
ArgumentException.ThrowIfNullOrWhiteSpace(imageDigest);
var tenantScope = ScannerTenantScope.Resolve(tenantId);
var sql = $"""
SELECT
SELECT
candidate_id, vuln_id, purl, image_digest,
suggested_status::TEXT, justification::TEXT, rationale,
suggested_status::TEXT AS suggested_status, justification::TEXT AS justification, rationale,
evidence_links, confidence, generated_at, expires_at,
requires_review, review_action::TEXT, reviewed_by, reviewed_at, review_comment
requires_review, review_action::TEXT AS review_action, reviewed_by, reviewed_at, review_comment
FROM {VexCandidatesTable}
WHERE tenant_id = @TenantId
AND image_digest = @ImageDigest
WHERE tenant_id = @p0
AND image_digest = @p1
ORDER BY confidence DESC
""";
await using var connection = await _dataSource.OpenConnectionAsync(TenantContext, ct).ConfigureAwait(false);
var rows = await connection.QueryAsync<VexCandidateRow>(sql, new { TenantId, ImageDigest = imageDigest.Trim() });
await using var connection = await _dataSource.OpenConnectionAsync(tenantScope.TenantContext, ct).ConfigureAwait(false);
await using var dbContext = ScannerDbContextFactory.Create(connection, _dataSource.CommandTimeoutSeconds, SchemaName);
var rows = await dbContext.Database.SqlQueryRaw<VexCandidateRow>(
sql, tenantScope.TenantId, imageDigest.Trim())
.ToListAsync(ct)
.ConfigureAwait(false);
return rows.Select(r => r.ToCandidate()).ToList();
}
public async Task<VexCandidate?> GetCandidateAsync(string candidateId, CancellationToken ct = default)
public async Task<VexCandidate?> GetCandidateAsync(string candidateId, CancellationToken ct = default, string? tenantId = null)
{
ArgumentException.ThrowIfNullOrWhiteSpace(candidateId);
var tenantScope = ScannerTenantScope.Resolve(tenantId);
var sql = $"""
SELECT
SELECT
candidate_id, vuln_id, purl, image_digest,
suggested_status::TEXT, justification::TEXT, rationale,
suggested_status::TEXT AS suggested_status, justification::TEXT AS justification, rationale,
evidence_links, confidence, generated_at, expires_at,
requires_review, review_action::TEXT, reviewed_by, reviewed_at, review_comment
requires_review, review_action::TEXT AS review_action, reviewed_by, reviewed_at, review_comment
FROM {VexCandidatesTable}
WHERE tenant_id = @TenantId
AND candidate_id = @CandidateId
WHERE tenant_id = @p0
AND candidate_id = @p1
""";
await using var connection = await _dataSource.OpenConnectionAsync(TenantContext, ct).ConfigureAwait(false);
var row = await connection.QuerySingleOrDefaultAsync<VexCandidateRow>(sql, new { TenantId, CandidateId = candidateId.Trim() });
await using var connection = await _dataSource.OpenConnectionAsync(tenantScope.TenantContext, ct).ConfigureAwait(false);
await using var dbContext = ScannerDbContextFactory.Create(connection, _dataSource.CommandTimeoutSeconds, SchemaName);
var row = await dbContext.Database.SqlQueryRaw<VexCandidateRow>(
sql, tenantScope.TenantId, candidateId.Trim())
.FirstOrDefaultAsync(ct)
.ConfigureAwait(false);
return row?.ToCandidate();
}
public async Task<bool> ReviewCandidateAsync(string candidateId, VexCandidateReview review, CancellationToken ct = default)
public async Task<bool> ReviewCandidateAsync(string candidateId, VexCandidateReview review, CancellationToken ct = default, string? tenantId = null)
{
ArgumentException.ThrowIfNullOrWhiteSpace(candidateId);
ArgumentNullException.ThrowIfNull(review);
var tenantScope = ScannerTenantScope.Resolve(tenantId);
var sql = $"""
UPDATE {VexCandidatesTable} SET
requires_review = FALSE,
review_action = @ReviewAction::vex_review_action,
reviewed_by = @ReviewedBy,
reviewed_at = @ReviewedAt,
review_comment = @ReviewComment
WHERE tenant_id = @TenantId
AND candidate_id = @CandidateId
review_action = @p0::vex_review_action,
reviewed_by = @p1,
reviewed_at = @p2,
review_comment = @p3
WHERE tenant_id = @p4
AND candidate_id = @p5
""";
await using var connection = await _dataSource.OpenConnectionAsync(TenantContext, ct).ConfigureAwait(false);
var affected = await connection.ExecuteAsync(sql, new
{
TenantId,
CandidateId = candidateId.Trim(),
ReviewAction = review.Action.ToString().ToLowerInvariant(),
ReviewedBy = review.Reviewer,
ReviewedAt = review.ReviewedAt,
ReviewComment = review.Comment
});
await using var connection = await _dataSource.OpenConnectionAsync(tenantScope.TenantContext, ct).ConfigureAwait(false);
await using var dbContext = ScannerDbContextFactory.Create(connection, _dataSource.CommandTimeoutSeconds, SchemaName);
var affected = await dbContext.Database.ExecuteSqlRawAsync(
sql,
[
review.Action.ToString().ToLowerInvariant(),
review.Reviewer,
review.ReviewedAt,
(object?)review.Comment ?? DBNull.Value,
tenantScope.TenantId,
candidateId.Trim()
],
ct).ConfigureAwait(false);
if (affected > 0)
{
@@ -146,6 +161,7 @@ public sealed class PostgresVexCandidateStore : IVexCandidateStore
private async Task InsertCandidateAsync(
NpgsqlConnection connection,
VexCandidate candidate,
Guid tenantId,
CancellationToken ct,
NpgsqlTransaction? transaction = null)
{
@@ -158,9 +174,9 @@ public sealed class PostgresVexCandidateStore : IVexCandidateStore
suggested_status, justification, rationale,
evidence_links, confidence, generated_at, expires_at, requires_review
) VALUES (
@TenantId, @CandidateId, @VulnId, @Purl, @ImageDigest,
@SuggestedStatus::vex_status_type, @Justification::vex_justification, @Rationale,
@EvidenceLinks::jsonb, @Confidence, @GeneratedAt, @ExpiresAt, @RequiresReview
$1, $2, $3, $4, $5,
$6::vex_status_type, $7::vex_justification, $8,
$9::jsonb, $10, $11, $12, $13
)
ON CONFLICT (candidate_id) DO UPDATE SET
suggested_status = EXCLUDED.suggested_status,
@@ -171,25 +187,24 @@ public sealed class PostgresVexCandidateStore : IVexCandidateStore
expires_at = EXCLUDED.expires_at
""";
var tenantId = TenantId;
var evidenceLinksJson = JsonSerializer.Serialize(candidate.EvidenceLinks, JsonOptions);
await connection.ExecuteAsync(new CommandDefinition(sql, new
{
TenantId = tenantId,
CandidateId = candidate.CandidateId,
VulnId = candidate.FindingKey.VulnId,
Purl = candidate.FindingKey.ComponentPurl,
ImageDigest = candidate.ImageDigest,
SuggestedStatus = MapVexStatus(candidate.SuggestedStatus),
Justification = MapJustification(candidate.Justification),
Rationale = candidate.Rationale,
EvidenceLinks = evidenceLinksJson,
Confidence = candidate.Confidence,
GeneratedAt = candidate.GeneratedAt,
ExpiresAt = candidate.ExpiresAt,
RequiresReview = candidate.RequiresReview
}, transaction: transaction, cancellationToken: ct));
await using var cmd = new NpgsqlCommand(sql, connection, transaction);
cmd.Parameters.AddWithValue(tenantId);
cmd.Parameters.AddWithValue(candidate.CandidateId);
cmd.Parameters.AddWithValue(candidate.FindingKey.VulnId);
cmd.Parameters.AddWithValue(candidate.FindingKey.ComponentPurl);
cmd.Parameters.AddWithValue(candidate.ImageDigest);
cmd.Parameters.AddWithValue(MapVexStatus(candidate.SuggestedStatus));
cmd.Parameters.AddWithValue(MapJustification(candidate.Justification));
cmd.Parameters.AddWithValue(candidate.Rationale);
cmd.Parameters.AddWithValue(evidenceLinksJson);
cmd.Parameters.AddWithValue(candidate.Confidence);
cmd.Parameters.AddWithValue(candidate.GeneratedAt);
cmd.Parameters.AddWithValue(candidate.ExpiresAt);
cmd.Parameters.AddWithValue(candidate.RequiresReview);
await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
}
private static string MapVexStatus(VexStatusType status)
@@ -218,7 +233,7 @@ public sealed class PostgresVexCandidateStore : IVexCandidateStore
}
/// <summary>
/// Row mapping class for Dapper.
/// Row mapping class for EF Core SqlQueryRaw.
/// </summary>
private sealed class VexCandidateRow
{

View File

@@ -0,0 +1,34 @@
using Microsoft.EntityFrameworkCore;
using Npgsql;
using StellaOps.Scanner.Storage.EfCore.CompiledModels;
using StellaOps.Scanner.Storage.EfCore.Context;
namespace StellaOps.Scanner.Storage.Postgres;
/// <summary>
/// Runtime factory for creating <see cref="ScannerDbContext"/> instances.
/// Uses compiled model for default schema to avoid runtime model-building overhead.
/// </summary>
internal static class ScannerDbContextFactory
{
public static ScannerDbContext Create(NpgsqlConnection connection, int commandTimeoutSeconds, string schemaName)
{
var normalizedSchema = string.IsNullOrWhiteSpace(schemaName)
? ScannerStorageDefaults.DefaultSchemaName
: schemaName.Trim();
var optionsBuilder = new DbContextOptionsBuilder<ScannerDbContext>()
.UseNpgsql(connection, npgsql => npgsql.CommandTimeout(commandTimeoutSeconds));
if (string.Equals(normalizedSchema, ScannerStorageDefaults.DefaultSchemaName, StringComparison.Ordinal))
{
// Use the static compiled model when schema matches the default for faster context startup.
if (ScannerDbContextModel.Instance.GetEntityTypes().Any())
{
optionsBuilder.UseModel(ScannerDbContextModel.Instance);
}
}
return new ScannerDbContext(optionsBuilder.Options, normalizedSchema);
}
}

View File

@@ -0,0 +1,27 @@
using StellaOps.Scanner.Core.Utility;
using System.Text;
namespace StellaOps.Scanner.Storage.Postgres;
internal static class ScannerTenantScope
{
private const string DefaultTenant = "default";
private static readonly Guid TenantNamespace = new("ac8f2b54-72ea-43fa-9c3b-6a87ebd2d48a");
public static (string TenantContext, Guid TenantId) Resolve(string? tenantId)
{
var normalizedTenant = string.IsNullOrWhiteSpace(tenantId)
? DefaultTenant
: 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

@@ -4,8 +4,7 @@ namespace StellaOps.Scanner.Storage.Repositories;
public interface ICallGraphSnapshotRepository
{
Task StoreAsync(CallGraphSnapshot snapshot, CancellationToken ct = default);
Task StoreAsync(CallGraphSnapshot snapshot, CancellationToken ct = default, string? tenantId = null);
Task<CallGraphSnapshot?> TryGetLatestAsync(string scanId, string language, CancellationToken ct = default);
Task<CallGraphSnapshot?> TryGetLatestAsync(string scanId, string language, CancellationToken ct = default, string? tenantId = null);
}

View File

@@ -4,6 +4,5 @@ namespace StellaOps.Scanner.Storage.Repositories;
public interface ICodeChangeRepository
{
Task StoreAsync(IReadOnlyList<CodeChangeFact> changes, CancellationToken ct = default);
Task StoreAsync(IReadOnlyList<CodeChangeFact> changes, CancellationToken ct = default, string? tenantId = null);
}

View File

@@ -4,18 +4,19 @@ namespace StellaOps.Scanner.Storage.Repositories;
public interface IReachabilityDriftResultRepository
{
Task StoreAsync(ReachabilityDriftResult result, CancellationToken ct = default);
Task StoreAsync(ReachabilityDriftResult result, CancellationToken ct = default, string? tenantId = null);
Task<ReachabilityDriftResult?> TryGetLatestForHeadAsync(string headScanId, string language, CancellationToken ct = default);
Task<ReachabilityDriftResult?> TryGetLatestForHeadAsync(string headScanId, string language, CancellationToken ct = default, string? tenantId = null);
Task<ReachabilityDriftResult?> TryGetByIdAsync(Guid driftId, CancellationToken ct = default);
Task<ReachabilityDriftResult?> TryGetByIdAsync(Guid driftId, CancellationToken ct = default, string? tenantId = null);
Task<bool> ExistsAsync(Guid driftId, CancellationToken ct = default);
Task<bool> ExistsAsync(Guid driftId, CancellationToken ct = default, string? tenantId = null);
Task<IReadOnlyList<DriftedSink>> ListSinksAsync(
Guid driftId,
DriftDirection direction,
int offset,
int limit,
CancellationToken ct = default);
CancellationToken ct = default,
string? tenantId = null);
}

Some files were not shown because too many files have changed in this diff Show More