From 26927eab0271bf2a08cb332b987d55aec405ea99 Mon Sep 17 00:00:00 2001 From: Pujitha Paladugu Date: Tue, 29 Sep 2026 14:15:36 -0700 Subject: [PATCH] fix: suppress misleading upstream CPE for PHP extension binaries (#5102) Motivation: syft's PHP interpreter cataloger reports each bundled PHP C extension (openssl, ldap, sqlite3, etc.) as its own package, using the PHP interpreter's own version number as the package version (since these extensions are compiled into a specific PHP release and have no independent version of their own). The classifier already attaches an explicit CPE like cpe:2.3:a:php-openssl:php-openssl:8.5.7:... to make clear this refers to the bundled PHP extension, not upstream OpenSSL. However, that CPE was marked with Source: cpe.GeneratedSource. Syft's package-finalization pipeline (internal/task/package_task_factory.go) only skips its generic, name-based CPE generation when a package already has an "authoritative" CPE (any Source other than GeneratedSource) - so it also generated and appended a second CPE, cpe:2.3:a:openssl:openssl:8.5.7:..., derived from the bare package name. That second CPE is indistinguishable from a real standalone OpenSSL/LDAP/SQLite installation and can cause vulnerability scanners that consume this SBOM to falsely flag PHP hosts as running vulnerable upstream libraries at version "8.5.7" (a PHP version, not a real OpenSSL/LDAP/SQLite release). See anchore/syft#5014 for a full repro. Approach: Mark the classifier's explicit CPE with Source: cpe.DeclaredSource instead of cpe.GeneratedSource, so it is treated as authoritative and the extra name-derived CPE is no longer generated. This mirrors the existing pattern in syft/pkg/cataloger/java/parse_jvm_release.go's newJvmCpe, which uses the same DeclaredSource + comment convention to mark a syft-synthesized (not literally file-declared) CPE as trustworthy and suppress further generation. It also brings this classifier in line with its sibling php-cli/php-fpm/php-apache classifiers in the same file, which already mark their CPEs as authoritative (via NVDDictionaryLookupSource, not applicable here since php- is not a real NVD dictionary entry). This change only suppresses the extra generated CPE; it does not change the package name, type, version, or PURL, and the existing php-:php- CPE is unaffected. Validation: go build ./... go test ./syft/pkg/cataloger/php/... ./internal/task/... ./syft/pkg/cataloger/internal/cpegenerate/... All pass except the two pre-existing docker-image-fixture subtests of Test_InterpreterCataloger, which fail in this sandbox only because the `docker` binary isn't available (exec: "docker": executable file not found in $PATH); confirmed via `git stash` that these two subtests fail identically on main without this change, i.e. this is a sandbox limitation and not a regression. Added a new unit test, Test_getClassifier_declaredCPESource, that directly asserts the classifier's CPE now carries Source: cpe.DeclaredSource. Fixes #5014 Signed-off-by: Pujitha Paladugu <10557236+pujitha24@users.noreply.github.com> Co-authored-by: Pujitha Paladugu <10557236+pujitha24@users.noreply.github.com> --- syft/pkg/cataloger/php/interpreter_cataloger.go | 5 ++++- .../cataloger/php/interpreter_cataloger_test.go | 17 +++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/syft/pkg/cataloger/php/interpreter_cataloger.go b/syft/pkg/cataloger/php/interpreter_cataloger.go index 80ae69d21..f9022d358 100644 --- a/syft/pkg/cataloger/php/interpreter_cataloger.go +++ b/syft/pkg/cataloger/php/interpreter_cataloger.go @@ -236,7 +236,10 @@ func (p interpreterCataloger) getClassifier(realPath string) (string, *binutils. Vendor: fmt.Sprintf("php-%s", name), Product: fmt.Sprintf("php-%s", name), }, - Source: cpe.GeneratedSource, + // note: we must use a declared source here so that this CPE is treated as authoritative and no + // further CPEs are generated from the (misleading) package name alone, e.g. cpe:...:openssl:openssl + // for the openssl PHP extension, which would imply this is the upstream OpenSSL project. + Source: cpe.DeclaredSource, }, }, } diff --git a/syft/pkg/cataloger/php/interpreter_cataloger_test.go b/syft/pkg/cataloger/php/interpreter_cataloger_test.go index 8d6d9e6d7..bff331d5c 100644 --- a/syft/pkg/cataloger/php/interpreter_cataloger_test.go +++ b/syft/pkg/cataloger/php/interpreter_cataloger_test.go @@ -3,6 +3,10 @@ package php import ( "testing" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/anchore/syft/syft/cpe" "github.com/anchore/syft/syft/pkg/cataloger/internal/pkgtest" ) @@ -147,3 +151,16 @@ func Test_InterpreterCataloger(t *testing.T) { }) } } + +func Test_getClassifier_declaredCPESource(t *testing.T) { + // regression test for https://github.com/anchore/syft/issues/5014 + // the CPE assigned to a PHP extension must be declared (not generated) so that downstream CPE + // generation does not also add a misleading CPE derived from the bare extension name alone + // (e.g. cpe:2.3:a:openssl:openssl:... for the openssl extension, implying the upstream OpenSSL project). + c := interpreterCataloger{name: "php-interpreter-cataloger"} + name, cls := c.getClassifier("/usr/lib/php85/modules/openssl.so") + require.Equal(t, "openssl", name) + require.NotNil(t, cls) + require.Len(t, cls.CPEs, 1) + assert.Equal(t, cpe.DeclaredSource, cls.CPEs[0].Source) +}