wip: doctor/cli/docs/api to vector db consolidation; api hardening for descriptions, tenant, and scopes; migrations and conversions of all DALs to EF v10
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Scanner.WebService.Contracts;
|
||||
using StellaOps.Scanner.WebService.Services;
|
||||
using StellaOps.Scanner.WebService.Tenancy;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Controllers;
|
||||
|
||||
@@ -47,7 +48,8 @@ public sealed class FindingsEvidenceController : ControllerBase
|
||||
return Forbid("Requires evidence:raw scope for raw source access");
|
||||
}
|
||||
|
||||
var finding = await _triageService.GetFindingAsync(findingId, ct).ConfigureAwait(false);
|
||||
var tenantId = ScannerRequestContextResolver.ResolveTenantOrDefault(HttpContext);
|
||||
var finding = await _triageService.GetFindingAsync(tenantId, findingId, ct).ConfigureAwait(false);
|
||||
if (finding is null)
|
||||
{
|
||||
return NotFound(new { error = "Finding not found", findingId });
|
||||
@@ -72,9 +74,10 @@ public sealed class FindingsEvidenceController : ControllerBase
|
||||
}
|
||||
|
||||
var results = new List<FindingEvidenceResponse>();
|
||||
var tenantId = ScannerRequestContextResolver.ResolveTenantOrDefault(HttpContext);
|
||||
foreach (var findingId in request.FindingIds)
|
||||
{
|
||||
var finding = await _triageService.GetFindingAsync(findingId, ct).ConfigureAwait(false);
|
||||
var finding = await _triageService.GetFindingAsync(tenantId, findingId, ct).ConfigureAwait(false);
|
||||
if (finding is null)
|
||||
{
|
||||
continue;
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Scanner.WebService.Contracts;
|
||||
using StellaOps.Scanner.WebService.Services;
|
||||
using StellaOps.Scanner.WebService.Tenancy;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Controllers;
|
||||
|
||||
@@ -61,7 +62,8 @@ public sealed class TriageController : ControllerBase
|
||||
{
|
||||
_logger.LogDebug("Getting gating status for finding {FindingId}", findingId);
|
||||
|
||||
var status = await _gatingService.GetGatingStatusAsync(findingId, ct)
|
||||
var tenantId = ScannerRequestContextResolver.ResolveTenantOrDefault(HttpContext);
|
||||
var status = await _gatingService.GetGatingStatusAsync(tenantId, findingId, ct)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (status is null)
|
||||
@@ -97,7 +99,8 @@ public sealed class TriageController : ControllerBase
|
||||
|
||||
_logger.LogDebug("Getting bulk gating status for {Count} findings", request.FindingIds.Count);
|
||||
|
||||
var statuses = await _gatingService.GetBulkGatingStatusAsync(request.FindingIds, ct)
|
||||
var tenantId = ScannerRequestContextResolver.ResolveTenantOrDefault(HttpContext);
|
||||
var statuses = await _gatingService.GetBulkGatingStatusAsync(tenantId, request.FindingIds, ct)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return Ok(statuses);
|
||||
@@ -123,7 +126,8 @@ public sealed class TriageController : ControllerBase
|
||||
{
|
||||
_logger.LogDebug("Getting gated buckets summary for scan {ScanId}", scanId);
|
||||
|
||||
var summary = await _gatingService.GetGatedBucketsSummaryAsync(scanId, ct)
|
||||
var tenantId = ScannerRequestContextResolver.ResolveTenantOrDefault(HttpContext);
|
||||
var summary = await _gatingService.GetGatedBucketsSummaryAsync(tenantId, scanId, ct)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (summary is null)
|
||||
@@ -182,7 +186,8 @@ public sealed class TriageController : ControllerBase
|
||||
IncludeReplayCommand = includeReplayCommand
|
||||
};
|
||||
|
||||
var evidence = await _evidenceService.GetUnifiedEvidenceAsync(findingId, options, ct)
|
||||
var tenantId = ScannerRequestContextResolver.ResolveTenantOrDefault(HttpContext);
|
||||
var evidence = await _evidenceService.GetUnifiedEvidenceAsync(tenantId, findingId, options, ct)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (evidence is null)
|
||||
@@ -261,7 +266,8 @@ public sealed class TriageController : ControllerBase
|
||||
IncludeReplayCommand = true
|
||||
};
|
||||
|
||||
var evidence = await _evidenceService.GetUnifiedEvidenceAsync(findingId, options, ct)
|
||||
var tenantId = ScannerRequestContextResolver.ResolveTenantOrDefault(HttpContext);
|
||||
var evidence = await _evidenceService.GetUnifiedEvidenceAsync(tenantId, findingId, options, ct)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (evidence is null)
|
||||
@@ -317,7 +323,8 @@ public sealed class TriageController : ControllerBase
|
||||
GenerateBundle = generateBundle
|
||||
};
|
||||
|
||||
var result = await _replayService.GenerateForFindingAsync(request, ct)
|
||||
var tenantId = ScannerRequestContextResolver.ResolveTenantOrDefault(HttpContext);
|
||||
var result = await _replayService.GenerateForFindingAsync(tenantId, request, ct)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (result is null)
|
||||
@@ -358,7 +365,8 @@ public sealed class TriageController : ControllerBase
|
||||
GenerateBundle = generateBundle
|
||||
};
|
||||
|
||||
var result = await _replayService.GenerateForScanAsync(request, ct)
|
||||
var tenantId = ScannerRequestContextResolver.ResolveTenantOrDefault(HttpContext);
|
||||
var result = await _replayService.GenerateForScanAsync(tenantId, request, ct)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (result is null)
|
||||
@@ -393,12 +401,13 @@ public sealed class TriageController : ControllerBase
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
_logger.LogDebug("Getting rationale for finding {FindingId} in format {Format}", findingId, format);
|
||||
var tenantId = ScannerRequestContextResolver.ResolveTenantOrDefault(HttpContext);
|
||||
|
||||
switch (format.ToLowerInvariant())
|
||||
{
|
||||
case "plaintext":
|
||||
case "text":
|
||||
var plainText = await _rationaleService.GetRationalePlainTextAsync(findingId, ct)
|
||||
var plainText = await _rationaleService.GetRationalePlainTextAsync(tenantId, findingId, ct)
|
||||
.ConfigureAwait(false);
|
||||
if (plainText is null)
|
||||
{
|
||||
@@ -408,7 +417,7 @@ public sealed class TriageController : ControllerBase
|
||||
|
||||
case "markdown":
|
||||
case "md":
|
||||
var markdown = await _rationaleService.GetRationaleMarkdownAsync(findingId, ct)
|
||||
var markdown = await _rationaleService.GetRationaleMarkdownAsync(tenantId, findingId, ct)
|
||||
.ConfigureAwait(false);
|
||||
if (markdown is null)
|
||||
{
|
||||
@@ -418,7 +427,7 @@ public sealed class TriageController : ControllerBase
|
||||
|
||||
case "json":
|
||||
default:
|
||||
var rationale = await _rationaleService.GetRationaleAsync(findingId, ct)
|
||||
var rationale = await _rationaleService.GetRationaleAsync(tenantId, findingId, ct)
|
||||
.ConfigureAwait(false);
|
||||
if (rationale is null)
|
||||
{
|
||||
|
||||
@@ -8,7 +8,8 @@ public sealed record ScanSnapshot(
|
||||
DateTimeOffset UpdatedAt,
|
||||
string? FailureReason,
|
||||
EntropySnapshot? Entropy,
|
||||
ReplayArtifacts? Replay);
|
||||
ReplayArtifacts? Replay,
|
||||
string TenantId = "default");
|
||||
|
||||
public sealed record ReplayArtifacts(
|
||||
string ManifestHash,
|
||||
|
||||
@@ -6,7 +6,8 @@ public sealed record ScanSubmission(
|
||||
ScanTarget Target,
|
||||
bool Force,
|
||||
string? ClientRequestId,
|
||||
IReadOnlyDictionary<string, string> Metadata);
|
||||
IReadOnlyDictionary<string, string> Metadata,
|
||||
string TenantId = "default");
|
||||
|
||||
public sealed record ScanSubmissionResult(
|
||||
ScanSnapshot Snapshot,
|
||||
|
||||
@@ -106,7 +106,7 @@ internal static class CallGraphEndpoints
|
||||
}
|
||||
|
||||
// Check for duplicate submission (idempotency)
|
||||
var existing = await ingestionService.FindByDigestAsync(parsed, contentDigest, cancellationToken)
|
||||
var existing = await ingestionService.FindByDigestAsync(parsed, snapshot.TenantId, contentDigest, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (existing is not null)
|
||||
@@ -127,7 +127,7 @@ internal static class CallGraphEndpoints
|
||||
}
|
||||
|
||||
// Ingest the call graph
|
||||
var result = await ingestionService.IngestAsync(parsed, request, contentDigest, cancellationToken)
|
||||
var result = await ingestionService.IngestAsync(parsed, snapshot.TenantId, request, contentDigest, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var response = new CallGraphAcceptedResponseDto(
|
||||
|
||||
@@ -11,6 +11,7 @@ using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using StellaOps.Scanner.Core.Epss;
|
||||
using StellaOps.Scanner.WebService.Security;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Endpoints;
|
||||
@@ -29,7 +30,8 @@ public static class EpssEndpoints
|
||||
#pragma warning disable ASPDEPR002 // WithOpenApi is deprecated - migration pending
|
||||
var group = endpoints.MapGroup("/epss")
|
||||
.WithTags("EPSS")
|
||||
.WithOpenApi();
|
||||
.WithOpenApi()
|
||||
.RequireAuthorization(ScannerPolicies.ScansRead);
|
||||
#pragma warning restore ASPDEPR002
|
||||
|
||||
group.MapPost("/current", GetCurrentBatch)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Scanner.Orchestration.Fidelity;
|
||||
using StellaOps.Scanner.WebService.Security;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Endpoints;
|
||||
|
||||
@@ -9,7 +10,7 @@ public static class FidelityEndpoints
|
||||
{
|
||||
var group = app.MapGroup("/api/v1/scan")
|
||||
.WithTags("Fidelity")
|
||||
.RequireAuthorization();
|
||||
.RequireAuthorization(ScannerPolicies.ScansWrite);
|
||||
|
||||
// POST /api/v1/scan/analyze?fidelity={level}
|
||||
group.MapPost("/analyze", async (
|
||||
|
||||
@@ -12,7 +12,9 @@ internal static class ObservabilityEndpoints
|
||||
|
||||
endpoints.MapGet("/metrics", HandleMetricsAsync)
|
||||
.WithName("scanner.metrics")
|
||||
.Produces(StatusCodes.Status200OK);
|
||||
.WithDescription("Exposes scanner service metrics in Prometheus text format (text/plain 0.0.4). Scraped by Prometheus without authentication.")
|
||||
.Produces(StatusCodes.Status200OK)
|
||||
.AllowAnonymous();
|
||||
}
|
||||
|
||||
private static IResult HandleMetricsAsync(OfflineKitMetricsStore metricsStore)
|
||||
|
||||
@@ -3,14 +3,13 @@ using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Scanner.Core.Configuration;
|
||||
using StellaOps.Scanner.WebService.Constants;
|
||||
using StellaOps.Scanner.WebService.Infrastructure;
|
||||
using StellaOps.Scanner.WebService.Security;
|
||||
using StellaOps.Scanner.WebService.Services;
|
||||
using StellaOps.Scanner.WebService.Tenancy;
|
||||
using System.Linq;
|
||||
using System.Security.Claims;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Endpoints;
|
||||
@@ -221,39 +220,12 @@ internal static class OfflineKitEndpoints
|
||||
|
||||
private static string ResolveTenant(HttpContext context)
|
||||
{
|
||||
var tenant = context.User?.FindFirstValue(StellaOpsClaimTypes.Tenant);
|
||||
if (!string.IsNullOrWhiteSpace(tenant))
|
||||
{
|
||||
return tenant.Trim();
|
||||
}
|
||||
|
||||
if (context.Request.Headers.TryGetValue("X-Stella-Tenant", out var headerTenant))
|
||||
{
|
||||
var headerValue = headerTenant.ToString();
|
||||
if (!string.IsNullOrWhiteSpace(headerValue))
|
||||
{
|
||||
return headerValue.Trim();
|
||||
}
|
||||
}
|
||||
|
||||
return "default";
|
||||
return ScannerRequestContextResolver.ResolveTenantOrDefault(context);
|
||||
}
|
||||
|
||||
private static string ResolveActor(HttpContext context)
|
||||
{
|
||||
var subject = context.User?.FindFirstValue(StellaOpsClaimTypes.Subject);
|
||||
if (!string.IsNullOrWhiteSpace(subject))
|
||||
{
|
||||
return subject.Trim();
|
||||
}
|
||||
|
||||
var clientId = context.User?.FindFirstValue(StellaOpsClaimTypes.ClientId);
|
||||
if (!string.IsNullOrWhiteSpace(clientId))
|
||||
{
|
||||
return clientId.Trim();
|
||||
}
|
||||
|
||||
return "anonymous";
|
||||
return ScannerRequestContextResolver.ResolveActor(context, fallback: "anonymous");
|
||||
}
|
||||
|
||||
// Sprint 026: OFFLINE-011 - Manifest retrieval handler
|
||||
@@ -339,4 +311,3 @@ internal static class OfflineKitEndpoints
|
||||
return Results.Ok(result);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ using StellaOps.Scanner.WebService.Domain;
|
||||
using StellaOps.Scanner.WebService.Infrastructure;
|
||||
using StellaOps.Scanner.WebService.Security;
|
||||
using StellaOps.Scanner.WebService.Services;
|
||||
using StellaOps.Scanner.WebService.Tenancy;
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
@@ -73,6 +74,10 @@ internal static class ReachabilityDriftEndpoints
|
||||
ArgumentNullException.ThrowIfNull(codeChangeRepository);
|
||||
ArgumentNullException.ThrowIfNull(driftDetector);
|
||||
ArgumentNullException.ThrowIfNull(driftRepository);
|
||||
if (!TryResolveTenant(context, out var tenantId, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
if (!ScanId.TryParse(scanId, out var headScan))
|
||||
{
|
||||
@@ -99,7 +104,11 @@ internal static class ReachabilityDriftEndpoints
|
||||
|
||||
if (string.IsNullOrWhiteSpace(baseScanId))
|
||||
{
|
||||
var existing = await driftRepository.TryGetLatestForHeadAsync(headScan.Value, resolvedLanguage, cancellationToken)
|
||||
var existing = await driftRepository.TryGetLatestForHeadAsync(
|
||||
headScan.Value,
|
||||
resolvedLanguage,
|
||||
cancellationToken,
|
||||
tenantId: tenantId)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (existing is null)
|
||||
@@ -136,7 +145,11 @@ internal static class ReachabilityDriftEndpoints
|
||||
detail: "Base scan could not be located.");
|
||||
}
|
||||
|
||||
var baseGraph = await callGraphSnapshots.TryGetLatestAsync(baseScan.Value, resolvedLanguage, cancellationToken)
|
||||
var baseGraph = await callGraphSnapshots.TryGetLatestAsync(
|
||||
baseScan.Value,
|
||||
resolvedLanguage,
|
||||
cancellationToken,
|
||||
tenantId: tenantId)
|
||||
.ConfigureAwait(false);
|
||||
if (baseGraph is null)
|
||||
{
|
||||
@@ -148,7 +161,11 @@ internal static class ReachabilityDriftEndpoints
|
||||
detail: $"No call graph snapshot found for base scan {baseScan.Value} (language={resolvedLanguage}).");
|
||||
}
|
||||
|
||||
var headGraph = await callGraphSnapshots.TryGetLatestAsync(headScan.Value, resolvedLanguage, cancellationToken)
|
||||
var headGraph = await callGraphSnapshots.TryGetLatestAsync(
|
||||
headScan.Value,
|
||||
resolvedLanguage,
|
||||
cancellationToken,
|
||||
tenantId: tenantId)
|
||||
.ConfigureAwait(false);
|
||||
if (headGraph is null)
|
||||
{
|
||||
@@ -163,7 +180,7 @@ internal static class ReachabilityDriftEndpoints
|
||||
try
|
||||
{
|
||||
var codeChanges = codeChangeFactExtractor.Extract(baseGraph, headGraph);
|
||||
await codeChangeRepository.StoreAsync(codeChanges, cancellationToken).ConfigureAwait(false);
|
||||
await codeChangeRepository.StoreAsync(codeChanges, cancellationToken, tenantId: tenantId).ConfigureAwait(false);
|
||||
|
||||
var drift = driftDetector.Detect(
|
||||
baseGraph,
|
||||
@@ -171,7 +188,7 @@ internal static class ReachabilityDriftEndpoints
|
||||
codeChanges,
|
||||
includeFullPath: includeFullPath == true);
|
||||
|
||||
await driftRepository.StoreAsync(drift, cancellationToken).ConfigureAwait(false);
|
||||
await driftRepository.StoreAsync(drift, cancellationToken, tenantId: tenantId).ConfigureAwait(false);
|
||||
return Json(drift, StatusCodes.Status200OK);
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
@@ -195,6 +212,10 @@ internal static class ReachabilityDriftEndpoints
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(driftRepository);
|
||||
if (!TryResolveTenant(context, out var tenantId, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
if (driftId == Guid.Empty)
|
||||
{
|
||||
@@ -238,7 +259,7 @@ internal static class ReachabilityDriftEndpoints
|
||||
detail: "limit must be between 1 and 500.");
|
||||
}
|
||||
|
||||
if (!await driftRepository.ExistsAsync(driftId, cancellationToken).ConfigureAwait(false))
|
||||
if (!await driftRepository.ExistsAsync(driftId, cancellationToken, tenantId: tenantId).ConfigureAwait(false))
|
||||
{
|
||||
return ProblemResultFactory.Create(
|
||||
context,
|
||||
@@ -253,7 +274,8 @@ internal static class ReachabilityDriftEndpoints
|
||||
parsedDirection,
|
||||
resolvedOffset,
|
||||
resolvedLimit,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
cancellationToken,
|
||||
tenantId: tenantId).ConfigureAwait(false);
|
||||
|
||||
var response = new DriftedSinksResponseDto(
|
||||
DriftId: driftId,
|
||||
@@ -297,6 +319,29 @@ internal static class ReachabilityDriftEndpoints
|
||||
var payload = JsonSerializer.Serialize(value, SerializerOptions);
|
||||
return Results.Content(payload, "application/json", System.Text.Encoding.UTF8, statusCode);
|
||||
}
|
||||
|
||||
private static bool TryResolveTenant(HttpContext context, out string tenantId, out IResult? failure)
|
||||
{
|
||||
tenantId = string.Empty;
|
||||
failure = null;
|
||||
|
||||
if (ScannerRequestContextResolver.TryResolveTenant(
|
||||
context,
|
||||
out tenantId,
|
||||
out var tenantError,
|
||||
allowDefaultTenant: true))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
failure = ProblemResultFactory.Create(
|
||||
context,
|
||||
ProblemTypes.Validation,
|
||||
"Invalid tenant context",
|
||||
StatusCodes.Status400BadRequest,
|
||||
detail: tenantError ?? "tenant_conflict");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed record DriftedSinksResponseDto(
|
||||
|
||||
@@ -9,6 +9,7 @@ using Microsoft.AspNetCore.Routing;
|
||||
using StellaOps.Scanner.Reachability.Jobs;
|
||||
using StellaOps.Scanner.Reachability.Services;
|
||||
using StellaOps.Scanner.Reachability.Vex;
|
||||
using StellaOps.Scanner.WebService.Security;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Endpoints;
|
||||
|
||||
@@ -24,7 +25,8 @@ public static class ReachabilityEvidenceEndpoints
|
||||
this IEndpointRouteBuilder routes)
|
||||
{
|
||||
var group = routes.MapGroup("/api/reachability")
|
||||
.WithTags("Reachability Evidence");
|
||||
.WithTags("Reachability Evidence")
|
||||
.RequireAuthorization(ScannerPolicies.ScansRead);
|
||||
|
||||
// Analyze reachability for a CVE
|
||||
group.MapPost("/analyze", AnalyzeAsync)
|
||||
@@ -32,7 +34,8 @@ public static class ReachabilityEvidenceEndpoints
|
||||
.WithSummary("Analyze reachability of a CVE in an image")
|
||||
.Produces<ReachabilityAnalyzeResponse>(StatusCodes.Status200OK)
|
||||
.Produces<ProblemDetails>(StatusCodes.Status400BadRequest)
|
||||
.Produces<ProblemDetails>(StatusCodes.Status404NotFound);
|
||||
.Produces<ProblemDetails>(StatusCodes.Status404NotFound)
|
||||
.RequireAuthorization(ScannerPolicies.ScansWrite);
|
||||
|
||||
// Get job result
|
||||
group.MapGet("/result/{jobId}", GetResultAsync)
|
||||
@@ -53,7 +56,8 @@ public static class ReachabilityEvidenceEndpoints
|
||||
.WithName("GenerateVexFromReachability")
|
||||
.WithSummary("Generate VEX statement from reachability analysis")
|
||||
.Produces<VexStatementResponse>(StatusCodes.Status200OK)
|
||||
.Produces<ProblemDetails>(StatusCodes.Status400BadRequest);
|
||||
.Produces<ProblemDetails>(StatusCodes.Status400BadRequest)
|
||||
.RequireAuthorization(ScannerPolicies.ScansWrite);
|
||||
|
||||
return routes;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using StellaOps.Scanner.WebService.Contracts;
|
||||
using StellaOps.Scanner.WebService.Domain;
|
||||
using StellaOps.Scanner.WebService.Security;
|
||||
using StellaOps.Scanner.WebService.Services;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Endpoints;
|
||||
@@ -10,7 +11,8 @@ internal static class ReplayEndpoints
|
||||
{
|
||||
public static void MapReplayEndpoints(this RouteGroupBuilder apiGroup)
|
||||
{
|
||||
var replay = apiGroup.MapGroup("/replay");
|
||||
var replay = apiGroup.MapGroup("/replay")
|
||||
.RequireAuthorization(ScannerPolicies.ScansWrite);
|
||||
|
||||
replay.MapPost("/{scanId}/attach", HandleAttachAsync)
|
||||
.WithName("scanner.replay.attach")
|
||||
|
||||
@@ -12,6 +12,7 @@ using StellaOps.Scanner.WebService.Infrastructure;
|
||||
using StellaOps.Scanner.WebService.Options;
|
||||
using StellaOps.Scanner.WebService.Security;
|
||||
using StellaOps.Scanner.WebService.Services;
|
||||
using StellaOps.Scanner.WebService.Tenancy;
|
||||
using System.Collections.Generic;
|
||||
using System.IO.Pipelines;
|
||||
using System.Linq;
|
||||
@@ -151,11 +152,26 @@ internal static class ScanEndpoints
|
||||
metadata["determinism.policy"] = determinism.PolicySnapshotId;
|
||||
}
|
||||
|
||||
if (!ScannerRequestContextResolver.TryResolveTenant(
|
||||
context,
|
||||
out var tenantId,
|
||||
out var tenantError,
|
||||
allowDefaultTenant: true))
|
||||
{
|
||||
return ProblemResultFactory.Create(
|
||||
context,
|
||||
ProblemTypes.Validation,
|
||||
"Invalid tenant context",
|
||||
StatusCodes.Status400BadRequest,
|
||||
detail: tenantError ?? "tenant_conflict");
|
||||
}
|
||||
|
||||
var submission = new ScanSubmission(
|
||||
Target: target,
|
||||
Force: request.Force,
|
||||
ClientRequestId: request.ClientRequestId?.Trim(),
|
||||
Metadata: metadata);
|
||||
Metadata: metadata,
|
||||
TenantId: tenantId);
|
||||
|
||||
ScanSubmissionResult result;
|
||||
try
|
||||
|
||||
@@ -10,6 +10,7 @@ using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using StellaOps.Scanner.Core;
|
||||
using StellaOps.Scanner.WebService.Contracts;
|
||||
using StellaOps.Scanner.WebService.Security;
|
||||
using StellaOps.Scanner.WebService.Services;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Endpoints;
|
||||
@@ -18,7 +19,8 @@ internal static class ScoreReplayEndpoints
|
||||
{
|
||||
public static void MapScoreReplayEndpoints(this RouteGroupBuilder apiGroup)
|
||||
{
|
||||
var score = apiGroup.MapGroup("/score");
|
||||
var score = apiGroup.MapGroup("/score")
|
||||
.RequireAuthorization(ScannerPolicies.ScansRead);
|
||||
|
||||
score.MapPost("/{scanId}/replay", HandleReplayAsync)
|
||||
.WithName("scanner.score.replay")
|
||||
@@ -26,7 +28,8 @@ internal static class ScoreReplayEndpoints
|
||||
.Produces<ProblemDetails>(StatusCodes.Status404NotFound)
|
||||
.Produces<ProblemDetails>(StatusCodes.Status400BadRequest)
|
||||
.Produces<ProblemDetails>(StatusCodes.Status422UnprocessableEntity)
|
||||
.WithDescription("Replay scoring for a previous scan using frozen inputs");
|
||||
.WithDescription("Replay scoring for a previous scan using frozen inputs")
|
||||
.RequireAuthorization(ScannerPolicies.ScansWrite);
|
||||
|
||||
score.MapGet("/{scanId}/bundle", HandleGetBundleAsync)
|
||||
.WithName("scanner.score.bundle")
|
||||
@@ -39,7 +42,8 @@ internal static class ScoreReplayEndpoints
|
||||
.Produces<ScoreVerifyResponse>(StatusCodes.Status200OK)
|
||||
.Produces<ProblemDetails>(StatusCodes.Status404NotFound)
|
||||
.Produces<ProblemDetails>(StatusCodes.Status422UnprocessableEntity)
|
||||
.WithDescription("Verify a proof bundle against expected root hash");
|
||||
.WithDescription("Verify a proof bundle against expected root hash")
|
||||
.RequireAuthorization(ScannerPolicies.ScansWrite);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -237,9 +237,8 @@ internal static class SecretDetectionSettingsEndpoints
|
||||
ISecretExceptionPatternService service,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var pattern = await service.GetPatternAsync(exceptionId, cancellationToken);
|
||||
|
||||
if (pattern is null || pattern.TenantId != tenantId)
|
||||
var pattern = await service.GetPatternAsync(tenantId, exceptionId, cancellationToken);
|
||||
if (pattern is null)
|
||||
{
|
||||
return Results.NotFound(new
|
||||
{
|
||||
@@ -284,9 +283,8 @@ internal static class SecretDetectionSettingsEndpoints
|
||||
HttpContext context,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// Verify pattern belongs to tenant
|
||||
var existing = await service.GetPatternAsync(exceptionId, cancellationToken);
|
||||
if (existing is null || existing.TenantId != tenantId)
|
||||
var existing = await service.GetPatternAsync(tenantId, exceptionId, cancellationToken);
|
||||
if (existing is null)
|
||||
{
|
||||
return Results.NotFound(new
|
||||
{
|
||||
@@ -298,6 +296,7 @@ internal static class SecretDetectionSettingsEndpoints
|
||||
|
||||
var username = context.User.Identity?.Name ?? "system";
|
||||
var (success, pattern, errors) = await service.UpdatePatternAsync(
|
||||
tenantId,
|
||||
exceptionId,
|
||||
request,
|
||||
username,
|
||||
@@ -333,9 +332,8 @@ internal static class SecretDetectionSettingsEndpoints
|
||||
ISecretExceptionPatternService service,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// Verify pattern belongs to tenant
|
||||
var existing = await service.GetPatternAsync(exceptionId, cancellationToken);
|
||||
if (existing is null || existing.TenantId != tenantId)
|
||||
var existing = await service.GetPatternAsync(tenantId, exceptionId, cancellationToken);
|
||||
if (existing is null)
|
||||
{
|
||||
return Results.NotFound(new
|
||||
{
|
||||
@@ -345,7 +343,7 @@ internal static class SecretDetectionSettingsEndpoints
|
||||
});
|
||||
}
|
||||
|
||||
var deleted = await service.DeletePatternAsync(exceptionId, cancellationToken);
|
||||
var deleted = await service.DeletePatternAsync(tenantId, exceptionId, cancellationToken);
|
||||
if (!deleted)
|
||||
{
|
||||
return Results.NotFound(new
|
||||
|
||||
@@ -5,6 +5,7 @@ using StellaOps.Scanner.SmartDiff.Detection;
|
||||
using StellaOps.Scanner.SmartDiff.Output;
|
||||
using StellaOps.Scanner.WebService.Security;
|
||||
using StellaOps.Scanner.WebService.Services;
|
||||
using StellaOps.Scanner.WebService.Tenancy;
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Endpoints;
|
||||
@@ -87,10 +88,16 @@ internal static class SmartDiffEndpoints
|
||||
IVexCandidateStore candidateStore,
|
||||
IScanMetadataRepository? metadataRepo = null,
|
||||
bool? pretty = null,
|
||||
HttpContext? context = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (!TryResolveTenant(context, out var tenantId, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
// Gather all data for the scan
|
||||
var changes = await changeRepo.GetChangesForScanAsync(scanId, ct);
|
||||
var changes = await changeRepo.GetChangesForScanAsync(scanId, ct, tenantId: tenantId);
|
||||
|
||||
// Get scan metadata if available
|
||||
string? baseDigest = null;
|
||||
@@ -111,7 +118,7 @@ internal static class SmartDiffEndpoints
|
||||
IReadOnlyList<StellaOps.Scanner.SmartDiff.Output.VexCandidate> vexCandidates = [];
|
||||
if (!string.IsNullOrWhiteSpace(targetDigest))
|
||||
{
|
||||
var candidates = await candidateStore.GetCandidatesAsync(targetDigest, ct).ConfigureAwait(false);
|
||||
var candidates = await candidateStore.GetCandidatesAsync(targetDigest, ct, tenantId: tenantId).ConfigureAwait(false);
|
||||
vexCandidates = candidates.Select(ToSarifVexCandidate).ToList();
|
||||
}
|
||||
|
||||
@@ -164,8 +171,14 @@ internal static class SmartDiffEndpoints
|
||||
IVexCandidateStore store,
|
||||
double? minConfidence = null,
|
||||
bool? pendingOnly = null,
|
||||
HttpContext? context = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (!TryResolveTenant(context, out var tenantId, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
var metadata = await metadataRepository.GetScanMetadataAsync(scanId, ct).ConfigureAwait(false);
|
||||
var targetDigest = NormalizeDigest(metadata?.TargetDigest);
|
||||
if (string.IsNullOrWhiteSpace(targetDigest))
|
||||
@@ -173,7 +186,7 @@ internal static class SmartDiffEndpoints
|
||||
return Results.NotFound(new { error = "Scan metadata not found", scanId });
|
||||
}
|
||||
|
||||
return await HandleGetCandidatesAsync(targetDigest, store, minConfidence, pendingOnly, ct).ConfigureAwait(false);
|
||||
return await HandleGetCandidatesAsync(targetDigest, store, minConfidence, pendingOnly, context, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static StellaOps.Scanner.SmartDiff.Output.RiskDirection ToSarifRiskDirection(MaterialRiskChangeResult change)
|
||||
@@ -230,9 +243,15 @@ internal static class SmartDiffEndpoints
|
||||
string scanId,
|
||||
IMaterialRiskChangeRepository repository,
|
||||
double? minPriority = null,
|
||||
HttpContext? context = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var changes = await repository.GetChangesForScanAsync(scanId, ct);
|
||||
if (!TryResolveTenant(context, out var tenantId, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
var changes = await repository.GetChangesForScanAsync(scanId, ct, tenantId: tenantId);
|
||||
|
||||
if (minPriority.HasValue)
|
||||
{
|
||||
@@ -257,15 +276,21 @@ internal static class SmartDiffEndpoints
|
||||
IVexCandidateStore store,
|
||||
double? minConfidence = null,
|
||||
bool? pendingOnly = null,
|
||||
HttpContext? context = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (!TryResolveTenant(context, out var tenantId, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
var normalizedDigest = NormalizeDigest(digest);
|
||||
if (string.IsNullOrWhiteSpace(normalizedDigest))
|
||||
{
|
||||
return Results.BadRequest(new { error = "Invalid image digest" });
|
||||
}
|
||||
|
||||
var candidates = await store.GetCandidatesAsync(normalizedDigest, ct);
|
||||
var candidates = await store.GetCandidatesAsync(normalizedDigest, ct, tenantId: tenantId);
|
||||
|
||||
if (minConfidence.HasValue)
|
||||
{
|
||||
@@ -293,9 +318,15 @@ internal static class SmartDiffEndpoints
|
||||
private static async Task<IResult> HandleGetCandidateAsync(
|
||||
string candidateId,
|
||||
IVexCandidateStore store,
|
||||
HttpContext? context = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var candidate = await store.GetCandidateAsync(candidateId, ct);
|
||||
if (!TryResolveTenant(context, out var tenantId, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
var candidate = await store.GetCandidateAsync(candidateId, ct, tenantId: tenantId);
|
||||
|
||||
if (candidate is null)
|
||||
{
|
||||
@@ -325,6 +356,10 @@ internal static class SmartDiffEndpoints
|
||||
{
|
||||
return Results.BadRequest(new { error = "Invalid action", validActions = new[] { "accept", "reject", "defer" } });
|
||||
}
|
||||
if (!TryResolveTenant(httpContext, out var tenantId, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
var reviewer = httpContext.User.Identity?.Name ?? "anonymous";
|
||||
var review = new VexCandidateReview(
|
||||
@@ -333,7 +368,7 @@ internal static class SmartDiffEndpoints
|
||||
ReviewedAt: timeProvider.GetUtcNow(),
|
||||
Comment: request.Comment);
|
||||
|
||||
var success = await store.ReviewCandidateAsync(candidateId, review, ct);
|
||||
var success = await store.ReviewCandidateAsync(candidateId, review, ct, tenantId: tenantId);
|
||||
|
||||
if (!success)
|
||||
{
|
||||
@@ -365,6 +400,10 @@ internal static class SmartDiffEndpoints
|
||||
{
|
||||
return Results.BadRequest(new { error = "CandidateId is required" });
|
||||
}
|
||||
if (!TryResolveTenant(httpContext, out var tenantId, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
var metadata = await metadataRepository.GetScanMetadataAsync(scanId, ct).ConfigureAwait(false);
|
||||
var targetDigest = NormalizeDigest(metadata?.TargetDigest);
|
||||
@@ -373,7 +412,7 @@ internal static class SmartDiffEndpoints
|
||||
return Results.NotFound(new { error = "Scan metadata not found", scanId });
|
||||
}
|
||||
|
||||
var candidate = await store.GetCandidateAsync(request.CandidateId, ct).ConfigureAwait(false);
|
||||
var candidate = await store.GetCandidateAsync(request.CandidateId, ct, tenantId: tenantId).ConfigureAwait(false);
|
||||
if (candidate is null || !string.Equals(candidate.ImageDigest, targetDigest, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return Results.NotFound(new { error = "Candidate not found for scan", scanId, candidateId = request.CandidateId });
|
||||
@@ -482,6 +521,40 @@ internal static class SmartDiffEndpoints
|
||||
};
|
||||
}
|
||||
|
||||
private static bool TryResolveTenant(HttpContext? context, out string tenantId, out IResult? failure)
|
||||
{
|
||||
tenantId = string.Empty;
|
||||
failure = null;
|
||||
|
||||
if (context is null)
|
||||
{
|
||||
failure = Results.BadRequest(new
|
||||
{
|
||||
type = "validation-error",
|
||||
title = "Invalid tenant context",
|
||||
detail = "tenant_missing"
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
if (ScannerRequestContextResolver.TryResolveTenant(
|
||||
context,
|
||||
out tenantId,
|
||||
out var tenantError,
|
||||
allowDefaultTenant: true))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
failure = Results.BadRequest(new
|
||||
{
|
||||
type = "validation-error",
|
||||
title = "Invalid tenant context",
|
||||
detail = tenantError ?? "tenant_conflict"
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Scanner.Sources.Configuration;
|
||||
using StellaOps.Scanner.Sources.Contracts;
|
||||
using StellaOps.Scanner.Sources.Domain;
|
||||
@@ -10,7 +9,6 @@ using StellaOps.Scanner.WebService.Constants;
|
||||
using StellaOps.Scanner.WebService.Infrastructure;
|
||||
using StellaOps.Scanner.WebService.Security;
|
||||
using StellaOps.Scanner.WebService.Tenancy;
|
||||
using System.Security.Claims;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
@@ -660,53 +658,16 @@ internal static class SourcesEndpoints
|
||||
|
||||
private static bool TryResolveTenant(HttpContext context, out string tenantId)
|
||||
{
|
||||
tenantId = string.Empty;
|
||||
|
||||
var tenant = context.User?.FindFirstValue(StellaOpsClaimTypes.Tenant);
|
||||
if (!string.IsNullOrWhiteSpace(tenant))
|
||||
{
|
||||
tenantId = tenant.Trim();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (context.Request.Headers.TryGetValue("X-Stella-Tenant", out var headerTenant))
|
||||
{
|
||||
var headerValue = headerTenant.ToString();
|
||||
if (!string.IsNullOrWhiteSpace(headerValue))
|
||||
{
|
||||
tenantId = headerValue.Trim();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if (context.Request.Headers.TryGetValue("X-Tenant-Id", out var legacyTenant))
|
||||
{
|
||||
var headerValue = legacyTenant.ToString();
|
||||
if (!string.IsNullOrWhiteSpace(headerValue))
|
||||
{
|
||||
tenantId = headerValue.Trim();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
return ScannerRequestContextResolver.TryResolveTenant(
|
||||
context,
|
||||
out tenantId,
|
||||
out _,
|
||||
allowDefaultTenant: false);
|
||||
}
|
||||
|
||||
private static string ResolveActor(HttpContext context)
|
||||
{
|
||||
var subject = context.User?.FindFirstValue(StellaOpsClaimTypes.Subject);
|
||||
if (!string.IsNullOrWhiteSpace(subject))
|
||||
{
|
||||
return subject.Trim();
|
||||
}
|
||||
|
||||
var clientId = context.User?.FindFirstValue(StellaOpsClaimTypes.ClientId);
|
||||
if (!string.IsNullOrWhiteSpace(clientId))
|
||||
{
|
||||
return clientId.Trim();
|
||||
}
|
||||
|
||||
return "system";
|
||||
return ScannerRequestContextResolver.ResolveActor(context);
|
||||
}
|
||||
|
||||
private static IResult Json<T>(T value, int statusCode)
|
||||
|
||||
@@ -8,6 +8,7 @@ using StellaOps.Scanner.Triage.Models;
|
||||
using StellaOps.Scanner.Triage.Services;
|
||||
using StellaOps.Scanner.WebService.Contracts;
|
||||
using StellaOps.Scanner.WebService.Security;
|
||||
using StellaOps.Scanner.WebService.Tenancy;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Endpoints.Triage;
|
||||
|
||||
@@ -46,6 +47,7 @@ internal static class BatchTriageEndpoints
|
||||
[FromServices] IFindingQueryService findingService,
|
||||
[FromServices] IExploitPathGroupingService groupingService,
|
||||
[FromServices] TimeProvider timeProvider,
|
||||
HttpContext context,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(artifactDigest))
|
||||
@@ -58,7 +60,8 @@ internal static class BatchTriageEndpoints
|
||||
});
|
||||
}
|
||||
|
||||
var findings = await findingService.GetFindingsForArtifactAsync(artifactDigest, cancellationToken).ConfigureAwait(false);
|
||||
var tenantId = ScannerRequestContextResolver.ResolveTenantOrDefault(context);
|
||||
var findings = await findingService.GetFindingsForArtifactAsync(tenantId, artifactDigest, cancellationToken).ConfigureAwait(false);
|
||||
var clusters = similarityThreshold.HasValue
|
||||
? await groupingService.GroupFindingsAsync(artifactDigest, findings, similarityThreshold.Value, cancellationToken).ConfigureAwait(false)
|
||||
: await groupingService.GroupFindingsAsync(artifactDigest, findings, cancellationToken).ConfigureAwait(false);
|
||||
@@ -86,6 +89,7 @@ internal static class BatchTriageEndpoints
|
||||
[FromServices] IExploitPathGroupingService groupingService,
|
||||
[FromServices] ITriageStatusService triageStatusService,
|
||||
[FromServices] TimeProvider timeProvider,
|
||||
HttpContext context,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.ArtifactDigest))
|
||||
@@ -108,7 +112,8 @@ internal static class BatchTriageEndpoints
|
||||
});
|
||||
}
|
||||
|
||||
var findings = await findingService.GetFindingsForArtifactAsync(request.ArtifactDigest, cancellationToken).ConfigureAwait(false);
|
||||
var tenantId = ScannerRequestContextResolver.ResolveTenantOrDefault(context);
|
||||
var findings = await findingService.GetFindingsForArtifactAsync(tenantId, request.ArtifactDigest, cancellationToken).ConfigureAwait(false);
|
||||
var clusters = request.SimilarityThreshold.HasValue
|
||||
? await groupingService.GroupFindingsAsync(request.ArtifactDigest, findings, request.SimilarityThreshold.Value, cancellationToken).ConfigureAwait(false)
|
||||
: await groupingService.GroupFindingsAsync(request.ArtifactDigest, findings, cancellationToken).ConfigureAwait(false);
|
||||
@@ -138,7 +143,7 @@ internal static class BatchTriageEndpoints
|
||||
Actor = actor
|
||||
};
|
||||
|
||||
var result = await triageStatusService.UpdateStatusAsync(findingId, updateRequest, actor, cancellationToken).ConfigureAwait(false);
|
||||
var result = await triageStatusService.UpdateStatusAsync(tenantId, findingId, updateRequest, actor, cancellationToken).ConfigureAwait(false);
|
||||
if (result is not null)
|
||||
{
|
||||
updated.Add(findingId);
|
||||
|
||||
@@ -11,6 +11,7 @@ using Microsoft.AspNetCore.Routing;
|
||||
using StellaOps.Scanner.Triage.Models;
|
||||
using StellaOps.Scanner.Triage.Services;
|
||||
using StellaOps.Scanner.WebService.Security;
|
||||
using StellaOps.Scanner.WebService.Tenancy;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
@@ -55,6 +56,7 @@ internal static class TriageInboxEndpoints
|
||||
[FromServices] IExploitPathGroupingService groupingService,
|
||||
[FromServices] IFindingQueryService findingService,
|
||||
[FromServices] TimeProvider timeProvider,
|
||||
HttpContext context,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(groupingService);
|
||||
@@ -70,7 +72,9 @@ internal static class TriageInboxEndpoints
|
||||
});
|
||||
}
|
||||
|
||||
var findings = await findingService.GetFindingsForArtifactAsync(artifactDigest, cancellationToken);
|
||||
var tenantId = ScannerRequestContextResolver.ResolveTenantOrDefault(context);
|
||||
|
||||
var findings = await findingService.GetFindingsForArtifactAsync(tenantId, artifactDigest, cancellationToken);
|
||||
var paths = similarityThreshold.HasValue
|
||||
? await groupingService.GroupFindingsAsync(artifactDigest, findings, similarityThreshold.Value, cancellationToken)
|
||||
: await groupingService.GroupFindingsAsync(artifactDigest, findings, cancellationToken);
|
||||
@@ -150,5 +154,5 @@ public sealed record TriageInboxResponse
|
||||
|
||||
public interface IFindingQueryService
|
||||
{
|
||||
Task<IReadOnlyList<Finding>> GetFindingsForArtifactAsync(string artifactDigest, CancellationToken ct);
|
||||
Task<IReadOnlyList<Finding>> GetFindingsForArtifactAsync(string tenantId, string artifactDigest, CancellationToken ct);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ using Microsoft.AspNetCore.Routing;
|
||||
using StellaOps.Scanner.WebService.Contracts;
|
||||
using StellaOps.Scanner.WebService.Security;
|
||||
using StellaOps.Scanner.WebService.Services;
|
||||
using StellaOps.Scanner.WebService.Tenancy;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
@@ -98,7 +99,21 @@ internal static class TriageStatusEndpoints
|
||||
});
|
||||
}
|
||||
|
||||
var status = await triageService.GetFindingStatusAsync(findingId, cancellationToken);
|
||||
if (!ScannerRequestContextResolver.TryResolveTenant(
|
||||
context,
|
||||
out var tenantId,
|
||||
out var tenantError,
|
||||
allowDefaultTenant: true))
|
||||
{
|
||||
return Results.BadRequest(new
|
||||
{
|
||||
type = "validation-error",
|
||||
title = "Invalid tenant context",
|
||||
detail = tenantError ?? "tenant_conflict"
|
||||
});
|
||||
}
|
||||
|
||||
var status = await triageService.GetFindingStatusAsync(tenantId, findingId, cancellationToken);
|
||||
if (status is null)
|
||||
{
|
||||
return Results.NotFound(new
|
||||
@@ -135,7 +150,21 @@ internal static class TriageStatusEndpoints
|
||||
// Get actor from context or request
|
||||
var actor = request.Actor ?? context.User?.Identity?.Name ?? "anonymous";
|
||||
|
||||
var result = await triageService.UpdateStatusAsync(findingId, request, actor, cancellationToken);
|
||||
if (!ScannerRequestContextResolver.TryResolveTenant(
|
||||
context,
|
||||
out var tenantId,
|
||||
out var tenantError,
|
||||
allowDefaultTenant: true))
|
||||
{
|
||||
return Results.BadRequest(new
|
||||
{
|
||||
type = "validation-error",
|
||||
title = "Invalid tenant context",
|
||||
detail = tenantError ?? "tenant_conflict"
|
||||
});
|
||||
}
|
||||
|
||||
var result = await triageService.UpdateStatusAsync(tenantId, findingId, request, actor, cancellationToken);
|
||||
if (result is null)
|
||||
{
|
||||
return Results.NotFound(new
|
||||
@@ -204,7 +233,21 @@ internal static class TriageStatusEndpoints
|
||||
}
|
||||
|
||||
var actor = request.IssuedBy ?? context.User?.Identity?.Name ?? "anonymous";
|
||||
var result = await triageService.SubmitVexStatementAsync(findingId, request, actor, cancellationToken);
|
||||
if (!ScannerRequestContextResolver.TryResolveTenant(
|
||||
context,
|
||||
out var tenantId,
|
||||
out var tenantError,
|
||||
allowDefaultTenant: true))
|
||||
{
|
||||
return Results.BadRequest(new
|
||||
{
|
||||
type = "validation-error",
|
||||
title = "Invalid tenant context",
|
||||
detail = tenantError ?? "tenant_conflict"
|
||||
});
|
||||
}
|
||||
|
||||
var result = await triageService.SubmitVexStatementAsync(tenantId, findingId, request, actor, cancellationToken);
|
||||
|
||||
if (result is null)
|
||||
{
|
||||
@@ -231,7 +274,21 @@ internal static class TriageStatusEndpoints
|
||||
// Apply reasonable defaults
|
||||
var limit = Math.Min(request.Limit ?? 100, 1000);
|
||||
|
||||
var result = await triageService.QueryFindingsAsync(request, limit, cancellationToken);
|
||||
if (!ScannerRequestContextResolver.TryResolveTenant(
|
||||
context,
|
||||
out var tenantId,
|
||||
out var tenantError,
|
||||
allowDefaultTenant: true))
|
||||
{
|
||||
return Results.BadRequest(new
|
||||
{
|
||||
type = "validation-error",
|
||||
title = "Invalid tenant context",
|
||||
detail = tenantError ?? "tenant_conflict"
|
||||
});
|
||||
}
|
||||
|
||||
var result = await triageService.QueryFindingsAsync(tenantId, request, limit, cancellationToken);
|
||||
return Results.Ok(result);
|
||||
}
|
||||
|
||||
@@ -253,7 +310,21 @@ internal static class TriageStatusEndpoints
|
||||
});
|
||||
}
|
||||
|
||||
var summary = await triageService.GetSummaryAsync(artifactDigest, cancellationToken);
|
||||
if (!ScannerRequestContextResolver.TryResolveTenant(
|
||||
context,
|
||||
out var tenantId,
|
||||
out var tenantError,
|
||||
allowDefaultTenant: true))
|
||||
{
|
||||
return Results.BadRequest(new
|
||||
{
|
||||
type = "validation-error",
|
||||
title = "Invalid tenant context",
|
||||
detail = tenantError ?? "tenant_conflict"
|
||||
});
|
||||
}
|
||||
|
||||
var summary = await triageService.GetSummaryAsync(tenantId, artifactDigest, cancellationToken);
|
||||
return Results.Ok(summary);
|
||||
}
|
||||
}
|
||||
@@ -267,12 +338,13 @@ public interface ITriageStatusService
|
||||
/// <summary>
|
||||
/// Gets triage status for a finding.
|
||||
/// </summary>
|
||||
Task<FindingTriageStatusDto?> GetFindingStatusAsync(string findingId, CancellationToken ct = default);
|
||||
Task<FindingTriageStatusDto?> GetFindingStatusAsync(string tenantId, string findingId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Updates triage status for a finding.
|
||||
/// </summary>
|
||||
Task<UpdateTriageStatusResponseDto?> UpdateStatusAsync(
|
||||
string tenantId,
|
||||
string findingId,
|
||||
UpdateTriageStatusRequestDto request,
|
||||
string actor,
|
||||
@@ -282,6 +354,7 @@ public interface ITriageStatusService
|
||||
/// Submits a VEX statement for a finding.
|
||||
/// </summary>
|
||||
Task<SubmitVexStatementResponseDto?> SubmitVexStatementAsync(
|
||||
string tenantId,
|
||||
string findingId,
|
||||
SubmitVexStatementRequestDto request,
|
||||
string actor,
|
||||
@@ -291,6 +364,7 @@ public interface ITriageStatusService
|
||||
/// Queries findings with filtering.
|
||||
/// </summary>
|
||||
Task<BulkTriageQueryResponseDto> QueryFindingsAsync(
|
||||
string tenantId,
|
||||
BulkTriageQueryRequestDto request,
|
||||
int limit,
|
||||
CancellationToken ct = default);
|
||||
@@ -298,5 +372,5 @@ public interface ITriageStatusService
|
||||
/// <summary>
|
||||
/// Gets triage summary for an artifact.
|
||||
/// </summary>
|
||||
Task<TriageSummaryDto> GetSummaryAsync(string artifactDigest, CancellationToken ct = default);
|
||||
Task<TriageSummaryDto> GetSummaryAsync(string tenantId, string artifactDigest, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
@@ -1,323 +1,460 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// UnknownsEndpoints.cs
|
||||
// Sprint: SPRINT_3600_0002_0001_unknowns_ranking_containment
|
||||
// Task: UNK-RANK-007, UNK-RANK-008 - Implement GET /unknowns API with sorting/pagination
|
||||
// Description: REST API for querying and filtering unknowns
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using StellaOps.Unknowns.Core.Models;
|
||||
using StellaOps.Unknowns.Core.Repositories;
|
||||
using StellaOps.Unknowns.Core.Services;
|
||||
using StellaOps.Scanner.WebService.Security;
|
||||
using StellaOps.Scanner.WebService.Services;
|
||||
using StellaOps.Scanner.WebService.Tenancy;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Endpoints;
|
||||
|
||||
internal static class UnknownsEndpoints
|
||||
{
|
||||
private const double HotBandThreshold = 0.70;
|
||||
private const double WarmBandThreshold = 0.40;
|
||||
private const string ExternalUnknownIdPrefix = "unk-";
|
||||
|
||||
public static void MapUnknownsEndpoints(this RouteGroupBuilder apiGroup)
|
||||
{
|
||||
var unknowns = apiGroup.MapGroup("/unknowns");
|
||||
ArgumentNullException.ThrowIfNull(apiGroup);
|
||||
|
||||
var unknowns = apiGroup.MapGroup("/unknowns")
|
||||
.WithTags("Unknowns")
|
||||
.RequireAuthorization(ScannerPolicies.ScansRead);
|
||||
|
||||
unknowns.MapGet("/", HandleListAsync)
|
||||
.WithName("scanner.unknowns.list")
|
||||
.Produces<UnknownsListResponse>(StatusCodes.Status200OK)
|
||||
.Produces<ProblemDetails>(StatusCodes.Status400BadRequest)
|
||||
.WithDescription("List unknowns with optional sorting and filtering");
|
||||
.Produces(StatusCodes.Status400BadRequest)
|
||||
.WithDescription("Lists unknown entries with tenant-scoped filtering.");
|
||||
|
||||
unknowns.MapGet("/stats", HandleGetStatsAsync)
|
||||
.WithName("scanner.unknowns.stats")
|
||||
.Produces<UnknownsStatsResponse>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status400BadRequest)
|
||||
.WithDescription("Returns tenant-scoped unknown summary statistics.");
|
||||
|
||||
unknowns.MapGet("/bands", HandleGetBandsAsync)
|
||||
.WithName("scanner.unknowns.bands")
|
||||
.Produces<UnknownsBandsResponse>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status400BadRequest)
|
||||
.WithDescription("Returns tenant-scoped unknown distribution by triage band.");
|
||||
|
||||
unknowns.MapGet("/{id}/evidence", HandleGetEvidenceAsync)
|
||||
.WithName("scanner.unknowns.evidence")
|
||||
.Produces<UnknownEvidenceResponse>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status404NotFound)
|
||||
.Produces(StatusCodes.Status400BadRequest)
|
||||
.WithDescription("Returns tenant-scoped unknown evidence metadata.");
|
||||
|
||||
unknowns.MapGet("/{id}/history", HandleGetHistoryAsync)
|
||||
.WithName("scanner.unknowns.history")
|
||||
.Produces<UnknownHistoryResponse>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status404NotFound)
|
||||
.Produces(StatusCodes.Status400BadRequest)
|
||||
.WithDescription("Returns tenant-scoped unknown history.");
|
||||
|
||||
unknowns.MapGet("/{id}", HandleGetByIdAsync)
|
||||
.WithName("scanner.unknowns.get")
|
||||
.Produces<UnknownDetailResponse>(StatusCodes.Status200OK)
|
||||
.Produces<ProblemDetails>(StatusCodes.Status404NotFound)
|
||||
.WithDescription("Get details of a specific unknown");
|
||||
|
||||
unknowns.MapGet("/{id}/proof", HandleGetProofAsync)
|
||||
.WithName("scanner.unknowns.proof")
|
||||
.Produces<UnknownProofResponse>(StatusCodes.Status200OK)
|
||||
.Produces<ProblemDetails>(StatusCodes.Status404NotFound)
|
||||
.WithDescription("Get the proof trail for an unknown ranking");
|
||||
.Produces(StatusCodes.Status404NotFound)
|
||||
.Produces(StatusCodes.Status400BadRequest)
|
||||
.WithDescription("Returns tenant-scoped unknown detail.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// GET /unknowns?sort=score&order=desc&artifact=sha256:...&reason=missing_vex&page=1&limit=50
|
||||
/// </summary>
|
||||
private static async Task<IResult> HandleListAsync(
|
||||
[FromQuery] string? sort,
|
||||
[FromQuery] string? order,
|
||||
[FromQuery] string? artifact,
|
||||
[FromQuery] string? reason,
|
||||
[FromQuery] string? kind,
|
||||
[FromQuery] string? severity,
|
||||
[FromQuery] double? minScore,
|
||||
[FromQuery] double? maxScore,
|
||||
[FromQuery] int? page,
|
||||
[FromQuery] string? artifactDigest,
|
||||
[FromQuery] string? vulnId,
|
||||
[FromQuery] string? band,
|
||||
[FromQuery] string? sortBy,
|
||||
[FromQuery] string? sortOrder,
|
||||
[FromQuery] int? limit,
|
||||
IUnknownRepository repository,
|
||||
IUnknownRanker ranker,
|
||||
TimeProvider timeProvider,
|
||||
[FromQuery] int? offset,
|
||||
IUnknownsQueryService queryService,
|
||||
HttpContext context,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// Validate and default pagination
|
||||
var pageNum = Math.Max(1, page ?? 1);
|
||||
var pageSize = Math.Clamp(limit ?? 50, 1, 200);
|
||||
|
||||
// Parse sort field
|
||||
var sortField = (sort?.ToLowerInvariant()) switch
|
||||
if (!TryResolveTenant(context, out var tenantId, out var failure))
|
||||
{
|
||||
"score" => UnknownSortField.Score,
|
||||
"created" => UnknownSortField.Created,
|
||||
"updated" => UnknownSortField.Updated,
|
||||
"severity" => UnknownSortField.Severity,
|
||||
"popularity" => UnknownSortField.Popularity,
|
||||
_ => UnknownSortField.Score // Default to score
|
||||
return failure!;
|
||||
}
|
||||
|
||||
if (!TryMapBand(band, out var mappedBand))
|
||||
{
|
||||
return Results.BadRequest(new
|
||||
{
|
||||
type = "validation-error",
|
||||
title = "Invalid band",
|
||||
detail = "Band must be one of HOT, WARM, or COLD."
|
||||
});
|
||||
}
|
||||
|
||||
var query = new UnknownsListQuery
|
||||
{
|
||||
ArtifactDigest = string.IsNullOrWhiteSpace(artifactDigest) ? null : artifactDigest.Trim(),
|
||||
VulnerabilityId = string.IsNullOrWhiteSpace(vulnId) ? null : vulnId.Trim(),
|
||||
Band = mappedBand,
|
||||
SortBy = MapSortField(sortBy),
|
||||
SortOrder = MapSortOrder(sortOrder),
|
||||
Limit = Math.Clamp(limit ?? 50, 1, 500),
|
||||
Offset = Math.Max(offset ?? 0, 0)
|
||||
};
|
||||
|
||||
var sortOrder = (order?.ToLowerInvariant()) switch
|
||||
var result = await queryService.ListAsync(tenantId, query, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.Ok(new UnknownsListResponse
|
||||
{
|
||||
"asc" => SortOrder.Ascending,
|
||||
_ => SortOrder.Descending // Default to descending (highest first)
|
||||
};
|
||||
|
||||
// Parse filters
|
||||
UnknownKind? kindFilter = kind != null && Enum.TryParse<UnknownKind>(kind, true, out var k) ? k : null;
|
||||
UnknownSeverity? severityFilter = severity != null && Enum.TryParse<UnknownSeverity>(severity, true, out var s) ? s : null;
|
||||
|
||||
var query = new UnknownListQuery(
|
||||
ArtifactDigest: artifact,
|
||||
Reason: reason,
|
||||
Kind: kindFilter,
|
||||
Severity: severityFilter,
|
||||
MinScore: minScore,
|
||||
MaxScore: maxScore,
|
||||
SortField: sortField,
|
||||
SortOrder: sortOrder,
|
||||
Page: pageNum,
|
||||
PageSize: pageSize);
|
||||
|
||||
var result = await repository.ListUnknownsAsync(query, cancellationToken);
|
||||
var now = timeProvider.GetUtcNow();
|
||||
|
||||
return Results.Ok(new UnknownsListResponse(
|
||||
Items: result.Items.Select(item => UnknownItemResponse.FromUnknownItem(item, now)).ToList(),
|
||||
TotalCount: result.TotalCount,
|
||||
Page: pageNum,
|
||||
PageSize: pageSize,
|
||||
TotalPages: (int)Math.Ceiling((double)result.TotalCount / pageSize),
|
||||
HasNextPage: pageNum * pageSize < result.TotalCount,
|
||||
HasPreviousPage: pageNum > 1));
|
||||
Items = result.Items
|
||||
.Select(MapItem)
|
||||
.ToArray(),
|
||||
TotalCount = result.TotalCount,
|
||||
Limit = query.Limit,
|
||||
Offset = query.Offset
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// GET /unknowns/{id}
|
||||
/// </summary>
|
||||
private static async Task<IResult> HandleGetByIdAsync(
|
||||
Guid id,
|
||||
IUnknownRepository repository,
|
||||
string id,
|
||||
IUnknownsQueryService queryService,
|
||||
HttpContext context,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var unknown = await repository.GetByIdAsync(id, cancellationToken);
|
||||
|
||||
if (unknown is null)
|
||||
if (!TryResolveTenant(context, out var tenantId, out var failure))
|
||||
{
|
||||
return Results.NotFound(new ProblemDetails
|
||||
{
|
||||
Title = "Unknown not found",
|
||||
Detail = $"No unknown found with ID: {id}",
|
||||
Status = StatusCodes.Status404NotFound
|
||||
});
|
||||
return failure!;
|
||||
}
|
||||
|
||||
return Results.Ok(UnknownDetailResponse.FromUnknown(unknown));
|
||||
if (!TryParseUnknownId(id, out var unknownId))
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
var detail = await queryService.GetByIdAsync(tenantId, unknownId, cancellationToken).ConfigureAwait(false);
|
||||
if (detail is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
return Results.Ok(MapDetail(detail));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// GET /unknowns/{id}/proof
|
||||
/// </summary>
|
||||
private static async Task<IResult> HandleGetProofAsync(
|
||||
Guid id,
|
||||
IUnknownRepository repository,
|
||||
private static async Task<IResult> HandleGetEvidenceAsync(
|
||||
string id,
|
||||
IUnknownsQueryService queryService,
|
||||
HttpContext context,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var unknown = await repository.GetByIdAsync(id, cancellationToken);
|
||||
|
||||
if (unknown is null)
|
||||
if (!TryResolveTenant(context, out var tenantId, out var failure))
|
||||
{
|
||||
return Results.NotFound(new ProblemDetails
|
||||
{
|
||||
Title = "Unknown not found",
|
||||
Detail = $"No unknown found with ID: {id}",
|
||||
Status = StatusCodes.Status404NotFound
|
||||
});
|
||||
return failure!;
|
||||
}
|
||||
|
||||
var proofRef = unknown.ProofRef;
|
||||
if (string.IsNullOrEmpty(proofRef))
|
||||
if (!TryParseUnknownId(id, out var unknownId))
|
||||
{
|
||||
return Results.NotFound(new ProblemDetails
|
||||
{
|
||||
Title = "Proof not available",
|
||||
Detail = $"No proof trail available for unknown: {id}",
|
||||
Status = StatusCodes.Status404NotFound
|
||||
});
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
// In a real implementation, read proof from storage
|
||||
return Results.Ok(new UnknownProofResponse(
|
||||
UnknownId: id,
|
||||
ProofRef: proofRef,
|
||||
CreatedAt: unknown.SysFrom));
|
||||
var detail = await queryService.GetByIdAsync(tenantId, unknownId, cancellationToken).ConfigureAwait(false);
|
||||
if (detail is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
return Results.Ok(new UnknownEvidenceResponse
|
||||
{
|
||||
Id = ToExternalUnknownId(detail.UnknownId),
|
||||
ProofRef = detail.ProofRef,
|
||||
LastUpdatedAtUtc = detail.UpdatedAtUtc
|
||||
});
|
||||
}
|
||||
|
||||
private static async Task<IResult> HandleGetHistoryAsync(
|
||||
string id,
|
||||
IUnknownsQueryService queryService,
|
||||
HttpContext context,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!TryResolveTenant(context, out var tenantId, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
if (!TryParseUnknownId(id, out var unknownId))
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
var detail = await queryService.GetByIdAsync(tenantId, unknownId, cancellationToken).ConfigureAwait(false);
|
||||
if (detail is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
return Results.Ok(new UnknownHistoryResponse
|
||||
{
|
||||
Id = ToExternalUnknownId(detail.UnknownId),
|
||||
History = new[]
|
||||
{
|
||||
new UnknownHistoryEntryResponse
|
||||
{
|
||||
CapturedAtUtc = detail.UpdatedAtUtc,
|
||||
Score = detail.Score,
|
||||
Band = DetermineBand(detail.Score)
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static async Task<IResult> HandleGetStatsAsync(
|
||||
IUnknownsQueryService queryService,
|
||||
HttpContext context,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!TryResolveTenant(context, out var tenantId, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
var stats = await queryService.GetStatsAsync(tenantId, cancellationToken).ConfigureAwait(false);
|
||||
return Results.Ok(new UnknownsStatsResponse
|
||||
{
|
||||
Total = stats.Total,
|
||||
Hot = stats.Hot,
|
||||
Warm = stats.Warm,
|
||||
Cold = stats.Cold
|
||||
});
|
||||
}
|
||||
|
||||
private static async Task<IResult> HandleGetBandsAsync(
|
||||
IUnknownsQueryService queryService,
|
||||
HttpContext context,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!TryResolveTenant(context, out var tenantId, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
var distribution = await queryService.GetBandDistributionAsync(tenantId, cancellationToken).ConfigureAwait(false);
|
||||
return Results.Ok(new UnknownsBandsResponse
|
||||
{
|
||||
Bands = distribution
|
||||
});
|
||||
}
|
||||
|
||||
private static bool TryResolveTenant(HttpContext context, out string tenantId, out IResult? failure)
|
||||
{
|
||||
tenantId = string.Empty;
|
||||
failure = null;
|
||||
|
||||
if (ScannerRequestContextResolver.TryResolveTenant(
|
||||
context,
|
||||
out tenantId,
|
||||
out var tenantError,
|
||||
allowDefaultTenant: true))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
failure = Results.BadRequest(new
|
||||
{
|
||||
type = "validation-error",
|
||||
title = "Invalid tenant context",
|
||||
detail = tenantError ?? "tenant_conflict"
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
private static UnknownsListItemResponse MapItem(UnknownsListItem item)
|
||||
{
|
||||
return new UnknownsListItemResponse
|
||||
{
|
||||
Id = ToExternalUnknownId(item.UnknownId),
|
||||
ArtifactDigest = item.ArtifactDigest,
|
||||
VulnerabilityId = item.VulnerabilityId,
|
||||
PackagePurl = item.PackagePurl,
|
||||
Score = item.Score,
|
||||
Band = DetermineBand(item.Score),
|
||||
CreatedAtUtc = item.CreatedAtUtc,
|
||||
UpdatedAtUtc = item.UpdatedAtUtc
|
||||
};
|
||||
}
|
||||
|
||||
private static UnknownDetailResponse MapDetail(UnknownsDetail detail)
|
||||
{
|
||||
return new UnknownDetailResponse
|
||||
{
|
||||
Id = ToExternalUnknownId(detail.UnknownId),
|
||||
ArtifactDigest = detail.ArtifactDigest,
|
||||
VulnerabilityId = detail.VulnerabilityId,
|
||||
PackagePurl = detail.PackagePurl,
|
||||
Score = detail.Score,
|
||||
Band = DetermineBand(detail.Score),
|
||||
ProofRef = detail.ProofRef,
|
||||
CreatedAtUtc = detail.CreatedAtUtc,
|
||||
UpdatedAtUtc = detail.UpdatedAtUtc
|
||||
};
|
||||
}
|
||||
|
||||
private static UnknownsSortField MapSortField(string? rawValue)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(rawValue))
|
||||
{
|
||||
return UnknownsSortField.Score;
|
||||
}
|
||||
|
||||
return rawValue.Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"score" => UnknownsSortField.Score,
|
||||
"created" => UnknownsSortField.CreatedAt,
|
||||
"createdat" => UnknownsSortField.CreatedAt,
|
||||
"updated" => UnknownsSortField.UpdatedAt,
|
||||
"updatedat" => UnknownsSortField.UpdatedAt,
|
||||
"lastseen" => UnknownsSortField.UpdatedAt,
|
||||
_ => UnknownsSortField.Score
|
||||
};
|
||||
}
|
||||
|
||||
private static UnknownsSortOrder MapSortOrder(string? rawValue)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(rawValue))
|
||||
{
|
||||
return UnknownsSortOrder.Descending;
|
||||
}
|
||||
|
||||
return rawValue.Trim().Equals("asc", StringComparison.OrdinalIgnoreCase)
|
||||
? UnknownsSortOrder.Ascending
|
||||
: UnknownsSortOrder.Descending;
|
||||
}
|
||||
|
||||
private static bool TryMapBand(string? rawValue, out UnknownsBand? band)
|
||||
{
|
||||
band = null;
|
||||
if (string.IsNullOrWhiteSpace(rawValue))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
switch (rawValue.Trim().ToUpperInvariant())
|
||||
{
|
||||
case "HOT":
|
||||
band = UnknownsBand.Hot;
|
||||
return true;
|
||||
case "WARM":
|
||||
band = UnknownsBand.Warm;
|
||||
return true;
|
||||
case "COLD":
|
||||
band = UnknownsBand.Cold;
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static string DetermineBand(double score)
|
||||
{
|
||||
if (score >= HotBandThreshold)
|
||||
{
|
||||
return "HOT";
|
||||
}
|
||||
|
||||
if (score >= WarmBandThreshold)
|
||||
{
|
||||
return "WARM";
|
||||
}
|
||||
|
||||
return "COLD";
|
||||
}
|
||||
|
||||
private static string ToExternalUnknownId(Guid unknownId)
|
||||
=> $"{ExternalUnknownIdPrefix}{unknownId:N}";
|
||||
|
||||
private static bool TryParseUnknownId(string rawValue, out Guid unknownId)
|
||||
{
|
||||
unknownId = Guid.Empty;
|
||||
if (string.IsNullOrWhiteSpace(rawValue))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var trimmed = rawValue.Trim();
|
||||
if (Guid.TryParse(trimmed, out unknownId))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!trimmed.StartsWith(ExternalUnknownIdPrefix, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var guidPart = trimmed[ExternalUnknownIdPrefix.Length..];
|
||||
return Guid.TryParseExact(guidPart, "N", out unknownId)
|
||||
|| Guid.TryParse(guidPart, out unknownId);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response model for unknowns list.
|
||||
/// </summary>
|
||||
public sealed record UnknownsListResponse(
|
||||
IReadOnlyList<UnknownItemResponse> Items,
|
||||
int TotalCount,
|
||||
int Page,
|
||||
int PageSize,
|
||||
int TotalPages,
|
||||
bool HasNextPage,
|
||||
bool HasPreviousPage);
|
||||
|
||||
/// <summary>
|
||||
/// Compact unknown item for list response.
|
||||
/// </summary>
|
||||
public sealed record UnknownItemResponse(
|
||||
Guid Id,
|
||||
string SubjectRef,
|
||||
string Kind,
|
||||
string? Severity,
|
||||
double Score,
|
||||
string TriageBand,
|
||||
string Priority,
|
||||
BlastRadiusResponse? BlastRadius,
|
||||
ContainmentResponse? Containment,
|
||||
DateTimeOffset CreatedAt)
|
||||
public sealed record UnknownsListResponse
|
||||
{
|
||||
public static UnknownItemResponse FromUnknownItem(UnknownItem item, DateTimeOffset now) => new(
|
||||
Id: Guid.TryParse(item.Id, out var id) ? id : Guid.Empty,
|
||||
SubjectRef: item.ArtifactPurl ?? item.ArtifactDigest,
|
||||
Kind: string.Join(",", item.Reasons),
|
||||
Severity: null, // Would come from full Unknown
|
||||
Score: item.Score,
|
||||
TriageBand: item.Score.ToTriageBand().ToString(),
|
||||
Priority: item.Score.ToPriorityLabel(),
|
||||
BlastRadius: item.BlastRadius != null
|
||||
? new BlastRadiusResponse(item.BlastRadius.Dependents, item.BlastRadius.NetFacing, item.BlastRadius.Privilege)
|
||||
: null,
|
||||
Containment: item.Containment != null
|
||||
? new ContainmentResponse(item.Containment.Seccomp, item.Containment.Fs)
|
||||
: null,
|
||||
CreatedAt: now); // Would come from Unknown.SysFrom
|
||||
public required IReadOnlyList<UnknownsListItemResponse> Items { get; init; }
|
||||
public required int TotalCount { get; init; }
|
||||
public required int Limit { get; init; }
|
||||
public required int Offset { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Blast radius in API response.
|
||||
/// </summary>
|
||||
public sealed record BlastRadiusResponse(int Dependents, bool NetFacing, string Privilege);
|
||||
|
||||
/// <summary>
|
||||
/// Containment signals in API response.
|
||||
/// </summary>
|
||||
public sealed record ContainmentResponse(string Seccomp, string Fs);
|
||||
|
||||
/// <summary>
|
||||
/// Detailed unknown response.
|
||||
/// </summary>
|
||||
public sealed record UnknownDetailResponse(
|
||||
Guid Id,
|
||||
string TenantId,
|
||||
string SubjectHash,
|
||||
string SubjectType,
|
||||
string SubjectRef,
|
||||
string Kind,
|
||||
string? Severity,
|
||||
double Score,
|
||||
string TriageBand,
|
||||
double PopularityScore,
|
||||
int DeploymentCount,
|
||||
double UncertaintyScore,
|
||||
BlastRadiusResponse? BlastRadius,
|
||||
ContainmentResponse? Containment,
|
||||
string? ProofRef,
|
||||
DateTimeOffset ValidFrom,
|
||||
DateTimeOffset? ValidTo,
|
||||
DateTimeOffset SysFrom,
|
||||
DateTimeOffset? ResolvedAt,
|
||||
string? ResolutionType,
|
||||
string? ResolutionRef)
|
||||
public sealed record UnknownsListItemResponse
|
||||
{
|
||||
public static UnknownDetailResponse FromUnknown(Unknown u) => new(
|
||||
Id: u.Id,
|
||||
TenantId: u.TenantId,
|
||||
SubjectHash: u.SubjectHash,
|
||||
SubjectType: u.SubjectType.ToString(),
|
||||
SubjectRef: u.SubjectRef,
|
||||
Kind: u.Kind.ToString(),
|
||||
Severity: u.Severity?.ToString(),
|
||||
Score: u.TriageScore,
|
||||
TriageBand: u.TriageScore.ToTriageBand().ToString(),
|
||||
PopularityScore: u.PopularityScore,
|
||||
DeploymentCount: u.DeploymentCount,
|
||||
UncertaintyScore: u.UncertaintyScore,
|
||||
BlastRadius: u.BlastDependents.HasValue
|
||||
? new BlastRadiusResponse(u.BlastDependents.Value, u.BlastNetFacing ?? false, u.BlastPrivilege ?? "user")
|
||||
: null,
|
||||
Containment: !string.IsNullOrEmpty(u.ContainmentSeccomp) || !string.IsNullOrEmpty(u.ContainmentFs)
|
||||
? new ContainmentResponse(u.ContainmentSeccomp ?? "unknown", u.ContainmentFs ?? "unknown")
|
||||
: null,
|
||||
ProofRef: u.ProofRef,
|
||||
ValidFrom: u.ValidFrom,
|
||||
ValidTo: u.ValidTo,
|
||||
SysFrom: u.SysFrom,
|
||||
ResolvedAt: u.ResolvedAt,
|
||||
ResolutionType: u.ResolutionType?.ToString(),
|
||||
ResolutionRef: u.ResolutionRef);
|
||||
public required string Id { get; init; }
|
||||
public required string ArtifactDigest { get; init; }
|
||||
public required string VulnerabilityId { get; init; }
|
||||
public required string PackagePurl { get; init; }
|
||||
public required double Score { get; init; }
|
||||
public required string Band { get; init; }
|
||||
public required DateTimeOffset CreatedAtUtc { get; init; }
|
||||
public required DateTimeOffset UpdatedAtUtc { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Proof trail response.
|
||||
/// </summary>
|
||||
public sealed record UnknownProofResponse(
|
||||
Guid UnknownId,
|
||||
string ProofRef,
|
||||
DateTimeOffset CreatedAt);
|
||||
|
||||
/// <summary>
|
||||
/// Sort fields for unknowns query.
|
||||
/// </summary>
|
||||
public enum UnknownSortField
|
||||
public sealed record UnknownDetailResponse
|
||||
{
|
||||
Score,
|
||||
Created,
|
||||
Updated,
|
||||
Severity,
|
||||
Popularity
|
||||
public required string Id { get; init; }
|
||||
public required string ArtifactDigest { get; init; }
|
||||
public required string VulnerabilityId { get; init; }
|
||||
public required string PackagePurl { get; init; }
|
||||
public required double Score { get; init; }
|
||||
public required string Band { get; init; }
|
||||
public string? ProofRef { get; init; }
|
||||
public required DateTimeOffset CreatedAtUtc { get; init; }
|
||||
public required DateTimeOffset UpdatedAtUtc { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sort order.
|
||||
/// </summary>
|
||||
public enum SortOrder
|
||||
public sealed record UnknownEvidenceResponse
|
||||
{
|
||||
Ascending,
|
||||
Descending
|
||||
public required string Id { get; init; }
|
||||
public string? ProofRef { get; init; }
|
||||
public required DateTimeOffset LastUpdatedAtUtc { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Query parameters for listing unknowns.
|
||||
/// </summary>
|
||||
public sealed record UnknownListQuery(
|
||||
string? ArtifactDigest,
|
||||
string? Reason,
|
||||
UnknownKind? Kind,
|
||||
UnknownSeverity? Severity,
|
||||
double? MinScore,
|
||||
double? MaxScore,
|
||||
UnknownSortField SortField,
|
||||
SortOrder SortOrder,
|
||||
int Page,
|
||||
int PageSize);
|
||||
public sealed record UnknownHistoryResponse
|
||||
{
|
||||
public required string Id { get; init; }
|
||||
public required IReadOnlyList<UnknownHistoryEntryResponse> History { get; init; }
|
||||
}
|
||||
|
||||
public sealed record UnknownHistoryEntryResponse
|
||||
{
|
||||
public required DateTimeOffset CapturedAtUtc { get; init; }
|
||||
public required double Score { get; init; }
|
||||
public required string Band { get; init; }
|
||||
}
|
||||
|
||||
public sealed record UnknownsStatsResponse
|
||||
{
|
||||
public required long Total { get; init; }
|
||||
public required long Hot { get; init; }
|
||||
public required long Warm { get; init; }
|
||||
public required long Cold { get; init; }
|
||||
}
|
||||
|
||||
public sealed record UnknownsBandsResponse
|
||||
{
|
||||
public required IReadOnlyDictionary<string, long> Bands { get; init; }
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ internal static class ValidationEndpoints
|
||||
|
||||
var group = app.MapGroup("/api/v1/sbom")
|
||||
.WithTags("Validation")
|
||||
.RequireAuthorization();
|
||||
.RequireAuthorization(ScannerPolicies.ScansRead);
|
||||
|
||||
// POST /api/v1/sbom/validate
|
||||
group.MapPost("/validate", ValidateSbomAsync)
|
||||
|
||||
@@ -11,7 +11,7 @@ using StellaOps.Scanner.Sources.Triggers;
|
||||
using StellaOps.Scanner.WebService.Constants;
|
||||
using StellaOps.Scanner.WebService.Infrastructure;
|
||||
using StellaOps.Scanner.WebService.Services;
|
||||
using System.Security.Cryptography;
|
||||
using StellaOps.Scanner.WebService.Tenancy;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
@@ -109,6 +109,22 @@ internal static class WebhookEndpoints
|
||||
HttpContext context,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var hasTenantContext = ScannerRequestContextResolver.TryResolveTenant(
|
||||
context,
|
||||
out var tenantId,
|
||||
out var tenantError,
|
||||
allowDefaultTenant: false);
|
||||
|
||||
if (!hasTenantContext && string.Equals(tenantError, "tenant_conflict", StringComparison.Ordinal))
|
||||
{
|
||||
return ProblemResultFactory.Create(
|
||||
context,
|
||||
ProblemTypes.Validation,
|
||||
"Invalid tenant context",
|
||||
StatusCodes.Status400BadRequest,
|
||||
detail: tenantError);
|
||||
}
|
||||
|
||||
// Read the raw payload
|
||||
using var reader = new StreamReader(context.Request.Body);
|
||||
var payloadString = await reader.ReadToEndAsync(ct);
|
||||
@@ -125,6 +141,15 @@ internal static class WebhookEndpoints
|
||||
StatusCodes.Status404NotFound);
|
||||
}
|
||||
|
||||
if (hasTenantContext && !string.Equals(source.TenantId, tenantId, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return ProblemResultFactory.Create(
|
||||
context,
|
||||
ProblemTypes.NotFound,
|
||||
"Source not found",
|
||||
StatusCodes.Status404NotFound);
|
||||
}
|
||||
|
||||
// Get the handler
|
||||
var handler = handlers.FirstOrDefault(h => h.SourceType == source.SourceType);
|
||||
if (handler == null || handler is not IWebhookCapableHandler webhookHandler)
|
||||
@@ -269,10 +294,24 @@ internal static class WebhookEndpoints
|
||||
HttpContext context,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (!ScannerRequestContextResolver.TryResolveTenant(
|
||||
context,
|
||||
out var tenantId,
|
||||
out var tenantError,
|
||||
allowDefaultTenant: false))
|
||||
{
|
||||
return ProblemResultFactory.Create(
|
||||
context,
|
||||
ProblemTypes.Validation,
|
||||
"Invalid tenant context",
|
||||
StatusCodes.Status400BadRequest,
|
||||
detail: tenantError ?? "tenant_missing");
|
||||
}
|
||||
|
||||
// Docker Hub uses callback_url for validation
|
||||
// and sends signature in body.callback_url when configured
|
||||
|
||||
var source = await FindSourceByNameAsync(sourceRepository, sourceName, SbomSourceType.Zastava, ct);
|
||||
var source = await FindSourceByNameAsync(sourceRepository, tenantId, sourceName, SbomSourceType.Zastava, ct);
|
||||
if (source == null)
|
||||
{
|
||||
return ProblemResultFactory.Create(
|
||||
@@ -308,6 +347,20 @@ internal static class WebhookEndpoints
|
||||
HttpContext context,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (!ScannerRequestContextResolver.TryResolveTenant(
|
||||
context,
|
||||
out var tenantId,
|
||||
out var tenantError,
|
||||
allowDefaultTenant: false))
|
||||
{
|
||||
return ProblemResultFactory.Create(
|
||||
context,
|
||||
ProblemTypes.Validation,
|
||||
"Invalid tenant context",
|
||||
StatusCodes.Status400BadRequest,
|
||||
detail: tenantError ?? "tenant_missing");
|
||||
}
|
||||
|
||||
// GitHub can send ping events for webhook validation
|
||||
if (eventType == "ping")
|
||||
{
|
||||
@@ -320,7 +373,7 @@ internal static class WebhookEndpoints
|
||||
return Results.Ok(new { message = $"Event type '{eventType}' ignored" });
|
||||
}
|
||||
|
||||
var source = await FindSourceByNameAsync(sourceRepository, sourceName, SbomSourceType.Git, ct);
|
||||
var source = await FindSourceByNameAsync(sourceRepository, tenantId, sourceName, SbomSourceType.Git, ct);
|
||||
if (source == null)
|
||||
{
|
||||
return ProblemResultFactory.Create(
|
||||
@@ -358,13 +411,27 @@ internal static class WebhookEndpoints
|
||||
HttpContext context,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (!ScannerRequestContextResolver.TryResolveTenant(
|
||||
context,
|
||||
out var tenantId,
|
||||
out var tenantError,
|
||||
allowDefaultTenant: false))
|
||||
{
|
||||
return ProblemResultFactory.Create(
|
||||
context,
|
||||
ProblemTypes.Validation,
|
||||
"Invalid tenant context",
|
||||
StatusCodes.Status400BadRequest,
|
||||
detail: tenantError ?? "tenant_missing");
|
||||
}
|
||||
|
||||
// Only process push and merge request events
|
||||
if (eventType != "Push Hook" && eventType != "Merge Request Hook" && eventType != "Tag Push Hook")
|
||||
{
|
||||
return Results.Ok(new { message = $"Event type '{eventType}' ignored" });
|
||||
}
|
||||
|
||||
var source = await FindSourceByNameAsync(sourceRepository, sourceName, SbomSourceType.Git, ct);
|
||||
var source = await FindSourceByNameAsync(sourceRepository, tenantId, sourceName, SbomSourceType.Git, ct);
|
||||
if (source == null)
|
||||
{
|
||||
return ProblemResultFactory.Create(
|
||||
@@ -400,7 +467,21 @@ internal static class WebhookEndpoints
|
||||
HttpContext context,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var source = await FindSourceByNameAsync(sourceRepository, sourceName, SbomSourceType.Zastava, ct);
|
||||
if (!ScannerRequestContextResolver.TryResolveTenant(
|
||||
context,
|
||||
out var tenantId,
|
||||
out var tenantError,
|
||||
allowDefaultTenant: false))
|
||||
{
|
||||
return ProblemResultFactory.Create(
|
||||
context,
|
||||
ProblemTypes.Validation,
|
||||
"Invalid tenant context",
|
||||
StatusCodes.Status400BadRequest,
|
||||
detail: tenantError ?? "tenant_missing");
|
||||
}
|
||||
|
||||
var source = await FindSourceByNameAsync(sourceRepository, tenantId, sourceName, SbomSourceType.Zastava, ct);
|
||||
if (source == null)
|
||||
{
|
||||
return ProblemResultFactory.Create(
|
||||
@@ -421,17 +502,17 @@ internal static class WebhookEndpoints
|
||||
ct);
|
||||
}
|
||||
|
||||
private static async Task<SbomSource?> FindSourceByNameAsync(
|
||||
internal static async Task<SbomSource?> FindSourceByNameAsync(
|
||||
ISbomSourceRepository repository,
|
||||
string tenantId,
|
||||
string name,
|
||||
SbomSourceType expectedType,
|
||||
CancellationToken ct)
|
||||
{
|
||||
// Search across all tenants for the source by name
|
||||
// Note: In production, this should be scoped to a specific tenant
|
||||
// extracted from the webhook URL or a custom header
|
||||
var sources = await repository.SearchByNameAsync(name, ct);
|
||||
return sources.FirstOrDefault(s => s.SourceType == expectedType);
|
||||
var source = await repository.GetByNameAsync(tenantId, name, ct).ConfigureAwait(false);
|
||||
return source is not null && source.SourceType == expectedType
|
||||
? source
|
||||
: null;
|
||||
}
|
||||
|
||||
private static async Task<IResult> ProcessWebhookAsync(
|
||||
|
||||
@@ -11,6 +11,7 @@ using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Scanner.WebService.Security;
|
||||
using StellaOps.Scanner.WebService.Tenancy;
|
||||
using System.Threading.RateLimiting;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Extensions;
|
||||
@@ -42,7 +43,7 @@ public static class RateLimitingExtensions
|
||||
// Proof replay: 100 requests per hour per tenant
|
||||
options.AddPolicy(ProofReplayPolicy, context =>
|
||||
{
|
||||
var tenantId = GetTenantId(context);
|
||||
var tenantId = GetPartitionKey(context);
|
||||
return RateLimitPartition.GetFixedWindowLimiter(
|
||||
partitionKey: $"proof-replay:{tenantId}",
|
||||
factory: _ => new FixedWindowRateLimiterOptions
|
||||
@@ -57,7 +58,7 @@ public static class RateLimitingExtensions
|
||||
// Manifest: 100 requests per hour per tenant
|
||||
options.AddPolicy(ManifestPolicy, context =>
|
||||
{
|
||||
var tenantId = GetTenantId(context);
|
||||
var tenantId = GetPartitionKey(context);
|
||||
return RateLimitPartition.GetFixedWindowLimiter(
|
||||
partitionKey: $"manifest:{tenantId}",
|
||||
factory: _ => new FixedWindowRateLimiterOptions
|
||||
@@ -98,31 +99,8 @@ public static class RateLimitingExtensions
|
||||
/// <summary>
|
||||
/// Extract tenant ID from the HTTP context for rate limiting partitioning.
|
||||
/// </summary>
|
||||
private static string GetTenantId(HttpContext context)
|
||||
private static string GetPartitionKey(HttpContext context)
|
||||
{
|
||||
// Try to get tenant from claims
|
||||
var tenantClaim = context.User?.FindFirst(ScannerClaims.TenantId);
|
||||
if (tenantClaim is not null && !string.IsNullOrWhiteSpace(tenantClaim.Value))
|
||||
{
|
||||
return tenantClaim.Value;
|
||||
}
|
||||
|
||||
// Fallback to tenant header
|
||||
if (context.Request.Headers.TryGetValue("X-Tenant-Id", out var headerValue) &&
|
||||
!string.IsNullOrWhiteSpace(headerValue))
|
||||
{
|
||||
return headerValue.ToString();
|
||||
}
|
||||
|
||||
// Fallback to IP address for unauthenticated requests
|
||||
return context.Connection.RemoteIpAddress?.ToString() ?? "unknown";
|
||||
return ScannerRequestContextResolver.ResolveTenantPartitionKey(context);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Scanner claims constants.
|
||||
/// </summary>
|
||||
public static class ScannerClaims
|
||||
{
|
||||
public const string TenantId = "tenant_id";
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ using Microsoft.Extensions.Options;
|
||||
using StellaOps.Scanner.Storage.Entities;
|
||||
using StellaOps.Scanner.Storage.Repositories;
|
||||
using StellaOps.Scanner.WebService.Options;
|
||||
using StellaOps.Scanner.WebService.Tenancy;
|
||||
using System.IO;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
@@ -79,8 +80,7 @@ public sealed class IdempotencyMiddleware
|
||||
return;
|
||||
}
|
||||
|
||||
// Get tenant ID from claims or use default
|
||||
var tenantId = GetTenantId(context);
|
||||
var tenantId = ScannerRequestContextResolver.ResolveTenantPartitionKey(context);
|
||||
|
||||
// Check for existing idempotency key
|
||||
var existingKey = await repository.TryGetAsync(tenantId, contentDigest, path, context.RequestAborted)
|
||||
@@ -188,20 +188,6 @@ public sealed class IdempotencyMiddleware
|
||||
return $"sha-256=:{base64Hash}:";
|
||||
}
|
||||
|
||||
private static string GetTenantId(HttpContext context)
|
||||
{
|
||||
// Try to get tenant from claims
|
||||
var tenantClaim = context.User?.FindFirst("tenant_id")?.Value;
|
||||
if (!string.IsNullOrEmpty(tenantClaim))
|
||||
{
|
||||
return tenantClaim;
|
||||
}
|
||||
|
||||
// Fall back to client IP or default
|
||||
var clientIp = context.Connection.RemoteIpAddress?.ToString();
|
||||
return !string.IsNullOrEmpty(clientIp) ? $"ip:{clientIp}" : "default";
|
||||
}
|
||||
|
||||
private static async Task WriteCachedResponseAsync(HttpContext context, IdempotencyKeyRow key)
|
||||
{
|
||||
context.Response.StatusCode = key.ResponseStatus;
|
||||
|
||||
@@ -18,6 +18,7 @@ using StellaOps.Configuration;
|
||||
using StellaOps.Cryptography.DependencyInjection;
|
||||
using StellaOps.Cryptography.Plugin.BouncyCastle;
|
||||
using StellaOps.Determinism;
|
||||
using StellaOps.Infrastructure.Postgres.Options;
|
||||
using StellaOps.Plugin.DependencyInjection;
|
||||
using StellaOps.Policy;
|
||||
using StellaOps.Policy.Explainability;
|
||||
@@ -31,6 +32,8 @@ using StellaOps.Scanner.Emit.Composition;
|
||||
using StellaOps.Scanner.Gate;
|
||||
using StellaOps.Scanner.ReachabilityDrift.DependencyInjection;
|
||||
using StellaOps.Scanner.SmartDiff.Detection;
|
||||
using StellaOps.Scanner.Sources.DependencyInjection;
|
||||
using StellaOps.Scanner.Sources.Persistence;
|
||||
using StellaOps.Scanner.Storage;
|
||||
using StellaOps.Scanner.Storage.Extensions;
|
||||
using StellaOps.Scanner.Storage.Postgres;
|
||||
@@ -206,6 +209,7 @@ builder.Services.AddScoped<ITriageQueryService, TriageQueryService>();
|
||||
builder.Services.AddScoped<ITriageStatusService, TriageStatusService>();
|
||||
builder.Services.TryAddScoped<IFindingQueryService, FindingQueryService>();
|
||||
builder.Services.TryAddSingleton<IExploitPathGroupingService, ExploitPathGroupingService>();
|
||||
builder.Services.AddScoped<IUnknownsQueryService, UnknownsQueryService>();
|
||||
|
||||
// Verdict rationale rendering (Sprint: SPRINT_20260106_001_001_LB_verdict_rationale_renderer)
|
||||
builder.Services.AddVerdictExplainability();
|
||||
@@ -329,6 +333,20 @@ builder.Services.AddScannerStorage(storageOptions =>
|
||||
storageOptions.ObjectStore.RustFs.BaseUrl = string.Empty;
|
||||
}
|
||||
});
|
||||
builder.Services.AddOptions<PostgresOptions>()
|
||||
.Configure(options =>
|
||||
{
|
||||
options.ConnectionString = bootstrapOptions.Storage.Dsn;
|
||||
options.CommandTimeoutSeconds = bootstrapOptions.Storage.CommandTimeoutSeconds;
|
||||
options.SchemaName = string.IsNullOrWhiteSpace(bootstrapOptions.Storage.Database)
|
||||
? ScannerStorageDefaults.DefaultSchemaName
|
||||
: bootstrapOptions.Storage.Database!.Trim();
|
||||
options.AutoMigrate = false;
|
||||
options.MigrationsPath = null;
|
||||
});
|
||||
builder.Services.TryAddSingleton<ScannerSourcesDataSource>();
|
||||
builder.Services.AddSbomSources();
|
||||
builder.Services.AddSbomSourceCredentialResolver<NullCredentialResolver>();
|
||||
builder.Services.AddSingleton<IPostConfigureOptions<ScannerStorageOptions>, ScannerStorageOptionsPostConfigurator>();
|
||||
builder.Services.AddOptions<StellaOps.Scanner.ProofSpine.Options.ProofSpineDsseSigningOptions>()
|
||||
.Bind(builder.Configuration.GetSection(StellaOps.Scanner.ProofSpine.Options.ProofSpineDsseSigningOptions.SectionName));
|
||||
@@ -633,6 +651,8 @@ if (app.Environment.IsEnvironment("Testing"))
|
||||
}
|
||||
|
||||
apiGroup.MapScanEndpoints(resolvedOptions.Api.ScansSegment);
|
||||
apiGroup.MapSourcesEndpoints();
|
||||
apiGroup.MapWebhookEndpoints();
|
||||
apiGroup.MapSbomUploadEndpoints();
|
||||
apiGroup.MapReachabilityDriftRootEndpoints();
|
||||
apiGroup.MapDeltaCompareEndpoints();
|
||||
@@ -652,6 +672,7 @@ apiGroup.MapTriageStatusEndpoints();
|
||||
apiGroup.MapTriageInboxEndpoints();
|
||||
apiGroup.MapBatchTriageEndpoints();
|
||||
apiGroup.MapProofBundleEndpoints();
|
||||
apiGroup.MapUnknownsEndpoints();
|
||||
apiGroup.MapSecretDetectionSettingsEndpoints(); // Sprint: SPRINT_20260104_006_BE
|
||||
apiGroup.MapSecurityAdapterEndpoints(); // Pack v2 security adapter routes
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using NpgsqlTypes;
|
||||
using StellaOps.Scanner.Core.Utility;
|
||||
using StellaOps.Scanner.Storage.Postgres;
|
||||
using StellaOps.Scanner.WebService.Contracts;
|
||||
using StellaOps.Scanner.WebService.Domain;
|
||||
@@ -14,8 +15,7 @@ namespace StellaOps.Scanner.WebService.Services;
|
||||
|
||||
internal sealed class CallGraphIngestionService : ICallGraphIngestionService
|
||||
{
|
||||
private const string TenantContext = "00000000-0000-0000-0000-000000000001";
|
||||
private static readonly Guid TenantId = Guid.Parse(TenantContext);
|
||||
private static readonly Guid TenantNamespace = new("ac8f2b54-72ea-43fa-9c3b-6a87ebd2d48a");
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
@@ -81,6 +81,7 @@ internal sealed class CallGraphIngestionService : ICallGraphIngestionService
|
||||
|
||||
public async Task<ExistingCallGraphDto?> FindByDigestAsync(
|
||||
ScanId scanId,
|
||||
string tenantId,
|
||||
string contentDigest,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
@@ -94,6 +95,8 @@ internal sealed class CallGraphIngestionService : ICallGraphIngestionService
|
||||
return null;
|
||||
}
|
||||
|
||||
var (tenantContext, tenantGuid) = ResolveTenantKey(tenantId);
|
||||
|
||||
var sql = $"""
|
||||
SELECT id, content_digest, created_at_utc
|
||||
FROM {CallGraphIngestionsTable}
|
||||
@@ -103,10 +106,10 @@ internal sealed class CallGraphIngestionService : ICallGraphIngestionService
|
||||
LIMIT 1
|
||||
""";
|
||||
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(TenantContext, "reader", cancellationToken)
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(tenantContext, "reader", cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
await using var command = new NpgsqlCommand(sql, connection);
|
||||
command.Parameters.AddWithValue("tenant_id", TenantId);
|
||||
command.Parameters.AddWithValue("tenant_id", tenantGuid);
|
||||
command.Parameters.AddWithValue("scan_id", scanId.Value.Trim());
|
||||
command.Parameters.AddWithValue("content_digest", contentDigest.Trim());
|
||||
|
||||
@@ -124,14 +127,17 @@ internal sealed class CallGraphIngestionService : ICallGraphIngestionService
|
||||
|
||||
public async Task<CallGraphIngestionResult> IngestAsync(
|
||||
ScanId scanId,
|
||||
string tenantId,
|
||||
CallGraphV1Dto callGraph,
|
||||
string contentDigest,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(callGraph);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(scanId.Value);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(contentDigest);
|
||||
|
||||
var (tenantContext, tenantGuid) = ResolveTenantKey(tenantId);
|
||||
var normalizedDigest = contentDigest.Trim();
|
||||
var callgraphId = CreateCallGraphId(scanId, normalizedDigest);
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
@@ -174,13 +180,13 @@ internal sealed class CallGraphIngestionService : ICallGraphIngestionService
|
||||
LIMIT 1
|
||||
""";
|
||||
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(TenantContext, "writer", cancellationToken)
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(tenantContext, "writer", cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
await using (var insert = new NpgsqlCommand(insertSql, connection))
|
||||
{
|
||||
insert.Parameters.AddWithValue("id", callgraphId);
|
||||
insert.Parameters.AddWithValue("tenant_id", TenantId);
|
||||
insert.Parameters.AddWithValue("tenant_id", tenantGuid);
|
||||
insert.Parameters.AddWithValue("scan_id", scanId.Value.Trim());
|
||||
insert.Parameters.AddWithValue("content_digest", normalizedDigest);
|
||||
insert.Parameters.AddWithValue("language", language);
|
||||
@@ -193,7 +199,7 @@ internal sealed class CallGraphIngestionService : ICallGraphIngestionService
|
||||
}
|
||||
|
||||
await using var select = new NpgsqlCommand(selectSql, connection);
|
||||
select.Parameters.AddWithValue("tenant_id", TenantId);
|
||||
select.Parameters.AddWithValue("tenant_id", tenantGuid);
|
||||
select.Parameters.AddWithValue("scan_id", scanId.Value.Trim());
|
||||
select.Parameters.AddWithValue("content_digest", normalizedDigest);
|
||||
|
||||
@@ -229,5 +235,21 @@ internal sealed class CallGraphIngestionService : ICallGraphIngestionService
|
||||
var hash = SHA256.HashData(bytes);
|
||||
return $"cg_{Convert.ToHexString(hash).ToLowerInvariant()}";
|
||||
}
|
||||
}
|
||||
|
||||
private static (string TenantContext, Guid TenantId) ResolveTenantKey(string tenantId)
|
||||
{
|
||||
var normalizedTenant = string.IsNullOrWhiteSpace(tenantId)
|
||||
? "default"
|
||||
: tenantId.Trim().ToLowerInvariant();
|
||||
|
||||
if (Guid.TryParse(normalizedTenant, out var parsed))
|
||||
{
|
||||
return (parsed.ToString("D"), parsed);
|
||||
}
|
||||
|
||||
var deterministic = ScannerIdentifiers.CreateDeterministicGuid(
|
||||
TenantNamespace,
|
||||
Encoding.UTF8.GetBytes(normalizedTenant));
|
||||
return (deterministic.ToString("D"), deterministic);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -171,11 +171,16 @@ internal sealed class DeltaScanRequestHandler : IDeltaScanRequestHandler
|
||||
metadata["stellaops:drift.newBinariesCount"] = newBinaries.Count.ToString();
|
||||
}
|
||||
|
||||
var tenantId = string.IsNullOrWhiteSpace(runtimeEvent.Tenant)
|
||||
? "default"
|
||||
: runtimeEvent.Tenant.Trim().ToLowerInvariant();
|
||||
|
||||
var submission = new ScanSubmission(
|
||||
scanTarget.Normalize(),
|
||||
Force: false,
|
||||
ClientRequestId: $"drift:{runtimeEvent.EventId}",
|
||||
Metadata: metadata);
|
||||
Metadata: metadata,
|
||||
TenantId: tenantId);
|
||||
|
||||
try
|
||||
{
|
||||
|
||||
@@ -21,18 +21,25 @@ public sealed class FindingQueryService : IFindingQueryService
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<Finding>> GetFindingsForArtifactAsync(string artifactDigest, CancellationToken ct)
|
||||
public async Task<IReadOnlyList<Finding>> GetFindingsForArtifactAsync(
|
||||
string tenantId,
|
||||
string artifactDigest,
|
||||
CancellationToken ct)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(artifactDigest))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var normalizedTenantId = tenantId.Trim().ToLowerInvariant();
|
||||
|
||||
var findings = await _dbContext.Findings
|
||||
.Include(static f => f.RiskResults)
|
||||
.Include(static f => f.ReachabilityResults)
|
||||
.AsNoTracking()
|
||||
.Where(f => f.ArtifactDigest == artifactDigest)
|
||||
.Where(f => f.TenantId == normalizedTenantId && f.ArtifactDigest == artifactDigest)
|
||||
.OrderBy(f => f.Id)
|
||||
.ToListAsync(ct)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
@@ -32,11 +32,15 @@ internal sealed class FindingRationaleService : IFindingRationaleService
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<VerdictRationaleResponseDto?> GetRationaleAsync(string findingId, CancellationToken ct = default)
|
||||
public async Task<VerdictRationaleResponseDto?> GetRationaleAsync(
|
||||
string tenantId,
|
||||
string findingId,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(findingId);
|
||||
|
||||
var finding = await _triageQueryService.GetFindingAsync(findingId, ct).ConfigureAwait(false);
|
||||
var finding = await _triageQueryService.GetFindingAsync(tenantId, findingId, ct).ConfigureAwait(false);
|
||||
if (finding is null)
|
||||
{
|
||||
_logger.LogDebug("Finding {FindingId} not found", findingId);
|
||||
@@ -52,11 +56,15 @@ internal sealed class FindingRationaleService : IFindingRationaleService
|
||||
return MapToDto(findingId, rationale);
|
||||
}
|
||||
|
||||
public async Task<RationalePlainTextResponseDto?> GetRationalePlainTextAsync(string findingId, CancellationToken ct = default)
|
||||
public async Task<RationalePlainTextResponseDto?> GetRationalePlainTextAsync(
|
||||
string tenantId,
|
||||
string findingId,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(findingId);
|
||||
|
||||
var finding = await _triageQueryService.GetFindingAsync(findingId, ct).ConfigureAwait(false);
|
||||
var finding = await _triageQueryService.GetFindingAsync(tenantId, findingId, ct).ConfigureAwait(false);
|
||||
if (finding is null)
|
||||
{
|
||||
return null;
|
||||
@@ -75,11 +83,15 @@ internal sealed class FindingRationaleService : IFindingRationaleService
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<RationalePlainTextResponseDto?> GetRationaleMarkdownAsync(string findingId, CancellationToken ct = default)
|
||||
public async Task<RationalePlainTextResponseDto?> GetRationaleMarkdownAsync(
|
||||
string tenantId,
|
||||
string findingId,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(findingId);
|
||||
|
||||
var finding = await _triageQueryService.GetFindingAsync(findingId, ct).ConfigureAwait(false);
|
||||
var finding = await _triageQueryService.GetFindingAsync(tenantId, findingId, ct).ConfigureAwait(false);
|
||||
if (finding is null)
|
||||
{
|
||||
return null;
|
||||
|
||||
@@ -35,21 +35,28 @@ public sealed class GatingReasonService : IGatingReasonService
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<FindingGatingStatusDto?> GetGatingStatusAsync(
|
||||
string tenantId,
|
||||
string findingId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
|
||||
if (!Guid.TryParse(findingId, out var id))
|
||||
{
|
||||
_logger.LogWarning("Invalid finding id format: {FindingId}", findingId);
|
||||
return null;
|
||||
}
|
||||
|
||||
var normalizedTenantId = tenantId.Trim().ToLowerInvariant();
|
||||
|
||||
var finding = await _dbContext.Findings
|
||||
.Include(f => f.ReachabilityResults)
|
||||
.Include(f => f.EffectiveVexRecords)
|
||||
.Include(f => f.PolicyDecisions)
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(f => f.Id == id, cancellationToken)
|
||||
.FirstOrDefaultAsync(
|
||||
f => f.Id == id && f.TenantId == normalizedTenantId,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (finding is null)
|
||||
@@ -63,9 +70,12 @@ public sealed class GatingReasonService : IGatingReasonService
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<FindingGatingStatusDto>> GetBulkGatingStatusAsync(
|
||||
string tenantId,
|
||||
IReadOnlyList<string> findingIds,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
|
||||
var validIds = findingIds
|
||||
.Where(id => Guid.TryParse(id, out _))
|
||||
.Select(Guid.Parse)
|
||||
@@ -76,12 +86,14 @@ public sealed class GatingReasonService : IGatingReasonService
|
||||
return Array.Empty<FindingGatingStatusDto>();
|
||||
}
|
||||
|
||||
var normalizedTenantId = tenantId.Trim().ToLowerInvariant();
|
||||
|
||||
var findings = await _dbContext.Findings
|
||||
.Include(f => f.ReachabilityResults)
|
||||
.Include(f => f.EffectiveVexRecords)
|
||||
.Include(f => f.PolicyDecisions)
|
||||
.AsNoTracking()
|
||||
.Where(f => validIds.Contains(f.Id))
|
||||
.Where(f => f.TenantId == normalizedTenantId && validIds.Contains(f.Id))
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
@@ -92,21 +104,26 @@ public sealed class GatingReasonService : IGatingReasonService
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<GatedBucketsSummaryDto?> GetGatedBucketsSummaryAsync(
|
||||
string tenantId,
|
||||
string scanId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
|
||||
if (!Guid.TryParse(scanId, out var id))
|
||||
{
|
||||
_logger.LogWarning("Invalid scan id format: {ScanId}", scanId);
|
||||
return null;
|
||||
}
|
||||
|
||||
var normalizedTenantId = tenantId.Trim().ToLowerInvariant();
|
||||
|
||||
var findings = await _dbContext.Findings
|
||||
.Include(f => f.ReachabilityResults)
|
||||
.Include(f => f.EffectiveVexRecords)
|
||||
.Include(f => f.PolicyDecisions)
|
||||
.AsNoTracking()
|
||||
.Where(f => f.ScanId == id)
|
||||
.Where(f => f.TenantId == normalizedTenantId && f.ScanId == id)
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ public interface ICallGraphIngestionService
|
||||
/// </summary>
|
||||
Task<ExistingCallGraphDto?> FindByDigestAsync(
|
||||
ScanId scanId,
|
||||
string tenantId,
|
||||
string contentDigest,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
@@ -30,6 +31,7 @@ public interface ICallGraphIngestionService
|
||||
/// </summary>
|
||||
Task<CallGraphIngestionResult> IngestAsync(
|
||||
ScanId scanId,
|
||||
string tenantId,
|
||||
CallGraphV1Dto callGraph,
|
||||
string contentDigest,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
@@ -20,7 +20,7 @@ public interface IFindingRationaleService
|
||||
/// <param name="findingId">Finding identifier.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Rationale response or null if finding not found.</returns>
|
||||
Task<VerdictRationaleResponseDto?> GetRationaleAsync(string findingId, CancellationToken ct = default);
|
||||
Task<VerdictRationaleResponseDto?> GetRationaleAsync(string tenantId, string findingId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Get the rationale as plain text (4-line format).
|
||||
@@ -28,7 +28,7 @@ public interface IFindingRationaleService
|
||||
/// <param name="findingId">Finding identifier.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Plain text response or null if finding not found.</returns>
|
||||
Task<RationalePlainTextResponseDto?> GetRationalePlainTextAsync(string findingId, CancellationToken ct = default);
|
||||
Task<RationalePlainTextResponseDto?> GetRationalePlainTextAsync(string tenantId, string findingId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Get the rationale as Markdown.
|
||||
@@ -36,5 +36,5 @@ public interface IFindingRationaleService
|
||||
/// <param name="findingId">Finding identifier.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Markdown response or null if finding not found.</returns>
|
||||
Task<RationalePlainTextResponseDto?> GetRationaleMarkdownAsync(string findingId, CancellationToken ct = default);
|
||||
Task<RationalePlainTextResponseDto?> GetRationaleMarkdownAsync(string tenantId, string findingId, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
@@ -16,30 +16,36 @@ public interface IGatingReasonService
|
||||
/// <summary>
|
||||
/// Computes the gating status for a single finding.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">Tenant identifier.</param>
|
||||
/// <param name="findingId">Finding identifier.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Gating status or null if finding not found.</returns>
|
||||
Task<FindingGatingStatusDto?> GetGatingStatusAsync(
|
||||
string tenantId,
|
||||
string findingId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Computes gating status for multiple findings.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">Tenant identifier.</param>
|
||||
/// <param name="findingIds">Finding identifiers.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Gating status for each finding.</returns>
|
||||
Task<IReadOnlyList<FindingGatingStatusDto>> GetBulkGatingStatusAsync(
|
||||
string tenantId,
|
||||
IReadOnlyList<string> findingIds,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Computes the gated buckets summary for a scan.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">Tenant identifier.</param>
|
||||
/// <param name="scanId">Scan identifier.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Summary of gated buckets or null if scan not found.</returns>
|
||||
Task<GatedBucketsSummaryDto?> GetGatedBucketsSummaryAsync(
|
||||
string tenantId,
|
||||
string scanId,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
@@ -16,20 +16,24 @@ public interface IReplayCommandService
|
||||
/// <summary>
|
||||
/// Generates replay commands for a finding.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">Tenant identifier.</param>
|
||||
/// <param name="request">Request parameters.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Replay command response or null if finding not found.</returns>
|
||||
Task<ReplayCommandResponseDto?> GenerateForFindingAsync(
|
||||
string tenantId,
|
||||
GenerateReplayCommandRequestDto request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Generates replay commands for an entire scan.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">Tenant identifier.</param>
|
||||
/// <param name="request">Request parameters.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Replay command response or null if scan not found.</returns>
|
||||
Task<ScanReplayCommandResponseDto?> GenerateForScanAsync(
|
||||
string tenantId,
|
||||
GenerateScanReplayCommandRequestDto request,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
@@ -4,5 +4,5 @@ namespace StellaOps.Scanner.WebService.Services;
|
||||
|
||||
public interface ITriageQueryService
|
||||
{
|
||||
Task<TriageFinding?> GetFindingAsync(string findingId, CancellationToken cancellationToken = default);
|
||||
Task<TriageFinding?> GetFindingAsync(string tenantId, string findingId, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
@@ -16,11 +16,13 @@ public interface IUnifiedEvidenceService
|
||||
/// <summary>
|
||||
/// Gets the complete unified evidence package for a finding.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">Tenant identifier.</param>
|
||||
/// <param name="findingId">Finding identifier.</param>
|
||||
/// <param name="options">Options controlling what evidence to include.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Unified evidence package or null if finding not found.</returns>
|
||||
Task<UnifiedEvidenceResponseDto?> GetUnifiedEvidenceAsync(
|
||||
string tenantId,
|
||||
string findingId,
|
||||
UnifiedEvidenceOptions? options = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
namespace StellaOps.Scanner.WebService.Services;
|
||||
|
||||
public interface IUnknownsQueryService
|
||||
{
|
||||
Task<UnknownsListResult> ListAsync(string tenantId, UnknownsListQuery query, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<UnknownsDetail?> GetByIdAsync(string tenantId, Guid unknownId, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<UnknownsStats> GetStatsAsync(string tenantId, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<IReadOnlyDictionary<string, long>> GetBandDistributionAsync(
|
||||
string tenantId,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public sealed record UnknownsListResult
|
||||
{
|
||||
public required IReadOnlyList<UnknownsListItem> Items { get; init; }
|
||||
public required int TotalCount { get; init; }
|
||||
}
|
||||
|
||||
public sealed record UnknownsListItem
|
||||
{
|
||||
public required Guid UnknownId { get; init; }
|
||||
public required string ArtifactDigest { get; init; }
|
||||
public required string VulnerabilityId { get; init; }
|
||||
public required string PackagePurl { get; init; }
|
||||
public required double Score { get; init; }
|
||||
public required DateTimeOffset CreatedAtUtc { get; init; }
|
||||
public required DateTimeOffset UpdatedAtUtc { get; init; }
|
||||
}
|
||||
|
||||
public sealed record UnknownsDetail
|
||||
{
|
||||
public required Guid UnknownId { get; init; }
|
||||
public required string ArtifactDigest { get; init; }
|
||||
public required string VulnerabilityId { get; init; }
|
||||
public required string PackagePurl { get; init; }
|
||||
public required double Score { get; init; }
|
||||
public string? ProofRef { get; init; }
|
||||
public required DateTimeOffset CreatedAtUtc { get; init; }
|
||||
public required DateTimeOffset UpdatedAtUtc { get; init; }
|
||||
}
|
||||
|
||||
public sealed record UnknownsStats
|
||||
{
|
||||
public required long Total { get; init; }
|
||||
public required long Hot { get; init; }
|
||||
public required long Warm { get; init; }
|
||||
public required long Cold { get; init; }
|
||||
}
|
||||
|
||||
public sealed record UnknownsListQuery
|
||||
{
|
||||
public string? ArtifactDigest { get; init; }
|
||||
public string? VulnerabilityId { get; init; }
|
||||
public UnknownsBand? Band { get; init; }
|
||||
public UnknownsSortField SortBy { get; init; } = UnknownsSortField.Score;
|
||||
public UnknownsSortOrder SortOrder { get; init; } = UnknownsSortOrder.Descending;
|
||||
public int Limit { get; init; } = 50;
|
||||
public int Offset { get; init; } = 0;
|
||||
}
|
||||
|
||||
public enum UnknownsBand
|
||||
{
|
||||
Hot,
|
||||
Warm,
|
||||
Cold
|
||||
}
|
||||
|
||||
public enum UnknownsSortField
|
||||
{
|
||||
Score,
|
||||
CreatedAt,
|
||||
UpdatedAt
|
||||
}
|
||||
|
||||
public enum UnknownsSortOrder
|
||||
{
|
||||
Ascending,
|
||||
Descending
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using StellaOps.Scanner.WebService.Domain;
|
||||
using StellaOps.Scanner.WebService.Tenancy;
|
||||
using StellaOps.Scanner.WebService.Utilities;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
@@ -8,6 +10,8 @@ namespace StellaOps.Scanner.WebService.Services;
|
||||
|
||||
public sealed class InMemoryScanCoordinator : IScanCoordinator
|
||||
{
|
||||
private const string DefaultTenant = "default";
|
||||
|
||||
private sealed record ScanEntry(ScanSnapshot Snapshot);
|
||||
|
||||
private readonly ConcurrentDictionary<string, ScanEntry> scans = new(StringComparer.OrdinalIgnoreCase);
|
||||
@@ -15,11 +19,21 @@ public sealed class InMemoryScanCoordinator : IScanCoordinator
|
||||
private readonly ConcurrentDictionary<string, string> scansByReference = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly TimeProvider timeProvider;
|
||||
private readonly IScanProgressPublisher progressPublisher;
|
||||
private readonly IHttpContextAccessor? httpContextAccessor;
|
||||
|
||||
public InMemoryScanCoordinator(TimeProvider timeProvider, IScanProgressPublisher progressPublisher)
|
||||
: this(timeProvider, progressPublisher, null)
|
||||
{
|
||||
}
|
||||
|
||||
public InMemoryScanCoordinator(
|
||||
TimeProvider timeProvider,
|
||||
IScanProgressPublisher progressPublisher,
|
||||
IHttpContextAccessor? httpContextAccessor)
|
||||
{
|
||||
this.timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
this.progressPublisher = progressPublisher ?? throw new ArgumentNullException(nameof(progressPublisher));
|
||||
this.httpContextAccessor = httpContextAccessor;
|
||||
}
|
||||
|
||||
public ValueTask<ScanSubmissionResult> SubmitAsync(ScanSubmission submission, CancellationToken cancellationToken)
|
||||
@@ -28,12 +42,19 @@ public sealed class InMemoryScanCoordinator : IScanCoordinator
|
||||
|
||||
var normalizedTarget = submission.Target.Normalize();
|
||||
var metadata = submission.Metadata ?? new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
var scanId = ScanIdGenerator.Create(normalizedTarget, submission.Force, submission.ClientRequestId, metadata);
|
||||
var normalizedTenant = ResolveSubmissionTenant(submission.TenantId);
|
||||
var scanId = ScanIdGenerator.Create(
|
||||
normalizedTarget,
|
||||
submission.Force,
|
||||
submission.ClientRequestId,
|
||||
metadata,
|
||||
normalizedTenant);
|
||||
var now = timeProvider.GetUtcNow();
|
||||
|
||||
var eventData = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["force"] = submission.Force,
|
||||
["tenant"] = normalizedTenant,
|
||||
};
|
||||
foreach (var pair in metadata)
|
||||
{
|
||||
@@ -50,7 +71,8 @@ public sealed class InMemoryScanCoordinator : IScanCoordinator
|
||||
now,
|
||||
null,
|
||||
null,
|
||||
null)),
|
||||
null,
|
||||
normalizedTenant)),
|
||||
(_, existing) =>
|
||||
{
|
||||
if (submission.Force)
|
||||
@@ -67,7 +89,7 @@ public sealed class InMemoryScanCoordinator : IScanCoordinator
|
||||
return existing;
|
||||
});
|
||||
|
||||
IndexTarget(scanId.Value, normalizedTarget);
|
||||
IndexTarget(scanId.Value, normalizedTarget, normalizedTenant);
|
||||
|
||||
var created = entry.Snapshot.CreatedAt == now;
|
||||
var state = entry.Snapshot.Status.ToString();
|
||||
@@ -79,6 +101,11 @@ public sealed class InMemoryScanCoordinator : IScanCoordinator
|
||||
{
|
||||
if (scans.TryGetValue(scanId.Value, out var entry))
|
||||
{
|
||||
if (!IsTenantAuthorized(entry.Snapshot.TenantId))
|
||||
{
|
||||
return ValueTask.FromResult<ScanSnapshot?>(null);
|
||||
}
|
||||
|
||||
return ValueTask.FromResult<ScanSnapshot?>(entry.Snapshot);
|
||||
}
|
||||
|
||||
@@ -87,13 +114,20 @@ public sealed class InMemoryScanCoordinator : IScanCoordinator
|
||||
|
||||
public ValueTask<ScanSnapshot?> TryFindByTargetAsync(string? reference, string? digest, CancellationToken cancellationToken)
|
||||
{
|
||||
var requestTenant = ResolveRequestTenantOrDefault();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(digest))
|
||||
{
|
||||
var normalizedDigest = NormalizeDigest(digest);
|
||||
if (normalizedDigest is not null &&
|
||||
scansByDigest.TryGetValue(normalizedDigest, out var digestScanId) &&
|
||||
scansByDigest.TryGetValue(BuildTargetKey(requestTenant, normalizedDigest), out var digestScanId) &&
|
||||
scans.TryGetValue(digestScanId, out var digestEntry))
|
||||
{
|
||||
if (!IsTenantAuthorized(digestEntry.Snapshot.TenantId))
|
||||
{
|
||||
return ValueTask.FromResult<ScanSnapshot?>(null);
|
||||
}
|
||||
|
||||
return ValueTask.FromResult<ScanSnapshot?>(digestEntry.Snapshot);
|
||||
}
|
||||
}
|
||||
@@ -102,9 +136,14 @@ public sealed class InMemoryScanCoordinator : IScanCoordinator
|
||||
{
|
||||
var normalizedReference = NormalizeReference(reference);
|
||||
if (normalizedReference is not null &&
|
||||
scansByReference.TryGetValue(normalizedReference, out var referenceScanId) &&
|
||||
scansByReference.TryGetValue(BuildTargetKey(requestTenant, normalizedReference), out var referenceScanId) &&
|
||||
scans.TryGetValue(referenceScanId, out var referenceEntry))
|
||||
{
|
||||
if (!IsTenantAuthorized(referenceEntry.Snapshot.TenantId))
|
||||
{
|
||||
return ValueTask.FromResult<ScanSnapshot?>(null);
|
||||
}
|
||||
|
||||
return ValueTask.FromResult<ScanSnapshot?>(referenceEntry.Snapshot);
|
||||
}
|
||||
}
|
||||
@@ -121,6 +160,11 @@ public sealed class InMemoryScanCoordinator : IScanCoordinator
|
||||
return ValueTask.FromResult(false);
|
||||
}
|
||||
|
||||
if (!IsTenantAuthorized(existing.Snapshot.TenantId))
|
||||
{
|
||||
return ValueTask.FromResult(false);
|
||||
}
|
||||
|
||||
var updated = existing.Snapshot with
|
||||
{
|
||||
Replay = replay,
|
||||
@@ -145,6 +189,11 @@ public sealed class InMemoryScanCoordinator : IScanCoordinator
|
||||
return ValueTask.FromResult(false);
|
||||
}
|
||||
|
||||
if (!IsTenantAuthorized(existing.Snapshot.TenantId))
|
||||
{
|
||||
return ValueTask.FromResult(false);
|
||||
}
|
||||
|
||||
var updated = existing.Snapshot with
|
||||
{
|
||||
Entropy = entropy,
|
||||
@@ -160,19 +209,88 @@ public sealed class InMemoryScanCoordinator : IScanCoordinator
|
||||
return ValueTask.FromResult(true);
|
||||
}
|
||||
|
||||
private void IndexTarget(string scanId, ScanTarget target)
|
||||
private void IndexTarget(string scanId, ScanTarget target, string tenantId)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(target.Digest))
|
||||
{
|
||||
scansByDigest[target.Digest!] = scanId;
|
||||
scansByDigest[BuildTargetKey(tenantId, target.Digest!)] = scanId;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(target.Reference))
|
||||
{
|
||||
scansByReference[target.Reference!] = scanId;
|
||||
scansByReference[BuildTargetKey(tenantId, target.Reference!)] = scanId;
|
||||
}
|
||||
}
|
||||
|
||||
private string ResolveSubmissionTenant(string? submissionTenant)
|
||||
{
|
||||
var normalizedTenant = NormalizeTenant(submissionTenant);
|
||||
if (!string.Equals(normalizedTenant, DefaultTenant, StringComparison.Ordinal))
|
||||
{
|
||||
return normalizedTenant;
|
||||
}
|
||||
|
||||
if (TryResolveRequestTenant(out var requestTenant))
|
||||
{
|
||||
return requestTenant;
|
||||
}
|
||||
|
||||
return normalizedTenant;
|
||||
}
|
||||
|
||||
private string ResolveRequestTenantOrDefault()
|
||||
{
|
||||
if (TryResolveRequestTenant(out var requestTenant))
|
||||
{
|
||||
return requestTenant;
|
||||
}
|
||||
|
||||
return DefaultTenant;
|
||||
}
|
||||
|
||||
private bool IsTenantAuthorized(string snapshotTenant)
|
||||
{
|
||||
var context = httpContextAccessor?.HttpContext;
|
||||
if (context is null)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!ScannerRequestContextResolver.TryResolveTenant(
|
||||
context,
|
||||
out var requestTenant,
|
||||
out _,
|
||||
allowDefaultTenant: true))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return string.Equals(
|
||||
requestTenant,
|
||||
NormalizeTenant(snapshotTenant),
|
||||
StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private bool TryResolveRequestTenant(out string tenantId)
|
||||
{
|
||||
tenantId = string.Empty;
|
||||
|
||||
var context = httpContextAccessor?.HttpContext;
|
||||
if (context is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return ScannerRequestContextResolver.TryResolveTenant(
|
||||
context,
|
||||
out tenantId,
|
||||
out _,
|
||||
allowDefaultTenant: true);
|
||||
}
|
||||
|
||||
private static string BuildTargetKey(string tenantId, string targetValue)
|
||||
=> $"{NormalizeTenant(tenantId)}|{targetValue}";
|
||||
|
||||
private static string? NormalizeDigest(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
@@ -195,4 +313,11 @@ public sealed class InMemoryScanCoordinator : IScanCoordinator
|
||||
|
||||
return value.Trim();
|
||||
}
|
||||
|
||||
private static string NormalizeTenant(string? value)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(value)
|
||||
? DefaultTenant
|
||||
: value.Trim().ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
using StellaOps.Scanner.Sources.Services;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Safe default credential resolver for source and webhook endpoints.
|
||||
/// Deployments can replace this via DI with a vault-backed implementation.
|
||||
/// </summary>
|
||||
internal sealed class NullCredentialResolver : ICredentialResolver
|
||||
{
|
||||
public Task<ResolvedCredential?> ResolveAsync(string authRef, CancellationToken ct = default)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
return Task.FromResult<ResolvedCredential?>(null);
|
||||
}
|
||||
|
||||
public Task<bool> ValidateRefAsync(string authRef, CancellationToken ct = default)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
}
|
||||
@@ -38,19 +38,26 @@ public sealed class ReplayCommandService : IReplayCommandService
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ReplayCommandResponseDto?> GenerateForFindingAsync(
|
||||
string tenantId,
|
||||
GenerateReplayCommandRequestDto request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
|
||||
if (!Guid.TryParse(request.FindingId, out var id))
|
||||
{
|
||||
_logger.LogWarning("Invalid finding id format: {FindingId}", request.FindingId);
|
||||
return null;
|
||||
}
|
||||
|
||||
var normalizedTenantId = tenantId.Trim().ToLowerInvariant();
|
||||
|
||||
var finding = await _dbContext.Findings
|
||||
.Include(f => f.Scan)
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(f => f.Id == id, cancellationToken)
|
||||
.FirstOrDefaultAsync(
|
||||
f => f.Id == id && f.TenantId == normalizedTenantId,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (finding is null)
|
||||
@@ -102,18 +109,25 @@ public sealed class ReplayCommandService : IReplayCommandService
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ScanReplayCommandResponseDto?> GenerateForScanAsync(
|
||||
string tenantId,
|
||||
GenerateScanReplayCommandRequestDto request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
|
||||
if (!Guid.TryParse(request.ScanId, out var id))
|
||||
{
|
||||
_logger.LogWarning("Invalid scan id format: {ScanId}", request.ScanId);
|
||||
return null;
|
||||
}
|
||||
|
||||
var normalizedTenantId = tenantId.Trim().ToLowerInvariant();
|
||||
|
||||
var scan = await _dbContext.Scans
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(s => s.Id == id, cancellationToken)
|
||||
.FirstOrDefaultAsync(
|
||||
s => s.Id == id && s.TenantId == normalizedTenantId,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (scan is null)
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Determinism;
|
||||
using StellaOps.Policy;
|
||||
using StellaOps.Scanner.Core.Utility;
|
||||
@@ -10,12 +9,12 @@ using StellaOps.Scanner.Storage.Models;
|
||||
using StellaOps.Scanner.Storage.Services;
|
||||
using StellaOps.Scanner.WebService.Contracts;
|
||||
using StellaOps.Scanner.WebService.Options;
|
||||
using StellaOps.Scanner.WebService.Tenancy;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Diagnostics;
|
||||
using System.Linq;
|
||||
using System.Security.Claims;
|
||||
using System.Text;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Services;
|
||||
@@ -364,22 +363,7 @@ internal sealed class ReportEventDispatcher : IReportEventDispatcher
|
||||
|
||||
private static string ResolveTenant(HttpContext context)
|
||||
{
|
||||
var tenant = context.User?.FindFirstValue(StellaOpsClaimTypes.Tenant);
|
||||
if (!string.IsNullOrWhiteSpace(tenant))
|
||||
{
|
||||
return tenant.Trim();
|
||||
}
|
||||
|
||||
if (context.Request.Headers.TryGetValue("X-Stella-Tenant", out var headerTenant))
|
||||
{
|
||||
var headerValue = headerTenant.ToString();
|
||||
if (!string.IsNullOrWhiteSpace(headerValue))
|
||||
{
|
||||
return headerValue.Trim();
|
||||
}
|
||||
}
|
||||
|
||||
return DefaultTenant;
|
||||
return ScannerRequestContextResolver.ResolveTenantOrDefault(context, DefaultTenant);
|
||||
}
|
||||
|
||||
private static OrchestratorEventScope BuildScope(ReportRequestDto request, ReportDocumentDto document)
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Scanner.Storage.Catalog;
|
||||
using StellaOps.Scanner.WebService.Contracts;
|
||||
using StellaOps.Scanner.WebService.Domain;
|
||||
using StellaOps.Scanner.WebService.Tenancy;
|
||||
using StellaOps.Scanner.WebService.Utilities;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
@@ -30,19 +32,22 @@ internal sealed class SbomByosUploadService : ISbomByosUploadService
|
||||
private readonly ISbomUploadStore _uploadStore;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<SbomByosUploadService> _logger;
|
||||
private readonly IHttpContextAccessor _httpContextAccessor;
|
||||
|
||||
public SbomByosUploadService(
|
||||
IScanCoordinator scanCoordinator,
|
||||
ISbomIngestionService ingestionService,
|
||||
ISbomUploadStore uploadStore,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<SbomByosUploadService> logger)
|
||||
ILogger<SbomByosUploadService> logger,
|
||||
IHttpContextAccessor httpContextAccessor)
|
||||
{
|
||||
_scanCoordinator = scanCoordinator ?? throw new ArgumentNullException(nameof(scanCoordinator));
|
||||
_ingestionService = ingestionService ?? throw new ArgumentNullException(nameof(ingestionService));
|
||||
_uploadStore = uploadStore ?? throw new ArgumentNullException(nameof(uploadStore));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_httpContextAccessor = httpContextAccessor ?? throw new ArgumentNullException(nameof(httpContextAccessor));
|
||||
}
|
||||
|
||||
public async Task<(SbomUploadResponseDto Response, SbomValidationSummaryDto Validation)> UploadAsync(
|
||||
@@ -118,7 +123,8 @@ internal sealed class SbomByosUploadService : ISbomByosUploadService
|
||||
|
||||
var metadata = BuildMetadata(request, format, formatVersion, digest, sbomId);
|
||||
var target = new ScanTarget(request.ArtifactRef.Trim(), request.ArtifactDigest?.Trim()).Normalize();
|
||||
var scanId = ScanIdGenerator.Create(target, force: false, clientRequestId: null, metadata);
|
||||
var tenantId = ResolveTenantId();
|
||||
var scanId = ScanIdGenerator.Create(target, force: false, clientRequestId: null, metadata, tenantId);
|
||||
|
||||
var ingestion = await _ingestionService
|
||||
.IngestAsync(
|
||||
@@ -131,7 +137,7 @@ internal sealed class SbomByosUploadService : ISbomByosUploadService
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var submission = new ScanSubmission(target, false, null, metadata);
|
||||
var submission = new ScanSubmission(target, false, null, metadata, tenantId);
|
||||
var scanResult = await _scanCoordinator.SubmitAsync(submission, cancellationToken).ConfigureAwait(false);
|
||||
if (!string.Equals(scanResult.Snapshot.ScanId.Value, scanId.Value, StringComparison.Ordinal))
|
||||
{
|
||||
@@ -241,6 +247,14 @@ internal sealed class SbomByosUploadService : ISbomByosUploadService
|
||||
return null;
|
||||
}
|
||||
|
||||
private string ResolveTenantId()
|
||||
{
|
||||
var context = _httpContextAccessor.HttpContext;
|
||||
return context is null
|
||||
? "default"
|
||||
: ScannerRequestContextResolver.ResolveTenantOrDefault(context);
|
||||
}
|
||||
|
||||
private static (string Format, string FormatVersion) ResolveFormat(JsonElement root, string? requestedFormat)
|
||||
{
|
||||
var format = string.IsNullOrWhiteSpace(requestedFormat)
|
||||
|
||||
@@ -56,6 +56,7 @@ public interface ISecretExceptionPatternService
|
||||
|
||||
/// <summary>Gets a specific pattern by ID.</summary>
|
||||
Task<SecretExceptionPatternResponseDto?> GetPatternAsync(
|
||||
Guid tenantId,
|
||||
Guid patternId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
@@ -68,6 +69,7 @@ public interface ISecretExceptionPatternService
|
||||
|
||||
/// <summary>Updates an exception pattern.</summary>
|
||||
Task<(bool Success, SecretExceptionPatternResponseDto? Pattern, IReadOnlyList<string> Errors)> UpdatePatternAsync(
|
||||
Guid tenantId,
|
||||
Guid patternId,
|
||||
SecretExceptionPatternDto pattern,
|
||||
string updatedBy,
|
||||
@@ -75,6 +77,7 @@ public interface ISecretExceptionPatternService
|
||||
|
||||
/// <summary>Deletes an exception pattern.</summary>
|
||||
Task<bool> DeletePatternAsync(
|
||||
Guid tenantId,
|
||||
Guid patternId,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -346,10 +349,11 @@ public sealed class SecretExceptionPatternService : ISecretExceptionPatternServi
|
||||
}
|
||||
|
||||
public async Task<SecretExceptionPatternResponseDto?> GetPatternAsync(
|
||||
Guid tenantId,
|
||||
Guid patternId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var pattern = await _repository.GetByIdAsync(patternId, cancellationToken).ConfigureAwait(false);
|
||||
var pattern = await _repository.GetByIdAsync(tenantId, patternId, cancellationToken).ConfigureAwait(false);
|
||||
return pattern is null ? null : MapToDto(pattern);
|
||||
}
|
||||
|
||||
@@ -384,12 +388,13 @@ public sealed class SecretExceptionPatternService : ISecretExceptionPatternServi
|
||||
}
|
||||
|
||||
public async Task<(bool Success, SecretExceptionPatternResponseDto? Pattern, IReadOnlyList<string> Errors)> UpdatePatternAsync(
|
||||
Guid tenantId,
|
||||
Guid patternId,
|
||||
SecretExceptionPatternDto pattern,
|
||||
string updatedBy,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var existing = await _repository.GetByIdAsync(patternId, cancellationToken).ConfigureAwait(false);
|
||||
var existing = await _repository.GetByIdAsync(tenantId, patternId, cancellationToken).ConfigureAwait(false);
|
||||
if (existing is null)
|
||||
{
|
||||
return (false, null, ["Pattern not found"]);
|
||||
@@ -412,21 +417,22 @@ public sealed class SecretExceptionPatternService : ISecretExceptionPatternServi
|
||||
existing.UpdatedBy = updatedBy;
|
||||
existing.UpdatedAt = _timeProvider.GetUtcNow();
|
||||
|
||||
var success = await _repository.UpdateAsync(existing, cancellationToken).ConfigureAwait(false);
|
||||
var success = await _repository.UpdateAsync(tenantId, existing, cancellationToken).ConfigureAwait(false);
|
||||
if (!success)
|
||||
{
|
||||
return (false, null, ["Failed to update pattern"]);
|
||||
}
|
||||
|
||||
var updated = await _repository.GetByIdAsync(patternId, cancellationToken).ConfigureAwait(false);
|
||||
var updated = await _repository.GetByIdAsync(tenantId, patternId, cancellationToken).ConfigureAwait(false);
|
||||
return (true, updated is null ? null : MapToDto(updated), []);
|
||||
}
|
||||
|
||||
public async Task<bool> DeletePatternAsync(
|
||||
Guid tenantId,
|
||||
Guid patternId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _repository.DeleteAsync(patternId, cancellationToken).ConfigureAwait(false);
|
||||
return await _repository.DeleteAsync(tenantId, patternId, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> ValidatePattern(SecretExceptionPatternDto pattern)
|
||||
|
||||
@@ -15,21 +15,30 @@ public sealed class TriageQueryService : ITriageQueryService
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<TriageFinding?> GetFindingAsync(string findingId, CancellationToken cancellationToken = default)
|
||||
public async Task<TriageFinding?> GetFindingAsync(
|
||||
string tenantId,
|
||||
string findingId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
|
||||
if (!Guid.TryParse(findingId, out var id))
|
||||
{
|
||||
_logger.LogWarning("Invalid finding id: {FindingId}", findingId);
|
||||
return null;
|
||||
}
|
||||
|
||||
var normalizedTenantId = tenantId.Trim().ToLowerInvariant();
|
||||
|
||||
return await _dbContext.Findings
|
||||
.Include(f => f.ReachabilityResults)
|
||||
.Include(f => f.RiskResults)
|
||||
.Include(f => f.EffectiveVexRecords)
|
||||
.Include(f => f.EvidenceArtifacts)
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(f => f.Id == id, cancellationToken)
|
||||
.FirstOrDefaultAsync(
|
||||
f => f.Id == id && f.TenantId == normalizedTenantId,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,12 +39,14 @@ public sealed class TriageStatusService : ITriageStatusService
|
||||
}
|
||||
|
||||
public async Task<FindingTriageStatusDto?> GetFindingStatusAsync(
|
||||
string tenantId,
|
||||
string findingId,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
_logger.LogDebug("Getting triage status for finding {FindingId}", findingId);
|
||||
|
||||
var finding = await _queryService.GetFindingAsync(findingId, ct);
|
||||
var finding = await _queryService.GetFindingAsync(tenantId, findingId, ct);
|
||||
if (finding is null)
|
||||
{
|
||||
return null;
|
||||
@@ -54,14 +56,16 @@ public sealed class TriageStatusService : ITriageStatusService
|
||||
}
|
||||
|
||||
public async Task<UpdateTriageStatusResponseDto?> UpdateStatusAsync(
|
||||
string tenantId,
|
||||
string findingId,
|
||||
UpdateTriageStatusRequestDto request,
|
||||
string actor,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
_logger.LogDebug("Updating triage status for finding {FindingId} by {Actor}", findingId, actor);
|
||||
|
||||
var finding = await _queryService.GetFindingAsync(findingId, ct);
|
||||
var finding = await _queryService.GetFindingAsync(tenantId, findingId, ct);
|
||||
if (finding is null)
|
||||
{
|
||||
return null;
|
||||
@@ -95,14 +99,16 @@ public sealed class TriageStatusService : ITriageStatusService
|
||||
}
|
||||
|
||||
public async Task<SubmitVexStatementResponseDto?> SubmitVexStatementAsync(
|
||||
string tenantId,
|
||||
string findingId,
|
||||
SubmitVexStatementRequestDto request,
|
||||
string actor,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
_logger.LogDebug("Submitting VEX statement for finding {FindingId} by {Actor}", findingId, actor);
|
||||
|
||||
var finding = await _queryService.GetFindingAsync(findingId, ct);
|
||||
var finding = await _queryService.GetFindingAsync(tenantId, findingId, ct);
|
||||
if (finding is null)
|
||||
{
|
||||
return null;
|
||||
@@ -137,10 +143,12 @@ public sealed class TriageStatusService : ITriageStatusService
|
||||
}
|
||||
|
||||
public Task<BulkTriageQueryResponseDto> QueryFindingsAsync(
|
||||
string tenantId,
|
||||
BulkTriageQueryRequestDto request,
|
||||
int limit,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
_logger.LogDebug("Querying findings with limit {Limit}", limit);
|
||||
|
||||
// In a full implementation, this would query the database
|
||||
@@ -162,8 +170,9 @@ public sealed class TriageStatusService : ITriageStatusService
|
||||
return Task.FromResult(response);
|
||||
}
|
||||
|
||||
public Task<TriageSummaryDto> GetSummaryAsync(string artifactDigest, CancellationToken ct = default)
|
||||
public Task<TriageSummaryDto> GetSummaryAsync(string tenantId, string artifactDigest, CancellationToken ct = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
_logger.LogDebug("Getting triage summary for artifact {ArtifactDigest}", artifactDigest);
|
||||
|
||||
// In a full implementation, this would aggregate data from the database
|
||||
|
||||
@@ -45,10 +45,12 @@ public sealed class UnifiedEvidenceService : IUnifiedEvidenceService
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<UnifiedEvidenceResponseDto?> GetUnifiedEvidenceAsync(
|
||||
string tenantId,
|
||||
string findingId,
|
||||
UnifiedEvidenceOptions? options = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
options ??= new UnifiedEvidenceOptions();
|
||||
|
||||
if (!Guid.TryParse(findingId, out var id))
|
||||
@@ -57,6 +59,8 @@ public sealed class UnifiedEvidenceService : IUnifiedEvidenceService
|
||||
return null;
|
||||
}
|
||||
|
||||
var normalizedTenantId = tenantId.Trim().ToLowerInvariant();
|
||||
|
||||
var finding = await _dbContext.Findings
|
||||
.Include(f => f.ReachabilityResults)
|
||||
.Include(f => f.EffectiveVexRecords)
|
||||
@@ -64,7 +68,9 @@ public sealed class UnifiedEvidenceService : IUnifiedEvidenceService
|
||||
.Include(f => f.EvidenceArtifacts)
|
||||
.Include(f => f.Attestations)
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(f => f.Id == id, cancellationToken)
|
||||
.FirstOrDefaultAsync(
|
||||
f => f.Id == id && f.TenantId == normalizedTenantId,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (finding is null)
|
||||
@@ -83,6 +89,7 @@ public sealed class UnifiedEvidenceService : IUnifiedEvidenceService
|
||||
|
||||
// Get replay commands
|
||||
var replayResponse = await _replayService.GenerateForFindingAsync(
|
||||
normalizedTenantId,
|
||||
new GenerateReplayCommandRequestDto { FindingId = findingId },
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
|
||||
@@ -0,0 +1,322 @@
|
||||
using StellaOps.Scanner.Storage.Postgres;
|
||||
using System.Data.Common;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Services;
|
||||
|
||||
internal sealed class UnknownsQueryService : IUnknownsQueryService
|
||||
{
|
||||
private const double HotBandThreshold = 0.70;
|
||||
private const double WarmBandThreshold = 0.40;
|
||||
|
||||
private readonly ScannerDataSource _dataSource;
|
||||
private readonly ILogger<UnknownsQueryService> _logger;
|
||||
|
||||
public UnknownsQueryService(ScannerDataSource dataSource, ILogger<UnknownsQueryService> logger)
|
||||
{
|
||||
_dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<UnknownsListResult> ListAsync(
|
||||
string tenantId,
|
||||
UnknownsListQuery query,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentNullException.ThrowIfNull(query);
|
||||
|
||||
try
|
||||
{
|
||||
await using var connection = await _dataSource
|
||||
.OpenConnectionAsync(tenantId, "reader", cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var whereClause = BuildWhereClause(query);
|
||||
var orderByClause = BuildOrderByClause(query.SortBy, query.SortOrder);
|
||||
|
||||
var countSql = $"""
|
||||
SELECT COUNT(*)
|
||||
FROM unknowns
|
||||
WHERE {whereClause}
|
||||
""";
|
||||
|
||||
var selectSql = $"""
|
||||
SELECT
|
||||
unknown_id,
|
||||
artifact_digest,
|
||||
vuln_id,
|
||||
package_purl,
|
||||
score,
|
||||
created_at_utc,
|
||||
updated_at_utc
|
||||
FROM unknowns
|
||||
WHERE {whereClause}
|
||||
ORDER BY {orderByClause}
|
||||
LIMIT @limit
|
||||
OFFSET @offset
|
||||
""";
|
||||
|
||||
await using var countCommand = connection.CreateCommand();
|
||||
countCommand.CommandText = countSql;
|
||||
countCommand.CommandTimeout = _dataSource.CommandTimeoutSeconds;
|
||||
BindCommonParameters(countCommand, tenantId, query);
|
||||
|
||||
var totalCountObject = await countCommand.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
|
||||
var totalCount = Convert.ToInt32(totalCountObject ?? 0, System.Globalization.CultureInfo.InvariantCulture);
|
||||
|
||||
await using var selectCommand = connection.CreateCommand();
|
||||
selectCommand.CommandText = selectSql;
|
||||
selectCommand.CommandTimeout = _dataSource.CommandTimeoutSeconds;
|
||||
BindCommonParameters(selectCommand, tenantId, query);
|
||||
AddParameter(selectCommand, "limit", query.Limit);
|
||||
AddParameter(selectCommand, "offset", query.Offset);
|
||||
|
||||
var items = new List<UnknownsListItem>();
|
||||
await using var reader = await selectCommand.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
items.Add(new UnknownsListItem
|
||||
{
|
||||
UnknownId = reader.GetGuid(0),
|
||||
ArtifactDigest = reader.GetString(1),
|
||||
VulnerabilityId = reader.GetString(2),
|
||||
PackagePurl = reader.GetString(3),
|
||||
Score = reader.IsDBNull(4) ? 0 : reader.GetDouble(4),
|
||||
CreatedAtUtc = reader.GetFieldValue<DateTimeOffset>(5),
|
||||
UpdatedAtUtc = reader.GetFieldValue<DateTimeOffset>(6)
|
||||
});
|
||||
}
|
||||
|
||||
return new UnknownsListResult
|
||||
{
|
||||
Items = items,
|
||||
TotalCount = totalCount
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
ex,
|
||||
"Failed to query unknowns list for tenant {TenantId}. Returning empty result.",
|
||||
tenantId);
|
||||
|
||||
return new UnknownsListResult
|
||||
{
|
||||
Items = [],
|
||||
TotalCount = 0
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<UnknownsDetail?> GetByIdAsync(
|
||||
string tenantId,
|
||||
Guid unknownId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
|
||||
try
|
||||
{
|
||||
await using var connection = await _dataSource
|
||||
.OpenConnectionAsync(tenantId, "reader", cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
const string sql = """
|
||||
SELECT
|
||||
unknown_id,
|
||||
artifact_digest,
|
||||
vuln_id,
|
||||
package_purl,
|
||||
score,
|
||||
proof_ref,
|
||||
created_at_utc,
|
||||
updated_at_utc
|
||||
FROM unknowns
|
||||
WHERE tenant_id::text = @tenantId
|
||||
AND unknown_id = @unknownId
|
||||
""";
|
||||
|
||||
await using var command = connection.CreateCommand();
|
||||
command.CommandText = sql;
|
||||
command.CommandTimeout = _dataSource.CommandTimeoutSeconds;
|
||||
AddParameter(command, "tenantId", tenantId);
|
||||
AddParameter(command, "unknownId", unknownId);
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (!await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new UnknownsDetail
|
||||
{
|
||||
UnknownId = reader.GetGuid(0),
|
||||
ArtifactDigest = reader.GetString(1),
|
||||
VulnerabilityId = reader.GetString(2),
|
||||
PackagePurl = reader.GetString(3),
|
||||
Score = reader.IsDBNull(4) ? 0 : reader.GetDouble(4),
|
||||
ProofRef = reader.IsDBNull(5) ? null : reader.GetString(5),
|
||||
CreatedAtUtc = reader.GetFieldValue<DateTimeOffset>(6),
|
||||
UpdatedAtUtc = reader.GetFieldValue<DateTimeOffset>(7)
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
ex,
|
||||
"Failed to query unknown {UnknownId} for tenant {TenantId}. Returning no result.",
|
||||
unknownId,
|
||||
tenantId);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<UnknownsStats> GetStatsAsync(string tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
|
||||
try
|
||||
{
|
||||
await using var connection = await _dataSource
|
||||
.OpenConnectionAsync(tenantId, "reader", cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
const string sql = """
|
||||
SELECT
|
||||
COUNT(*) AS total,
|
||||
COUNT(*) FILTER (WHERE score >= @hotThreshold) AS hot,
|
||||
COUNT(*) FILTER (WHERE score >= @warmThreshold AND score < @hotThreshold) AS warm,
|
||||
COUNT(*) FILTER (WHERE score < @warmThreshold) AS cold
|
||||
FROM unknowns
|
||||
WHERE tenant_id::text = @tenantId
|
||||
""";
|
||||
|
||||
await using var command = connection.CreateCommand();
|
||||
command.CommandText = sql;
|
||||
command.CommandTimeout = _dataSource.CommandTimeoutSeconds;
|
||||
AddParameter(command, "tenantId", tenantId);
|
||||
AddParameter(command, "hotThreshold", HotBandThreshold);
|
||||
AddParameter(command, "warmThreshold", WarmBandThreshold);
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (!await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
return EmptyStats();
|
||||
}
|
||||
|
||||
return new UnknownsStats
|
||||
{
|
||||
Total = reader.IsDBNull(0) ? 0 : reader.GetInt64(0),
|
||||
Hot = reader.IsDBNull(1) ? 0 : reader.GetInt64(1),
|
||||
Warm = reader.IsDBNull(2) ? 0 : reader.GetInt64(2),
|
||||
Cold = reader.IsDBNull(3) ? 0 : reader.GetInt64(3)
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
ex,
|
||||
"Failed to query unknown stats for tenant {TenantId}. Returning empty stats.",
|
||||
tenantId);
|
||||
return EmptyStats();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyDictionary<string, long>> GetBandDistributionAsync(
|
||||
string tenantId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var stats = await GetStatsAsync(tenantId, cancellationToken).ConfigureAwait(false);
|
||||
return new Dictionary<string, long>(StringComparer.Ordinal)
|
||||
{
|
||||
["HOT"] = stats.Hot,
|
||||
["WARM"] = stats.Warm,
|
||||
["COLD"] = stats.Cold
|
||||
};
|
||||
}
|
||||
|
||||
private static UnknownsStats EmptyStats()
|
||||
=> new()
|
||||
{
|
||||
Total = 0,
|
||||
Hot = 0,
|
||||
Warm = 0,
|
||||
Cold = 0
|
||||
};
|
||||
|
||||
private static string BuildWhereClause(UnknownsListQuery query)
|
||||
{
|
||||
var predicates = new List<string>
|
||||
{
|
||||
"tenant_id::text = @tenantId"
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(query.ArtifactDigest))
|
||||
{
|
||||
predicates.Add("artifact_digest = @artifactDigest");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(query.VulnerabilityId))
|
||||
{
|
||||
predicates.Add("vuln_id = @vulnerabilityId");
|
||||
}
|
||||
|
||||
if (query.Band.HasValue)
|
||||
{
|
||||
switch (query.Band.Value)
|
||||
{
|
||||
case UnknownsBand.Hot:
|
||||
predicates.Add($"score >= {HotBandThreshold.ToString(System.Globalization.CultureInfo.InvariantCulture)}");
|
||||
break;
|
||||
case UnknownsBand.Warm:
|
||||
predicates.Add(
|
||||
$"score >= {WarmBandThreshold.ToString(System.Globalization.CultureInfo.InvariantCulture)} AND " +
|
||||
$"score < {HotBandThreshold.ToString(System.Globalization.CultureInfo.InvariantCulture)}");
|
||||
break;
|
||||
case UnknownsBand.Cold:
|
||||
predicates.Add($"score < {WarmBandThreshold.ToString(System.Globalization.CultureInfo.InvariantCulture)}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return string.Join(" AND ", predicates);
|
||||
}
|
||||
|
||||
private static string BuildOrderByClause(UnknownsSortField sortField, UnknownsSortOrder sortOrder)
|
||||
{
|
||||
var column = sortField switch
|
||||
{
|
||||
UnknownsSortField.Score => "score",
|
||||
UnknownsSortField.CreatedAt => "created_at_utc",
|
||||
UnknownsSortField.UpdatedAt => "updated_at_utc",
|
||||
_ => "score"
|
||||
};
|
||||
|
||||
var direction = sortOrder == UnknownsSortOrder.Ascending ? "ASC" : "DESC";
|
||||
return $"{column} {direction}, unknown_id ASC";
|
||||
}
|
||||
|
||||
private static void BindCommonParameters(DbCommand command, string tenantId, UnknownsListQuery query)
|
||||
{
|
||||
AddParameter(command, "tenantId", tenantId);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(query.ArtifactDigest))
|
||||
{
|
||||
AddParameter(command, "artifactDigest", query.ArtifactDigest);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(query.VulnerabilityId))
|
||||
{
|
||||
AddParameter(command, "vulnerabilityId", query.VulnerabilityId);
|
||||
}
|
||||
}
|
||||
|
||||
private static void AddParameter(DbCommand command, string parameterName, object value)
|
||||
{
|
||||
var parameter = command.CreateParameter();
|
||||
parameter.ParameterName = parameterName;
|
||||
parameter.Value = value;
|
||||
_ = command.Parameters.Add(parameter);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,9 +61,6 @@
|
||||
<ProjectReference Include="../../Router/__Libraries/StellaOps.Router.AspNet/StellaOps.Router.AspNet.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Compile Remove="Endpoints\\UnknownsEndpoints.cs" />
|
||||
</ItemGroup>
|
||||
<PropertyGroup Label="StellaOpsReleaseVersion">
|
||||
<Version>1.0.0-alpha1</Version>
|
||||
<InformationalVersion>1.0.0-alpha1</InformationalVersion>
|
||||
|
||||
@@ -16,3 +16,7 @@ Source of truth: `docs/implplan/SPRINT_20260112_003_BE_csproj_audit_pending_appl
|
||||
| HOT-003 | DONE | `SPRINT_20260210_001_DOCS_sbom_attestation_hot_lookup_contract.md`: wired SBOM ingestion projection writes into Scanner WebService pipeline. |
|
||||
| HOT-004 | DONE | `SPRINT_20260210_001_DOCS_sbom_attestation_hot_lookup_contract.md`: added SBOM hot-lookup read endpoints with bounded pagination. |
|
||||
| SPRINT-20260212-002-SMARTDIFF-001 | DONE | `SPRINT_20260212_002_Scanner_unchecked_feature_verification_batch1.md`: wired SmartDiff endpoints into Program, added scan-scoped VEX candidate/review API compatibility, and embedded VEX candidates in SARIF output (2026-02-12). |
|
||||
| SPRINT-20260222-057-SCAN-TEN | DONE | `SPRINT_20260222_057_Scanner_tenant_isolation_for_scans_triage_webhooks.md`: enforced tenant-scoped triage/finding service contracts and endpoint propagation for SCAN-TEN-04/08 (2026-02-22). |
|
||||
| SPRINT-20260222-057-SCAN-TEN-10 | DONE | `SPRINT_20260222_057_Scanner_tenant_isolation_for_scans_triage_webhooks.md`: activated `/api/v1/unknowns` endpoint map with tenant-aware resolver + query service wiring (2026-02-22). |
|
||||
| SPRINT-20260222-057-SCAN-TEN-11 | DONE | `SPRINT_20260222_057_Scanner_tenant_isolation_for_scans_triage_webhooks.md`: propagated resolved tenant context through SmartDiff/Reachability endpoints into tenant-partitioned repository queries (2026-02-23). |
|
||||
| SPRINT-20260222-057-SCAN-TEN-13 | DONE | `SPRINT_20260222_057_Scanner_tenant_isolation_for_scans_triage_webhooks.md`: updated source-run and secret-exception service/endpoints to require tenant-scoped repository lookups for API-backed tenant tables (2026-02-23). |
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using System.Security.Claims;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Tenancy;
|
||||
|
||||
internal static class ScannerRequestContextResolver
|
||||
{
|
||||
private const string DefaultTenant = "default";
|
||||
private const string LegacyTenantClaim = "tid";
|
||||
private const string LegacyTenantIdClaim = "tenant_id";
|
||||
private const string LegacyTenantHeader = "X-Stella-Tenant";
|
||||
private const string AlternateTenantHeader = "X-Tenant-Id";
|
||||
private const string ActorHeader = "X-StellaOps-Actor";
|
||||
|
||||
public static bool TryResolveTenant(
|
||||
HttpContext context,
|
||||
out string tenantId,
|
||||
out string? error,
|
||||
bool allowDefaultTenant = false,
|
||||
string defaultTenant = DefaultTenant)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
|
||||
tenantId = string.Empty;
|
||||
error = null;
|
||||
|
||||
var claimTenant = NormalizeTenant(ResolveTenantClaim(context.User));
|
||||
var canonicalHeaderTenant = ReadTenantHeader(context, StellaOpsHttpHeaderNames.Tenant);
|
||||
var legacyHeaderTenant = ReadTenantHeader(context, LegacyTenantHeader);
|
||||
var alternateHeaderTenant = ReadTenantHeader(context, AlternateTenantHeader);
|
||||
|
||||
if (HasConflictingTenants(canonicalHeaderTenant, legacyHeaderTenant, alternateHeaderTenant))
|
||||
{
|
||||
error = "tenant_conflict";
|
||||
return false;
|
||||
}
|
||||
|
||||
var headerTenant = canonicalHeaderTenant ?? legacyHeaderTenant ?? alternateHeaderTenant;
|
||||
if (!string.IsNullOrWhiteSpace(claimTenant))
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(headerTenant)
|
||||
&& !string.Equals(claimTenant, headerTenant, StringComparison.Ordinal))
|
||||
{
|
||||
error = "tenant_conflict";
|
||||
return false;
|
||||
}
|
||||
|
||||
tenantId = claimTenant;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(headerTenant))
|
||||
{
|
||||
tenantId = headerTenant;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (allowDefaultTenant)
|
||||
{
|
||||
tenantId = NormalizeTenant(defaultTenant) ?? DefaultTenant;
|
||||
return true;
|
||||
}
|
||||
|
||||
error = "tenant_missing";
|
||||
return false;
|
||||
}
|
||||
|
||||
public static string ResolveTenantOrDefault(HttpContext context, string defaultTenant = DefaultTenant)
|
||||
{
|
||||
if (TryResolveTenant(context, out var tenantId, out _, allowDefaultTenant: true, defaultTenant))
|
||||
{
|
||||
return tenantId;
|
||||
}
|
||||
|
||||
return NormalizeTenant(defaultTenant) ?? DefaultTenant;
|
||||
}
|
||||
|
||||
public static string ResolveTenantPartitionKey(HttpContext context)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
|
||||
if (TryResolveTenant(context, out var tenantId, out _, allowDefaultTenant: false))
|
||||
{
|
||||
return tenantId;
|
||||
}
|
||||
|
||||
var remoteIp = context.Connection.RemoteIpAddress?.ToString();
|
||||
if (!string.IsNullOrWhiteSpace(remoteIp))
|
||||
{
|
||||
return $"ip:{remoteIp.Trim()}";
|
||||
}
|
||||
|
||||
return "anonymous";
|
||||
}
|
||||
|
||||
public static string ResolveActor(HttpContext context, string fallback = "system")
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
|
||||
var subject = context.User.FindFirstValue(StellaOpsClaimTypes.Subject);
|
||||
if (!string.IsNullOrWhiteSpace(subject))
|
||||
{
|
||||
return subject.Trim();
|
||||
}
|
||||
|
||||
var clientId = context.User.FindFirstValue(StellaOpsClaimTypes.ClientId);
|
||||
if (!string.IsNullOrWhiteSpace(clientId))
|
||||
{
|
||||
return clientId.Trim();
|
||||
}
|
||||
|
||||
if (TryResolveHeader(context, ActorHeader, out var actorHeaderValue))
|
||||
{
|
||||
return actorHeaderValue;
|
||||
}
|
||||
|
||||
var identityName = context.User.Identity?.Name;
|
||||
if (!string.IsNullOrWhiteSpace(identityName))
|
||||
{
|
||||
return identityName.Trim();
|
||||
}
|
||||
|
||||
return fallback;
|
||||
}
|
||||
|
||||
private static bool HasConflictingTenants(params string?[] tenantCandidates)
|
||||
{
|
||||
string? baseline = null;
|
||||
foreach (var candidate in tenantCandidates)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(candidate))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (baseline is null)
|
||||
{
|
||||
baseline = candidate;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!string.Equals(baseline, candidate, StringComparison.Ordinal))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static string? ResolveTenantClaim(ClaimsPrincipal principal)
|
||||
{
|
||||
return principal.FindFirstValue(StellaOpsClaimTypes.Tenant)
|
||||
?? principal.FindFirstValue(LegacyTenantClaim)
|
||||
?? principal.FindFirstValue(LegacyTenantIdClaim);
|
||||
}
|
||||
|
||||
private static string? ReadTenantHeader(HttpContext context, string headerName)
|
||||
{
|
||||
return TryResolveHeader(context, headerName, out var value)
|
||||
? NormalizeTenant(value)
|
||||
: null;
|
||||
}
|
||||
|
||||
private static bool TryResolveHeader(HttpContext context, string headerName, out string value)
|
||||
{
|
||||
value = string.Empty;
|
||||
|
||||
if (!context.Request.Headers.TryGetValue(headerName, out var values))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var raw = values.ToString();
|
||||
if (string.IsNullOrWhiteSpace(raw))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
value = raw.Trim();
|
||||
return true;
|
||||
}
|
||||
|
||||
private static string? NormalizeTenant(string? value)
|
||||
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim().ToLowerInvariant();
|
||||
}
|
||||
@@ -13,11 +13,18 @@ internal static class ScanIdGenerator
|
||||
ScanTarget target,
|
||||
bool force,
|
||||
string? clientRequestId,
|
||||
IReadOnlyDictionary<string, string>? metadata)
|
||||
IReadOnlyDictionary<string, string>? metadata,
|
||||
string? tenantId = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(target);
|
||||
|
||||
var normalizedTenant = string.IsNullOrWhiteSpace(tenantId)
|
||||
? "default"
|
||||
: tenantId.Trim().ToLowerInvariant();
|
||||
|
||||
var builder = new StringBuilder();
|
||||
builder.Append("tenant:");
|
||||
builder.Append(normalizedTenant);
|
||||
builder.Append('|');
|
||||
builder.Append(target.Reference?.Trim().ToLowerInvariant() ?? string.Empty);
|
||||
builder.Append('|');
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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")),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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). |
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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))]
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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>();
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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!;
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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!;
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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)
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user