Files
git.stella-ops.org/src/Scanner/__Libraries/StellaOps.Scanner.Validation/CycloneDxValidator.cs
2026-01-08 20:46:43 +02:00

434 lines
14 KiB
C#

// <copyright file="CycloneDxValidator.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
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;
/// <summary>
/// CycloneDX validator using sbom-utility CLI tool.
/// Sprint: SPRINT_20260107_005_003 Task VG-002
/// </summary>
public sealed partial class CycloneDxValidator : ISbomValidator
{
private readonly CycloneDxValidatorOptions _options;
private readonly ILogger<CycloneDxValidator> _logger;
private readonly TimeProvider _timeProvider;
private const string ValidatorName = "sbom-utility";
/// <summary>
/// Initializes a new instance of the <see cref="CycloneDxValidator"/> class.
/// </summary>
public CycloneDxValidator(
IOptions<CycloneDxValidatorOptions> options,
ILogger<CycloneDxValidator> logger,
TimeProvider timeProvider)
{
_options = options.Value;
_logger = logger;
_timeProvider = timeProvider;
}
/// <inheritdoc/>
public bool SupportsFormat(SbomFormat format) =>
format is SbomFormat.CycloneDxJson or SbomFormat.CycloneDxXml;
/// <inheritdoc/>
public async Task<ValidatorInfo> 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
};
}
}
/// <inheritdoc/>
public async Task<SbomValidationResult> 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<string?> 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<SbomValidationDiagnostic> ParseValidatorOutput(
string stdout,
string stderr,
bool includeWarnings)
{
var diagnostics = new List<SbomValidationDiagnostic>();
// 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<SbomValidationDiagnostic> 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();
}
/// <summary>
/// Configuration options for CycloneDX validator.
/// </summary>
public sealed class CycloneDxValidatorOptions
{
/// <summary>
/// Gets or sets the path to the sbom-utility executable.
/// Default: "sbom-utility" (expects it in PATH).
/// </summary>
public string ExecutablePath { get; set; } = "sbom-utility";
/// <summary>
/// Gets or sets the default timeout for validation.
/// Default: 30 seconds.
/// </summary>
public TimeSpan DefaultTimeout { get; set; } = TimeSpan.FromSeconds(30);
}