save progress

This commit is contained in:
master
2026-01-09 18:27:36 +02:00
parent e608752924
commit a21d3dbc1f
361 changed files with 63068 additions and 1192 deletions

View File

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

View File

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

View File

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

View File

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