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 16:16:58 -04:00 committed by GitHub
parent 78ef2cf53b
commit 529840bfc0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
27 changed files with 3358 additions and 152 deletions

View File

@ -140,11 +140,11 @@ Note that flags using the @<version> can be used for earlier versions of each sp
- Linux kernel archives (vmlinz) - Linux kernel archives (vmlinz)
- Linux kernel modules (ko) - Linux kernel modules (ko)
- Nix (outputs in /nix/store) - Nix (outputs in /nix/store)
- PHP (composer) - PHP (composer, PECL, Pear)
- Python (wheel, egg, poetry, requirements.txt) - Python (wheel, egg, poetry, requirements.txt)
- Red Hat (rpm) - Red Hat (rpm)
- Ruby (gem) - Ruby (gem)
- Rust (cargo.lock) - Rust (cargo.lock, auditable binary)
- Swift (cocoapods, swift-package-manager) - Swift (cocoapods, swift-package-manager)
- Wordpress plugins - Wordpress plugins
- Terraform providers (.terraform.lock.hcl) - Terraform providers (.terraform.lock.hcl)

View File

@ -502,8 +502,8 @@ var commonTestCases = []testCase{
}, },
}, },
{ {
name: "find php pecl package", name: "find php pear/pecl package",
pkgType: pkg.PhpPeclPkg, pkgType: pkg.PhpPearPkg,
pkgLanguage: pkg.PHP, pkgLanguage: pkg.PHP,
pkgInfo: map[string]string{ pkgInfo: map[string]string{
"memcached": "3.2.0", "memcached": "3.2.0",

View File

@ -85,6 +85,7 @@ func TestPkgCoverageImage(t *testing.T) {
definedPkgs.Remove(string(pkg.GithubActionPkg)) definedPkgs.Remove(string(pkg.GithubActionPkg))
definedPkgs.Remove(string(pkg.GithubActionWorkflowPkg)) definedPkgs.Remove(string(pkg.GithubActionWorkflowPkg))
definedPkgs.Remove(string(pkg.TerraformPkg)) definedPkgs.Remove(string(pkg.TerraformPkg))
definedPkgs.Remove(string(pkg.PhpPeclPkg)) // we have coverage for pear instead
var cases []testCase var cases []testCase
cases = append(cases, commonTestCases...) cases = append(cases, commonTestCases...)
@ -227,6 +228,7 @@ func TestPkgCoverageDirectory(t *testing.T) {
definedPkgs.Remove(string(pkg.LinuxKernelModulePkg)) definedPkgs.Remove(string(pkg.LinuxKernelModulePkg))
definedPkgs.Remove(string(pkg.Rpkg)) definedPkgs.Remove(string(pkg.Rpkg))
definedPkgs.Remove(string(pkg.UnknownPkg)) definedPkgs.Remove(string(pkg.UnknownPkg))
definedPkgs.Remove(string(pkg.PhpPeclPkg)) // this is covered as pear packages
// for directory scans we should not expect to see any of the following package types // for directory scans we should not expect to see any of the following package types
definedPkgs.Remove(string(pkg.KbPkg)) definedPkgs.Remove(string(pkg.KbPkg))

View File

@ -56,6 +56,7 @@ func TestAllPackageCatalogersReachableInTasks(t *testing.T) {
// not reachable since they are deprecated // not reachable since they are deprecated
"dotnet-portable-executable-cataloger", "dotnet-portable-executable-cataloger",
"dotnet-deps-cataloger", "dotnet-deps-cataloger",
"php-pecl-serialized-cataloger",
// not reachable by design // not reachable by design
"sbom-cataloger", "sbom-cataloger",
) )

View File

@ -1 +1 @@
a:5:{s:4:"name";s:9:"memcached";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";}} 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";}}

View File

@ -3,5 +3,5 @@ package internal
const ( const (
// JSONSchemaVersion is the current schema version output by the JSON encoder // JSONSchemaVersion is the current schema version output by the JSON encoder
// This is roughly following the "SchemaVer" guidelines for versioning the JSON schema. Please see schema/json/README.md for details on how to increment. // This is roughly following the "SchemaVer" guidelines for versioning the JSON schema. Please see schema/json/README.md for details on how to increment.
JSONSchemaVersion = "16.0.25" JSONSchemaVersion = "16.0.26"
) )

View File

@ -100,7 +100,7 @@ func DefaultPackageTaskFactories() Factories {
pkgcataloging.DeclaredTag, pkgcataloging.DirectoryTag, pkgcataloging.LanguageTag, JavaScript, Node, NPM, pkgcataloging.DeclaredTag, pkgcataloging.DirectoryTag, pkgcataloging.LanguageTag, JavaScript, Node, NPM,
), ),
newSimplePackageTaskFactory(php.NewComposerLockCataloger, pkgcataloging.DeclaredTag, pkgcataloging.DirectoryTag, pkgcataloging.LanguageTag, "php", "composer"), newSimplePackageTaskFactory(php.NewComposerLockCataloger, pkgcataloging.DeclaredTag, pkgcataloging.DirectoryTag, pkgcataloging.LanguageTag, "php", "composer"),
newSimplePackageTaskFactory(php.NewPeclCataloger, pkgcataloging.DeclaredTag, pkgcataloging.DirectoryTag, pkgcataloging.LanguageTag, pkgcataloging.ImageTag, "php", "pecl"), newSimplePackageTaskFactory(php.NewPearCataloger, pkgcataloging.DeclaredTag, pkgcataloging.DirectoryTag, pkgcataloging.LanguageTag, pkgcataloging.ImageTag, "php", "pear"),
newPackageTaskFactory( newPackageTaskFactory(
func(cfg CatalogingFactoryConfig) pkg.Cataloger { func(cfg CatalogingFactoryConfig) pkg.Cataloger {
return python.NewPackageCataloger(cfg.PackagesConfig.Python) return python.NewPackageCataloger(cfg.PackagesConfig.Python)
@ -166,5 +166,6 @@ func DefaultPackageTaskFactories() Factories {
// these are catalogers that should not be selectable other than specific inclusion via name or "deprecated" tag (to remain backwards compatible) // these are catalogers that should not be selectable other than specific inclusion via name or "deprecated" tag (to remain backwards compatible)
newSimplePackageTaskFactory(dotnet.NewDotnetDepsCataloger, pkgcataloging.DeprecatedTag), // TODO: remove in syft v2.0 newSimplePackageTaskFactory(dotnet.NewDotnetDepsCataloger, pkgcataloging.DeprecatedTag), // TODO: remove in syft v2.0
newSimplePackageTaskFactory(dotnet.NewDotnetPortableExecutableCataloger, pkgcataloging.DeprecatedTag), // TODO: remove in syft v2.0 newSimplePackageTaskFactory(dotnet.NewDotnetPortableExecutableCataloger, pkgcataloging.DeprecatedTag), // TODO: remove in syft v2.0
newSimplePackageTaskFactory(php.NewPeclCataloger, pkgcataloging.DeprecatedTag), // TODO: remove in syft v2.0
} }
} }

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
{ {
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "anchore.io/schema/syft/json/16.0.25/document", "$id": "anchore.io/schema/syft/json/16.0.26/document",
"$ref": "#/$defs/Document", "$ref": "#/$defs/Document",
"$defs": { "$defs": {
"AlpmDbEntry": { "AlpmDbEntry": {
@ -1872,6 +1872,9 @@
{ {
"$ref": "#/$defs/PhpComposerLockEntry" "$ref": "#/$defs/PhpComposerLockEntry"
}, },
{
"$ref": "#/$defs/PhpPearEntry"
},
{ {
"$ref": "#/$defs/PhpPeclEntry" "$ref": "#/$defs/PhpPeclEntry"
}, },
@ -2164,11 +2167,38 @@
"dist" "dist"
] ]
}, },
"PhpPearEntry": {
"properties": {
"name": {
"type": "string"
},
"channel": {
"type": "string"
},
"version": {
"type": "string"
},
"license": {
"items": {
"type": "string"
},
"type": "array"
}
},
"type": "object",
"required": [
"name",
"version"
]
},
"PhpPeclEntry": { "PhpPeclEntry": {
"properties": { "properties": {
"name": { "name": {
"type": "string" "type": "string"
}, },
"channel": {
"type": "string"
},
"version": { "version": {
"type": "string" "type": "string"
}, },

View File

@ -34,6 +34,7 @@ func Test_OriginatorSupplier(t *testing.T) {
pkg.NixStoreEntry{}, pkg.NixStoreEntry{},
pkg.NpmPackageLockEntry{}, pkg.NpmPackageLockEntry{},
pkg.PhpComposerInstalledEntry{}, pkg.PhpComposerInstalledEntry{},
pkg.PhpPearEntry{},
pkg.PhpPeclEntry{}, pkg.PhpPeclEntry{},
pkg.PortageEntry{}, pkg.PortageEntry{},
pkg.PythonPipfileLockEntry{}, pkg.PythonPipfileLockEntry{},

View File

@ -40,6 +40,8 @@ func SourceInfo(p pkg.Package) string {
answer = "acquired package info from rust cargo manifest" answer = "acquired package info from rust cargo manifest"
case pkg.PhpComposerPkg: case pkg.PhpComposerPkg:
answer = "acquired package info from PHP composer manifest" answer = "acquired package info from PHP composer manifest"
case pkg.PhpPearPkg:
answer = "acquired package info from PHP Pear manifest"
case pkg.PhpPeclPkg: case pkg.PhpPeclPkg:
answer = "acquired package info from PHP Pecl manifest" answer = "acquired package info from PHP Pecl manifest"
case pkg.CocoapodsPkg: case pkg.CocoapodsPkg:

View File

@ -143,6 +143,14 @@ func Test_SourceInfo(t *testing.T) {
"from PHP Pecl manifest", "from PHP Pecl manifest",
}, },
}, },
{
input: pkg.Package{
Type: pkg.PhpPearPkg,
},
expected: []string{
"from PHP Pear manifest",
},
},
{ {
input: pkg.Package{ input: pkg.Package{
Type: pkg.DartPubPkg, Type: pkg.DartPubPkg,

View File

@ -42,6 +42,7 @@ func AllTypes() []any {
pkg.OpamPackage{}, pkg.OpamPackage{},
pkg.PhpComposerInstalledEntry{}, pkg.PhpComposerInstalledEntry{},
pkg.PhpComposerLockEntry{}, pkg.PhpComposerLockEntry{},
pkg.PhpPearEntry{},
pkg.PhpPeclEntry{}, pkg.PhpPeclEntry{},
pkg.PortageEntry{}, pkg.PortageEntry{},
pkg.PythonPackage{}, pkg.PythonPackage{},

View File

@ -96,6 +96,7 @@ var jsonTypes = makeJSONTypes(
jsonNames(pkg.PhpComposerLockEntry{}, "php-composer-lock-entry", "PhpComposerJsonMetadata"), jsonNames(pkg.PhpComposerLockEntry{}, "php-composer-lock-entry", "PhpComposerJsonMetadata"),
jsonNamesWithoutLookup(pkg.PhpComposerInstalledEntry{}, "php-composer-installed-entry", "PhpComposerJsonMetadata"), // the legacy value is split into two types, where the other is preferred jsonNamesWithoutLookup(pkg.PhpComposerInstalledEntry{}, "php-composer-installed-entry", "PhpComposerJsonMetadata"), // the legacy value is split into two types, where the other is preferred
jsonNames(pkg.PhpPeclEntry{}, "php-pecl-entry", "PhpPeclMetadata"), jsonNames(pkg.PhpPeclEntry{}, "php-pecl-entry", "PhpPeclMetadata"),
jsonNames(pkg.PhpPearEntry{}, "php-pear-entry"),
jsonNames(pkg.PortageEntry{}, "portage-db-entry", "PortageMetadata"), jsonNames(pkg.PortageEntry{}, "portage-db-entry", "PortageMetadata"),
jsonNames(pkg.PythonPackage{}, "python-package", "PythonPackageMetadata"), jsonNames(pkg.PythonPackage{}, "python-package", "PythonPackageMetadata"),
jsonNames(pkg.PythonPipfileLockEntry{}, "python-pipfile-lock-entry", "PythonPipfileLockMetadata"), jsonNames(pkg.PythonPipfileLockEntry{}, "python-pipfile-lock-entry", "PythonPipfileLockMetadata"),

View File

@ -23,8 +23,16 @@ func NewComposerLockCataloger() pkg.Cataloger {
WithParserByGlobs(parseComposerLock, "**/composer.lock") 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 { func NewPeclCataloger() pkg.Cataloger {
return generic.NewCataloger("php-pecl-serialized-cataloger"). return generic.NewCataloger("php-pecl-serialized-cataloger").
WithParserByGlobs(parsePeclSerialized, "**/php/.registry/.channel.*/*.reg") WithParserByGlobs(parsePecl, "**/php/.registry/.channel.*/*.reg")
} }

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) { func Test_PeclCataloger_Globs(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
@ -63,7 +88,7 @@ func Test_PeclCataloger_Globs(t *testing.T) {
expected []string expected []string
}{ }{
{ {
name: "obtain pecl files", name: "obtain pear files",
fixture: "test-fixtures/glob-paths", fixture: "test-fixtures/glob-paths",
expected: []string{ expected: []string{
"php/.registry/.channel.pecl.php.net/memcached.reg", "php/.registry/.channel.pecl.php.net/memcached.reg",

View File

@ -14,7 +14,7 @@ func newComposerLockPackage(pd parsedLockData, indexLocation file.Location) pkg.
Version: pd.Version, Version: pd.Version,
Locations: file.NewLocationSet(indexLocation.WithAnnotation(pkg.EvidenceAnnotationKey, pkg.PrimaryEvidenceAnnotation)), Locations: file.NewLocationSet(indexLocation.WithAnnotation(pkg.EvidenceAnnotationKey, pkg.PrimaryEvidenceAnnotation)),
Licenses: pkg.NewLicenseSet(pkg.NewLicensesFromLocation(indexLocation, pd.License...)...), Licenses: pkg.NewLicenseSet(pkg.NewLicensesFromLocation(indexLocation, pd.License...)...),
PURL: packageURL(pd.Name, pd.Version), PURL: packageURLFromComposer(pd.Name, pd.Version),
Language: pkg.PHP, Language: pkg.PHP,
Type: pkg.PhpComposerPkg, Type: pkg.PhpComposerPkg,
Metadata: pd.PhpComposerLockEntry, Metadata: pd.PhpComposerLockEntry,
@ -30,7 +30,7 @@ func newComposerInstalledPackage(pd parsedInstalledData, indexLocation file.Loca
Version: pd.Version, Version: pd.Version,
Locations: file.NewLocationSet(indexLocation.WithAnnotation(pkg.EvidenceAnnotationKey, pkg.PrimaryEvidenceAnnotation)), Locations: file.NewLocationSet(indexLocation.WithAnnotation(pkg.EvidenceAnnotationKey, pkg.PrimaryEvidenceAnnotation)),
Licenses: pkg.NewLicenseSet(pkg.NewLicensesFromLocation(indexLocation, pd.License...)...), Licenses: pkg.NewLicenseSet(pkg.NewLicensesFromLocation(indexLocation, pd.License...)...),
PURL: packageURL(pd.Name, pd.Version), PURL: packageURLFromComposer(pd.Name, pd.Version),
Language: pkg.PHP, Language: pkg.PHP,
Type: pkg.PhpComposerPkg, Type: pkg.PhpComposerPkg,
Metadata: pd.PhpComposerInstalledEntry, Metadata: pd.PhpComposerInstalledEntry,
@ -40,23 +40,39 @@ func newComposerInstalledPackage(pd parsedInstalledData, indexLocation file.Loca
return p return p
} }
func newPeclPackage(pd pkg.PhpPeclEntry, indexLocation file.Location) pkg.Package { func newPearPackage(pd peclPearData, indexLocation file.Location) pkg.Package {
p := pkg.Package{ p := pkg.Package{
Name: pd.Name, Name: pd.Name,
Version: pd.Version, Version: pd.Version,
Locations: file.NewLocationSet(indexLocation.WithAnnotation(pkg.EvidenceAnnotationKey, pkg.PrimaryEvidenceAnnotation)), Locations: file.NewLocationSet(indexLocation.WithAnnotation(pkg.EvidenceAnnotationKey, pkg.PrimaryEvidenceAnnotation)),
Licenses: pkg.NewLicenseSet(pkg.NewLicensesFromLocation(indexLocation, pd.License...)...), 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, Language: pkg.PHP,
Type: pkg.PhpPeclPkg, Type: pkg.PhpPearPkg,
Metadata: pd, Metadata: pd.ToPear(),
} }
p.SetID() p.SetID()
return p 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 var pkgName, vendor string
fields := strings.Split(name, "/") fields := strings.Split(name, "/")
switch len(fields) { switch len(fields) {
@ -82,10 +98,15 @@ func packageURL(name, version string) string {
return pURL.ToString() 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( pURL := packageurl.NewPackageURL(
"pecl", "pear",
"", namespace,
pkgName, pkgName,
version, version,
nil, nil,

View File

@ -35,7 +35,7 @@ func Test_packageURL(t *testing.T) {
for _, test := range tests { for _, test := range tests {
t.Run(test.name, func(t *testing.T) { t.Run(test.name, func(t *testing.T) {
actual := packageURL(test.packageName, test.packageVersion) actual := packageURLFromComposer(test.packageName, test.packageVersion)
if actual != test.expected { if actual != test.expected {
dmp := diffmatchpatch.New() dmp := diffmatchpatch.New()
diffs := dmp.DiffMain(test.expected, actual, true) 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 { tests := []struct {
name string name string
channel string
version string version string
expected string expected string
}{ }{
{ {
name: "memcached", name: "memcached",
channel: "pear.php.net",
version: "3.2.0", 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 { for _, test := range tests {
t.Run(test.name, func(t *testing.T) { 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 { if actual != test.expected {
dmp := diffmatchpatch.New() dmp := diffmatchpatch.New()
diffs := dmp.DiffMain(test.expected, actual, true) diffs := dmp.DiffMain(test.expected, actual, true)

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
}

View File

@ -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)
}

View File

@ -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
}

View File

@ -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)
}

View File

@ -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";}}

View File

@ -37,8 +37,14 @@ type PhpComposerAuthors struct {
Homepage string `json:"homepage,omitempty"` Homepage string `json:"homepage,omitempty"`
} }
type PhpPeclEntry struct { // PhpPeclEntry represents a single package entry found within php pecl metadata files.
// Deprecated: please use PhpPearEntry instead with the pear cataloger.
type PhpPeclEntry PhpPearEntry
// PhpPearEntry represents a single package entry found within php pear metadata files.
type PhpPearEntry struct {
Name string `json:"name"` Name string `json:"name"`
Channel string `json:"channel,omitempty"`
Version string `json:"version"` Version string `json:"version"`
License []string `json:"license,omitempty"` License []string `json:"license,omitempty"`
} }

View File

@ -36,7 +36,8 @@ const (
NpmPkg Type = "npm" NpmPkg Type = "npm"
OpamPkg Type = "opam" OpamPkg Type = "opam"
PhpComposerPkg Type = "php-composer" PhpComposerPkg Type = "php-composer"
PhpPeclPkg Type = "php-pecl" PhpPeclPkg Type = "php-pecl" // Deprecated: will be removed in syft v2.0
PhpPearPkg Type = "php-pear"
PortagePkg Type = "portage" PortagePkg Type = "portage"
PythonPkg Type = "python" PythonPkg Type = "python"
Rpkg Type = "R-package" Rpkg Type = "R-package"
@ -78,6 +79,7 @@ var AllPkgs = []Type{
OpamPkg, OpamPkg,
PhpComposerPkg, PhpComposerPkg,
PhpPeclPkg, PhpPeclPkg,
PhpPearPkg,
PortagePkg, PortagePkg,
PythonPkg, PythonPkg,
Rpkg, Rpkg,
@ -132,8 +134,8 @@ func (t Type) PackageURLType() string {
return packageurl.TypeGeneric return packageurl.TypeGeneric
case PhpComposerPkg: case PhpComposerPkg:
return packageurl.TypeComposer return packageurl.TypeComposer
case PhpPeclPkg: case PhpPearPkg, PhpPeclPkg:
return "pecl" return "pear"
case PythonPkg: case PythonPkg:
return packageurl.TypePyPi return packageurl.TypePyPi
case PortagePkg: case PortagePkg:
@ -192,8 +194,8 @@ func TypeByName(name string) Type {
return DebPkg return DebPkg
case packageurl.TypeComposer: case packageurl.TypeComposer:
return PhpComposerPkg return PhpComposerPkg
case "pecl": case "pear", "pecl":
return PhpPeclPkg return PhpPearPkg
case packageurl.TypeGolang: case packageurl.TypeGolang:
return GoModulePkg return GoModulePkg
case packageurl.TypeGem: case packageurl.TypeGem:

View File

@ -63,8 +63,16 @@ func TestTypeFromPURL(t *testing.T) {
expected: PhpComposerPkg, expected: PhpComposerPkg,
}, },
{ {
purl: "pkg:pecl/memcached@3.2.0", purl: "pkg:pear/pecl.php.net/memcached@3.2.0", // pecl namespace
expected: PhpPeclPkg, expected: PhpPearPkg,
},
{
purl: "pkg:pear/pear.php.net/memcached@3.2.0", // pear namespace
expected: PhpPearPkg,
},
{
purl: "pkg:pecl/pecl.php.net/memcached@3.2.0", // note: this is an invalid purl, but we will handle it anyway in case folks created the type pre-emptively
expected: PhpPearPkg, // we should still consider this a pear package
}, },
{ {
purl: "pkg:maven/org.apache.xmlgraphics/batik-anim@1.9.1?type=zip&classifier=dist", purl: "pkg:maven/org.apache.xmlgraphics/batik-anim@1.9.1?type=zip&classifier=dist",
@ -124,7 +132,7 @@ func TestTypeFromPURL(t *testing.T) {
}, },
} }
var pkgTypes []string var pkgTypes = strset.New()
var expectedTypes = strset.New() var expectedTypes = strset.New()
for _, ty := range AllPkgs { for _, ty := range AllPkgs {
expectedTypes.Add(string(ty)) expectedTypes.Add(string(ty))
@ -141,19 +149,19 @@ func TestTypeFromPURL(t *testing.T) {
expectedTypes.Remove(string(WordpressPluginPkg)) expectedTypes.Remove(string(WordpressPluginPkg))
expectedTypes.Remove(string(TerraformPkg)) expectedTypes.Remove(string(TerraformPkg))
expectedTypes.Remove(string(GraalVMNativeImagePkg)) expectedTypes.Remove(string(GraalVMNativeImagePkg))
expectedTypes.Remove(string(PhpPeclPkg)) // we should always consider this a pear package
for _, test := range tests { for _, test := range tests {
t.Run(string(test.expected), func(t *testing.T) { t.Run(string(test.expected), func(t *testing.T) {
actual := TypeFromPURL(test.purl) actual := TypeFromPURL(test.purl)
if actual != "" { if actual != "" {
pkgTypes = append(pkgTypes, string(actual)) pkgTypes.Add(string(actual))
} }
assert.Equal(t, test.expected, actual) assert.Equal(t, test.expected, actual)
}) })
} }
assert.ElementsMatch(t, expectedTypes.List(), pkgTypes, "missing one or more package types to test against (maybe a package type was added?)") assert.ElementsMatch(t, expectedTypes.List(), pkgTypes.List(), "missing one or more package types to test against (maybe a package type was added?)")
} }