save progress
This commit is contained in:
@@ -0,0 +1,26 @@
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using StellaOps.Scanner.WebService.Services;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Endpoints;
|
||||
|
||||
internal static class ObservabilityEndpoints
|
||||
{
|
||||
public static void MapObservabilityEndpoints(this IEndpointRouteBuilder endpoints)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(endpoints);
|
||||
|
||||
endpoints.MapGet("/metrics", HandleMetricsAsync)
|
||||
.WithName("scanner.metrics")
|
||||
.Produces(StatusCodes.Status200OK);
|
||||
}
|
||||
|
||||
private static IResult HandleMetricsAsync(OfflineKitMetricsStore metricsStore)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(metricsStore);
|
||||
|
||||
var payload = metricsStore.RenderPrometheus();
|
||||
return Results.Text(payload, contentType: "text/plain; version=0.0.4; charset=utf-8");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
using System.Linq;
|
||||
using System.Security.Claims;
|
||||
using System.Text.Json;
|
||||
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;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Endpoints;
|
||||
|
||||
internal static class OfflineKitEndpoints
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
public static void MapOfflineKitEndpoints(this IEndpointRouteBuilder endpoints)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(endpoints);
|
||||
|
||||
var group = endpoints
|
||||
.MapGroup("/api/offline-kit")
|
||||
.WithTags("Offline Kit");
|
||||
|
||||
group.MapPost("/import", HandleImportAsync)
|
||||
.WithName("scanner.offline-kit.import")
|
||||
.RequireAuthorization(ScannerPolicies.OfflineKitImport)
|
||||
.Produces<OfflineKitImportResponseTransport>(StatusCodes.Status202Accepted)
|
||||
.Produces<ProblemDetails>(StatusCodes.Status400BadRequest)
|
||||
.Produces<ProblemDetails>(StatusCodes.Status404NotFound)
|
||||
.Produces<ProblemDetails>(StatusCodes.Status422UnprocessableEntity);
|
||||
|
||||
group.MapGet("/status", HandleStatusAsync)
|
||||
.WithName("scanner.offline-kit.status")
|
||||
.RequireAuthorization(ScannerPolicies.OfflineKitStatusRead)
|
||||
.Produces<OfflineKitStatusTransport>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status204NoContent)
|
||||
.Produces<ProblemDetails>(StatusCodes.Status404NotFound);
|
||||
}
|
||||
|
||||
private static async Task<IResult> HandleImportAsync(
|
||||
HttpContext context,
|
||||
HttpRequest request,
|
||||
IOptionsMonitor<OfflineKitOptions> offlineKitOptions,
|
||||
OfflineKitImportService importService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
ArgumentNullException.ThrowIfNull(offlineKitOptions);
|
||||
ArgumentNullException.ThrowIfNull(importService);
|
||||
|
||||
if (!offlineKitOptions.CurrentValue.Enabled)
|
||||
{
|
||||
return ProblemResultFactory.Create(
|
||||
context,
|
||||
ProblemTypes.NotFound,
|
||||
"Offline kit import is not enabled",
|
||||
StatusCodes.Status404NotFound);
|
||||
}
|
||||
|
||||
if (!request.HasFormContentType)
|
||||
{
|
||||
return ProblemResultFactory.Create(
|
||||
context,
|
||||
ProblemTypes.Validation,
|
||||
"Invalid offline kit import request",
|
||||
StatusCodes.Status400BadRequest,
|
||||
detail: "Request must be multipart/form-data.");
|
||||
}
|
||||
|
||||
var form = await request.ReadFormAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var metadataJson = form["metadata"].FirstOrDefault();
|
||||
if (string.IsNullOrWhiteSpace(metadataJson))
|
||||
{
|
||||
return ProblemResultFactory.Create(
|
||||
context,
|
||||
ProblemTypes.Validation,
|
||||
"Invalid offline kit import request",
|
||||
StatusCodes.Status400BadRequest,
|
||||
detail: "Missing 'metadata' form field.");
|
||||
}
|
||||
|
||||
OfflineKitImportMetadata? metadata;
|
||||
try
|
||||
{
|
||||
metadata = JsonSerializer.Deserialize<OfflineKitImportMetadata>(metadataJson, JsonOptions);
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
return ProblemResultFactory.Create(
|
||||
context,
|
||||
ProblemTypes.Validation,
|
||||
"Invalid offline kit import request",
|
||||
StatusCodes.Status400BadRequest,
|
||||
detail: $"Failed to parse metadata JSON: {ex.Message}");
|
||||
}
|
||||
|
||||
if (metadata is null)
|
||||
{
|
||||
return ProblemResultFactory.Create(
|
||||
context,
|
||||
ProblemTypes.Validation,
|
||||
"Invalid offline kit import request",
|
||||
StatusCodes.Status400BadRequest,
|
||||
detail: "Metadata payload is empty.");
|
||||
}
|
||||
|
||||
var bundle = form.Files.GetFile("bundle");
|
||||
if (bundle is null)
|
||||
{
|
||||
return ProblemResultFactory.Create(
|
||||
context,
|
||||
ProblemTypes.Validation,
|
||||
"Invalid offline kit import request",
|
||||
StatusCodes.Status400BadRequest,
|
||||
detail: "Missing 'bundle' file upload.");
|
||||
}
|
||||
|
||||
var manifest = form.Files.GetFile("manifest");
|
||||
var bundleSignature = form.Files.GetFile("bundleSignature");
|
||||
var manifestSignature = form.Files.GetFile("manifestSignature");
|
||||
|
||||
var tenantId = ResolveTenant(context);
|
||||
var actor = ResolveActor(context);
|
||||
|
||||
try
|
||||
{
|
||||
var response = await importService.ImportAsync(
|
||||
new OfflineKitImportRequest(
|
||||
tenantId,
|
||||
actor,
|
||||
metadata,
|
||||
bundle,
|
||||
manifest,
|
||||
bundleSignature,
|
||||
manifestSignature),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.Accepted("/api/offline-kit/status", response);
|
||||
}
|
||||
catch (OfflineKitImportException ex)
|
||||
{
|
||||
return ProblemResultFactory.Create(
|
||||
context,
|
||||
ProblemTypes.Validation,
|
||||
"Offline kit import failed",
|
||||
ex.StatusCode,
|
||||
detail: ex.Message,
|
||||
extensions: new Dictionary<string, object?>
|
||||
{
|
||||
["reason_code"] = ex.ReasonCode,
|
||||
["notes"] = ex.Notes
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> HandleStatusAsync(
|
||||
HttpContext context,
|
||||
IOptionsMonitor<OfflineKitOptions> offlineKitOptions,
|
||||
OfflineKitStateStore stateStore,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
ArgumentNullException.ThrowIfNull(offlineKitOptions);
|
||||
ArgumentNullException.ThrowIfNull(stateStore);
|
||||
|
||||
if (!offlineKitOptions.CurrentValue.Enabled)
|
||||
{
|
||||
return ProblemResultFactory.Create(
|
||||
context,
|
||||
ProblemTypes.NotFound,
|
||||
"Offline kit status is not enabled",
|
||||
StatusCodes.Status404NotFound);
|
||||
}
|
||||
|
||||
var tenantId = ResolveTenant(context);
|
||||
var status = await stateStore.LoadStatusAsync(tenantId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return status is null
|
||||
? Results.NoContent()
|
||||
: Results.Ok(status);
|
||||
}
|
||||
|
||||
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";
|
||||
}
|
||||
|
||||
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";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,307 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using StellaOps.Scanner.ReachabilityDrift;
|
||||
using StellaOps.Scanner.ReachabilityDrift.Services;
|
||||
using StellaOps.Scanner.Storage.Repositories;
|
||||
using StellaOps.Scanner.WebService.Constants;
|
||||
using StellaOps.Scanner.WebService.Domain;
|
||||
using StellaOps.Scanner.WebService.Infrastructure;
|
||||
using StellaOps.Scanner.WebService.Security;
|
||||
using StellaOps.Scanner.WebService.Services;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Endpoints;
|
||||
|
||||
internal static class ReachabilityDriftEndpoints
|
||||
{
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
Converters = { new JsonStringEnumConverter() }
|
||||
};
|
||||
|
||||
public static void MapReachabilityDriftScanEndpoints(this RouteGroupBuilder scansGroup)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(scansGroup);
|
||||
|
||||
// GET /scans/{scanId}/drift?baseScanId=...&language=dotnet&includeFullPath=false
|
||||
scansGroup.MapGet("/{scanId}/drift", HandleGetDriftAsync)
|
||||
.WithName("scanner.scans.reachability-drift")
|
||||
.WithTags("ReachabilityDrift")
|
||||
.Produces<ReachabilityDriftResult>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status400BadRequest)
|
||||
.Produces(StatusCodes.Status404NotFound)
|
||||
.RequireAuthorization(ScannerPolicies.ScansRead);
|
||||
}
|
||||
|
||||
public static void MapReachabilityDriftRootEndpoints(this RouteGroupBuilder apiGroup)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(apiGroup);
|
||||
|
||||
var driftGroup = apiGroup.MapGroup("/drift");
|
||||
|
||||
// GET /drift/{driftId}/sinks?direction=became_reachable&offset=0&limit=100
|
||||
driftGroup.MapGet("/{driftId:guid}/sinks", HandleListSinksAsync)
|
||||
.WithName("scanner.drift.sinks")
|
||||
.WithTags("ReachabilityDrift")
|
||||
.Produces<DriftedSinksResponseDto>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status400BadRequest)
|
||||
.Produces(StatusCodes.Status404NotFound)
|
||||
.RequireAuthorization(ScannerPolicies.ScansRead);
|
||||
}
|
||||
|
||||
private static async Task<IResult> HandleGetDriftAsync(
|
||||
string scanId,
|
||||
string? baseScanId,
|
||||
string? language,
|
||||
bool? includeFullPath,
|
||||
IScanCoordinator coordinator,
|
||||
ICallGraphSnapshotRepository callGraphSnapshots,
|
||||
CodeChangeFactExtractor codeChangeFactExtractor,
|
||||
ICodeChangeRepository codeChangeRepository,
|
||||
ReachabilityDriftDetector driftDetector,
|
||||
IReachabilityDriftResultRepository driftRepository,
|
||||
HttpContext context,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(coordinator);
|
||||
ArgumentNullException.ThrowIfNull(callGraphSnapshots);
|
||||
ArgumentNullException.ThrowIfNull(codeChangeFactExtractor);
|
||||
ArgumentNullException.ThrowIfNull(codeChangeRepository);
|
||||
ArgumentNullException.ThrowIfNull(driftDetector);
|
||||
ArgumentNullException.ThrowIfNull(driftRepository);
|
||||
|
||||
if (!ScanId.TryParse(scanId, out var headScan))
|
||||
{
|
||||
return ProblemResultFactory.Create(
|
||||
context,
|
||||
ProblemTypes.Validation,
|
||||
"Invalid scan identifier",
|
||||
StatusCodes.Status400BadRequest,
|
||||
detail: "Scan identifier is required.");
|
||||
}
|
||||
|
||||
var resolvedLanguage = string.IsNullOrWhiteSpace(language) ? "dotnet" : language.Trim();
|
||||
|
||||
var headSnapshot = await coordinator.GetAsync(headScan, cancellationToken).ConfigureAwait(false);
|
||||
if (headSnapshot is null)
|
||||
{
|
||||
return ProblemResultFactory.Create(
|
||||
context,
|
||||
ProblemTypes.NotFound,
|
||||
"Scan not found",
|
||||
StatusCodes.Status404NotFound,
|
||||
detail: "Requested scan could not be located.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(baseScanId))
|
||||
{
|
||||
var existing = await driftRepository.TryGetLatestForHeadAsync(headScan.Value, resolvedLanguage, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (existing is null)
|
||||
{
|
||||
return ProblemResultFactory.Create(
|
||||
context,
|
||||
ProblemTypes.NotFound,
|
||||
"Drift result not found",
|
||||
StatusCodes.Status404NotFound,
|
||||
detail: $"No reachability drift result recorded for scan {scanId} (language={resolvedLanguage}).");
|
||||
}
|
||||
|
||||
return Json(existing, StatusCodes.Status200OK);
|
||||
}
|
||||
|
||||
if (!ScanId.TryParse(baseScanId, out var baseScan))
|
||||
{
|
||||
return ProblemResultFactory.Create(
|
||||
context,
|
||||
ProblemTypes.Validation,
|
||||
"Invalid base scan identifier",
|
||||
StatusCodes.Status400BadRequest,
|
||||
detail: "Query parameter 'baseScanId' must be a valid scan id.");
|
||||
}
|
||||
|
||||
var baselineSnapshot = await coordinator.GetAsync(baseScan, cancellationToken).ConfigureAwait(false);
|
||||
if (baselineSnapshot is null)
|
||||
{
|
||||
return ProblemResultFactory.Create(
|
||||
context,
|
||||
ProblemTypes.NotFound,
|
||||
"Base scan not found",
|
||||
StatusCodes.Status404NotFound,
|
||||
detail: "Base scan could not be located.");
|
||||
}
|
||||
|
||||
var baseGraph = await callGraphSnapshots.TryGetLatestAsync(baseScan.Value, resolvedLanguage, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
if (baseGraph is null)
|
||||
{
|
||||
return ProblemResultFactory.Create(
|
||||
context,
|
||||
ProblemTypes.NotFound,
|
||||
"Base call graph not found",
|
||||
StatusCodes.Status404NotFound,
|
||||
detail: $"No call graph snapshot found for base scan {baseScan.Value} (language={resolvedLanguage}).");
|
||||
}
|
||||
|
||||
var headGraph = await callGraphSnapshots.TryGetLatestAsync(headScan.Value, resolvedLanguage, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
if (headGraph is null)
|
||||
{
|
||||
return ProblemResultFactory.Create(
|
||||
context,
|
||||
ProblemTypes.NotFound,
|
||||
"Head call graph not found",
|
||||
StatusCodes.Status404NotFound,
|
||||
detail: $"No call graph snapshot found for head scan {headScan.Value} (language={resolvedLanguage}).");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var codeChanges = codeChangeFactExtractor.Extract(baseGraph, headGraph);
|
||||
await codeChangeRepository.StoreAsync(codeChanges, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var drift = driftDetector.Detect(
|
||||
baseGraph,
|
||||
headGraph,
|
||||
codeChanges,
|
||||
includeFullPath: includeFullPath == true);
|
||||
|
||||
await driftRepository.StoreAsync(drift, cancellationToken).ConfigureAwait(false);
|
||||
return Json(drift, StatusCodes.Status200OK);
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
return ProblemResultFactory.Create(
|
||||
context,
|
||||
ProblemTypes.Validation,
|
||||
"Invalid drift request",
|
||||
StatusCodes.Status400BadRequest,
|
||||
detail: ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> HandleListSinksAsync(
|
||||
Guid driftId,
|
||||
string? direction,
|
||||
int? offset,
|
||||
int? limit,
|
||||
IReachabilityDriftResultRepository driftRepository,
|
||||
HttpContext context,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(driftRepository);
|
||||
|
||||
if (driftId == Guid.Empty)
|
||||
{
|
||||
return ProblemResultFactory.Create(
|
||||
context,
|
||||
ProblemTypes.Validation,
|
||||
"Invalid drift identifier",
|
||||
StatusCodes.Status400BadRequest,
|
||||
detail: "driftId must be a non-empty GUID.");
|
||||
}
|
||||
|
||||
if (!TryParseDirection(direction, out var parsedDirection))
|
||||
{
|
||||
return ProblemResultFactory.Create(
|
||||
context,
|
||||
ProblemTypes.Validation,
|
||||
"Invalid direction",
|
||||
StatusCodes.Status400BadRequest,
|
||||
detail: "direction must be 'became_reachable' or 'became_unreachable'.");
|
||||
}
|
||||
|
||||
var resolvedOffset = offset ?? 0;
|
||||
if (resolvedOffset < 0)
|
||||
{
|
||||
return ProblemResultFactory.Create(
|
||||
context,
|
||||
ProblemTypes.Validation,
|
||||
"Invalid offset",
|
||||
StatusCodes.Status400BadRequest,
|
||||
detail: "offset must be >= 0.");
|
||||
}
|
||||
|
||||
var resolvedLimit = limit ?? 100;
|
||||
if (resolvedLimit <= 0 || resolvedLimit > 500)
|
||||
{
|
||||
return ProblemResultFactory.Create(
|
||||
context,
|
||||
ProblemTypes.Validation,
|
||||
"Invalid limit",
|
||||
StatusCodes.Status400BadRequest,
|
||||
detail: "limit must be between 1 and 500.");
|
||||
}
|
||||
|
||||
if (!await driftRepository.ExistsAsync(driftId, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
return ProblemResultFactory.Create(
|
||||
context,
|
||||
ProblemTypes.NotFound,
|
||||
"Drift result not found",
|
||||
StatusCodes.Status404NotFound,
|
||||
detail: "Requested drift result could not be located.");
|
||||
}
|
||||
|
||||
var sinks = await driftRepository.ListSinksAsync(
|
||||
driftId,
|
||||
parsedDirection,
|
||||
resolvedOffset,
|
||||
resolvedLimit,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var response = new DriftedSinksResponseDto(
|
||||
DriftId: driftId,
|
||||
Direction: parsedDirection,
|
||||
Offset: resolvedOffset,
|
||||
Limit: resolvedLimit,
|
||||
Count: sinks.Count,
|
||||
Sinks: sinks.ToImmutableArray());
|
||||
|
||||
return Json(response, StatusCodes.Status200OK);
|
||||
}
|
||||
|
||||
private static bool TryParseDirection(string? direction, out DriftDirection parsed)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(direction))
|
||||
{
|
||||
parsed = DriftDirection.BecameReachable;
|
||||
return true;
|
||||
}
|
||||
|
||||
var normalized = direction.Trim().ToLowerInvariant();
|
||||
parsed = normalized switch
|
||||
{
|
||||
"became_reachable" or "newly_reachable" or "reachable" or "up" => DriftDirection.BecameReachable,
|
||||
"became_unreachable" or "newly_unreachable" or "unreachable" or "down" => DriftDirection.BecameUnreachable,
|
||||
_ => DriftDirection.BecameReachable
|
||||
};
|
||||
|
||||
return normalized is "became_reachable"
|
||||
or "newly_reachable"
|
||||
or "reachable"
|
||||
or "up"
|
||||
or "became_unreachable"
|
||||
or "newly_unreachable"
|
||||
or "unreachable"
|
||||
or "down";
|
||||
}
|
||||
|
||||
private static IResult Json<T>(T value, int statusCode)
|
||||
{
|
||||
var payload = JsonSerializer.Serialize(value, SerializerOptions);
|
||||
return Results.Content(payload, "application/json", System.Text.Encoding.UTF8, statusCode);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed record DriftedSinksResponseDto(
|
||||
Guid DriftId,
|
||||
DriftDirection Direction,
|
||||
int Offset,
|
||||
int Limit,
|
||||
int Count,
|
||||
ImmutableArray<DriftedSink> Sinks);
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using StellaOps.Scanner.WebService.Constants;
|
||||
using StellaOps.Scanner.WebService.Contracts;
|
||||
@@ -63,7 +64,7 @@ internal static class ReachabilityEndpoints
|
||||
string scanId,
|
||||
ComputeReachabilityRequestDto? request,
|
||||
IScanCoordinator coordinator,
|
||||
IReachabilityComputeService computeService,
|
||||
[FromServices] IReachabilityComputeService computeService,
|
||||
HttpContext context,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
|
||||
@@ -83,6 +83,7 @@ internal static class ScanEndpoints
|
||||
scans.MapCallGraphEndpoints();
|
||||
scans.MapSbomEndpoints();
|
||||
scans.MapReachabilityEndpoints();
|
||||
scans.MapReachabilityDriftScanEndpoints();
|
||||
scans.MapExportEndpoints();
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ using Microsoft.AspNetCore.Routing;
|
||||
using StellaOps.Scanner.SmartDiff.Detection;
|
||||
using StellaOps.Scanner.SmartDiff.Output;
|
||||
using StellaOps.Scanner.Storage.Postgres;
|
||||
using StellaOps.Scanner.WebService.Services;
|
||||
using StellaOps.Scanner.WebService.Security;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Endpoints;
|
||||
@@ -80,7 +81,7 @@ internal static class SmartDiffEndpoints
|
||||
// Get scan metadata if available
|
||||
string? baseDigest = null;
|
||||
string? targetDigest = null;
|
||||
DateTimeOffset scanTime = DateTimeOffset.UtcNow;
|
||||
DateTimeOffset scanTime = DateTimeOffset.UnixEpoch;
|
||||
|
||||
if (metadataRepo is not null)
|
||||
{
|
||||
@@ -99,13 +100,16 @@ internal static class SmartDiffEndpoints
|
||||
ScanTime: scanTime,
|
||||
BaseDigest: baseDigest,
|
||||
TargetDigest: targetDigest,
|
||||
MaterialChanges: changes.Select(c => new MaterialRiskChange(
|
||||
VulnId: c.VulnId,
|
||||
ComponentPurl: c.ComponentPurl,
|
||||
Direction: c.IsRiskIncrease ? RiskDirection.Increased : RiskDirection.Decreased,
|
||||
Reason: c.ChangeReason,
|
||||
FilePath: c.FilePath
|
||||
)).ToList(),
|
||||
MaterialChanges: changes
|
||||
.Where(c => c.HasMaterialChange)
|
||||
.Select(c => new MaterialRiskChange(
|
||||
VulnId: c.FindingKey.VulnId,
|
||||
ComponentPurl: c.FindingKey.ComponentPurl,
|
||||
Direction: ToSarifRiskDirection(c),
|
||||
Reason: ToSarifReason(c),
|
||||
FilePath: null
|
||||
))
|
||||
.ToList(),
|
||||
HardeningRegressions: [],
|
||||
VexCandidates: [],
|
||||
ReachabilityChanges: []);
|
||||
@@ -120,7 +124,7 @@ internal static class SmartDiffEndpoints
|
||||
};
|
||||
|
||||
var generator = new SarifOutputGenerator();
|
||||
var sarifJson = generator.Generate(sarifInput, options);
|
||||
var sarifJson = generator.GenerateJson(sarifInput, options);
|
||||
|
||||
// Return as SARIF content type with proper filename
|
||||
var fileName = $"smartdiff-{scanId}.sarif";
|
||||
@@ -130,6 +134,46 @@ internal static class SmartDiffEndpoints
|
||||
statusCode: StatusCodes.Status200OK);
|
||||
}
|
||||
|
||||
private static StellaOps.Scanner.SmartDiff.Output.RiskDirection ToSarifRiskDirection(MaterialRiskChangeResult change)
|
||||
{
|
||||
if (change.Changes.IsDefaultOrEmpty)
|
||||
{
|
||||
return StellaOps.Scanner.SmartDiff.Output.RiskDirection.Changed;
|
||||
}
|
||||
|
||||
var hasIncreased = change.Changes.Any(c => c.Direction == StellaOps.Scanner.SmartDiff.Detection.RiskDirection.Increased);
|
||||
var hasDecreased = change.Changes.Any(c => c.Direction == StellaOps.Scanner.SmartDiff.Detection.RiskDirection.Decreased);
|
||||
|
||||
return (hasIncreased, hasDecreased) switch
|
||||
{
|
||||
(true, false) => StellaOps.Scanner.SmartDiff.Output.RiskDirection.Increased,
|
||||
(false, true) => StellaOps.Scanner.SmartDiff.Output.RiskDirection.Decreased,
|
||||
_ => StellaOps.Scanner.SmartDiff.Output.RiskDirection.Changed
|
||||
};
|
||||
}
|
||||
|
||||
private static string ToSarifReason(MaterialRiskChangeResult change)
|
||||
{
|
||||
if (change.Changes.IsDefaultOrEmpty)
|
||||
{
|
||||
return "material_change";
|
||||
}
|
||||
|
||||
var reasons = change.Changes
|
||||
.Select(c => c.Reason)
|
||||
.Where(r => !string.IsNullOrWhiteSpace(r))
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.Order(StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
return reasons.Length switch
|
||||
{
|
||||
0 => "material_change",
|
||||
1 => reasons[0],
|
||||
_ => string.Join("; ", reasons)
|
||||
};
|
||||
}
|
||||
|
||||
private static string GetScannerVersion()
|
||||
{
|
||||
var assembly = typeof(SmartDiffEndpoints).Assembly;
|
||||
@@ -289,7 +333,7 @@ internal static class SmartDiffEndpoints
|
||||
};
|
||||
}
|
||||
|
||||
private static VexCandidateDto ToCandidateDto(VexCandidate candidate)
|
||||
private static VexCandidateDto ToCandidateDto(StellaOps.Scanner.SmartDiff.Detection.VexCandidate candidate)
|
||||
{
|
||||
return new VexCandidateDto
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user