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; }
|
||||
}
|
||||
@@ -2,8 +2,10 @@
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Canonical.Json;
|
||||
using StellaOps.Scanner.Core.Contracts;
|
||||
using StellaOps.Scanner.Emit.Composition;
|
||||
using StellaOps.Scanner.Emit.Spdx;
|
||||
using StellaOps.Scanner.WebService.Domain;
|
||||
@@ -102,8 +104,8 @@ public sealed class SbomExportService : ISbomExportService
|
||||
artifact.JsonBytes,
|
||||
SbomExportFormat.Spdx3,
|
||||
profile,
|
||||
artifact.JsonDigest,
|
||||
artifact.ComponentCount));
|
||||
artifact.JsonSha256,
|
||||
0)); // ComponentCount not available on SpdxArtifact
|
||||
}
|
||||
|
||||
private async Task<SbomExportResult> ExportSpdx2Async(
|
||||
@@ -177,25 +179,73 @@ public sealed class SbomExportService : ISbomExportService
|
||||
ScanSnapshot snapshot,
|
||||
IReadOnlyList<SbomLayerFragment> layerFragments)
|
||||
{
|
||||
// Convert SbomLayerFragment to the format expected by SpdxComposer
|
||||
var fragments = layerFragments.Select(f => new Scanner.Core.Contracts.LayerSbomFragment
|
||||
// Convert SbomLayerFragment to LayerComponentFragment for SpdxComposer
|
||||
var fragments = layerFragments.Select(f => new LayerComponentFragment
|
||||
{
|
||||
LayerDigest = f.LayerDigest,
|
||||
Order = f.Order,
|
||||
ComponentPurls = f.ComponentPurls.ToList()
|
||||
}).ToList();
|
||||
Components = f.ComponentPurls
|
||||
.Select(purl => new ComponentRecord
|
||||
{
|
||||
Identity = ComponentIdentity.Create(
|
||||
key: purl,
|
||||
name: ExtractNameFromPurl(purl),
|
||||
version: ExtractVersionFromPurl(purl),
|
||||
purl: purl),
|
||||
LayerDigest = f.LayerDigest
|
||||
})
|
||||
.ToImmutableArray()
|
||||
}).ToImmutableArray();
|
||||
|
||||
return new SbomCompositionRequest
|
||||
var image = new ImageArtifactDescriptor
|
||||
{
|
||||
Image = new Scanner.Core.Contracts.ImageReference
|
||||
{
|
||||
ImageDigest = snapshot.Target.Digest ?? string.Empty,
|
||||
ImageRef = snapshot.Target.Reference ?? string.Empty
|
||||
},
|
||||
LayerFragments = fragments,
|
||||
GeneratedAt = _timeProvider.GetUtcNow(),
|
||||
GeneratorVersion = "StellaOps-Scanner/1.0"
|
||||
ImageDigest = snapshot.Target.Digest ?? string.Empty,
|
||||
ImageReference = snapshot.Target.Reference
|
||||
};
|
||||
|
||||
return SbomCompositionRequest.Create(
|
||||
image,
|
||||
fragments,
|
||||
_timeProvider.GetUtcNow(),
|
||||
generatorName: "StellaOps-Scanner",
|
||||
generatorVersion: "1.0");
|
||||
}
|
||||
|
||||
private static string ExtractNameFromPurl(string purl)
|
||||
{
|
||||
// Basic PURL parsing: pkg:type/namespace/name@version
|
||||
// Returns the name portion
|
||||
try
|
||||
{
|
||||
var withoutScheme = purl.StartsWith("pkg:", StringComparison.OrdinalIgnoreCase)
|
||||
? purl[4..]
|
||||
: purl;
|
||||
var atIndex = withoutScheme.IndexOf('@');
|
||||
var pathPart = atIndex >= 0 ? withoutScheme[..atIndex] : withoutScheme;
|
||||
var slashIndex = pathPart.LastIndexOf('/');
|
||||
return slashIndex >= 0 ? pathPart[(slashIndex + 1)..] : pathPart;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return purl;
|
||||
}
|
||||
}
|
||||
|
||||
private static string? ExtractVersionFromPurl(string purl)
|
||||
{
|
||||
// Basic PURL parsing: pkg:type/namespace/name@version
|
||||
// Returns the version portion
|
||||
try
|
||||
{
|
||||
var atIndex = purl.IndexOf('@');
|
||||
if (atIndex < 0) return null;
|
||||
var versionPart = purl[(atIndex + 1)..];
|
||||
var queryIndex = versionPart.IndexOf('?');
|
||||
return queryIndex >= 0 ? versionPart[..queryIndex] : versionPart;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static int EstimateComponentCount(byte[] sbomBytes)
|
||||
|
||||
@@ -83,7 +83,7 @@ public interface ISecretExceptionPatternService
|
||||
/// </summary>
|
||||
public sealed class SecretDetectionSettingsService : ISecretDetectionSettingsService
|
||||
{
|
||||
private readonly ISecretDetectionSettingsRepository _repository;
|
||||
private readonly Storage.Repositories.ISecretDetectionSettingsRepository _repository;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
@@ -92,7 +92,7 @@ public sealed class SecretDetectionSettingsService : ISecretDetectionSettingsSer
|
||||
};
|
||||
|
||||
public SecretDetectionSettingsService(
|
||||
ISecretDetectionSettingsRepository repository,
|
||||
Storage.Repositories.ISecretDetectionSettingsRepository repository,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
|
||||
|
||||
@@ -53,6 +53,7 @@
|
||||
<ProjectReference Include="../__Libraries/StellaOps.Scanner.Orchestration/StellaOps.Scanner.Orchestration.csproj" />
|
||||
<ProjectReference Include="../__Libraries/StellaOps.Scanner.Sources/StellaOps.Scanner.Sources.csproj" />
|
||||
<ProjectReference Include="../__Libraries/StellaOps.Scanner.Emit/StellaOps.Scanner.Emit.csproj" />
|
||||
<ProjectReference Include="../__Libraries/StellaOps.Scanner.Validation/StellaOps.Scanner.Validation.csproj" />
|
||||
<ProjectReference Include="../../Router/__Libraries/StellaOps.Router.AspNet/StellaOps.Router.AspNet.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user