save progress

This commit is contained in:
StellaOps Bot
2025-12-18 09:10:36 +02:00
parent b4235c134c
commit 28823a8960
169 changed files with 11995 additions and 449 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -83,6 +83,7 @@ internal static class ScanEndpoints
scans.MapCallGraphEndpoints();
scans.MapSbomEndpoints();
scans.MapReachabilityEndpoints();
scans.MapReachabilityDriftScanEndpoints();
scans.MapExportEndpoints();
}

View File

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