Add tests and implement StubBearer authentication for Signer endpoints
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
- Created SignerEndpointsTests to validate the SignDsse and VerifyReferrers endpoints. - Implemented StubBearerAuthenticationDefaults and StubBearerAuthenticationHandler for token-based authentication. - Developed ConcelierExporterClient for managing Trivy DB settings and export operations. - Added TrivyDbSettingsPageComponent for UI interactions with Trivy DB settings, including form handling and export triggering. - Implemented styles and HTML structure for Trivy DB settings page. - Created NotifySmokeCheck tool for validating Redis event streams and Notify deliveries.
This commit is contained in:
@@ -1,8 +1,10 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using StellaOps.Policy;
|
||||
@@ -51,18 +53,30 @@ internal static class PolicyEndpoints
|
||||
return operation;
|
||||
});
|
||||
|
||||
policyGroup.MapPost("/preview", HandlePreviewAsync)
|
||||
.WithName("scanner.policy.preview")
|
||||
.Produces<PolicyPreviewResponseDto>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status400BadRequest)
|
||||
.RequireAuthorization(ScannerPolicies.Reports)
|
||||
.WithOpenApi(operation =>
|
||||
{
|
||||
operation.Summary = "Preview policy impact against findings.";
|
||||
operation.Description = "Evaluates the supplied findings against the active or proposed policy, returning diffs, quieted verdicts, and actionable validation messages.";
|
||||
return operation;
|
||||
});
|
||||
}
|
||||
policyGroup.MapPost("/preview", HandlePreviewAsync)
|
||||
.WithName("scanner.policy.preview")
|
||||
.Produces<PolicyPreviewResponseDto>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status400BadRequest)
|
||||
.RequireAuthorization(ScannerPolicies.Reports)
|
||||
.WithOpenApi(operation =>
|
||||
{
|
||||
operation.Summary = "Preview policy impact against findings.";
|
||||
operation.Description = "Evaluates the supplied findings against the active or proposed policy, returning diffs, quieted verdicts, and actionable validation messages.";
|
||||
return operation;
|
||||
});
|
||||
|
||||
policyGroup.MapPost("/runtime", HandleRuntimePolicyAsync)
|
||||
.WithName("scanner.policy.runtime")
|
||||
.Produces<RuntimePolicyResponseDto>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status400BadRequest)
|
||||
.RequireAuthorization(ScannerPolicies.Reports)
|
||||
.WithOpenApi(operation =>
|
||||
{
|
||||
operation.Summary = "Evaluate runtime policy for digests.";
|
||||
operation.Description = "Returns per-image policy verdicts, signature and SBOM metadata, and cache hints for admission controllers.";
|
||||
return operation;
|
||||
});
|
||||
}
|
||||
|
||||
private static IResult HandleSchemaAsync(HttpContext context)
|
||||
{
|
||||
@@ -152,9 +166,97 @@ internal static class PolicyEndpoints
|
||||
|
||||
var domainRequest = PolicyDtoMapper.ToDomain(request);
|
||||
var response = await previewService.PreviewAsync(domainRequest, cancellationToken).ConfigureAwait(false);
|
||||
var payload = PolicyDtoMapper.ToDto(response);
|
||||
return Json(payload);
|
||||
}
|
||||
var payload = PolicyDtoMapper.ToDto(response);
|
||||
return Json(payload);
|
||||
}
|
||||
|
||||
private static async Task<IResult> HandleRuntimePolicyAsync(
|
||||
RuntimePolicyRequestDto request,
|
||||
IRuntimePolicyService runtimePolicyService,
|
||||
HttpContext context,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
ArgumentNullException.ThrowIfNull(runtimePolicyService);
|
||||
|
||||
if (request.Images is null || request.Images.Count == 0)
|
||||
{
|
||||
return ProblemResultFactory.Create(
|
||||
context,
|
||||
ProblemTypes.Validation,
|
||||
"Invalid runtime policy request",
|
||||
StatusCodes.Status400BadRequest,
|
||||
detail: "images collection must include at least one digest.");
|
||||
}
|
||||
|
||||
var normalizedImages = new List<string>();
|
||||
var seen = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (var image in request.Images)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(image))
|
||||
{
|
||||
return ProblemResultFactory.Create(
|
||||
context,
|
||||
ProblemTypes.Validation,
|
||||
"Invalid runtime policy request",
|
||||
StatusCodes.Status400BadRequest,
|
||||
detail: "Image digests must be non-empty.");
|
||||
}
|
||||
|
||||
var trimmed = image.Trim();
|
||||
if (!trimmed.Contains(':', StringComparison.Ordinal))
|
||||
{
|
||||
return ProblemResultFactory.Create(
|
||||
context,
|
||||
ProblemTypes.Validation,
|
||||
"Invalid runtime policy request",
|
||||
StatusCodes.Status400BadRequest,
|
||||
detail: "Image digests must include an algorithm prefix (e.g. sha256:...).");
|
||||
}
|
||||
|
||||
if (seen.Add(trimmed))
|
||||
{
|
||||
normalizedImages.Add(trimmed);
|
||||
}
|
||||
}
|
||||
|
||||
if (normalizedImages.Count == 0)
|
||||
{
|
||||
return ProblemResultFactory.Create(
|
||||
context,
|
||||
ProblemTypes.Validation,
|
||||
"Invalid runtime policy request",
|
||||
StatusCodes.Status400BadRequest,
|
||||
detail: "images collection must include at least one unique digest.");
|
||||
}
|
||||
|
||||
var namespaceValue = string.IsNullOrWhiteSpace(request.Namespace) ? null : request.Namespace.Trim();
|
||||
var normalizedLabels = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
if (request.Labels is not null)
|
||||
{
|
||||
foreach (var pair in request.Labels)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(pair.Key))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var key = pair.Key.Trim();
|
||||
var value = pair.Value?.Trim() ?? string.Empty;
|
||||
normalizedLabels[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
var evaluationRequest = new RuntimePolicyEvaluationRequest(
|
||||
namespaceValue,
|
||||
new ReadOnlyDictionary<string, string>(normalizedLabels),
|
||||
normalizedImages);
|
||||
|
||||
var evaluation = await runtimePolicyService.EvaluateAsync(evaluationRequest, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var resultPayload = MapRuntimePolicyResponse(evaluation);
|
||||
return Json(resultPayload);
|
||||
}
|
||||
|
||||
private static string NormalizeSegment(string segment)
|
||||
{
|
||||
@@ -167,9 +269,53 @@ internal static class PolicyEndpoints
|
||||
return "/" + trimmed;
|
||||
}
|
||||
|
||||
private static IResult Json<T>(T value)
|
||||
{
|
||||
var payload = JsonSerializer.Serialize(value, SerializerOptions);
|
||||
return Results.Content(payload, "application/json", Encoding.UTF8);
|
||||
}
|
||||
}
|
||||
private static IResult Json<T>(T value)
|
||||
{
|
||||
var payload = JsonSerializer.Serialize(value, SerializerOptions);
|
||||
return Results.Content(payload, "application/json", Encoding.UTF8);
|
||||
}
|
||||
|
||||
private static RuntimePolicyResponseDto MapRuntimePolicyResponse(RuntimePolicyEvaluationResult evaluation)
|
||||
{
|
||||
var results = new Dictionary<string, RuntimePolicyImageResponseDto>(evaluation.Results.Count, StringComparer.Ordinal);
|
||||
foreach (var pair in evaluation.Results)
|
||||
{
|
||||
var decision = pair.Value;
|
||||
RuntimePolicyRekorDto? rekor = null;
|
||||
if (decision.Rekor is not null)
|
||||
{
|
||||
rekor = new RuntimePolicyRekorDto
|
||||
{
|
||||
Uuid = decision.Rekor.Uuid,
|
||||
Url = decision.Rekor.Url,
|
||||
Verified = decision.Rekor.Verified
|
||||
};
|
||||
}
|
||||
|
||||
IDictionary<string, object?>? metadata = null;
|
||||
if (decision.Metadata is not null && decision.Metadata.Count > 0)
|
||||
{
|
||||
metadata = new Dictionary<string, object?>(decision.Metadata, StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
results[pair.Key] = new RuntimePolicyImageResponseDto
|
||||
{
|
||||
PolicyVerdict = decision.PolicyVerdict,
|
||||
Signed = decision.Signed,
|
||||
HasSbomReferrers = decision.HasSbomReferrers,
|
||||
HasSbomLegacy = decision.HasSbomReferrers,
|
||||
Reasons = decision.Reasons.ToArray(),
|
||||
Rekor = rekor,
|
||||
Metadata = metadata
|
||||
};
|
||||
}
|
||||
|
||||
return new RuntimePolicyResponseDto
|
||||
{
|
||||
TtlSeconds = evaluation.TtlSeconds,
|
||||
ExpiresAtUtc = evaluation.ExpiresAtUtc,
|
||||
PolicyRevision = evaluation.PolicyRevision,
|
||||
Results = results
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
253
src/StellaOps.Scanner.WebService/Endpoints/RuntimeEndpoints.cs
Normal file
253
src/StellaOps.Scanner.WebService/Endpoints/RuntimeEndpoints.cs
Normal file
@@ -0,0 +1,253 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Scanner.WebService.Constants;
|
||||
using StellaOps.Scanner.WebService.Contracts;
|
||||
using StellaOps.Scanner.WebService.Infrastructure;
|
||||
using StellaOps.Scanner.WebService.Options;
|
||||
using StellaOps.Scanner.WebService.Security;
|
||||
using StellaOps.Scanner.WebService.Services;
|
||||
using StellaOps.Zastava.Core.Contracts;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Endpoints;
|
||||
|
||||
internal static class RuntimeEndpoints
|
||||
{
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
||||
};
|
||||
|
||||
public static void MapRuntimeEndpoints(this RouteGroupBuilder apiGroup, string runtimeSegment)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(apiGroup);
|
||||
|
||||
var runtime = apiGroup
|
||||
.MapGroup(NormalizeSegment(runtimeSegment))
|
||||
.WithTags("Runtime");
|
||||
|
||||
runtime.MapPost("/events", HandleRuntimeEventsAsync)
|
||||
.WithName("scanner.runtime.events.ingest")
|
||||
.Produces<RuntimeEventsIngestResponseDto>(StatusCodes.Status202Accepted)
|
||||
.Produces(StatusCodes.Status400BadRequest)
|
||||
.Produces(StatusCodes.Status429TooManyRequests)
|
||||
.RequireAuthorization(ScannerPolicies.RuntimeIngest);
|
||||
}
|
||||
|
||||
private static async Task<IResult> HandleRuntimeEventsAsync(
|
||||
RuntimeEventsIngestRequestDto request,
|
||||
IRuntimeEventIngestionService ingestionService,
|
||||
IOptions<ScannerWebServiceOptions> options,
|
||||
HttpContext context,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
ArgumentNullException.ThrowIfNull(ingestionService);
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
|
||||
var runtimeOptions = options.Value.Runtime ?? new ScannerWebServiceOptions.RuntimeOptions();
|
||||
var validationError = ValidateRequest(request, runtimeOptions, context, out var envelopes);
|
||||
if (validationError is { } problem)
|
||||
{
|
||||
return problem;
|
||||
}
|
||||
|
||||
var result = await ingestionService.IngestAsync(envelopes, request.BatchId, cancellationToken).ConfigureAwait(false);
|
||||
if (result.IsPayloadTooLarge)
|
||||
{
|
||||
var extensions = new Dictionary<string, object?>
|
||||
{
|
||||
["payloadBytes"] = result.PayloadBytes,
|
||||
["maxPayloadBytes"] = result.PayloadLimit
|
||||
};
|
||||
|
||||
return ProblemResultFactory.Create(
|
||||
context,
|
||||
ProblemTypes.Validation,
|
||||
"Runtime event batch too large",
|
||||
StatusCodes.Status400BadRequest,
|
||||
detail: "Runtime batch payload exceeds configured budget.",
|
||||
extensions: extensions);
|
||||
}
|
||||
|
||||
if (result.IsRateLimited)
|
||||
{
|
||||
var retryAfterSeconds = Math.Max(1, (int)Math.Ceiling(result.RetryAfter.TotalSeconds));
|
||||
context.Response.Headers.RetryAfter = retryAfterSeconds.ToString(CultureInfo.InvariantCulture);
|
||||
|
||||
var extensions = new Dictionary<string, object?>
|
||||
{
|
||||
["scope"] = result.RateLimitedScope,
|
||||
["key"] = result.RateLimitedKey,
|
||||
["retryAfterSeconds"] = retryAfterSeconds
|
||||
};
|
||||
|
||||
return ProblemResultFactory.Create(
|
||||
context,
|
||||
ProblemTypes.RateLimited,
|
||||
"Runtime ingestion rate limited",
|
||||
StatusCodes.Status429TooManyRequests,
|
||||
detail: "Runtime ingestion exceeded configured rate limits.",
|
||||
extensions: extensions);
|
||||
}
|
||||
|
||||
var payload = new RuntimeEventsIngestResponseDto
|
||||
{
|
||||
Accepted = result.Accepted,
|
||||
Duplicates = result.Duplicates
|
||||
};
|
||||
|
||||
return Json(payload, StatusCodes.Status202Accepted);
|
||||
}
|
||||
|
||||
private static IResult? ValidateRequest(
|
||||
RuntimeEventsIngestRequestDto request,
|
||||
ScannerWebServiceOptions.RuntimeOptions runtimeOptions,
|
||||
HttpContext context,
|
||||
out IReadOnlyList<RuntimeEventEnvelope> envelopes)
|
||||
{
|
||||
envelopes = request.Events ?? Array.Empty<RuntimeEventEnvelope>();
|
||||
if (envelopes.Count == 0)
|
||||
{
|
||||
return ProblemResultFactory.Create(
|
||||
context,
|
||||
ProblemTypes.Validation,
|
||||
"Invalid runtime ingest request",
|
||||
StatusCodes.Status400BadRequest,
|
||||
detail: "events array must include at least one item.");
|
||||
}
|
||||
|
||||
if (envelopes.Count > runtimeOptions.MaxBatchSize)
|
||||
{
|
||||
var extensions = new Dictionary<string, object?>
|
||||
{
|
||||
["maxBatchSize"] = runtimeOptions.MaxBatchSize,
|
||||
["eventCount"] = envelopes.Count
|
||||
};
|
||||
|
||||
return ProblemResultFactory.Create(
|
||||
context,
|
||||
ProblemTypes.Validation,
|
||||
"Invalid runtime ingest request",
|
||||
StatusCodes.Status400BadRequest,
|
||||
detail: "events array exceeds allowed batch size.",
|
||||
extensions: extensions);
|
||||
}
|
||||
|
||||
var seenEventIds = new HashSet<string>(StringComparer.Ordinal);
|
||||
for (var i = 0; i < envelopes.Count; i++)
|
||||
{
|
||||
var envelope = envelopes[i];
|
||||
if (envelope is null)
|
||||
{
|
||||
return ProblemResultFactory.Create(
|
||||
context,
|
||||
ProblemTypes.Validation,
|
||||
"Invalid runtime ingest request",
|
||||
StatusCodes.Status400BadRequest,
|
||||
detail: $"events[{i}] must not be null.");
|
||||
}
|
||||
|
||||
if (!envelope.IsSupported())
|
||||
{
|
||||
var extensions = new Dictionary<string, object?>
|
||||
{
|
||||
["schemaVersion"] = envelope.SchemaVersion
|
||||
};
|
||||
|
||||
return ProblemResultFactory.Create(
|
||||
context,
|
||||
ProblemTypes.Validation,
|
||||
"Unsupported runtime schema version",
|
||||
StatusCodes.Status400BadRequest,
|
||||
detail: "Runtime event schemaVersion is not supported.",
|
||||
extensions: extensions);
|
||||
}
|
||||
|
||||
var runtimeEvent = envelope.Event;
|
||||
if (runtimeEvent is null)
|
||||
{
|
||||
return ProblemResultFactory.Create(
|
||||
context,
|
||||
ProblemTypes.Validation,
|
||||
"Invalid runtime ingest request",
|
||||
StatusCodes.Status400BadRequest,
|
||||
detail: $"events[{i}].event must not be null.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(runtimeEvent.EventId))
|
||||
{
|
||||
return ProblemResultFactory.Create(
|
||||
context,
|
||||
ProblemTypes.Validation,
|
||||
"Invalid runtime ingest request",
|
||||
StatusCodes.Status400BadRequest,
|
||||
detail: $"events[{i}].eventId is required.");
|
||||
}
|
||||
|
||||
if (!seenEventIds.Add(runtimeEvent.EventId))
|
||||
{
|
||||
return ProblemResultFactory.Create(
|
||||
context,
|
||||
ProblemTypes.Validation,
|
||||
"Invalid runtime ingest request",
|
||||
StatusCodes.Status400BadRequest,
|
||||
detail: $"Duplicate eventId detected within batch ('{runtimeEvent.EventId}').");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(runtimeEvent.Tenant))
|
||||
{
|
||||
return ProblemResultFactory.Create(
|
||||
context,
|
||||
ProblemTypes.Validation,
|
||||
"Invalid runtime ingest request",
|
||||
StatusCodes.Status400BadRequest,
|
||||
detail: $"events[{i}].tenant is required.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(runtimeEvent.Node))
|
||||
{
|
||||
return ProblemResultFactory.Create(
|
||||
context,
|
||||
ProblemTypes.Validation,
|
||||
"Invalid runtime ingest request",
|
||||
StatusCodes.Status400BadRequest,
|
||||
detail: $"events[{i}].node is required.");
|
||||
}
|
||||
|
||||
if (runtimeEvent.Workload is null)
|
||||
{
|
||||
return ProblemResultFactory.Create(
|
||||
context,
|
||||
ProblemTypes.Validation,
|
||||
"Invalid runtime ingest request",
|
||||
StatusCodes.Status400BadRequest,
|
||||
detail: $"events[{i}].workload is required.");
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string NormalizeSegment(string segment)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(segment))
|
||||
{
|
||||
return "/runtime";
|
||||
}
|
||||
|
||||
var trimmed = segment.Trim('/');
|
||||
return "/" + trimmed;
|
||||
}
|
||||
|
||||
private static IResult Json<T>(T value, int statusCode)
|
||||
{
|
||||
var payload = JsonSerializer.Serialize(value, SerializerOptions);
|
||||
return Results.Content(payload, "application/json", Encoding.UTF8, statusCode);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user