using StellaOps.Determinism; using StellaOps.VexLens.Models; using System.Diagnostics; using System.Security.Cryptography; using System.Text; using System.Text.Json; namespace StellaOps.VexLens.Normalization; /// /// Normalizer for CSAF VEX format documents. /// CSAF VEX documents follow the OASIS CSAF 2.0 specification with profile "VEX". /// public sealed class CsafVexNormalizer : IVexNormalizer { private readonly IGuidProvider _guidProvider; public CsafVexNormalizer(IGuidProvider? guidProvider = null) { _guidProvider = guidProvider ?? SystemGuidProvider.Instance; } public VexSourceFormat SourceFormat => VexSourceFormat.CsafVex; public bool CanNormalize(string content) { if (string.IsNullOrWhiteSpace(content)) { return false; } try { using var doc = JsonDocument.Parse(content); var root = doc.RootElement; // CSAF documents have document.category = "csaf_vex" if (root.TryGetProperty("document", out var document)) { if (document.TryGetProperty("category", out var category)) { var categoryStr = category.GetString(); return categoryStr?.Equals("csaf_vex", StringComparison.OrdinalIgnoreCase) == true; } } 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 metadata if (!root.TryGetProperty("document", out var documentElement)) { stopwatch.Stop(); return Task.FromResult(NormalizationResult.Failed( [new NormalizationError("ERR_CSAF_001", "Missing 'document' element", "document", null)], new NormalizationMetrics( Duration: stopwatch.Elapsed, SourceBytes: Encoding.UTF8.GetByteCount(content), StatementsExtracted: 0, StatementsSkipped: 0, ProductsMapped: 0), warnings)); } // Extract document ID var documentId = ExtractDocumentId(documentElement); if (string.IsNullOrWhiteSpace(documentId)) { documentId = $"csaf:{_guidProvider.NewGuid():N}"; warnings.Add(new NormalizationWarning( "WARN_CSAF_001", "Document tracking ID not found; generated a random ID", "document.tracking.id")); } // Extract issuer from publisher var issuer = ExtractIssuer(documentElement, warnings); // Extract timestamps var (issuedAt, lastUpdatedAt) = ExtractTimestamps(documentElement); // Extract product tree for product resolution var productTree = root.TryGetProperty("product_tree", out var pt) ? pt : default; // Extract vulnerabilities and convert to statements var statements = ExtractStatements(root, productTree, 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: ["csaf-vex-to-normalized-v1"]); var normalizedDoc = new NormalizedVexDocument( SchemaVersion: NormalizedVexDocument.CurrentSchemaVersion, DocumentId: documentId, SourceFormat: VexSourceFormat.CsafVex, 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_CSAF_002", "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_CSAF_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 document) { if (document.TryGetProperty("tracking", out var tracking) && tracking.TryGetProperty("id", out var id)) { return id.GetString(); } return null; } private static VexIssuer? ExtractIssuer(JsonElement document, List warnings) { if (!document.TryGetProperty("publisher", out var publisher)) { warnings.Add(new NormalizationWarning( "WARN_CSAF_002", "No publisher found in document", "document.publisher")); return null; } var issuerId = publisher.TryGetProperty("namespace", out var nsProp) ? nsProp.GetString() ?? "unknown" : "unknown"; var issuerName = publisher.TryGetProperty("name", out var nameProp) ? nameProp.GetString() ?? issuerId : issuerId; var categoryStr = publisher.TryGetProperty("category", out var catProp) ? catProp.GetString() : null; var category = MapPublisherCategory(categoryStr); return new VexIssuer( Id: issuerId, Name: issuerName, Category: category, TrustTier: TrustTier.Unknown, KeyFingerprints: null); } private static IssuerCategory? MapPublisherCategory(string? category) { return category?.ToLowerInvariant() switch { "vendor" => IssuerCategory.Vendor, "discoverer" or "coordinator" => IssuerCategory.Community, "user" => IssuerCategory.Internal, "other" => null, _ => null }; } private static (DateTimeOffset? IssuedAt, DateTimeOffset? LastUpdatedAt) ExtractTimestamps(JsonElement document) { DateTimeOffset? issuedAt = null; DateTimeOffset? lastUpdatedAt = null; if (document.TryGetProperty("tracking", out var tracking)) { if (tracking.TryGetProperty("initial_release_date", out var initialRelease) && initialRelease.ValueKind == JsonValueKind.String) { if (DateTimeOffset.TryParse(initialRelease.GetString(), out var parsed)) { issuedAt = parsed; } } if (tracking.TryGetProperty("current_release_date", out var currentRelease) && currentRelease.ValueKind == JsonValueKind.String) { if (DateTimeOffset.TryParse(currentRelease.GetString(), out var parsed)) { lastUpdatedAt = parsed; } } } return (issuedAt, lastUpdatedAt); } private static IReadOnlyList ExtractStatements( JsonElement root, JsonElement productTree, List warnings, ref int skipped) { if (!root.TryGetProperty("vulnerabilities", out var vulnerabilities) || vulnerabilities.ValueKind != JsonValueKind.Array) { warnings.Add(new NormalizationWarning( "WARN_CSAF_003", "No vulnerabilities array found", "vulnerabilities")); return []; } var statements = new List(); var statementIndex = 0; foreach (var vuln in vulnerabilities.EnumerateArray()) { var vulnStatements = ExtractVulnerabilityStatements( vuln, productTree, statementIndex, warnings, ref skipped); statements.AddRange(vulnStatements); statementIndex += vulnStatements.Count; } return statements; } private static List ExtractVulnerabilityStatements( JsonElement vuln, JsonElement productTree, int startIndex, List warnings, ref int skipped) { var statements = new List(); // Extract vulnerability ID (CVE or other identifier) string? vulnerabilityId = null; var aliases = new List(); if (vuln.TryGetProperty("cve", out var cve)) { vulnerabilityId = cve.GetString(); } if (vuln.TryGetProperty("ids", out var ids) && ids.ValueKind == JsonValueKind.Array) { foreach (var id in ids.EnumerateArray()) { if (id.TryGetProperty("text", out var text)) { var idStr = text.GetString(); if (!string.IsNullOrWhiteSpace(idStr)) { if (vulnerabilityId == null) { vulnerabilityId = idStr; } else if (idStr != vulnerabilityId) { aliases.Add(idStr); } } } } } if (string.IsNullOrWhiteSpace(vulnerabilityId)) { warnings.Add(new NormalizationWarning( "WARN_CSAF_004", "Vulnerability missing CVE or ID; skipped", "vulnerabilities[].cve")); skipped++; return statements; } // Extract product_status for VEX statements if (!vuln.TryGetProperty("product_status", out var productStatus)) { warnings.Add(new NormalizationWarning( "WARN_CSAF_005", $"Vulnerability {vulnerabilityId} has no product_status", "vulnerabilities[].product_status")); return statements; } // Process each status category var localIndex = 0; // Known not affected if (productStatus.TryGetProperty("known_not_affected", out var knownNotAffected) && knownNotAffected.ValueKind == JsonValueKind.Array) { foreach (var productRef in knownNotAffected.EnumerateArray()) { var product = ResolveProduct(productRef, productTree); if (product != null) { var justification = ExtractJustification(vuln, productRef.GetString()); statements.Add(CreateStatement( startIndex + localIndex++, vulnerabilityId, aliases, product, VexStatus.NotAffected, justification, vuln)); } } } // Fixed if (productStatus.TryGetProperty("fixed", out var fixedProducts) && fixedProducts.ValueKind == JsonValueKind.Array) { foreach (var productRef in fixedProducts.EnumerateArray()) { var product = ResolveProduct(productRef, productTree); if (product != null) { statements.Add(CreateStatement( startIndex + localIndex++, vulnerabilityId, aliases, product, VexStatus.Fixed, null, vuln)); } } } // Known affected if (productStatus.TryGetProperty("known_affected", out var knownAffected) && knownAffected.ValueKind == JsonValueKind.Array) { foreach (var productRef in knownAffected.EnumerateArray()) { var product = ResolveProduct(productRef, productTree); if (product != null) { statements.Add(CreateStatement( startIndex + localIndex++, vulnerabilityId, aliases, product, VexStatus.Affected, null, vuln)); } } } // Under investigation if (productStatus.TryGetProperty("under_investigation", out var underInvestigation) && underInvestigation.ValueKind == JsonValueKind.Array) { foreach (var productRef in underInvestigation.EnumerateArray()) { var product = ResolveProduct(productRef, productTree); if (product != null) { statements.Add(CreateStatement( startIndex + localIndex++, vulnerabilityId, aliases, product, VexStatus.UnderInvestigation, null, vuln)); } } } // Explicit unknown status if (productStatus.TryGetProperty("known_unknown", out var knownUnknown) && knownUnknown.ValueKind == JsonValueKind.Array) { foreach (var productRef in knownUnknown.EnumerateArray()) { var product = ResolveProduct(productRef, productTree); if (product != null) { statements.Add(CreateStatement( startIndex + localIndex++, vulnerabilityId, aliases, product, VexStatus.Unknown, null, vuln)); } } } if (productStatus.TryGetProperty("unknown", out var unknown) && unknown.ValueKind == JsonValueKind.Array) { foreach (var productRef in unknown.EnumerateArray()) { var product = ResolveProduct(productRef, productTree); if (product != null) { statements.Add(CreateStatement( startIndex + localIndex++, vulnerabilityId, aliases, product, VexStatus.Unknown, null, vuln)); } } } return statements; } private static NormalizedProduct? ResolveProduct(JsonElement productRef, JsonElement productTree) { if (productRef.ValueKind != JsonValueKind.String) { return null; } var productId = productRef.GetString(); if (string.IsNullOrWhiteSpace(productId)) { return null; } // Try to find product details in product_tree string? name = null; string? version = null; string? purl = null; string? cpe = null; if (productTree.ValueKind == JsonValueKind.Object) { // Search in full_product_names if (productTree.TryGetProperty("full_product_names", out var fullNames) && fullNames.ValueKind == JsonValueKind.Array) { foreach (var fpn in fullNames.EnumerateArray()) { if (fpn.TryGetProperty("product_id", out var pid) && pid.GetString() == productId) { name = fpn.TryGetProperty("name", out var n) ? n.GetString() : null; if (fpn.TryGetProperty("product_identification_helper", out var pih)) { purl = pih.TryGetProperty("purl", out var p) ? p.GetString() : null; cpe = pih.TryGetProperty("cpe", out var c) ? c.GetString() : null; } break; } } } // Search in branches recursively if (name == null && productTree.TryGetProperty("branches", out var branches)) { var result = SearchBranches(branches, productId); if (result.HasValue) { name = result.Value.Name; version = result.Value.Version; purl = result.Value.Purl; cpe = result.Value.Cpe; } } } return new NormalizedProduct( Key: productId, Name: name, Version: version, Purl: purl, Cpe: cpe, Hashes: null); } private static (string? Name, string? Version, string? Purl, string? Cpe)? SearchBranches( JsonElement branches, string productId) { if (branches.ValueKind != JsonValueKind.Array) { return null; } foreach (var branch in branches.EnumerateArray()) { // Check product in this branch if (branch.TryGetProperty("product", out var product) && product.TryGetProperty("product_id", out var pid) && pid.GetString() == productId) { var name = product.TryGetProperty("name", out var n) ? n.GetString() : null; var version = branch.TryGetProperty("name", out var bn) && branch.TryGetProperty("category", out var bc) && bc.GetString() == "product_version" ? bn.GetString() : null; string? purl = null; string? cpe = null; if (product.TryGetProperty("product_identification_helper", out var pih)) { purl = pih.TryGetProperty("purl", out var p) ? p.GetString() : null; cpe = pih.TryGetProperty("cpe", out var c) ? c.GetString() : null; } return (name, version, purl, cpe); } // Recurse into sub-branches if (branch.TryGetProperty("branches", out var subBranches)) { var result = SearchBranches(subBranches, productId); if (result.HasValue) { return result; } } } return null; } private static VexJustification? ExtractJustification(JsonElement vuln, string? productId) { // Look for flags that indicate justification if (!vuln.TryGetProperty("flags", out var flags) || flags.ValueKind != JsonValueKind.Array) { return null; } foreach (var flag in flags.EnumerateArray()) { // Check if this flag applies to our product if (flag.TryGetProperty("product_ids", out var productIds) && productIds.ValueKind == JsonValueKind.Array) { var applies = false; foreach (var pid in productIds.EnumerateArray()) { if (pid.GetString() == productId) { applies = true; break; } } if (!applies) { continue; } } if (flag.TryGetProperty("label", out var label)) { var labelStr = label.GetString(); var justification = MapCsafFlagToJustification(labelStr); if (justification.HasValue) { return justification; } } } return null; } private static VexJustification? MapCsafFlagToJustification(string? label) { return label?.ToLowerInvariant() switch { "component_not_present" => VexJustification.ComponentNotPresent, "vulnerable_code_not_present" => VexJustification.VulnerableCodeNotPresent, "vulnerable_code_not_in_execute_path" or "vulnerable_code_cannot_be_controlled_by_adversary" => VexJustification.VulnerableCodeNotInExecutePath, "inline_mitigations_already_exist" => VexJustification.InlineMitigationsAlreadyExist, _ => null }; } private static NormalizedStatement CreateStatement( int index, string vulnerabilityId, List aliases, NormalizedProduct product, VexStatus status, VexJustification? justification, JsonElement vuln) { // Extract notes for status notes string? statusNotes = null; if (vuln.TryGetProperty("notes", out var notes) && notes.ValueKind == JsonValueKind.Array) { foreach (var note in notes.EnumerateArray()) { if (note.TryGetProperty("category", out var cat) && cat.GetString() == "description" && note.TryGetProperty("text", out var text)) { statusNotes = text.GetString(); break; } } } // Extract action statement from remediations string? actionStatement = null; DateTimeOffset? actionTimestamp = null; if (vuln.TryGetProperty("remediations", out var remediations) && remediations.ValueKind == JsonValueKind.Array) { foreach (var rem in remediations.EnumerateArray()) { if (rem.TryGetProperty("details", out var details)) { actionStatement = details.GetString(); } if (rem.TryGetProperty("date", out var date) && date.ValueKind == JsonValueKind.String) { if (DateTimeOffset.TryParse(date.GetString(), out var parsed)) { actionTimestamp = parsed; } } break; // Take first remediation } } // Extract release date as timestamp DateTimeOffset? timestamp = null; if (vuln.TryGetProperty("release_date", out var releaseDate) && releaseDate.ValueKind == JsonValueKind.String) { if (DateTimeOffset.TryParse(releaseDate.GetString(), out var parsed)) { timestamp = parsed; } } return new NormalizedStatement( StatementId: $"stmt-{index}", VulnerabilityId: vulnerabilityId, VulnerabilityAliases: aliases.Count > 0 ? aliases : null, Product: product, Status: status, StatusNotes: statusNotes, Justification: justification, ImpactStatement: null, ActionStatement: actionStatement, ActionStatementTimestamp: actionTimestamp, Versions: null, Subcomponents: null, FirstSeen: timestamp, LastSeen: timestamp); } private static string ComputeDigest(string content) { var hash = SHA256.HashData(Encoding.UTF8.GetBytes(content)); return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}"; } }