normalize CPE strings

This commit is contained in:
Oliver Kieselbach
2026-04-07 09:50:59 +02:00
parent 3a5bf83b7c
commit 82ba96ad68
4 changed files with 83 additions and 2 deletions
@@ -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:*:*"));
}
}
@@ -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("?", "_");
@@ -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<string>()) },
{ "ExcludePatternsJson", System.Text.Json.JsonSerializer.Serialize(mapping.ExcludePatterns ?? new List<string>()) },
@@ -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);