// // Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later. // using System.Collections.Immutable; using System.Diagnostics; using System.Globalization; using System.Text; using System.Text.Json; using System.Text.RegularExpressions; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; namespace StellaOps.Scanner.Validation; /// /// CycloneDX validator using sbom-utility CLI tool. /// Sprint: SPRINT_20260107_005_003 Task VG-002 /// public sealed partial class CycloneDxValidator : ISbomValidator { private readonly CycloneDxValidatorOptions _options; private readonly ILogger _logger; private readonly TimeProvider _timeProvider; private const string ValidatorName = "sbom-utility"; /// /// Initializes a new instance of the class. /// public CycloneDxValidator( IOptions options, ILogger logger, TimeProvider timeProvider) { _options = options.Value; _logger = logger; _timeProvider = timeProvider; } /// public bool SupportsFormat(SbomFormat format) => format is SbomFormat.CycloneDxJson or SbomFormat.CycloneDxXml; /// public async Task GetInfoAsync(CancellationToken cancellationToken = default) { try { var version = await GetValidatorVersionAsync(cancellationToken).ConfigureAwait(false); return new ValidatorInfo { Name = ValidatorName, Version = version ?? "unknown", IsAvailable = version is not null, SupportedFormats = ImmutableArray.Create(SbomFormat.CycloneDxJson, SbomFormat.CycloneDxXml), SupportedSchemaVersions = ImmutableArray.Create("1.4", "1.5", "1.6", "1.7") }; } catch (Exception ex) { _logger.LogWarning(ex, "Failed to get validator info"); return new ValidatorInfo { Name = ValidatorName, Version = "unknown", IsAvailable = false }; } } /// public async Task ValidateAsync( byte[] sbomBytes, SbomFormat format, SbomValidationOptions? options = null, CancellationToken cancellationToken = default) { if (!SupportsFormat(format)) { return SbomValidationResult.Failure( format, ValidatorName, "n/a", TimeSpan.Zero, new[] { new SbomValidationDiagnostic { Severity = SbomValidationSeverity.Error, Code = "UNSUPPORTED_FORMAT", Message = $"Format {format} is not supported by CycloneDX validator" } }); } var validationOptions = options ?? new SbomValidationOptions(); var startTime = _timeProvider.GetUtcNow(); // Write SBOM to temp file var extension = format == SbomFormat.CycloneDxXml ? ".xml" : ".json"; var tempFile = Path.Combine(Path.GetTempPath(), $"stellaops-validate-{Guid.NewGuid():N}{extension}"); try { await File.WriteAllBytesAsync(tempFile, sbomBytes, cancellationToken).ConfigureAwait(false); // Run sbom-utility validate var (exitCode, stdout, stderr) = await RunValidatorAsync( tempFile, validationOptions.Timeout, cancellationToken).ConfigureAwait(false); var duration = _timeProvider.GetUtcNow() - startTime; var version = await GetValidatorVersionAsync(cancellationToken).ConfigureAwait(false) ?? "unknown"; // Parse output var diagnostics = ParseValidatorOutput(stdout, stderr, validationOptions.IncludeWarnings); var isValid = exitCode == 0 && !diagnostics.Any(d => d.Severity == SbomValidationSeverity.Error); return isValid ? SbomValidationResult.Success(format, ValidatorName, version, duration, diagnostics) : SbomValidationResult.Failure(format, ValidatorName, version, duration, diagnostics); } catch (FileNotFoundException) { return SbomValidationResult.ValidatorUnavailable( format, ValidatorName, $"sbom-utility not found at '{_options.ExecutablePath}'"); } catch (Exception ex) when (ex is not OperationCanceledException) { _logger.LogError(ex, "Validation failed"); return SbomValidationResult.Failure( format, ValidatorName, "unknown", _timeProvider.GetUtcNow() - startTime, new[] { new SbomValidationDiagnostic { Severity = SbomValidationSeverity.Error, Code = "VALIDATION_ERROR", Message = ex.Message } }); } finally { // Cleanup temp file try { if (File.Exists(tempFile)) { File.Delete(tempFile); } } catch { // Ignore cleanup errors } } } private async Task GetValidatorVersionAsync(CancellationToken cancellationToken) { try { var (exitCode, stdout, _) = await RunCommandAsync( "--version", TimeSpan.FromSeconds(10), cancellationToken).ConfigureAwait(false); if (exitCode != 0) { return null; } // Parse version from output like "sbom-utility version 0.16.0" var match = VersionRegex().Match(stdout); return match.Success ? match.Groups[1].Value : stdout.Trim(); } catch { return null; } } private async Task<(int ExitCode, string Stdout, string Stderr)> RunValidatorAsync( string inputFile, TimeSpan timeout, CancellationToken cancellationToken) { var args = $"validate --input-file \"{inputFile}\" --format json"; return await RunCommandAsync(args, timeout, cancellationToken).ConfigureAwait(false); } private async Task<(int ExitCode, string Stdout, string Stderr)> RunCommandAsync( string arguments, TimeSpan timeout, CancellationToken cancellationToken) { var executablePath = _options.ExecutablePath; if (!File.Exists(executablePath)) { // Try to find in PATH executablePath = FindInPath("sbom-utility") ?? _options.ExecutablePath; } var psi = new ProcessStartInfo { FileName = executablePath, Arguments = arguments, RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, CreateNoWindow = true, StandardOutputEncoding = Encoding.UTF8, StandardErrorEncoding = Encoding.UTF8 }; using var process = new Process { StartInfo = psi }; var stdoutBuilder = new StringBuilder(); var stderrBuilder = new StringBuilder(); process.OutputDataReceived += (_, e) => { if (e.Data is not null) { stdoutBuilder.AppendLine(e.Data); } }; process.ErrorDataReceived += (_, e) => { if (e.Data is not null) { stderrBuilder.AppendLine(e.Data); } }; process.Start(); process.BeginOutputReadLine(); process.BeginErrorReadLine(); using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); cts.CancelAfter(timeout); try { await process.WaitForExitAsync(cts.Token).ConfigureAwait(false); } catch (OperationCanceledException) { try { process.Kill(entireProcessTree: true); } catch { // Ignore } throw; } return (process.ExitCode, stdoutBuilder.ToString(), stderrBuilder.ToString()); } private ImmutableArray ParseValidatorOutput( string stdout, string stderr, bool includeWarnings) { var diagnostics = new List(); // Try to parse JSON output first if (TryParseJsonOutput(stdout, diagnostics, includeWarnings)) { return diagnostics.ToImmutableArray(); } // Fall back to line-by-line parsing var allOutput = stdout + "\n" + stderr; foreach (var line in allOutput.Split('\n', StringSplitOptions.RemoveEmptyEntries)) { var trimmed = line.Trim(); if (trimmed.Contains("error", StringComparison.OrdinalIgnoreCase)) { diagnostics.Add(new SbomValidationDiagnostic { Severity = SbomValidationSeverity.Error, Code = "CYCLONEDX_ERROR", Message = trimmed }); } else if (includeWarnings && trimmed.Contains("warning", StringComparison.OrdinalIgnoreCase)) { diagnostics.Add(new SbomValidationDiagnostic { Severity = SbomValidationSeverity.Warning, Code = "CYCLONEDX_WARNING", Message = trimmed }); } } // If no errors found but exit was non-zero, add generic error if (!diagnostics.Any(d => d.Severity == SbomValidationSeverity.Error) && stderr.Length > 0) { diagnostics.Add(new SbomValidationDiagnostic { Severity = SbomValidationSeverity.Error, Code = "VALIDATION_FAILED", Message = stderr.Trim() }); } return diagnostics.ToImmutableArray(); } private static bool TryParseJsonOutput( string stdout, List diagnostics, bool includeWarnings) { try { using var doc = JsonDocument.Parse(stdout); var root = doc.RootElement; if (root.TryGetProperty("errors", out var errors)) { foreach (var error in errors.EnumerateArray()) { diagnostics.Add(new SbomValidationDiagnostic { Severity = SbomValidationSeverity.Error, Code = error.TryGetProperty("code", out var code) ? code.GetString() ?? "ERROR" : "ERROR", Message = error.TryGetProperty("message", out var msg) ? msg.GetString() ?? "Unknown error" : "Unknown error", Path = error.TryGetProperty("path", out var path) ? path.GetString() : null }); } } if (includeWarnings && root.TryGetProperty("warnings", out var warnings)) { foreach (var warning in warnings.EnumerateArray()) { diagnostics.Add(new SbomValidationDiagnostic { Severity = SbomValidationSeverity.Warning, Code = warning.TryGetProperty("code", out var code) ? code.GetString() ?? "WARNING" : "WARNING", Message = warning.TryGetProperty("message", out var msg) ? msg.GetString() ?? "Unknown warning" : "Unknown warning", Path = warning.TryGetProperty("path", out var path) ? path.GetString() : null }); } } return true; } catch (JsonException) { return false; } } private static string? FindInPath(string executable) { var pathEnv = Environment.GetEnvironmentVariable("PATH"); if (string.IsNullOrEmpty(pathEnv)) { return null; } var paths = pathEnv.Split(Path.PathSeparator); var extensions = OperatingSystem.IsWindows() ? new[] { ".exe", ".cmd", ".bat", "" } : new[] { "" }; foreach (var path in paths) { foreach (var ext in extensions) { var fullPath = Path.Combine(path, executable + ext); if (File.Exists(fullPath)) { return fullPath; } } } return null; } [GeneratedRegex(@"version\s+(\d+\.\d+\.\d+)", RegexOptions.IgnoreCase | RegexOptions.Compiled)] private static partial Regex VersionRegex(); } /// /// Configuration options for CycloneDX validator. /// public sealed class CycloneDxValidatorOptions { /// /// Gets or sets the path to the sbom-utility executable. /// Default: "sbom-utility" (expects it in PATH). /// public string ExecutablePath { get; set; } = "sbom-utility"; /// /// Gets or sets the default timeout for validation. /// Default: 30 seconds. /// public TimeSpan DefaultTimeout { get; set; } = TimeSpan.FromSeconds(30); }