// =============================================================================
// CycloneDxParser.cs
// CycloneDX SBOM parser implementation
// Part of Step 2: Evidence Collection (Task T5)
// =============================================================================
using System.Globalization;
using System.Text.Json;
using System.Text.Json.Nodes;
namespace StellaOps.AirGap.Importer.Reconciliation.Parsers;
///
/// Parser for CycloneDX SBOM format (JSON).
/// Supports CycloneDX 1.4, 1.5, and 1.6 schemas.
///
public sealed class CycloneDxParser : ISbomParser
{
private static readonly JsonDocumentOptions DocumentOptions = new()
{
AllowTrailingCommas = true,
CommentHandling = JsonCommentHandling.Skip
};
public SbomFormat DetectFormat(string filePath)
{
ArgumentException.ThrowIfNullOrWhiteSpace(filePath);
// CycloneDX files typically end with .cdx.json or .bom.json
if (filePath.EndsWith(".cdx.json", StringComparison.OrdinalIgnoreCase) ||
filePath.EndsWith(".bom.json", StringComparison.OrdinalIgnoreCase))
{
return SbomFormat.CycloneDx;
}
// Try to detect from content
if (File.Exists(filePath))
{
try
{
using var stream = File.OpenRead(filePath);
using var reader = new StreamReader(stream);
var firstChars = new char[1024];
var read = reader.Read(firstChars, 0, firstChars.Length);
var content = new string(firstChars, 0, read);
if (content.Contains("\"bomFormat\"", StringComparison.OrdinalIgnoreCase) ||
content.Contains("\"$schema\"", StringComparison.OrdinalIgnoreCase) &&
content.Contains("cyclonedx", StringComparison.OrdinalIgnoreCase))
{
return SbomFormat.CycloneDx;
}
}
catch
{
// Ignore detection errors
}
}
return SbomFormat.Unknown;
}
public async Task ParseAsync(string filePath, CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(filePath);
if (!File.Exists(filePath))
{
return SbomParseResult.Failure($"File not found: {filePath}", SbomFormat.CycloneDx);
}
try
{
await using var stream = File.OpenRead(filePath);
return await ParseAsync(stream, SbomFormat.CycloneDx, cancellationToken);
}
catch (Exception ex)
{
return SbomParseResult.Failure($"Failed to parse CycloneDX file: {ex.Message}", SbomFormat.CycloneDx);
}
}
public async Task ParseAsync(Stream stream, SbomFormat format, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(stream);
try
{
using var document = await JsonDocument.ParseAsync(stream, DocumentOptions, cancellationToken);
var root = document.RootElement;
// Validate bomFormat
if (!root.TryGetProperty("bomFormat", out var bomFormatProp) ||
!bomFormatProp.GetString()?.Equals("CycloneDX", StringComparison.OrdinalIgnoreCase) == true)
{
// Try alternative detection
if (!root.TryGetProperty("$schema", out var schemaProp) ||
!schemaProp.GetString()?.Contains("cyclonedx", StringComparison.OrdinalIgnoreCase) == true)
{
return SbomParseResult.Failure("Not a valid CycloneDX document", SbomFormat.CycloneDx);
}
}
// Extract spec version
string? specVersion = null;
if (root.TryGetProperty("specVersion", out var specProp))
{
specVersion = specProp.GetString();
}
// Extract serial number
string? serialNumber = null;
if (root.TryGetProperty("serialNumber", out var serialProp))
{
serialNumber = serialProp.GetString();
}
// Extract creation timestamp
DateTimeOffset? createdAt = null;
if (root.TryGetProperty("metadata", out var metadataProp))
{
if (metadataProp.TryGetProperty("timestamp", out var timestampProp))
{
if (TryParseTimestamp(timestampProp.GetString(), out var parsed))
{
createdAt = parsed;
}
}
}
// Extract generator tool
string? generatorTool = null;
if (root.TryGetProperty("metadata", out var meta) &&
meta.TryGetProperty("tools", out var toolsProp))
{
generatorTool = ExtractToolInfo(toolsProp);
}
// Extract primary component (metadata.component)
SbomSubject? primarySubject = null;
if (root.TryGetProperty("metadata", out var metaData) &&
metaData.TryGetProperty("component", out var primaryComponent))
{
primarySubject = ParseComponent(primaryComponent);
}
// Extract all components
var subjects = new List();
int totalComponentCount = 0;
if (root.TryGetProperty("components", out var componentsProp) &&
componentsProp.ValueKind == JsonValueKind.Array)
{
foreach (var component in componentsProp.EnumerateArray())
{
totalComponentCount++;
var subject = ParseComponent(component);
if (subject is not null)
{
subjects.Add(subject);
}
}
}
// Add primary subject if it has a digest and isn't already in the list
if (primarySubject is not null &&
!subjects.Any(s => s.Digest.Equals(primarySubject.Digest, StringComparison.OrdinalIgnoreCase)))
{
subjects.Insert(0, primarySubject);
}
// Sort subjects for deterministic ordering
subjects = subjects
.OrderBy(s => s.Digest, StringComparer.Ordinal)
.ThenBy(s => s.Name ?? string.Empty, StringComparer.Ordinal)
.ToList();
return SbomParseResult.Success(
format: SbomFormat.CycloneDx,
subjects: subjects,
specVersion: specVersion,
serialNumber: serialNumber,
createdAt: createdAt,
generatorTool: generatorTool,
primarySubject: primarySubject,
totalComponentCount: totalComponentCount);
}
catch (JsonException ex)
{
return SbomParseResult.Failure($"JSON parsing error: {ex.Message}", SbomFormat.CycloneDx);
}
}
private static SbomSubject? ParseComponent(JsonElement component)
{
// Extract hashes
var hashes = new Dictionary(StringComparer.OrdinalIgnoreCase);
if (component.TryGetProperty("hashes", out var hashesProp) &&
hashesProp.ValueKind == JsonValueKind.Array)
{
foreach (var hash in hashesProp.EnumerateArray())
{
if (hash.TryGetProperty("alg", out var algProp) &&
hash.TryGetProperty("content", out var contentProp))
{
var alg = algProp.GetString();
var content = contentProp.GetString();
if (!string.IsNullOrEmpty(alg) && !string.IsNullOrEmpty(content))
{
hashes[alg] = content;
}
}
}
}
// Determine primary digest (prefer SHA-256)
var digest = TrySelectSha256Digest(hashes);
// If no digest, this component can't be indexed by digest
if (string.IsNullOrEmpty(digest))
{
return null;
}
// Extract other properties
string? name = null;
if (component.TryGetProperty("name", out var nameProp))
{
name = nameProp.GetString();
}
string? version = null;
if (component.TryGetProperty("version", out var versionProp))
{
version = versionProp.GetString();
}
string? purl = null;
if (component.TryGetProperty("purl", out var purlProp))
{
purl = purlProp.GetString();
}
string? type = null;
if (component.TryGetProperty("type", out var typeProp))
{
type = typeProp.GetString();
}
string? bomRef = null;
if (component.TryGetProperty("bom-ref", out var bomRefProp))
{
bomRef = bomRefProp.GetString();
}
return new SbomSubject
{
Digest = digest,
Name = name,
Version = version,
Purl = purl,
Type = type,
BomRef = bomRef,
Hashes = hashes
};
}
private static string? ExtractToolInfo(JsonElement tools)
{
// CycloneDX 1.5+ uses tools.components array
if (tools.TryGetProperty("components", out var components) &&
components.ValueKind == JsonValueKind.Array)
{
var toolList = new List();
foreach (var tool in components.EnumerateArray())
{
if (tool.TryGetProperty("name", out var name))
{
var toolName = name.GetString();
if (!string.IsNullOrEmpty(toolName))
{
if (tool.TryGetProperty("version", out var version))
{
toolName += $"@{version.GetString()}";
}
toolList.Add(toolName);
}
}
}
return toolList.Count > 0 ? string.Join(", ", toolList) : null;
}
// CycloneDX 1.4 and earlier uses tools array directly
if (tools.ValueKind == JsonValueKind.Array)
{
var toolList = new List();
foreach (var tool in tools.EnumerateArray())
{
if (tool.TryGetProperty("name", out var name))
{
var toolName = name.GetString();
if (!string.IsNullOrEmpty(toolName))
{
if (tool.TryGetProperty("version", out var version))
{
toolName += $"@{version.GetString()}";
}
toolList.Add(toolName);
}
}
}
return toolList.Count > 0 ? string.Join(", ", toolList) : null;
}
return null;
}
private static string NormalizeDigest(string digest)
{
return ArtifactIndex.NormalizeDigest(digest);
}
private static bool TryParseTimestamp(string? value, out DateTimeOffset timestamp)
{
timestamp = default;
return !string.IsNullOrWhiteSpace(value) &&
DateTimeOffset.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out timestamp);
}
private static string? TrySelectSha256Digest(IReadOnlyDictionary hashes)
{
foreach (var key in new[] { "SHA-256", "SHA256", "sha256" })
{
if (hashes.TryGetValue(key, out var sha256))
{
return NormalizeDigest("sha256:" + sha256);
}
}
return null;
}
}