Add support for PHP Pear (#2775)

* Add support for PHP Pear and unify PECL with it

Signed-off-by: Laurent Goderre <laurent.goderre@docker.com>
Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>

* fix tests

Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>

* remove log statements

Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>

* fix struct comment

Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>

---------

Signed-off-by: Laurent Goderre <laurent.goderre@docker.com>
Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>
Co-authored-by: Alex Goodman <wagoodman@users.noreply.github.com>
This commit is contained in:
Laurent Goderre
2025-04-30 20:16:58 +00:00
committed by GitHub
co-authored by Alex Goodman
parent 78ef2cf53b
commit 529840bfc0
27 changed files with 3358 additions and 152 deletions
+10 -2
View File
@@ -23,8 +23,16 @@ func NewComposerLockCataloger() pkg.Cataloger {
WithParserByGlobs(parseComposerLock, "**/composer.lock")
}
// NewPeclCataloger returns a new cataloger for PHP PECL metadata“.
// NewPearCataloger returns a new cataloger for PHP Pear metadata (including Pecl metadata).
func NewPearCataloger() pkg.Cataloger {
return generic.NewCataloger("php-pear-serialized-cataloger").
WithParserByGlobs(parsePear, "**/php/.registry/**/*.reg")
}
// NewPeclCataloger returns a new cataloger for PHP Pecl metadata. Note: this will also catalog Pear metadata so should
// not be used in conjunction with the Pear Cataloger.
// Deprecated: please use NewPearCataloger instead.
func NewPeclCataloger() pkg.Cataloger {
return generic.NewCataloger("php-pecl-serialized-cataloger").
WithParserByGlobs(parsePeclSerialized, "**/php/.registry/.channel.*/*.reg")
WithParserByGlobs(parsePecl, "**/php/.registry/.channel.*/*.reg")
}
+26 -1
View File
@@ -56,6 +56,31 @@ func Test_ComposerLockCataloger_Globs(t *testing.T) {
}
}
func Test_PearCataloger_Globs(t *testing.T) {
tests := []struct {
name string
fixture string
expected []string
}{
{
name: "obtain pear files",
fixture: "test-fixtures/glob-paths",
expected: []string{
"php/.registry/.channel.pecl.php.net/memcached.reg",
},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
pkgtest.NewCatalogTester().
FromDirectory(t, test.fixture).
ExpectsResolverContentQueries(test.expected).
TestCataloger(t, NewPearCataloger())
})
}
}
func Test_PeclCataloger_Globs(t *testing.T) {
tests := []struct {
name string
@@ -63,7 +88,7 @@ func Test_PeclCataloger_Globs(t *testing.T) {
expected []string
}{
{
name: "obtain pecl files",
name: "obtain pear files",
fixture: "test-fixtures/glob-paths",
expected: []string{
"php/.registry/.channel.pecl.php.net/memcached.reg",
+31 -10
View File
@@ -14,7 +14,7 @@ func newComposerLockPackage(pd parsedLockData, indexLocation file.Location) pkg.
Version: pd.Version,
Locations: file.NewLocationSet(indexLocation.WithAnnotation(pkg.EvidenceAnnotationKey, pkg.PrimaryEvidenceAnnotation)),
Licenses: pkg.NewLicenseSet(pkg.NewLicensesFromLocation(indexLocation, pd.License...)...),
PURL: packageURL(pd.Name, pd.Version),
PURL: packageURLFromComposer(pd.Name, pd.Version),
Language: pkg.PHP,
Type: pkg.PhpComposerPkg,
Metadata: pd.PhpComposerLockEntry,
@@ -30,7 +30,7 @@ func newComposerInstalledPackage(pd parsedInstalledData, indexLocation file.Loca
Version: pd.Version,
Locations: file.NewLocationSet(indexLocation.WithAnnotation(pkg.EvidenceAnnotationKey, pkg.PrimaryEvidenceAnnotation)),
Licenses: pkg.NewLicenseSet(pkg.NewLicensesFromLocation(indexLocation, pd.License...)...),
PURL: packageURL(pd.Name, pd.Version),
PURL: packageURLFromComposer(pd.Name, pd.Version),
Language: pkg.PHP,
Type: pkg.PhpComposerPkg,
Metadata: pd.PhpComposerInstalledEntry,
@@ -40,23 +40,39 @@ func newComposerInstalledPackage(pd parsedInstalledData, indexLocation file.Loca
return p
}
func newPeclPackage(pd pkg.PhpPeclEntry, indexLocation file.Location) pkg.Package {
func newPearPackage(pd peclPearData, indexLocation file.Location) pkg.Package {
p := pkg.Package{
Name: pd.Name,
Version: pd.Version,
Locations: file.NewLocationSet(indexLocation.WithAnnotation(pkg.EvidenceAnnotationKey, pkg.PrimaryEvidenceAnnotation)),
Licenses: pkg.NewLicenseSet(pkg.NewLicensesFromLocation(indexLocation, pd.License...)...),
PURL: packageURLFromPecl(pd.Name, pd.Version),
PURL: packageURLFromPear(pd.Name, pd.Channel, pd.Version),
Language: pkg.PHP,
Type: pkg.PhpPeclPkg,
Metadata: pd,
Type: pkg.PhpPearPkg,
Metadata: pd.ToPear(),
}
p.SetID()
return p
}
func packageURL(name, version string) string {
func newPeclPackage(pd peclPearData, indexLocation file.Location) pkg.Package {
p := pkg.Package{
Name: pd.Name,
Version: pd.Version,
Locations: file.NewLocationSet(indexLocation.WithAnnotation(pkg.EvidenceAnnotationKey, pkg.PrimaryEvidenceAnnotation)),
Licenses: pkg.NewLicenseSet(pkg.NewLicensesFromLocation(indexLocation, pd.License...)...),
PURL: packageURLFromPear(pd.Name, pd.Channel, pd.Version),
Language: pkg.PHP,
Type: pkg.PhpPeclPkg,
Metadata: pd.ToPecl(),
}
p.SetID()
return p
}
func packageURLFromComposer(name, version string) string {
var pkgName, vendor string
fields := strings.Split(name, "/")
switch len(fields) {
@@ -82,10 +98,15 @@ func packageURL(name, version string) string {
return pURL.ToString()
}
func packageURLFromPecl(pkgName, version string) string {
func packageURLFromPear(pkgName, channel, version string) string {
namespace := channel
if namespace == "" {
namespace = "pecl.php.net"
}
pURL := packageurl.NewPackageURL(
"pecl",
"",
"pear",
namespace,
pkgName,
version,
nil,
+12 -4
View File
@@ -35,7 +35,7 @@ func Test_packageURL(t *testing.T) {
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
actual := packageURL(test.packageName, test.packageVersion)
actual := packageURLFromComposer(test.packageName, test.packageVersion)
if actual != test.expected {
dmp := diffmatchpatch.New()
diffs := dmp.DiffMain(test.expected, actual, true)
@@ -45,22 +45,30 @@ func Test_packageURL(t *testing.T) {
}
}
func Test_packageURLFromPecl(t *testing.T) {
func Test_packageURLFromPear(t *testing.T) {
tests := []struct {
name string
channel string
version string
expected string
}{
{
name: "memcached",
channel: "pear.php.net",
version: "3.2.0",
expected: "pkg:pecl/memcached@3.2.0",
expected: "pkg:pear/pear.php.net/memcached@3.2.0",
},
{
name: "memcached",
channel: "", // important!
version: "3.2.0",
expected: "pkg:pear/pecl.php.net/memcached@3.2.0",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
actual := packageURLFromPecl(test.name, test.version)
actual := packageURLFromPear(test.name, test.channel, test.version)
if actual != test.expected {
dmp := diffmatchpatch.New()
diffs := dmp.DiffMain(test.expected, actual, true)
+112
View File
@@ -0,0 +1,112 @@
package php
import (
"context"
"fmt"
"io"
"github.com/elliotchance/phpserialize"
"github.com/anchore/syft/internal/unknown"
"github.com/anchore/syft/syft/artifact"
"github.com/anchore/syft/syft/file"
"github.com/anchore/syft/syft/pkg"
"github.com/anchore/syft/syft/pkg/cataloger/generic"
)
type peclPearData struct {
Name string
Channel string
Version string
License []string
}
func (p *peclPearData) ToPear() pkg.PhpPearEntry {
return pkg.PhpPearEntry{
Name: p.Name,
Channel: p.Channel,
Version: p.Version,
License: p.License,
}
}
func (p *peclPearData) ToPecl() pkg.PhpPeclEntry {
return pkg.PhpPeclEntry(p.ToPear())
}
func parsePecl(_ context.Context, _ file.Resolver, _ *generic.Environment, reader file.LocationReadCloser) ([]pkg.Package, []artifact.Relationship, error) {
m, err := parsePeclPearSerialized(reader)
if err != nil {
return nil, nil, err
}
if m == nil {
return nil, nil, unknown.New(reader.Location, fmt.Errorf("no pecl package found"))
}
return []pkg.Package{newPeclPackage(*m, reader.Location)}, nil, nil
}
func parsePear(_ context.Context, _ file.Resolver, _ *generic.Environment, reader file.LocationReadCloser) ([]pkg.Package, []artifact.Relationship, error) {
m, err := parsePeclPearSerialized(reader)
if err != nil {
return nil, nil, err
}
if m == nil {
return nil, nil, unknown.New(reader.Location, fmt.Errorf("no pear package found"))
}
return []pkg.Package{newPearPackage(*m, reader.Location)}, nil, nil
}
// parsePeclPearSerialized is a parser function for Pear metadata contents, returning "Default" php packages discovered.
func parsePeclPearSerialized(reader file.LocationReadCloser) (*peclPearData, error) {
data, err := io.ReadAll(reader)
if err != nil {
return nil, fmt.Errorf("failed to read file: %w", err)
}
metadata, err := phpserialize.UnmarshalAssociativeArray(
data,
)
if err != nil {
return nil, fmt.Errorf("failed to parse pear metadata file: %w", err)
}
name, ok := metadata["name"].(string)
if !ok {
return nil, fmt.Errorf("failed to parse pear package name: %w", err)
}
channel, ok := metadata["channel"].(string)
if !ok {
// this could be the v5 format
channel = ""
}
version := readStruct(metadata, "version", "release")
license := readStruct(metadata, "license", "_content")
return &peclPearData{
Name: name,
Channel: channel,
Version: version,
License: []string{
license,
},
}, nil
}
func readStruct(metadata any, fields ...string) string {
if len(fields) > 0 {
value, ok := metadata.(map[any]any)
if !ok {
return ""
}
return readStruct(value[fields[0]], fields[1:]...)
}
value, ok := metadata.(string)
if !ok {
return ""
}
return value
}
@@ -0,0 +1,139 @@
package php
import (
"testing"
"github.com/anchore/syft/syft/artifact"
"github.com/anchore/syft/syft/file"
"github.com/anchore/syft/syft/pkg"
"github.com/anchore/syft/syft/pkg/cataloger/internal/pkgtest"
)
func TestParsePear(t *testing.T) {
tests := []struct {
name string
fixture string
expectedPkgs []pkg.Package
expectedRelationships []artifact.Relationship
}{
{
name: "v6 format",
fixture: "test-fixtures/memcached-v6-format.reg",
expectedPkgs: []pkg.Package{
{
Name: "memcached",
Version: "3.2.0",
PURL: "pkg:pear/pecl.php.net/memcached@3.2.0",
Locations: file.NewLocationSet(file.NewLocation("test-fixtures/memcached-v6-format.reg")),
Licenses: pkg.NewLicenseSet(
pkg.NewLicenseFromLocations("PHP License", file.NewLocation("test-fixtures/memcached-v6-format.reg")),
),
Language: pkg.PHP,
Type: pkg.PhpPearPkg,
Metadata: pkg.PhpPearEntry{
Name: "memcached",
Channel: "pecl.php.net",
Version: "3.2.0",
License: []string{"PHP License"},
},
},
},
},
{
name: "v5 format",
fixture: "test-fixtures/memcached-v5-format.reg",
expectedPkgs: []pkg.Package{
{
Name: "memcached",
Version: "3.2.0",
PURL: "pkg:pear/pecl.php.net/memcached@3.2.0",
Locations: file.NewLocationSet(file.NewLocation("test-fixtures/memcached-v5-format.reg")),
Licenses: pkg.NewLicenseSet(
pkg.NewLicenseFromLocations("PHP License", file.NewLocation("test-fixtures/memcached-v5-format.reg")),
),
Language: pkg.PHP,
Type: pkg.PhpPearPkg,
Metadata: pkg.PhpPearEntry{ // important: missing channel
Name: "memcached",
Version: "3.2.0",
License: []string{"PHP License"},
},
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
pkgtest.TestFileParser(t, tt.fixture, parsePear, tt.expectedPkgs, tt.expectedRelationships)
})
}
}
func TestParsePecl(t *testing.T) {
tests := []struct {
name string
fixture string
expectedPkgs []pkg.Package
expectedRelationships []artifact.Relationship
}{
{
name: "v6 format",
fixture: "test-fixtures/memcached-v6-format.reg",
expectedPkgs: []pkg.Package{
{
Name: "memcached",
Version: "3.2.0",
PURL: "pkg:pear/pecl.php.net/memcached@3.2.0",
Locations: file.NewLocationSet(file.NewLocation("test-fixtures/memcached-v6-format.reg")),
Licenses: pkg.NewLicenseSet(
pkg.NewLicenseFromLocations("PHP License", file.NewLocation("test-fixtures/memcached-v6-format.reg")),
),
Language: pkg.PHP,
Type: pkg.PhpPeclPkg, // important!
Metadata: pkg.PhpPeclEntry{ // important!
Name: "memcached",
Channel: "pecl.php.net",
Version: "3.2.0",
License: []string{"PHP License"},
},
},
},
},
{
name: "v5 format",
fixture: "test-fixtures/memcached-v5-format.reg",
expectedPkgs: []pkg.Package{
{
Name: "memcached",
Version: "3.2.0",
PURL: "pkg:pear/pecl.php.net/memcached@3.2.0",
Locations: file.NewLocationSet(file.NewLocation("test-fixtures/memcached-v5-format.reg")),
Licenses: pkg.NewLicenseSet(
pkg.NewLicenseFromLocations("PHP License", file.NewLocation("test-fixtures/memcached-v5-format.reg")),
),
Language: pkg.PHP,
Type: pkg.PhpPeclPkg, // important!
Metadata: pkg.PhpPeclEntry{ // important!
Name: "memcached",
Version: "3.2.0",
License: []string{"PHP License"},
},
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
pkgtest.TestFileParser(t, tt.fixture, parsePecl, tt.expectedPkgs, tt.expectedRelationships)
})
}
}
func Test_corruptPecl(t *testing.T) {
pkgtest.NewCatalogTester().
FromFile(t, "test-fixtures/glob-paths/php/.registry/.channel.pecl.php.net/memcached.reg").
WithError().
TestParser(t, parseComposerLock)
}
@@ -1,73 +0,0 @@
package php
import (
"context"
"fmt"
"io"
"github.com/elliotchance/phpserialize"
"github.com/anchore/syft/internal/log"
"github.com/anchore/syft/syft/artifact"
"github.com/anchore/syft/syft/file"
"github.com/anchore/syft/syft/pkg"
"github.com/anchore/syft/syft/pkg/cataloger/generic"
)
// parsePeclSerialized is a parser function for PECL metadata contents, returning "Default" php packages discovered.
func parsePeclSerialized(_ context.Context, _ file.Resolver, _ *generic.Environment, reader file.LocationReadCloser) ([]pkg.Package, []artifact.Relationship, error) {
var pkgs []pkg.Package
data, err := io.ReadAll(reader)
if err != nil {
return nil, nil, fmt.Errorf("failed to read file: %w", err)
}
metadata, err := phpserialize.UnmarshalAssociativeArray(
data,
)
if err != nil {
return nil, nil, fmt.Errorf("failed to parse pecl metadata file: %w", err)
}
name, ok := metadata["name"].(string)
if !ok {
return nil, nil, fmt.Errorf("failed to parse pecl package name: %w", err)
}
version := readStruct(metadata, "version", "release")
license := readStruct(metadata, "license", "_content")
pkgs = append(
pkgs,
newPeclPackage(
pkg.PhpPeclEntry{
Name: name,
Version: version,
License: []string{
license,
},
},
reader.Location,
),
)
return pkgs, nil, nil
}
func readStruct(metadata any, fields ...string) string {
if len(fields) > 0 {
value, ok := metadata.(map[any]any)
if !ok {
log.Tracef("unable to read '%s' from: %v", fields[0], metadata)
return ""
}
return readStruct(value[fields[0]], fields[1:]...)
}
value, ok := metadata.(string)
if !ok {
log.Tracef("unable to read value from: %v", metadata)
}
return value
}
@@ -1,42 +0,0 @@
package php
import (
"testing"
"github.com/anchore/syft/syft/artifact"
"github.com/anchore/syft/syft/file"
"github.com/anchore/syft/syft/pkg"
"github.com/anchore/syft/syft/pkg/cataloger/internal/pkgtest"
)
func TestParsePeclSerialized(t *testing.T) {
var expectedRelationships []artifact.Relationship
fixture := "test-fixtures/memcached.reg"
locations := file.NewLocationSet(file.NewLocation(fixture))
expectedPkgs := []pkg.Package{
{
Name: "memcached",
Version: "3.2.0",
PURL: "pkg:pecl/memcached@3.2.0",
Locations: locations,
Licenses: pkg.NewLicenseSet(
pkg.NewLicenseFromLocations("PHP License", file.NewLocation(fixture)),
),
Language: pkg.PHP,
Type: pkg.PhpPeclPkg,
Metadata: pkg.PhpPeclEntry{
Name: "memcached",
Version: "3.2.0",
License: []string{"PHP License"},
},
},
}
pkgtest.TestFileParser(t, fixture, parsePeclSerialized, expectedPkgs, expectedRelationships)
}
func Test_corruptPecl(t *testing.T) {
pkgtest.NewCatalogTester().
FromFile(t, "test-fixtures/glob-paths/php/.registry/.channel.pecl.php.net/memcached.reg").
WithError().
TestParser(t, parseComposerLock)
}
@@ -0,0 +1 @@
a:6:{s:4:"name";s:9:"memcached";s:7:"channel";s:12:"pecl.php.net";s:4:"date";s:10:"2022-01-11";s:4:"time";s:8:"15:23:47";s:7:"version";a:2:{s:7:"release";s:5:"3.2.0";s:3:"api";s:5:"3.2.0";}s:7:"license";a:2:{s:7:"attribs";a:1:{s:3:"uri";s:26:"http://www.php.net/license";}s:8:"_content";s:11:"PHP License";}}