more audit work
This commit is contained in:
@@ -0,0 +1,433 @@
|
||||
// <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);
|
||||
}
|
||||
Reference in New Issue
Block a user