using System.Diagnostics; using System.Security.Cryptography; using System.Text; using System.Text.Json; using StellaOps.Determinism; using StellaOps.VexLens.Models; namespace StellaOps.VexLens.Normalization; /// /// Normalizer for CycloneDX VEX format documents. /// CycloneDX VEX uses the vulnerabilities array in CycloneDX BOM format. /// public sealed class CycloneDxVexNormalizer : IVexNormalizer { private readonly IGuidProvider _guidProvider; public CycloneDxVexNormalizer(IGuidProvider? guidProvider = null) { _guidProvider = guidProvider ?? SystemGuidProvider.Instance; } public VexSourceFormat SourceFormat => VexSourceFormat.CycloneDxVex; public bool CanNormalize(string content) { if (string.IsNullOrWhiteSpace(content)) { return false; } try { using var doc = JsonDocument.Parse(content); var root = doc.RootElement; // CycloneDX documents have bomFormat = "CycloneDX" and must have vulnerabilities if (root.TryGetProperty("bomFormat", out var bomFormat)) { var formatStr = bomFormat.GetString(); if (formatStr?.Equals("CycloneDX", StringComparison.OrdinalIgnoreCase) == true) { // Must have vulnerabilities array to be a VEX document return root.TryGetProperty("vulnerabilities", out var vulns) && vulns.ValueKind == JsonValueKind.Array && vulns.GetArrayLength() > 0; } } return false; } catch { return false; } } public Task NormalizeAsync( string content, NormalizationContext context, CancellationToken cancellationToken = default) { var stopwatch = Stopwatch.StartNew(); var warnings = new List(); var statementsSkipped = 0; try { using var doc = JsonDocument.Parse(content); var root = doc.RootElement; // Extract document ID from serialNumber or metadata var documentId = ExtractDocumentId(root); if (string.IsNullOrWhiteSpace(documentId)) { documentId = $"cyclonedx:{_guidProvider.NewGuid():N}"; warnings.Add(new NormalizationWarning( "WARN_CDX_001", "Serial number not found; generated a random ID", "serialNumber")); } // Extract issuer from metadata var issuer = ExtractIssuer(root, warnings); // Extract timestamps var (issuedAt, lastUpdatedAt) = ExtractTimestamps(root); // Build component lookup for product resolution var componentLookup = BuildComponentLookup(root); // Extract vulnerabilities and convert to statements var statements = ExtractStatements(root, componentLookup, warnings, ref statementsSkipped); // Calculate source digest var sourceDigest = ComputeDigest(content); // Build provenance var provenance = new NormalizationProvenance( NormalizedAt: context.NormalizedAt, Normalizer: context.Normalizer, SourceRevision: null, TransformationRules: ["cyclonedx-vex-to-normalized-v1"]); var normalizedDoc = new NormalizedVexDocument( SchemaVersion: NormalizedVexDocument.CurrentSchemaVersion, DocumentId: documentId, SourceFormat: VexSourceFormat.CycloneDxVex, SourceDigest: sourceDigest, SourceUri: context.SourceUri, Issuer: issuer, IssuedAt: issuedAt, LastUpdatedAt: lastUpdatedAt, Statements: statements, Provenance: provenance); stopwatch.Stop(); return Task.FromResult(NormalizationResult.Successful( normalizedDoc, new NormalizationMetrics( Duration: stopwatch.Elapsed, SourceBytes: Encoding.UTF8.GetByteCount(content), StatementsExtracted: statements.Count, StatementsSkipped: statementsSkipped, ProductsMapped: statements.Count), warnings)); } catch (JsonException ex) { stopwatch.Stop(); return Task.FromResult(NormalizationResult.Failed( [new NormalizationError("ERR_CDX_001", "Invalid JSON", ex.Path, ex)], new NormalizationMetrics( Duration: stopwatch.Elapsed, SourceBytes: Encoding.UTF8.GetByteCount(content), StatementsExtracted: 0, StatementsSkipped: 0, ProductsMapped: 0), warnings)); } catch (Exception ex) { stopwatch.Stop(); return Task.FromResult(NormalizationResult.Failed( [new NormalizationError("ERR_CDX_999", "Unexpected error during normalization", null, ex)], new NormalizationMetrics( Duration: stopwatch.Elapsed, SourceBytes: Encoding.UTF8.GetByteCount(content), StatementsExtracted: 0, StatementsSkipped: 0, ProductsMapped: 0), warnings)); } } private static string? ExtractDocumentId(JsonElement root) { // Try serialNumber first if (root.TryGetProperty("serialNumber", out var serialNumber)) { return serialNumber.GetString(); } // Fall back to metadata.component.bom-ref if (root.TryGetProperty("metadata", out var metadata) && metadata.TryGetProperty("component", out var component) && component.TryGetProperty("bom-ref", out var bomRef)) { return bomRef.GetString(); } return null; } private static VexIssuer? ExtractIssuer(JsonElement root, List warnings) { if (!root.TryGetProperty("metadata", out var metadata)) { warnings.Add(new NormalizationWarning( "WARN_CDX_002", "No metadata found in document", "metadata")); return null; } // Try to extract from authors or supplier string? issuerId = null; string? issuerName = null; if (metadata.TryGetProperty("authors", out var authors) && authors.ValueKind == JsonValueKind.Array) { foreach (var author in authors.EnumerateArray()) { issuerName = author.TryGetProperty("name", out var name) ? name.GetString() : null; issuerId = author.TryGetProperty("email", out var email) ? email.GetString() : issuerName; if (!string.IsNullOrWhiteSpace(issuerName)) { break; } } } if (string.IsNullOrWhiteSpace(issuerName) && metadata.TryGetProperty("supplier", out var supplier)) { issuerName = supplier.TryGetProperty("name", out var name) ? name.GetString() : null; issuerId = supplier.TryGetProperty("url", out var url) ? url.ValueKind == JsonValueKind.Array ? url.EnumerateArray().FirstOrDefault().GetString() : url.GetString() : issuerName; } if (string.IsNullOrWhiteSpace(issuerName) && metadata.TryGetProperty("manufacture", out var manufacture)) { issuerName = manufacture.TryGetProperty("name", out var name) ? name.GetString() : null; issuerId = issuerName; } if (string.IsNullOrWhiteSpace(issuerName)) { warnings.Add(new NormalizationWarning( "WARN_CDX_003", "No author/supplier found in metadata", "metadata.authors")); return null; } return new VexIssuer( Id: issuerId ?? "unknown", Name: issuerName ?? "unknown", Category: null, TrustTier: TrustTier.Unknown, KeyFingerprints: null); } private static (DateTimeOffset? IssuedAt, DateTimeOffset? LastUpdatedAt) ExtractTimestamps(JsonElement root) { DateTimeOffset? issuedAt = null; if (root.TryGetProperty("metadata", out var metadata) && metadata.TryGetProperty("timestamp", out var timestamp) && timestamp.ValueKind == JsonValueKind.String) { if (DateTimeOffset.TryParse(timestamp.GetString(), out var parsed)) { issuedAt = parsed; } } return (issuedAt, null); } private static Dictionary BuildComponentLookup(JsonElement root) { var lookup = new Dictionary(StringComparer.OrdinalIgnoreCase); // Add metadata component if (root.TryGetProperty("metadata", out var metadata) && metadata.TryGetProperty("component", out var metaComponent)) { AddComponentToLookup(lookup, metaComponent); } // Add all components if (root.TryGetProperty("components", out var components) && components.ValueKind == JsonValueKind.Array) { AddComponentsRecursively(lookup, components); } return lookup; } private static void AddComponentsRecursively(Dictionary lookup, JsonElement components) { foreach (var component in components.EnumerateArray()) { AddComponentToLookup(lookup, component); // Handle nested components if (component.TryGetProperty("components", out var nested) && nested.ValueKind == JsonValueKind.Array) { AddComponentsRecursively(lookup, nested); } } } private static void AddComponentToLookup(Dictionary lookup, JsonElement component) { var bomRef = component.TryGetProperty("bom-ref", out var br) ? br.GetString() : null; var name = component.TryGetProperty("name", out var n) ? n.GetString() : null; var version = component.TryGetProperty("version", out var v) ? v.GetString() : null; var purl = component.TryGetProperty("purl", out var p) ? p.GetString() : null; var cpe = component.TryGetProperty("cpe", out var c) ? c.GetString() : null; // Extract hashes Dictionary? hashes = null; if (component.TryGetProperty("hashes", out var hashArray) && hashArray.ValueKind == JsonValueKind.Array) { hashes = []; foreach (var hash in hashArray.EnumerateArray()) { var alg = hash.TryGetProperty("alg", out var a) ? a.GetString() : null; var content = hash.TryGetProperty("content", out var cont) ? cont.GetString() : null; if (!string.IsNullOrWhiteSpace(alg) && !string.IsNullOrWhiteSpace(content)) { hashes[alg] = content; } } if (hashes.Count == 0) { hashes = null; } } var info = new ComponentInfo(name, version, purl, cpe, hashes); if (!string.IsNullOrWhiteSpace(bomRef)) { lookup[bomRef] = info; } if (!string.IsNullOrWhiteSpace(purl) && !lookup.ContainsKey(purl)) { lookup[purl] = info; } } private static IReadOnlyList ExtractStatements( JsonElement root, Dictionary componentLookup, List warnings, ref int skipped) { if (!root.TryGetProperty("vulnerabilities", out var vulnerabilities) || vulnerabilities.ValueKind != JsonValueKind.Array) { warnings.Add(new NormalizationWarning( "WARN_CDX_004", "No vulnerabilities array found", "vulnerabilities")); return []; } var statements = new List(); var index = 0; foreach (var vuln in vulnerabilities.EnumerateArray()) { var vulnStatements = ExtractVulnerabilityStatements( vuln, componentLookup, index, warnings, ref skipped); statements.AddRange(vulnStatements); index += vulnStatements.Count > 0 ? vulnStatements.Count : 1; } return statements; } private static List ExtractVulnerabilityStatements( JsonElement vuln, Dictionary componentLookup, int startIndex, List warnings, ref int skipped) { var statements = new List(); // Extract vulnerability ID var vulnerabilityId = vuln.TryGetProperty("id", out var id) ? id.GetString() : null; if (string.IsNullOrWhiteSpace(vulnerabilityId)) { warnings.Add(new NormalizationWarning( "WARN_CDX_005", "Vulnerability missing ID; skipped", "vulnerabilities[].id")); skipped++; return statements; } // Extract aliases from references with type = "advisory" var aliases = new List(); if (vuln.TryGetProperty("references", out var refs) && refs.ValueKind == JsonValueKind.Array) { foreach (var reference in refs.EnumerateArray()) { if (reference.TryGetProperty("id", out var refId)) { var refIdStr = refId.GetString(); if (!string.IsNullOrWhiteSpace(refIdStr) && refIdStr != vulnerabilityId) { aliases.Add(refIdStr); } } } } // Extract affected components if (!vuln.TryGetProperty("affects", out var affects) || affects.ValueKind != JsonValueKind.Array) { warnings.Add(new NormalizationWarning( "WARN_CDX_006", $"Vulnerability {vulnerabilityId} has no affects array", "vulnerabilities[].affects")); skipped++; return statements; } var localIndex = 0; foreach (var affect in affects.EnumerateArray()) { var refStr = affect.TryGetProperty("ref", out var refProp) ? refProp.GetString() : null; if (string.IsNullOrWhiteSpace(refStr)) { continue; } var product = ResolveProduct(refStr, componentLookup); if (product == null) { warnings.Add(new NormalizationWarning( "WARN_CDX_007", $"Could not resolve component ref '{refStr}'", "vulnerabilities[].affects[].ref")); continue; } // Extract analysis/status var status = VexStatus.UnderInvestigation; VexJustification? justification = null; string? statusNotes = null; string? actionStatement = null; if (vuln.TryGetProperty("analysis", out var analysis)) { var stateStr = analysis.TryGetProperty("state", out var state) ? state.GetString() : null; status = MapAnalysisState(stateStr) ?? VexStatus.UnderInvestigation; var justificationStr = analysis.TryGetProperty("justification", out var just) ? just.GetString() : null; justification = MapJustification(justificationStr); statusNotes = analysis.TryGetProperty("detail", out var detail) ? detail.GetString() : null; if (analysis.TryGetProperty("response", out var response) && response.ValueKind == JsonValueKind.Array) { var responses = new List(); foreach (var r in response.EnumerateArray()) { var rStr = r.GetString(); if (!string.IsNullOrWhiteSpace(rStr)) { responses.Add(rStr); } } if (responses.Count > 0) { actionStatement = string.Join(", ", responses); } } } // Extract timestamps DateTimeOffset? firstSeen = null; DateTimeOffset? lastSeen = null; if (vuln.TryGetProperty("created", out var created) && created.ValueKind == JsonValueKind.String) { if (DateTimeOffset.TryParse(created.GetString(), out var parsed)) { firstSeen = parsed; } } if (vuln.TryGetProperty("updated", out var updated) && updated.ValueKind == JsonValueKind.String) { if (DateTimeOffset.TryParse(updated.GetString(), out var parsed)) { lastSeen = parsed; } } else if (vuln.TryGetProperty("published", out var published) && published.ValueKind == JsonValueKind.String) { if (DateTimeOffset.TryParse(published.GetString(), out var parsed)) { lastSeen = parsed; } } // Extract version ranges if specified VersionRange? versions = null; if (affect.TryGetProperty("versions", out var versionsArray) && versionsArray.ValueKind == JsonValueKind.Array) { var affectedVersions = new List(); var fixedVersions = new List(); foreach (var ver in versionsArray.EnumerateArray()) { var verStr = ver.TryGetProperty("version", out var v) ? v.GetString() : null; var statusStr = ver.TryGetProperty("status", out var s) ? s.GetString() : null; if (!string.IsNullOrWhiteSpace(verStr)) { if (statusStr?.Equals("affected", StringComparison.OrdinalIgnoreCase) == true) { affectedVersions.Add(verStr); } else if (statusStr?.Equals("unaffected", StringComparison.OrdinalIgnoreCase) == true) { fixedVersions.Add(verStr); } } } if (affectedVersions.Count > 0 || fixedVersions.Count > 0) { versions = new VersionRange( Affected: affectedVersions.Count > 0 ? affectedVersions : null, Fixed: fixedVersions.Count > 0 ? fixedVersions : null, Unaffected: null); } } statements.Add(new NormalizedStatement( StatementId: $"stmt-{startIndex + localIndex}", VulnerabilityId: vulnerabilityId, VulnerabilityAliases: aliases.Count > 0 ? aliases : null, Product: product, Status: status, StatusNotes: statusNotes, Justification: justification, ImpactStatement: null, ActionStatement: actionStatement, ActionStatementTimestamp: null, Versions: versions, Subcomponents: null, FirstSeen: firstSeen, LastSeen: lastSeen ?? firstSeen)); localIndex++; } if (statements.Count == 0) { skipped++; } return statements; } private static NormalizedProduct? ResolveProduct(string refStr, Dictionary componentLookup) { if (componentLookup.TryGetValue(refStr, out var info)) { return new NormalizedProduct( Key: info.Purl ?? refStr, Name: info.Name, Version: info.Version, Purl: info.Purl, Cpe: info.Cpe, Hashes: info.Hashes); } // If not found in lookup, create a basic product entry if (refStr.StartsWith("pkg:", StringComparison.OrdinalIgnoreCase)) { return new NormalizedProduct( Key: refStr, Name: null, Version: null, Purl: refStr, Cpe: null, Hashes: null); } return new NormalizedProduct( Key: refStr, Name: null, Version: null, Purl: null, Cpe: null, Hashes: null); } private static VexStatus? MapAnalysisState(string? state) { return state?.ToLowerInvariant() switch { "not_affected" => VexStatus.NotAffected, "exploitable" or "in_triage" => VexStatus.Affected, "resolved" or "resolved_with_pedigree" => VexStatus.Fixed, "false_positive" => VexStatus.NotAffected, _ => null }; } private static VexJustification? MapJustification(string? justification) { return justification?.ToLowerInvariant() switch { "code_not_present" => VexJustification.ComponentNotPresent, "code_not_reachable" => VexJustification.VulnerableCodeNotInExecutePath, "requires_configuration" => VexJustification.VulnerableCodeCannotBeControlledByAdversary, "requires_dependency" => VexJustification.ComponentNotPresent, "requires_environment" => VexJustification.VulnerableCodeCannotBeControlledByAdversary, "protected_by_compiler" or "protected_by_mitigating_control" or "protected_at_runtime" or "protected_at_perimeter" => VexJustification.InlineMitigationsAlreadyExist, _ => null }; } private static string ComputeDigest(string content) { var hash = SHA256.HashData(Encoding.UTF8.GetBytes(content)); return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}"; } private sealed record ComponentInfo( string? Name, string? Version, string? Purl, string? Cpe, IReadOnlyDictionary? Hashes); }