From 82ba96ad6827ece32e1875c4faa9ebe5fa4e4dc4 Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Tue, 7 Apr 2026 09:50:59 +0200 Subject: [PATCH] normalize CPE strings --- .../CpeUriNormalizerTests.cs | 70 +++++++++++++++++++ .../SaveCustomCpeMappingFunction.cs | 4 ++ .../Services/TableStorageService.Rules.cs | 4 +- .../VulnerabilityCorrelationService.cs | 7 ++ 4 files changed, 83 insertions(+), 2 deletions(-) create mode 100644 src/Backend/AutopilotMonitor.Functions.Tests/CpeUriNormalizerTests.cs diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/CpeUriNormalizerTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/CpeUriNormalizerTests.cs new file mode 100644 index 00000000..6f4fd5b5 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions.Tests/CpeUriNormalizerTests.cs @@ -0,0 +1,70 @@ +using AutopilotMonitor.Functions.Services.Vulnerability; + +namespace AutopilotMonitor.Functions.Tests; + +public class CpeUriNormalizerTests +{ + [Fact] + public void Normalize_UrlEncodedPlus_LowerCase_BecomesBackslashQuoted() + { + Assert.Equal( + @"cpe:2.3:a:microsoft:visual_c\+\+", + CpeUriNormalizer.Normalize("cpe:2.3:a:microsoft:visual_c%2b%2b")); + } + + [Fact] + public void Normalize_UrlEncodedPlus_UpperCase_BecomesBackslashQuoted() + { + Assert.Equal( + @"cpe:2.3:a:microsoft:visual_c\+\+", + CpeUriNormalizer.Normalize("cpe:2.3:a:microsoft:visual_c%2B%2B")); + } + + [Fact] + public void Normalize_BarePlus_BecomesBackslashQuoted() + { + Assert.Equal( + @"cpe:2.3:a:microsoft:visual_c\+\+", + CpeUriNormalizer.Normalize("cpe:2.3:a:microsoft:visual_c++")); + } + + [Fact] + public void Normalize_AlreadyQuoted_IsIdempotent() + { + var canonical = @"cpe:2.3:a:microsoft:visual_c\+\+"; + Assert.Equal(canonical, CpeUriNormalizer.Normalize(canonical)); + } + + [Fact] + public void Normalize_NoSpecialChars_PassesThrough() + { + Assert.Equal( + "cpe:2.3:a:adobe:acrobat_dc", + CpeUriNormalizer.Normalize("cpe:2.3:a:adobe:acrobat_dc")); + } + + [Fact] + public void Normalize_NullOrEmpty_ReturnsEmpty() + { + Assert.Equal("", CpeUriNormalizer.Normalize(null)); + Assert.Equal("", CpeUriNormalizer.Normalize("")); + } + + [Fact] + public void Normalize_OtherPercentSequences_AreNotTouched() + { + // %20 (space), %2F (slash) etc. must NOT be rewritten — only %2B/%2b. + Assert.Equal( + "cpe:2.3:a:vendor:foo%20bar", + CpeUriNormalizer.Normalize("cpe:2.3:a:vendor:foo%20bar")); + } + + [Fact] + public void Normalize_Wildcard_IsNotTouched() + { + // '*' is the CPE 2.3 ANY wildcard and must remain unquoted. + Assert.Equal( + "cpe:2.3:a:microsoft:windows:*:*", + CpeUriNormalizer.Normalize("cpe:2.3:a:microsoft:windows:*:*")); + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Vulnerability/SaveCustomCpeMappingFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Vulnerability/SaveCustomCpeMappingFunction.cs index 944c5d38..450e7c9d 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Vulnerability/SaveCustomCpeMappingFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Vulnerability/SaveCustomCpeMappingFunction.cs @@ -52,6 +52,10 @@ namespace AutopilotMonitor.Functions.Functions.Vulnerability return badReq; } + // Normalize CPE 2.3 quoting (%2b/%2b/bare '+' -> '\+'). See CpeUriNormalizer for rationale. + mapping.CpeUri = CpeUriNormalizer.Normalize(mapping.CpeUri); + mapping.CpeProduct = CpeUriNormalizer.Normalize(mapping.CpeProduct); + var rowKey = $"{(mapping.NormalizedVendor ?? "unknown")}:{mapping.NormalizedProduct}".ToLowerInvariant(); // Sanitize RowKey (Table Storage doesn't allow /, \, #, ?) rowKey = rowKey.Replace("/", "_").Replace("\\", "_").Replace("#", "_").Replace("?", "_"); diff --git a/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Rules.cs b/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Rules.cs index 104ff97e..ee837a17 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Rules.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Rules.cs @@ -864,8 +864,8 @@ namespace AutopilotMonitor.Functions.Services { "NormalizedVendor", mapping.NormalizedVendor ?? "" }, { "NormalizedProduct", mapping.NormalizedProduct ?? "" }, { "CpeVendor", mapping.CpeVendor ?? "" }, - { "CpeProduct", mapping.CpeProduct ?? "" }, - { "CpeUri", mapping.CpeUri ?? "" }, + { "CpeProduct", CpeUriNormalizer.Normalize(mapping.CpeProduct) }, + { "CpeUri", CpeUriNormalizer.Normalize(mapping.CpeUri) }, { "Category", mapping.Category ?? "" }, { "DisplayNamePatternsJson", System.Text.Json.JsonSerializer.Serialize(mapping.DisplayNamePatterns ?? new List()) }, { "ExcludePatternsJson", System.Text.Json.JsonSerializer.Serialize(mapping.ExcludePatterns ?? new List()) }, diff --git a/src/Backend/AutopilotMonitor.Functions/Services/Vulnerability/VulnerabilityCorrelationService.cs b/src/Backend/AutopilotMonitor.Functions/Services/Vulnerability/VulnerabilityCorrelationService.cs index 6228a017..13d34bd3 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/Vulnerability/VulnerabilityCorrelationService.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/Vulnerability/VulnerabilityCorrelationService.cs @@ -417,6 +417,13 @@ namespace AutopilotMonitor.Functions.Services.Vulnerability var mapping = CpeMatchScorer.BuildMappingFromNvdResult( cpeEntry, normalizedVendor, normalizedName, displayName); + // Defense-in-depth: ensure CPE 2.3 quoting is canonical before we + // persist or pass this mapping to the NVD API. NVD itself returns + // the backslash form already, so this is normally a no-op, but + // protects against any upstream that returns URL-encoded variants. + mapping.CpeUri = CpeUriNormalizer.Normalize(mapping.CpeUri); + mapping.CpeProduct = CpeUriNormalizer.Normalize(mapping.CpeProduct); + _logger.LogInformation( "CPE auto-resolve HIT: '{DisplayName}' -> {CpeUri} (score={Score:F2}, candidates={CandidateCount}, cpeName={CpeName})", displayName, mapping.CpeUri, score, candidateCount, cpeEntry.CpeName);