From 0e59424ea3fc3afd42fd237b755f967e2ed9ef33 Mon Sep 17 00:00:00 2001 From: Matt Brown Date: Fri, 18 Sep 2026 01:42:27 -0400 Subject: [PATCH] fix: down-rank unbounded-range CVE matches on vendor-fork components A binary version banner like "hostapd v0.8.x_rtw_r24647.20171025" is a vendor SDK fork (here Realtek's "_rtw_" hostapd, ubiquitous on IoT Wi-Fi SoCs). The version was stripped to a base semver ("0.8") for matching, and the NVD CPE range for CVEs such as CVE-2022-23303/23304 (SAE side-channels) has no lower bound, so "0.8 < 2.10" matched them as high-signal even though SAE did not exist until hostapd 2.x -- the fork's base cannot contain the vulnerable code. Two changes: - binary-version detection now records the vendor-fork label (Component::fork) when the version tail continued past the base semver with a non-numeric tag, and keeps the full on-disk banner in the evidence instead of the truncated base (so the fork/revision is not lost). - the CVE join flags matches from an unbounded-below range and, on a fork component, drops them from the default high-signal view (KEV still overrides), annotating the basis "... lower-bound unknown -- verify". Such matches remain in the full list under --component-cves-all. This demotes the uncertain fork matches without asserting (in)applicability; a bounded-range CVE that genuinely covers the base version is unaffected. Add unit coverage for the fork label/evidence and the gate (fork+unbounded demoted, either flag alone kept, KEV overrides). --- src/binver.cpp | 37 +++++++++++++++++++++++++++++++++++++ src/component.hpp | 7 +++++++ src/cve.cpp | 14 ++++++++++++++ src/cve.hpp | 5 +++++ src/json.cpp | 1 + tests/unit/unit_tests.cpp | 35 +++++++++++++++++++++++++++++++++++ 6 files changed, 99 insertions(+) diff --git a/src/binver.cpp b/src/binver.cpp index 88865a1..6c245b6 100644 --- a/src/binver.cpp +++ b/src/binver.cpp @@ -1,6 +1,7 @@ #include "binver.hpp" #include +#include #include #include #include @@ -93,6 +94,20 @@ bool is_elf(std::span d) { return d.size() >= 4 && d[0] == 0x7f && d[1] == 'E' && d[2] == 'L' && d[3] == 'F'; } +// A vendor-SDK fork label for a version tail that continued past the parsed base +// semver (e.g. "0.8.x_rtw_r24647.20171025", "2.10_ATBM_0.2"). The Realtek "_rtw_" +// hostapd/wpa_supplicant fork is ubiquitous in IoT Wi-Fi SoCs; AltoBeam ("_ATBM") +// is another. Anything else that continued with a non-numeric tag is a generic +// vendor fork. Empty tail (a clean upstream version) returns "". +std::string vendor_fork_label(const std::string& tail) { + std::string lt = tail; + for (char& c : lt) c = static_cast(std::tolower(static_cast(c))); + if (lt.find("rtw") != std::string::npos || lt.find("realtek") != std::string::npos) + return "Realtek SDK"; + if (lt.find("atbm") != std::string::npos) return "AltoBeam SDK"; + return "vendor fork"; +} + } // namespace std::vector scan_kernel_version(std::span data, @@ -223,6 +238,28 @@ std::vector scan_binver(std::span data, const std::str c.origin_path = origin_path; c.confidence = 70; // weaker than a package-DB entry c.evidence = "binary version banner: " + m[0].str(); + + // Vendor-fork detection: read the characters that continued past the base + // version. A clean continuation is "." (already folded into the + // version); anything with a letter/'_'/'-'/'+' is a vendor tag. Keep the + // full on-disk string in the evidence and label the fork. + size_t tail_pos = off + static_cast(m.position(0)) + static_cast(m.length(0)); + auto is_vertail = [](uint8_t ch) { + return std::isalnum(ch) || ch == '.' || ch == '_' || ch == '-' || ch == '+' || ch == '~'; + }; + size_t t = tail_pos; + while (t < end && is_vertail(data[t])) ++t; + if (t > tail_pos) { + std::string tail(reinterpret_cast(data.data()) + tail_pos, t - tail_pos); + bool non_numeric = false; + for (char ch : tail) + if (ch != '.' && !(ch >= '0' && ch <= '9')) { non_numeric = true; break; } + if (non_numeric) { + c.fork = vendor_fork_label(tail); + c.evidence = "binary version banner: " + m[0].str() + tail; // full string + } + } + out.push_back(std::move(c)); return true; }); diff --git a/src/component.hpp b/src/component.hpp index ab2dc20..9e61b3f 100644 --- a/src/component.hpp +++ b/src/component.hpp @@ -31,6 +31,13 @@ struct Component { uint8_t confidence = 0; // 0-100 std::string evidence; // short reason, e.g. "dpkg status: install ok installed" + + // A vendor SDK fork label (e.g. "Realtek SDK") when the version string carried + // a fork suffix that was stripped to a base semver (e.g. "0.8.x_rtw_r24647" -> + // "0.8"). Empty for a clean upstream version. The CVE join uses this to + // down-rank unbounded-range matches, since a fork's feature/backport state is + // unknown (a 0.8.x_rtw hostapd predates the SAE code some CVEs live in). + std::string fork; }; } // namespace ft diff --git a/src/cve.cpp b/src/cve.cpp index 99509e3..7e7a790 100644 --- a/src/cve.cpp +++ b/src/cve.cpp @@ -233,6 +233,11 @@ double cvss_base_score(const std::string& vector) { // vector. bool cve_is_high_signal(const CveMatch& m) { if (m.kev) return true; // exploited in the wild + // A vendor-fork component matched only by an unbounded-below range: we cannot + // confirm the fork's base carries the vulnerable code (e.g. a 0.8.x_rtw hostapd + // predates SAE), so drop it from the default view. Still listed under + // --component-cves-all with the "verify" basis. KEV above overrides this. + if (m.fork && m.unbounded_below) return false; double score = cvss_base_score(m.severity); if (score >= kHighSignalCvss) return true; // Critical, any shape if (score < kHighFloorCvss) return false; // hard floor: High/Critical only @@ -388,6 +393,15 @@ std::vector nvd_join(const NvdDb& db, const std::vector& co m.component_purl = c.purl; m.severity = v.cvss; m.basis = "nvd-cpe-range (" + p.vendor + ":" + p.product + ")"; + // A range with no lower bound (no exact version, no start) matches every + // version below the ceiling -- including ones that predate the vulnerable + // code. On a vendor fork (stripped to a base semver) that is a likely + // false match, so flag both and annotate the basis for the down-rank. + m.unbounded_below = + v.version.empty() && v.start_incl.empty() && v.start_excl.empty(); + m.fork = !c.fork.empty(); + if (m.fork && m.unbounded_below) + m.basis += " [" + c.fork + "; range lower-bound unknown -- verify]"; out.push_back(std::move(m)); } } diff --git a/src/cve.hpp b/src/cve.hpp index d0490f4..635ecf0 100644 --- a/src/cve.hpp +++ b/src/cve.hpp @@ -66,6 +66,8 @@ struct CveMatch { std::string summary; bool kev = false; // on the CISA Known-Exploited catalog (annotation only) double epss = -1.0; // EPSS exploit-probability (0..1), -1 if unknown + bool fork = false; // component is a vendor SDK fork (Component::fork set) + bool unbounded_below = false; // matched an affected range with no lower bound }; // Canonicalize a vuln id to its CVE form when one is embedded ("DEBIAN-CVE-2021- @@ -102,6 +104,9 @@ double cvss_base_score(const std::string& vector); // The default human CVE view is gated to foothold-worthy findings for a manual // pentester; the JSON view stays complete. A component CVE is "high-signal" if: // - it is on the CISA KEV catalog (exploited in the wild) -- unconditional; OR +// - (it is NOT a vendor-fork component matched only by an unbounded-below range: +// such a match cannot be confirmed to apply -- a fork's base may predate the +// vulnerable code -- so it is demoted to the --component-cves-all list); AND // - its CVSS base score is >= 9.0 (Critical) -- any shape; OR // - it clears a hard High/Critical floor (base score >= 7.0) AND has EPSS // traction (>= 0.10) AND is low-complexity (AC:L) AND EITHER diff --git a/src/json.cpp b/src/json.cpp index 78edcf2..934ab65 100644 --- a/src/json.cpp +++ b/src/json.cpp @@ -70,6 +70,7 @@ void emit_component(std::string& o, const Component& c) { if (!c.arch.empty()) kv_str(o, "arch", c.arch, first); if (!c.license.empty()) kv_str(o, "license", c.license, first); kv_str(o, "source", c.source, first); + if (!c.fork.empty()) kv_str(o, "fork", c.fork, first); kv_str(o, "origin_path", c.origin_path, first); kv_num(o, "confidence", c.confidence, first); kv_str(o, "evidence", c.evidence, first); diff --git a/tests/unit/unit_tests.cpp b/tests/unit/unit_tests.cpp index bd6f28f..1b373d6 100644 --- a/tests/unit/unit_tests.cpp +++ b/tests/unit/unit_tests.cpp @@ -572,6 +572,22 @@ static void test_binver() { CHECK(ver_of("wpa_supplicant", "wpa_supplicant v2.10_ATBM_0.2_") == "2.10"); CHECK(ver_of("wpa_supplicant", "wpa_supplicant v0.8.x_rtw_r24") == "0.8"); CHECK(ver_of("wpa_supplicant", "wpa_supplicant v2.10-devel") == "2.10"); + + // Vendor-fork detection: the stripped suffix sets Component::fork (labeled for + // the known IoT SDKs) and the evidence keeps the full on-disk string; a clean + // upstream version has no fork. + auto comp_of = [](const std::string& name, const std::string& body) -> ft::Component { + std::string e = std::string("\x7f\x45\x4c\x46", 4) + " " + body + " end"; + for (const auto& c : binver(e)) + if (c.name == name) return c; + return {}; + }; + auto rtw = comp_of("hostapd", "hostapd v0.8.x_rtw_r24647.20171025"); + CHECK(rtw.version == "0.8" && rtw.fork == "Realtek SDK"); + CHECK(rtw.evidence.find("0.8.x_rtw_r24647.20171025") != std::string::npos); // full string + CHECK(comp_of("wpa_supplicant", "wpa_supplicant v2.10_ATBM_0.2_").fork == "AltoBeam SDK"); + CHECK(comp_of("hostapd", "hostapd v2.10-devel").fork == "vendor fork"); + CHECK(comp_of("hostapd", "hostapd v2.10").fork.empty()); // clean upstream -> no fork } // ---------------------------------------------------------------- u-boot banner @@ -1255,6 +1271,25 @@ static void test_cvss_gate() { CHECK(ft::cve_is_high_signal(mk("AV:N/AC:L/Au:N/C:P/I:P/A:P", false, 0.20))); // v2 Medium (local, 1.2) -> hidden by the floor even with high EPSS. CHECK(!ft::cve_is_high_signal(mk("AV:L/AC:H/Au:N/C:P/I:N/A:N", false, 0.90))); + + // --- vendor-fork + unbounded-below range: demoted (can't confirm the fork's + // base carries the vulnerable code). A Critical shape that would normally + // show is dropped when fork && unbounded_below; either flag alone keeps it. + auto crit = "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H"; // 9.8 + ft::CveMatch f = mk(crit, false, 0.0); + f.fork = true; + f.unbounded_below = true; + CHECK(!ft::cve_is_high_signal(f)); // fork + unbounded -> demoted + f.unbounded_below = false; + CHECK(ft::cve_is_high_signal(f)); // fork but bounded range -> kept + ft::CveMatch u = mk(crit, false, 0.0); + u.unbounded_below = true; // unbounded but not a fork -> kept + CHECK(ft::cve_is_high_signal(u)); + // KEV overrides the fork demotion (exploited in the wild). + ft::CveMatch k = mk(crit, true, 0.0); + k.fork = true; + k.unbounded_below = true; + CHECK(ft::cve_is_high_signal(k)); } // ---------------------------------------------------------------- nvd/cpe join