From f16389414ad86bb7fc2da760ec839346737a4889 Mon Sep 17 00:00:00 2001 From: Haksung Jang Date: Sun, 23 Aug 2026 14:38:39 +0900 Subject: [PATCH 1/3] fix(scanner): expand curated maven CPE map for mismapped groupIds Several maven groupIds pass enrich-maven-cpe.py's generic org.apache.* rule but land on the wrong NVD product (e.g. org.apache.sshd -> apache:sshd, but NVD files it under mina_sshd), or fall through with no CPE at all (single-segment groups like log4j, rhino). Add 17 curated MAVEN_CPE_MAP entries, each verified against NVD's own cpeMatch data and confirmed to recover the real CVE via a local grype CPE lookup. --- docker/lib/enrich-maven-cpe.py | 22 +++++++++++ tests/test-postprocess.sh | 68 ++++++++++++++++++++++++++++++++++ 2 files changed, 90 insertions(+) diff --git a/docker/lib/enrich-maven-cpe.py b/docker/lib/enrich-maven-cpe.py index 9c7f9084..d965b23c 100644 --- a/docker/lib/enrich-maven-cpe.py +++ b/docker/lib/enrich-maven-cpe.py @@ -62,10 +62,32 @@ "com.fasterxml.jackson.dataformat": ("fasterxml", "@artifact"), "org.springframework": ("vmware", "spring_framework"), "org.springframework.security": ("vmware", "spring_security"), + "org.springframework.boot": ("vmware", "spring_boot"), "commons-beanutils": ("apache", "commons_beanutils"), "commons-fileupload": ("apache", "commons_fileupload"), "commons-collections": ("apache", "commons_collections"), "org.json": ("stleary", "json-java"), + # The generic org.apache.* rule (vendor=2nd segment, product=last segment) + # only holds when NVD's own CPE reuses the groupId's tail. These groups + # need an explicit override because NVD's product (or vendor) differs from + # what the rule would derive — each verified against NVD's own cpeMatch + # data, not guessed. + "log4j": ("apache", "log4j"), # single-segment group: the generic rule needs 2+ segments, so this old-style Log4j 1.x groupId gets no CPE without an override + "org.apache.sshd": ("apache", "mina_sshd"), # rule would derive apache:sshd; NVD files it under mina_sshd + "org.apache.xmlgraphics": ("apache", "batik"), # rule would derive apache:xmlgraphics; this groupId is Batik's, and NVD's product is batik + "com.h2database": ("h2database", "h2"), # rule would derive h2database:h2database; NVD's product is h2 (the artifactId, not the groupId tail) + "rhino": ("mozilla", "rhino"), # single-segment group, map-only like log4j above + "net.sourceforge.nekohtml": ("cyberneko_html_project", "cyberneko_html"), # rule would derive sourceforge:nekohtml + "org.owasp.antisamy": ("antisamy_project", "antisamy"), # rule would derive owasp:antisamy + "org.postgresql": ("postgresql", "postgresql_jdbc_driver"), # rule would derive postgresql:postgresql + "org.quartz-scheduler": ("softwareag", "quartz"), # rule would derive quartz-scheduler:quartz-scheduler + "org.codehaus.woodstox": ("fasterxml", "woodstox"), # the pre-rename groupId; NVD files Woodstox CVEs under the current fasterxml vendor regardless of which groupId a given release used + "com.mchange": ("mchange", "c3p0"), # rule would derive mchange:mchange; NVD's product is the artifactId c3p0 + "io.opentelemetry.instrumentation": ("linuxfoundation", "opentelemetry_instrumentation_for_java"), # rule would derive opentelemetry:instrumentation + "io.undertow": ("redhat", "undertow"), # rule would derive undertow:undertow; NVD files it under redhat + "org.eclipse.angus": ("eclipse", "angus_mail"), # rule would derive eclipse:angus (missing the _mail suffix NVD's product carries) + "org.bouncycastle": ("bouncycastle", "bc-java"), # the modern (post-rename) Bouncy Castle groupId; rule would derive bouncycastle:bouncycastle + "bouncycastle": ("bouncycastle", "bouncy-castle-crypto-package"), # the legacy (pre-rename) groupId, single-segment so map-only; covers releases up to 1.35, distinct from bc-java above } # Versions with a CPE-unsafe shape are left alone: a ':' (cpe field separator), diff --git a/tests/test-postprocess.sh b/tests/test-postprocess.sh index 447034bd..40bba1b3 100755 --- a/tests/test-postprocess.sh +++ b/tests/test-postprocess.sh @@ -1431,6 +1431,74 @@ else echo " SKIP: grype not installed; skipping CVE-recovery regression (F-1c5)" fi +echo "== F-1c6: maven CPE enrichment — expanded curated map (groups where the generic rule derives the wrong product) ==" +# These groupIds all pass the generic org.apache.* (or 2-segment) rule and get +# SOME cpe, but the wrong one -- NVD's actual product differs from what the +# rule would derive (e.g. org.apache.sshd -> apache:sshd, but NVD's product is +# mina_sshd). Each entry below is verified against NVD's own cpeMatch data +# (docker/lib/enrich-maven-cpe.py's MAVEN_CPE_MAP comment has the per-entry +# rationale), not guessed. +cat > "$WORK/mvn-expanded.json" <<'JSON' +{"bomFormat":"CycloneDX","specVersion":"1.6","components":[ + {"type":"library","name":"log4j","version":"1.2.17","purl":"pkg:maven/log4j/log4j@1.2.17"}, + {"type":"library","name":"sshd-core","version":"2.12.1","purl":"pkg:maven/org.apache.sshd/sshd-core@2.12.1"}, + {"type":"library","name":"batik-css","version":"1.7","purl":"pkg:maven/org.apache.xmlgraphics/batik-css@1.7"}, + {"type":"library","name":"h2","version":"1.3.157","purl":"pkg:maven/com.h2database/h2@1.3.157"}, + {"type":"library","name":"js","version":"1.7R2","purl":"pkg:maven/rhino/js@1.7R2"}, + {"type":"library","name":"nekohtml","version":"1.9.12","purl":"pkg:maven/net.sourceforge.nekohtml/nekohtml@1.9.12"}, + {"type":"library","name":"antisamy","version":"1.4.3","purl":"pkg:maven/org.owasp.antisamy/antisamy@1.4.3"}, + {"type":"library","name":"postgresql","version":"42.1.4","purl":"pkg:maven/org.postgresql/postgresql@42.1.4"}, + {"type":"library","name":"quartz","version":"1.5.2","purl":"pkg:maven/org.quartz-scheduler/quartz@1.5.2"}, + {"type":"library","name":"spring-boot","version":"1.5.6.RELEASE","purl":"pkg:maven/org.springframework.boot/spring-boot@1.5.6.RELEASE"}, + {"type":"library","name":"woodstox-core-asl","version":"4.1.2","purl":"pkg:maven/org.codehaus.woodstox/woodstox-core-asl@4.1.2"}, + {"type":"library","name":"c3p0","version":"0.9.1.1","purl":"pkg:maven/com.mchange/c3p0@0.9.1.1"}, + {"type":"library","name":"opentelemetry-instrumentation-api","version":"2.10.0","purl":"pkg:maven/io.opentelemetry.instrumentation/opentelemetry-instrumentation-api@2.10.0"}, + {"type":"library","name":"undertow-core","version":"2.3.17.Final","purl":"pkg:maven/io.undertow/undertow-core@2.3.17.Final"}, + {"type":"library","name":"angus-mail","version":"2.0.3","purl":"pkg:maven/org.eclipse.angus/angus-mail@2.0.3"}, + {"type":"library","name":"bcprov-jdk15on","version":"1.36","purl":"pkg:maven/org.bouncycastle/bcprov-jdk15on@1.36"}, + {"type":"library","name":"bcmail-jdk14","version":"1.35","purl":"pkg:maven/bouncycastle/bcmail-jdk14@1.35"}]} +JSON +python3 "$MVNCPE" "$WORK/mvn-expanded.json" >/dev/null 2>&1 +exp_cpe_of() { jq -r --arg n "$1" '[.components[]|select(.name==$n)]|.[0].cpe // "NONE"' "$WORK/mvn-expanded.json"; } +[ "$(exp_cpe_of log4j)" = "cpe:2.3:a:apache:log4j:1.2.17:*:*:*:*:*:*:*" ] && pass "single-segment log4j groupId curated (apache:log4j)" || fail "log4j cpe='$(exp_cpe_of log4j)'" +[ "$(exp_cpe_of sshd-core)" = "cpe:2.3:a:apache:mina_sshd:2.12.1:*:*:*:*:*:*:*" ] && pass "org.apache.sshd curated (apache:mina_sshd, not apache:sshd)" || fail "sshd-core cpe='$(exp_cpe_of sshd-core)'" +[ "$(exp_cpe_of batik-css)" = "cpe:2.3:a:apache:batik:1.7:*:*:*:*:*:*:*" ] && pass "org.apache.xmlgraphics curated (apache:batik, not apache:xmlgraphics)" || fail "batik-css cpe='$(exp_cpe_of batik-css)'" +[ "$(exp_cpe_of h2)" = "cpe:2.3:a:h2database:h2:1.3.157:*:*:*:*:*:*:*" ] && pass "com.h2database curated (h2database:h2, not h2database:h2database)" || fail "h2 cpe='$(exp_cpe_of h2)'" +[ "$(exp_cpe_of js)" = "cpe:2.3:a:mozilla:rhino:1.7R2:*:*:*:*:*:*:*" ] && pass "single-segment rhino groupId curated (mozilla:rhino)" || fail "js cpe='$(exp_cpe_of js)'" +[ "$(exp_cpe_of nekohtml)" = "cpe:2.3:a:cyberneko_html_project:cyberneko_html:1.9.12:*:*:*:*:*:*:*" ] && pass "net.sourceforge.nekohtml curated (not sourceforge:nekohtml)" || fail "nekohtml cpe='$(exp_cpe_of nekohtml)'" +[ "$(exp_cpe_of antisamy)" = "cpe:2.3:a:antisamy_project:antisamy:1.4.3:*:*:*:*:*:*:*" ] && pass "org.owasp.antisamy curated (not owasp:antisamy)" || fail "antisamy cpe='$(exp_cpe_of antisamy)'" +[ "$(exp_cpe_of postgresql)" = "cpe:2.3:a:postgresql:postgresql_jdbc_driver:42.1.4:*:*:*:*:*:*:*" ] && pass "org.postgresql curated (postgresql_jdbc_driver, not postgresql)" || fail "postgresql cpe='$(exp_cpe_of postgresql)'" +[ "$(exp_cpe_of quartz)" = "cpe:2.3:a:softwareag:quartz:1.5.2:*:*:*:*:*:*:*" ] && pass "org.quartz-scheduler curated (softwareag:quartz)" || fail "quartz cpe='$(exp_cpe_of quartz)'" +[ "$(exp_cpe_of spring-boot)" = "cpe:2.3:a:vmware:spring_boot:1.5.6.RELEASE:*:*:*:*:*:*:*" ] && pass "org.springframework.boot curated (vmware:spring_boot)" || fail "spring-boot cpe='$(exp_cpe_of spring-boot)'" +[ "$(exp_cpe_of woodstox-core-asl)" = "cpe:2.3:a:fasterxml:woodstox:4.1.2:*:*:*:*:*:*:*" ] && pass "org.codehaus.woodstox curated to the post-rename vendor (fasterxml:woodstox)" || fail "woodstox-core-asl cpe='$(exp_cpe_of woodstox-core-asl)'" +[ "$(exp_cpe_of c3p0)" = "cpe:2.3:a:mchange:c3p0:0.9.1.1:*:*:*:*:*:*:*" ] && pass "com.mchange curated (mchange:c3p0, not mchange:mchange)" || fail "c3p0 cpe='$(exp_cpe_of c3p0)'" +[ "$(exp_cpe_of opentelemetry-instrumentation-api)" = "cpe:2.3:a:linuxfoundation:opentelemetry_instrumentation_for_java:2.10.0:*:*:*:*:*:*:*" ] && pass "io.opentelemetry.instrumentation curated" || fail "opentelemetry-instrumentation-api cpe='$(exp_cpe_of opentelemetry-instrumentation-api)'" +[ "$(exp_cpe_of undertow-core)" = "cpe:2.3:a:redhat:undertow:2.3.17.Final:*:*:*:*:*:*:*" ] && pass "io.undertow curated (redhat:undertow, not undertow:undertow)" || fail "undertow-core cpe='$(exp_cpe_of undertow-core)'" +[ "$(exp_cpe_of angus-mail)" = "cpe:2.3:a:eclipse:angus_mail:2.0.3:*:*:*:*:*:*:*" ] && pass "org.eclipse.angus curated (angus_mail, not angus)" || fail "angus-mail cpe='$(exp_cpe_of angus-mail)'" +[ "$(exp_cpe_of bcprov-jdk15on)" = "cpe:2.3:a:bouncycastle:bc-java:1.36:*:*:*:*:*:*:*" ] && pass "org.bouncycastle curated (bc-java, not bouncycastle:bouncycastle)" || fail "bcprov-jdk15on cpe='$(exp_cpe_of bcprov-jdk15on)'" +[ "$(exp_cpe_of bcmail-jdk14)" = "cpe:2.3:a:bouncycastle:bouncy-castle-crypto-package:1.35:*:*:*:*:*:*:*" ] && pass "legacy bouncycastle groupId curated (bouncy-castle-crypto-package)" || fail "bcmail-jdk14 cpe='$(exp_cpe_of bcmail-jdk14)'" +# idempotent. +cp "$WORK/mvn-expanded.json" "$WORK/mvn-expanded2.json"; python3 "$MVNCPE" "$WORK/mvn-expanded2.json" >/dev/null 2>&1 +diff -q "$WORK/mvn-expanded.json" "$WORK/mvn-expanded2.json" >/dev/null 2>&1 && pass "F-1c6 enrichment is idempotent" || fail "second run changed the SBOM" +# regression: feeding a couple of these through grype's CPE matcher actually +# recovers the real CVE, not just a syntactically-correct cpe string. +if command -v grype >/dev/null 2>&1; then + cat > "$WORK/exp-log4j.json" <<'JSON' +{"bomFormat":"CycloneDX","specVersion":"1.6","components":[ + {"type":"library","name":"log4j","version":"1.2.17","purl":"pkg:maven/log4j/log4j@1.2.17"}]} +JSON + python3 "$MVNCPE" "$WORK/exp-log4j.json" >/dev/null 2>&1 + python3 "$LIB/scan-nvd-cpe.py" "$WORK/exp-log4j.json" "$WORK/exp-log4j-out" >/dev/null 2>&1 + if [ -f "$WORK/exp-log4j-out_security_grype.json" ]; then + exp_log4j_n=$(jq '[.Results[0].Vulnerabilities[]] | length' "$WORK/exp-log4j-out_security_grype.json") + [ "${exp_log4j_n:-0}" -gt 0 ] && pass "grype CPE matcher recovers CVEs for log4j@1.2.17 (curated cpe)" || fail "no CVEs recovered for log4j@1.2.17" + else + echo " SKIP: grype produced no sidecar (offline DB unavailable?); skipping log4j CVE-recovery assertion" + fi +else + echo " SKIP: grype not installed; skipping CVE-recovery regression (F-1c6)" +fi + echo "== F-1d: NVD version filter (scan-nvd-cpe) — drops loose-range false positives ==" # The filter is what removes grype's over-broad nvd:cpe matches (a fixed-in-9.0.104 # Tomcat CVE that grype's DB matches to 7.0.50 because it dropped the >= 9.0.0 lower From 3a3bccb3cd7c58037cb13bf8308d94f373cd420e Mon Sep 17 00:00:00 2001 From: Haksung Jang Date: Sun, 23 Aug 2026 15:19:09 +0900 Subject: [PATCH 2/3] feat(scanner): support multiple NVD CPE vendors per maven component Some projects have NVD-filed CVEs under more than one CPE vendor across their history (a rename or corporate acquisition), and a CycloneDX component's cpe field can only hold one. Confirmed for Spring Framework (SpringSource -> Pivotal -> VMware) and Jetty's pre-Eclipse org.mortbay.jetty groupId, each via a direct grype CPE lookup showing CVEs one vendor alone would miss. MAVEN_CPE_MAP entries can now list extra (vendor, product) alternates, recorded on the component as bomlens:cpeAlternates and looked up by scan-nvd-cpe.py as individual bare-CPE grype queries merged into the same match list, so they get identical NVD-verify/severity handling with no duplicate (purl, cve) rows against the primary match. --- docker/lib/enrich-maven-cpe.py | 78 ++++++++++++++++++++++++++++------ docker/lib/scan-nvd-cpe.py | 59 ++++++++++++++++++++++++- tests/test-postprocess.sh | 49 +++++++++++++++++++++ 3 files changed, 172 insertions(+), 14 deletions(-) diff --git a/docker/lib/enrich-maven-cpe.py b/docker/lib/enrich-maven-cpe.py index d965b23c..ca6231c7 100644 --- a/docker/lib/enrich-maven-cpe.py +++ b/docker/lib/enrich-maven-cpe.py @@ -54,14 +54,38 @@ import re import sys -# Curated groupId -> (vendor, product_source). Verified against NVD. -# product_source: a literal string, or the sentinel "@artifact" meaning "use the -# component's artifactId as the product" (Jackson: fasterxml:jackson-databind). +# Curated groupId -> (vendor, product_source) or (vendor, product_source, +# alternates). Verified against NVD. product_source: a literal string, or the +# sentinel "@artifact" meaning "use the component's artifactId as the product" +# (Jackson: fasterxml:jackson-databind). +# +# alternates, when present, is a tuple of extra (vendor, product_source) pairs +# for a project NVD has filed under more than one CPE vendor over its history +# -- a rename or corporate acquisition, where NVD keeps assigning the vendor +# current at each CVE's disclosure date rather than backfilling old ones to +# the new name. A CycloneDX component carries exactly one cpe field, so the +# primary entry above still drives that field; the alternates are attached +# separately (bomlens:cpeAlternates) and looked up as their own grype CPE +# queries by scan-nvd-cpe.py, since a single cpe:2.3 string cannot express +# more than one vendor:product. MAVEN_CPE_MAP = { "com.fasterxml.jackson.core": ("fasterxml", "@artifact"), "com.fasterxml.jackson.dataformat": ("fasterxml", "@artifact"), - "org.springframework": ("vmware", "spring_framework"), - "org.springframework.security": ("vmware", "spring_security"), + # Spring Framework spans three NVD CPE vendors across its corporate history + # (SpringSource -> acquired by VMware in 2009 -> spun off as Pivotal in + # 2013 -> reabsorbed into VMware in 2019); NVD assigns whichever vendor was + # current at each CVE's disclosure date, never backfilling. Confirmed via + # direct grype CPE lookups: springsource:spring_framework and + # pivotal_software:spring_framework each carry CVEs vmware:spring_framework + # alone does not. + "org.springframework": ("vmware", "spring_framework", ( + ("pivotal_software", "spring_framework"), + ("springsource", "spring_framework"), + )), + # Same split, minus the SpringSource-era name (verified absent). + "org.springframework.security": ("vmware", "spring_security", ( + ("pivotal_software", "spring_security"), + )), "org.springframework.boot": ("vmware", "spring_boot"), "commons-beanutils": ("apache", "commons_beanutils"), "commons-fileupload": ("apache", "commons_fileupload"), @@ -88,6 +112,14 @@ "org.eclipse.angus": ("eclipse", "angus_mail"), # rule would derive eclipse:angus (missing the _mail suffix NVD's product carries) "org.bouncycastle": ("bouncycastle", "bc-java"), # the modern (post-rename) Bouncy Castle groupId; rule would derive bouncycastle:bouncycastle "bouncycastle": ("bouncycastle", "bouncy-castle-crypto-package"), # the legacy (pre-rename) groupId, single-segment so map-only; covers releases up to 1.35, distinct from bc-java above + # org.mortbay.jetty is Jetty's pre-Eclipse-Foundation groupId (6.x era). + # The generic rule already derives mortbay:jetty correctly for it -- this + # entry exists only to attach the eclipse:jetty alternate, since NVD filed + # at least one Jetty 6.x CVE (CVE-2009-5045) under the current Eclipse + # vendor even though the release predates the foundation's involvement. + "org.mortbay.jetty": ("mortbay", "jetty", ( + ("eclipse", "jetty"), + )), } # Versions with a CPE-unsafe shape are left alone: a ':' (cpe field separator), @@ -173,23 +205,33 @@ def _parse_maven(purl): return group, artifact, version +def _cpe_string(vendor, product, version): + return f"cpe:2.3:a:{vendor}:{product}:{version}:*:*:*:*:*:*:*" + + def derive_cpe(purl): - """Return an NVD-matchable cpe:2.3 string, or None if we cannot map it safely.""" + """Return (cpe, alt_cpes): an NVD-matchable cpe:2.3 string (or None if we + cannot map it safely) and a list of additional cpe:2.3 strings for a + project NVD splits across more than one CPE vendor (see MAVEN_CPE_MAP's + alternates). alt_cpes is always [] when derive_cpe returns None.""" parsed = _parse_maven(purl) if not parsed: - return None + return None, [] group, artifact, version = parsed if not _CPE_SAFE_VERSION.match(version): - return None + return None, [] vendor = product = None + alt_specs = () # 1. Curated map (longest groupId prefix wins, so *.security beats *). These # override the generic rule where it is known wrong (spring -> vmware, Jackson # product = artifact, org.json -> stleary:json-java). for prefix in sorted(MAVEN_CPE_MAP, key=len, reverse=True): if group == prefix or group.startswith(prefix + "."): - vendor, src = MAVEN_CPE_MAP[prefix] + entry = MAVEN_CPE_MAP[prefix] + vendor, src = entry[0], entry[1] product = artifact if src == "@artifact" else src + alt_specs = entry[2] if len(entry) > 2 else () break else: # 2. Generic reverse-domain rule for 2+ segment groups (parts[1]:parts[-1]): @@ -207,8 +249,12 @@ def derive_cpe(purl): vendor, product = parts[1], parts[-1] if not vendor or not product: - return None - return f"cpe:2.3:a:{vendor}:{product}:{version}:*:*:*:*:*:*:*" + return None, [] + alt_cpes = [ + _cpe_string(alt_vendor, artifact if alt_src == "@artifact" else alt_src, version) + for alt_vendor, alt_src in alt_specs + ] + return _cpe_string(vendor, product, version), alt_cpes def enrich(path): @@ -237,12 +283,18 @@ def enrich(path): or _is_submodule_mechanical_cpe(existing, parsed[0], parsed[1]) ): continue # a real cpe is already present; never overwrite it - cpe = derive_cpe(purl) + cpe, alt_cpes = derive_cpe(purl) if not cpe or cpe == existing: continue c["cpe"] = cpe - props = [p for p in (c.get("properties") or []) if p.get("name") != "bomlens:cpeSource"] + props = [p for p in (c.get("properties") or []) + if p.get("name") not in ("bomlens:cpeSource", "bomlens:cpeAlternates")] props.append({"name": "bomlens:cpeSource", "value": "maven-groupid"}) + if alt_cpes: + # A second cpe:2.3 the component itself cannot carry (CycloneDX + # allows exactly one), read back by scan-nvd-cpe.py as extra + # individual grype CPE lookups. See MAVEN_CPE_MAP's alternates. + props.append({"name": "bomlens:cpeAlternates", "value": json.dumps(alt_cpes)}) c["properties"] = props n += 1 diff --git a/docker/lib/scan-nvd-cpe.py b/docker/lib/scan-nvd-cpe.py index bd771e91..3e50cbd1 100644 --- a/docker/lib/scan-nvd-cpe.py +++ b/docker/lib/scan-nvd-cpe.py @@ -221,10 +221,61 @@ def run_grype(sbom): return None +def _run_grype_cpe(cpe): + """A single bare-CPE grype lookup (cheap: grype's local DB query, not a + second full-SBOM scan). Returns grype's raw matches list, or [] on any + failure -- an alternate CPE that can't be queried is silently skipped, + matching this script's existing "no path -> no finding" posture.""" + try: + p = subprocess.run([GRYPE, cpe, "-o", "json"], + capture_output=True, text=True, timeout=60) + if p.returncode != 0: + return [] + return json.loads(p.stdout).get("matches", []) + except Exception: + return [] + + +def _sbom_cpe_alternates(sbom_path): + """Yield (name, version, purl, [alt_cpe, ...]) for every maven component + enrich-maven-cpe.py tagged with bomlens:cpeAlternates -- a project NVD + splits across more than one CPE vendor (see MAVEN_CPE_MAP's alternates), + which a component's single cpe field cannot express on its own.""" + try: + with open(sbom_path) as f: + doc = json.load(f) + except (OSError, ValueError): + return + for c in doc.get("components") or []: + props = c.get("properties") or [] + raw = next((p.get("value") for p in props if p.get("name") == "bomlens:cpeAlternates"), None) + if not raw: + continue + try: + alt_cpes = json.loads(raw) + except ValueError: + continue + if alt_cpes: + yield c.get("name", ""), c.get("version", ""), c.get("purl", ""), alt_cpes + + def build_sidecar(sbom_path, out_prefix): g = run_grype(sbom_path) if g is None: return + grype_matches = g.get("matches", []) + # Alternate-CPE matches: fold each curated alternate's own bare-CPE lookup + # into the same match list (with the real component's name/version/purl + # substituted for grype's bare-CPE artifact, which carries no purl) so + # they get exactly the same NVD-verify/severity/record-building treatment + # as the primary SBOM scan's matches below -- no separate code path to + # keep in sync. + for name, version, purl, alt_cpes in _sbom_cpe_alternates(sbom_path): + for alt_cpe in alt_cpes: + for m in _run_grype_cpe(alt_cpe): + art = m.setdefault("artifact", {}) + art["name"], art["version"], art["purl"] = name, version, purl + grype_matches.append(m) # Default off: the base behavior stays offline (air-gap safe). When on it needs # network + NVD_API_KEY and adds minutes per scan, so it is opt-in; without it # nvd:cpe findings are kept and flagged bomlens:cpeVersionUnverified. @@ -243,7 +294,6 @@ def build_sidecar(sbom_path, out_prefix): except Exception: db_conn = None - grype_matches = g.get("matches", []) # The verify loop is the only part of this script with a known total, so it # is also the only part that reports [deep-cve-progress]. Count up front how # many findings will actually hit the NVD lookup (nvd:cpe CVE matches) so the @@ -257,6 +307,9 @@ def build_sidecar(sbom_path, out_prefix): last_percent = None kept, dropped, unverified = [], 0, 0 + seen = set() # (purl-or-name, cve): an alternate CPE lookup can rediscover + # a CVE the primary SBOM scan already found (their version ranges overlap), + # or two alternates can overlap each other -- keep the first hit only. for m in grype_matches: vuln = m.get("vulnerability", {}) cve = vuln.get("id", "") @@ -268,6 +321,9 @@ def build_sidecar(sbom_path, out_prefix): purl = art.get("purl", "") cpes = art.get("cpes") or [] product = _cpe_product(cpes[0]) if cpes else "" + dedup_key = (purl or art.get("name", ""), cve) + if dedup_key in seen: + continue # Take grype's GHSA matches too (its Java matcher catches maven CVEs Trivy # misses); the sidecar merge dedups against Trivy's findings by (purl, cve). @@ -316,6 +372,7 @@ def build_sidecar(sbom_path, out_prefix): rec["bomlens:cpeVersionUnverified"] = True unverified += 1 kept.append(rec) + seen.add(dedup_key) if db_conn is not None: try: diff --git a/tests/test-postprocess.sh b/tests/test-postprocess.sh index 40bba1b3..963a4889 100755 --- a/tests/test-postprocess.sh +++ b/tests/test-postprocess.sh @@ -1499,6 +1499,55 @@ else echo " SKIP: grype not installed; skipping CVE-recovery regression (F-1c6)" fi +echo "== F-1c7: maven CPE enrichment — alternate CPEs for NVD vendor-split projects ==" +# Some projects (a rename or corporate acquisition) have NVD-filed CVEs under +# more than one CPE vendor across their history, e.g. Spring Framework's +# SpringSource -> Pivotal -> VMware lineage or Jetty's pre-Eclipse Mortbay +# groupId. A CycloneDX component's cpe field can only hold one vendor, so +# MAVEN_CPE_MAP's alternates attach the rest as bomlens:cpeAlternates for +# scan-nvd-cpe.py to look up separately. +cat > "$WORK/mvn-alt.json" <<'JSON' +{"bomFormat":"CycloneDX","specVersion":"1.6","components":[ + {"type":"library","name":"spring-context","version":"2.5.6","purl":"pkg:maven/org.springframework/spring-context@2.5.6"}, + {"type":"library","name":"spring-security-core","version":"3.0.0","purl":"pkg:maven/org.springframework.security/spring-security-core@3.0.0"}, + {"type":"library","name":"jetty","version":"6.1.21","purl":"pkg:maven/org.mortbay.jetty/jetty@6.1.21"}, + {"type":"library","name":"spring-boot","version":"1.5.6.RELEASE","purl":"pkg:maven/org.springframework.boot/spring-boot@1.5.6.RELEASE"}]} +JSON +python3 "$MVNCPE" "$WORK/mvn-alt.json" >/dev/null 2>&1 +alt_props_of() { jq -r --arg n "$1" '[.components[]|select(.name==$n)]|.[0]|[(.properties//[])[]|select(.name=="bomlens:cpeAlternates")|.value][0] // "NONE"' "$WORK/mvn-alt.json"; } +[ "$(alt_props_of spring-context)" = '["cpe:2.3:a:pivotal_software:spring_framework:2.5.6:*:*:*:*:*:*:*", "cpe:2.3:a:springsource:spring_framework:2.5.6:*:*:*:*:*:*:*"]' ] \ + && pass "org.springframework carries both pivotal_software and springsource alternates" \ + || fail "spring-context alternates='$(alt_props_of spring-context)'" +[ "$(alt_props_of spring-security-core)" = '["cpe:2.3:a:pivotal_software:spring_security:3.0.0:*:*:*:*:*:*:*"]' ] \ + && pass "org.springframework.security carries the pivotal_software alternate" \ + || fail "spring-security-core alternates='$(alt_props_of spring-security-core)'" +[ "$(alt_props_of jetty)" = '["cpe:2.3:a:eclipse:jetty:6.1.21:*:*:*:*:*:*:*"]' ] \ + && pass "org.mortbay.jetty carries the eclipse:jetty alternate" \ + || fail "jetty alternates='$(alt_props_of jetty)'" +[ "$(alt_props_of spring-boot)" = "NONE" ] \ + && pass "org.springframework.boot (single vendor, verified no split) gets no alternates property" \ + || fail "spring-boot wrongly got alternates='$(alt_props_of spring-boot)'" +cp "$WORK/mvn-alt.json" "$WORK/mvn-alt2.json"; python3 "$MVNCPE" "$WORK/mvn-alt2.json" >/dev/null 2>&1 +diff -q "$WORK/mvn-alt.json" "$WORK/mvn-alt2.json" >/dev/null 2>&1 && pass "F-1c7 enrichment is idempotent" || fail "second run changed the SBOM" +# regression: scan-nvd-cpe.py actually looks the alternates up and recovers +# CVEs the primary cpe alone would miss, with no duplicate (purl, cve) rows. +if command -v grype >/dev/null 2>&1; then + cp "$WORK/mvn-alt.json" "$WORK/mvn-alt-scan.json" + python3 "$LIB/scan-nvd-cpe.py" "$WORK/mvn-alt-scan.json" "$WORK/mvn-alt-out" >/dev/null 2>&1 + if [ -f "$WORK/mvn-alt-out_security_grype.json" ]; then + alt_spring_cve=$(jq '[.Results[0].Vulnerabilities[] | select(.PkgName=="spring-context" and .VulnerabilityID=="CVE-2016-9878")] | length' "$WORK/mvn-alt-out_security_grype.json") + [ "${alt_spring_cve:-0}" -gt 0 ] && pass "alternate pivotal_software:spring_framework recovers CVE-2016-9878 (vmware alone misses it)" || fail "CVE-2016-9878 not recovered via alternate" + alt_jetty_cve=$(jq '[.Results[0].Vulnerabilities[] | select(.PkgName=="jetty" and .VulnerabilityID=="CVE-2009-5045")] | length' "$WORK/mvn-alt-out_security_grype.json") + [ "${alt_jetty_cve:-0}" -gt 0 ] && pass "alternate eclipse:jetty recovers CVE-2009-5045 (mortbay alone misses it)" || fail "CVE-2009-5045 not recovered via alternate" + alt_dupes=$(jq '[.Results[0].Vulnerabilities[] | ((.PkgIdentifier.PURL // .PkgName) + "|" + .VulnerabilityID)] | group_by(.) | map(select(length>1)) | length' "$WORK/mvn-alt-out_security_grype.json") + [ "$alt_dupes" = "0" ] && pass "no duplicate (purl, cve) rows between primary and alternate matches" || fail "$alt_dupes duplicate (purl, cve) row(s) found" + else + echo " SKIP: grype produced no sidecar (offline DB unavailable?); skipping alternate-CVE-recovery assertions" + fi +else + echo " SKIP: grype not installed; skipping CVE-recovery regression (F-1c7)" +fi + echo "== F-1d: NVD version filter (scan-nvd-cpe) — drops loose-range false positives ==" # The filter is what removes grype's over-broad nvd:cpe matches (a fixed-in-9.0.104 # Tomcat CVE that grype's DB matches to 7.0.50 because it dropped the >= 9.0.0 lower From 32c43b08796a9da09e39bce0b8456deb29f1eed1 Mon Sep 17 00:00:00 2001 From: Haksung Jang Date: Sun, 23 Aug 2026 16:51:24 +0900 Subject: [PATCH 3/3] feat(scanner): split org.apache.activemq CPE by artifactId (Classic vs Artemis) org.apache.activemq is shared by two different NVD products: Artemis (artifactIds prefixed "artemis-") and Classic ActiveMQ (everything else). The generic rule already derives apache:activemq correctly for Classic, but every Artemis artifact was getting the same wrong CPE. MAVEN_CPE_MAP entries can now be a dict keyed by artifactId prefix instead of a flat (vendor, product) tuple, with "" as the catch-all default. Investigated org.glassfish for the same treatment (also flagged as a shared-groupId gap): every org.glassfish/* artifact actually observed in the corpus is a generic Jakarta/Java EE API jar, not the GlassFish server itself, so there is no artifactId prefix to route on safely. Left uncurated. --- docker/lib/enrich-maven-cpe.py | 23 +++++++++++++++++++++++ tests/test-postprocess.sh | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+) diff --git a/docker/lib/enrich-maven-cpe.py b/docker/lib/enrich-maven-cpe.py index ca6231c7..e0b64c37 100644 --- a/docker/lib/enrich-maven-cpe.py +++ b/docker/lib/enrich-maven-cpe.py @@ -120,6 +120,18 @@ "org.mortbay.jetty": ("mortbay", "jetty", ( ("eclipse", "jetty"), )), + # org.apache.activemq is shared by two different NVD products: Artemis + # (artifactIds prefixed "artemis-") and Classic ActiveMQ (everything + # else -- activemq-client, activemq-broker, the legacy activeio-core, + # etc.). The generic rule already derives apache:activemq correctly for + # the Classic side; this entry exists only to route the artemis-* + # artifacts to apache:artemis instead. See the artifact_prefix branch in + # derive_cpe() -- "" is the fallback for every artifactId that matches no + # more specific prefix. + "org.apache.activemq": { + "artemis-": ("apache", "artemis"), + "": ("apache", "activemq"), + }, } # Versions with a CPE-unsafe shape are left alone: a ':' (cpe field separator), @@ -229,6 +241,17 @@ def derive_cpe(purl): for prefix in sorted(MAVEN_CPE_MAP, key=len, reverse=True): if group == prefix or group.startswith(prefix + "."): entry = MAVEN_CPE_MAP[prefix] + if isinstance(entry, dict): + # A groupId shared by more than one NVD product, split by + # artifactId prefix (e.g. org.apache.activemq's artemis-* + # artifacts vs everything else). Longest prefix wins; "" + # is the catch-all for whatever matches no specific prefix. + for art_prefix in sorted(entry, key=len, reverse=True): + if artifact.startswith(art_prefix): + entry = entry[art_prefix] + break + else: + break # no prefix matched (should not happen with a "" catch-all) vendor, src = entry[0], entry[1] product = artifact if src == "@artifact" else src alt_specs = entry[2] if len(entry) > 2 else () diff --git a/tests/test-postprocess.sh b/tests/test-postprocess.sh index 963a4889..00a238e3 100755 --- a/tests/test-postprocess.sh +++ b/tests/test-postprocess.sh @@ -1548,6 +1548,39 @@ else echo " SKIP: grype not installed; skipping CVE-recovery regression (F-1c7)" fi +echo "== F-1c8: maven CPE enrichment — artifactId-prefix branching for a shared groupId ==" +# org.apache.activemq is shared by two different NVD products: Artemis +# (artifactIds prefixed "artemis-") and Classic ActiveMQ (everything else). +# A MAVEN_CPE_MAP entry can be a dict keyed by artifactId prefix (longest +# wins, "" is the catch-all) instead of a flat (vendor, product) tuple. +cat > "$WORK/mvn-split.json" <<'JSON' +{"bomFormat":"CycloneDX","specVersion":"1.6","components":[ + {"type":"library","name":"artemis-commons","version":"2.44.0","purl":"pkg:maven/org.apache.activemq/artemis-commons@2.44.0"}, + {"type":"library","name":"activemq-client","version":"5.5.1","purl":"pkg:maven/org.apache.activemq/activemq-client@5.5.1"}, + {"type":"library","name":"activeio-core","version":"3.1.4","purl":"pkg:maven/org.apache.activemq/activeio-core@3.1.4"}]} +JSON +python3 "$MVNCPE" "$WORK/mvn-split.json" >/dev/null 2>&1 +split_cpe_of() { jq -r --arg n "$1" '[.components[]|select(.name==$n)]|.[0].cpe // "NONE"' "$WORK/mvn-split.json"; } +[ "$(split_cpe_of artemis-commons)" = "cpe:2.3:a:apache:artemis:2.44.0:*:*:*:*:*:*:*" ] \ + && pass "artemis-* artifactId under org.apache.activemq routes to apache:artemis" \ + || fail "artemis-commons cpe='$(split_cpe_of artemis-commons)'" +[ "$(split_cpe_of activemq-client)" = "cpe:2.3:a:apache:activemq:5.5.1:*:*:*:*:*:*:*" ] \ + && pass "non-artemis artifactId under org.apache.activemq falls back to apache:activemq" \ + || fail "activemq-client cpe='$(split_cpe_of activemq-client)'" +[ "$(split_cpe_of activeio-core)" = "cpe:2.3:a:apache:activemq:3.1.4:*:*:*:*:*:*:*" ] \ + && pass "an unrelated-looking artifactId under the same groupId also falls back to the \"\" default" \ + || fail "activeio-core cpe='$(split_cpe_of activeio-core)'" +cp "$WORK/mvn-split.json" "$WORK/mvn-split2.json"; python3 "$MVNCPE" "$WORK/mvn-split2.json" >/dev/null 2>&1 +diff -q "$WORK/mvn-split.json" "$WORK/mvn-split2.json" >/dev/null 2>&1 && pass "F-1c8 enrichment is idempotent" || fail "second run changed the SBOM" +# regression: grype's local DB confirms apache:artemis is a real, distinct NVD +# product from apache:activemq (a CVE only one of the two carries). +if command -v grype >/dev/null 2>&1; then + artemis_only_n=$(grype "cpe:2.3:a:apache:artemis:2.11.0:*:*:*:*:*:*:*" -o json 2>/dev/null | jq '[.matches[]] | length') + [ "${artemis_only_n:-0}" -gt 0 ] && pass "apache:artemis is a real, distinct NVD product (grype DB carries its own CVEs)" || echo " SKIP: could not confirm apache:artemis has its own CVEs on this grype DB build" +else + echo " SKIP: grype not installed; skipping CVE-recovery regression (F-1c8)" +fi + echo "== F-1d: NVD version filter (scan-nvd-cpe) — drops loose-range false positives ==" # The filter is what removes grype's over-broad nvd:cpe matches (a fixed-in-9.0.104 # Tomcat CVE that grype's DB matches to 7.0.50 because it dropped the >= 9.0.0 lower