save progress
This commit is contained in:
@@ -0,0 +1,346 @@
|
||||
// <copyright file="ValidationEndpoints.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Scanner.Validation;
|
||||
using StellaOps.Scanner.WebService.Security;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// SBOM validation endpoints.
|
||||
/// Sprint: SPRINT_20260107_005_003 Task VG-006
|
||||
/// </summary>
|
||||
internal static class ValidationEndpoints
|
||||
{
|
||||
/// <summary>
|
||||
/// Maps SBOM validation endpoints.
|
||||
/// </summary>
|
||||
public static void MapValidationEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(app);
|
||||
|
||||
var group = app.MapGroup("/api/v1/sbom")
|
||||
.WithTags("Validation")
|
||||
.RequireAuthorization();
|
||||
|
||||
// POST /api/v1/sbom/validate
|
||||
group.MapPost("/validate", ValidateSbomAsync)
|
||||
.WithName("scanner.sbom.validate")
|
||||
.WithDescription("Validates an SBOM document against CycloneDX or SPDX schemas")
|
||||
.Accepts<byte[]>(
|
||||
"application/vnd.cyclonedx+json",
|
||||
"application/vnd.cyclonedx+xml",
|
||||
"application/spdx+json",
|
||||
"text/spdx",
|
||||
"application/json",
|
||||
"application/octet-stream")
|
||||
.Produces<SbomValidationResponseDto>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status400BadRequest)
|
||||
.Produces(StatusCodes.Status503ServiceUnavailable)
|
||||
.RequireAuthorization(ScannerPolicies.ScansRead);
|
||||
|
||||
// GET /api/v1/sbom/validators
|
||||
group.MapGet("/validators", GetValidatorsAsync)
|
||||
.WithName("scanner.sbom.validators")
|
||||
.WithDescription("Gets information about available SBOM validators")
|
||||
.Produces<ValidatorsInfoResponseDto>(StatusCodes.Status200OK)
|
||||
.RequireAuthorization(ScannerPolicies.ScansRead);
|
||||
}
|
||||
|
||||
private static async Task<IResult> ValidateSbomAsync(
|
||||
HttpContext context,
|
||||
[FromServices] CompositeValidator validator,
|
||||
[FromServices] IOptions<ValidationGateOptions> options,
|
||||
[FromQuery] string? format = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var gateOptions = options.Value;
|
||||
|
||||
// Check if validation is enabled
|
||||
if (!gateOptions.Enabled || gateOptions.Mode == SbomValidationMode.Off)
|
||||
{
|
||||
return Results.Ok(new SbomValidationResponseDto
|
||||
{
|
||||
IsValid = true,
|
||||
Format = "unknown",
|
||||
ValidatorName = "validation-disabled",
|
||||
ValidatorVersion = "n/a",
|
||||
Message = "Validation is disabled",
|
||||
Diagnostics = Array.Empty<ValidationDiagnosticDto>()
|
||||
});
|
||||
}
|
||||
|
||||
// Read request body
|
||||
using var memoryStream = new MemoryStream();
|
||||
await context.Request.Body.CopyToAsync(memoryStream, cancellationToken).ConfigureAwait(false);
|
||||
var sbomBytes = memoryStream.ToArray();
|
||||
|
||||
if (sbomBytes.Length == 0)
|
||||
{
|
||||
return Results.BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Empty request body",
|
||||
Detail = "SBOM document is required",
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
});
|
||||
}
|
||||
|
||||
// Determine format
|
||||
SbomFormat sbomFormat;
|
||||
if (!string.IsNullOrWhiteSpace(format))
|
||||
{
|
||||
if (!Enum.TryParse<SbomFormat>(format, ignoreCase: true, out sbomFormat))
|
||||
{
|
||||
return Results.BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Invalid format",
|
||||
Detail = $"Unknown SBOM format: {format}. Supported: CycloneDxJson, CycloneDxXml, Spdx23Json, Spdx23TagValue, Spdx3JsonLd",
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
});
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Auto-detect format
|
||||
sbomFormat = CompositeValidator.DetectFormat(sbomBytes);
|
||||
if (sbomFormat == SbomFormat.Unknown)
|
||||
{
|
||||
// Try content-type header
|
||||
sbomFormat = DetectFormatFromContentType(context.Request.ContentType);
|
||||
}
|
||||
}
|
||||
|
||||
// Run validation
|
||||
var validationOptions = gateOptions.ToValidationOptions();
|
||||
SbomValidationResult result;
|
||||
|
||||
if (sbomFormat == SbomFormat.Unknown)
|
||||
{
|
||||
result = await validator.ValidateAutoAsync(sbomBytes, validationOptions, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
result = await validator.ValidateAsync(sbomBytes, sbomFormat, validationOptions, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// Check if validator is available
|
||||
if (result.Diagnostics.Any(d => d.Code == "VALIDATOR_UNAVAILABLE"))
|
||||
{
|
||||
return Results.Problem(
|
||||
title: "Validator unavailable",
|
||||
detail: result.Diagnostics.FirstOrDefault(d => d.Code == "VALIDATOR_UNAVAILABLE")?.Message,
|
||||
statusCode: StatusCodes.Status503ServiceUnavailable);
|
||||
}
|
||||
|
||||
var response = new SbomValidationResponseDto
|
||||
{
|
||||
IsValid = result.IsValid,
|
||||
Format = result.Format.ToString(),
|
||||
ValidatorName = result.ValidatorName,
|
||||
ValidatorVersion = result.ValidatorVersion,
|
||||
ValidationDurationMs = (int)result.ValidationDuration.TotalMilliseconds,
|
||||
ErrorCount = result.ErrorCount,
|
||||
WarningCount = result.WarningCount,
|
||||
SchemaVersion = result.SchemaVersion,
|
||||
Diagnostics = result.Diagnostics.Select(d => new ValidationDiagnosticDto
|
||||
{
|
||||
Severity = d.Severity.ToString(),
|
||||
Code = d.Code,
|
||||
Message = d.Message,
|
||||
Path = d.Path,
|
||||
Line = d.Line,
|
||||
Suggestion = d.Suggestion
|
||||
}).ToArray()
|
||||
};
|
||||
|
||||
return Results.Ok(response);
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetValidatorsAsync(
|
||||
[FromServices] CompositeValidator validator,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var info = await validator.GetInfoAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var response = new ValidatorsInfoResponseDto
|
||||
{
|
||||
IsAvailable = info.IsAvailable,
|
||||
Name = info.Name,
|
||||
Version = info.Version,
|
||||
SupportedFormats = info.SupportedFormats.Select(f => f.ToString()).ToArray(),
|
||||
SupportedSchemaVersions = info.SupportedSchemaVersions.ToArray()
|
||||
};
|
||||
|
||||
return Results.Ok(response);
|
||||
}
|
||||
|
||||
private static SbomFormat DetectFormatFromContentType(string? contentType)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(contentType))
|
||||
{
|
||||
return SbomFormat.Unknown;
|
||||
}
|
||||
|
||||
return contentType.ToLowerInvariant() switch
|
||||
{
|
||||
var ct when ct.Contains("cyclonedx+json") => SbomFormat.CycloneDxJson,
|
||||
var ct when ct.Contains("cyclonedx+xml") || ct.Contains("cyclonedx") && ct.Contains("xml") => SbomFormat.CycloneDxXml,
|
||||
var ct when ct.Contains("spdx+json") || ct.Contains("spdx") && ct.Contains("json") => SbomFormat.Spdx23Json,
|
||||
var ct when ct.Contains("text/spdx") => SbomFormat.Spdx23TagValue,
|
||||
_ => SbomFormat.Unknown
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response DTO for SBOM validation.
|
||||
/// </summary>
|
||||
public sealed class SbomValidationResponseDto
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets whether the SBOM is valid.
|
||||
/// </summary>
|
||||
[JsonPropertyName("isValid")]
|
||||
public bool IsValid { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the SBOM format.
|
||||
/// </summary>
|
||||
[JsonPropertyName("format")]
|
||||
public required string Format { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the validator name.
|
||||
/// </summary>
|
||||
[JsonPropertyName("validatorName")]
|
||||
public required string ValidatorName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the validator version.
|
||||
/// </summary>
|
||||
[JsonPropertyName("validatorVersion")]
|
||||
public required string ValidatorVersion { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the validation duration in milliseconds.
|
||||
/// </summary>
|
||||
[JsonPropertyName("validationDurationMs")]
|
||||
public int ValidationDurationMs { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the error count.
|
||||
/// </summary>
|
||||
[JsonPropertyName("errorCount")]
|
||||
public int ErrorCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the warning count.
|
||||
/// </summary>
|
||||
[JsonPropertyName("warningCount")]
|
||||
public int WarningCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the schema version validated against.
|
||||
/// </summary>
|
||||
[JsonPropertyName("schemaVersion")]
|
||||
public string? SchemaVersion { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a message (for disabled validation).
|
||||
/// </summary>
|
||||
[JsonPropertyName("message")]
|
||||
public string? Message { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the validation diagnostics.
|
||||
/// </summary>
|
||||
[JsonPropertyName("diagnostics")]
|
||||
public required ValidationDiagnosticDto[] Diagnostics { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DTO for a validation diagnostic.
|
||||
/// </summary>
|
||||
public sealed class ValidationDiagnosticDto
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the severity.
|
||||
/// </summary>
|
||||
[JsonPropertyName("severity")]
|
||||
public required string Severity { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the diagnostic code.
|
||||
/// </summary>
|
||||
[JsonPropertyName("code")]
|
||||
public required string Code { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the message.
|
||||
/// </summary>
|
||||
[JsonPropertyName("message")]
|
||||
public required string Message { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the JSON path.
|
||||
/// </summary>
|
||||
[JsonPropertyName("path")]
|
||||
public string? Path { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the line number.
|
||||
/// </summary>
|
||||
[JsonPropertyName("line")]
|
||||
public int? Line { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a suggestion.
|
||||
/// </summary>
|
||||
[JsonPropertyName("suggestion")]
|
||||
public string? Suggestion { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response DTO for validators info.
|
||||
/// </summary>
|
||||
public sealed class ValidatorsInfoResponseDto
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets whether validators are available.
|
||||
/// </summary>
|
||||
[JsonPropertyName("isAvailable")]
|
||||
public bool IsAvailable { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the composite validator name.
|
||||
/// </summary>
|
||||
[JsonPropertyName("name")]
|
||||
public required string Name { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the version.
|
||||
/// </summary>
|
||||
[JsonPropertyName("version")]
|
||||
public required string Version { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the supported formats.
|
||||
/// </summary>
|
||||
[JsonPropertyName("supportedFormats")]
|
||||
public required string[] SupportedFormats { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the supported schema versions.
|
||||
/// </summary>
|
||||
[JsonPropertyName("supportedSchemaVersions")]
|
||||
public required string[] SupportedSchemaVersions { get; set; }
|
||||
}
|
||||
Reference in New Issue
Block a user