434 lines
14 KiB
C#
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);
|
|
}
|