// ============================================================================= // 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; } }