diff --git a/internal/formats/syftjson/model/package.go b/internal/formats/syftjson/model/package.go index e65d357ac01..f5000350dfa 100644 --- a/internal/formats/syftjson/model/package.go +++ b/internal/formats/syftjson/model/package.go @@ -118,6 +118,14 @@ func (p *Package) UnmarshalJSON(b []byte) error { return err } p.Metadata = payload + case pkg.PhpComposerJSONMetadataType: + var payload pkg.PhpComposerJSONMetadata + if err := json.Unmarshal(unpacker.Metadata, &payload); err != nil { + return err + } + p.Metadata = payload + default: + log.Warnf("unknown package metadata type=%q for packageID=%q", p.MetadataType, p.ID) } return nil diff --git a/schema/json/generate.go b/schema/json/generate.go index 45cc16cc16c..140ccd3e7bd 100644 --- a/schema/json/generate.go +++ b/schema/json/generate.go @@ -36,6 +36,7 @@ type artifactMetadataContainer struct { Rpm pkg.RpmdbMetadata Cargo pkg.CargoPackageMetadata Go pkg.GolangBinMetadata + Php pkg.PhpComposerJSONMetadata } func main() { diff --git a/schema/json/schema-3.0.1.json b/schema/json/schema-3.0.1.json index 27466a680cd..7454f15640a 100644 --- a/schema/json/schema-3.0.1.json +++ b/schema/json/schema-3.0.1.json @@ -662,6 +662,9 @@ { "$ref": "#/definitions/NpmPackageJSONMetadata" }, + { + "$ref": "#/definitions/PhpComposerJSONMetadata" + }, { "$ref": "#/definitions/PythonPackageMetadata" }, @@ -674,6 +677,144 @@ "additionalProperties": true, "type": "object" }, + "PhpComposerAuthors": { + "required": [ + "name" + ], + "properties": { + "name": { + "type": "string" + }, + "email": { + "type": "string" + }, + "homepage": { + "type": "string" + } + }, + "additionalProperties": true, + "type": "object" + }, + "PhpComposerExternalReference": { + "required": [ + "type", + "url", + "reference" + ], + "properties": { + "type": { + "type": "string" + }, + "url": { + "type": "string" + }, + "reference": { + "type": "string" + }, + "shasum": { + "type": "string" + } + }, + "additionalProperties": true, + "type": "object" + }, + "PhpComposerJSONMetadata": { + "required": [ + "name", + "version", + "source", + "dist" + ], + "properties": { + "name": { + "type": "string" + }, + "version": { + "type": "string" + }, + "source": { + "$schema": "http://json-schema.org/draft-04/schema#", + "$ref": "#/definitions/PhpComposerExternalReference" + }, + "dist": { + "$ref": "#/definitions/PhpComposerExternalReference" + }, + "require": { + "patternProperties": { + ".*": { + "type": "string" + } + }, + "type": "object" + }, + "provide": { + "patternProperties": { + ".*": { + "type": "string" + } + }, + "type": "object" + }, + "require-dev": { + "patternProperties": { + ".*": { + "type": "string" + } + }, + "type": "object" + }, + "suggest": { + "patternProperties": { + ".*": { + "type": "string" + } + }, + "type": "object" + }, + "type": { + "type": "string" + }, + "notification-url": { + "type": "string" + }, + "bin": { + "items": { + "type": "string" + }, + "type": "array" + }, + "license": { + "items": { + "type": "string" + }, + "type": "array" + }, + "authors": { + "items": { + "$schema": "http://json-schema.org/draft-04/schema#", + "$ref": "#/definitions/PhpComposerAuthors" + }, + "type": "array" + }, + "description": { + "type": "string" + }, + "homepage": { + "type": "string" + }, + "keywords": { + "items": { + "type": "string" + }, + "type": "array" + }, + "time": { + "type": "string" + } + }, + "additionalProperties": true, + "type": "object" + }, "PomParent": { "required": [ "groupId", diff --git a/syft/pkg/cataloger/php/parse_composer_lock.go b/syft/pkg/cataloger/php/parse_composer_lock.go index 21052d9d94c..1ee120e609c 100644 --- a/syft/pkg/cataloger/php/parse_composer_lock.go +++ b/syft/pkg/cataloger/php/parse_composer_lock.go @@ -6,18 +6,12 @@ import ( "io" "github.com/anchore/syft/syft/artifact" - "github.com/anchore/syft/syft/pkg" ) -type ComposerLock struct { - Packages []Dependency `json:"packages"` - PackageDev []Dependency `json:"packages-dev"` -} - -type Dependency struct { - Name string `json:"name"` - Version string `json:"version"` +type composerLock struct { + Packages []pkg.PhpComposerJSONMetadata `json:"packages"` + PackageDev []pkg.PhpComposerJSONMetadata `json:"packages-dev"` } // parseComposerLock is a parser function for Composer.lock contents, returning "Default" php packages discovered. @@ -26,7 +20,7 @@ func parseComposerLock(_ string, reader io.Reader) ([]*pkg.Package, []artifact.R dec := json.NewDecoder(reader) for { - var lock ComposerLock + var lock composerLock if err := dec.Decode(&lock); err == io.EOF { break } else if err != nil { @@ -36,10 +30,12 @@ func parseComposerLock(_ string, reader io.Reader) ([]*pkg.Package, []artifact.R version := pkgMeta.Version name := pkgMeta.Name packages = append(packages, &pkg.Package{ - Name: name, - Version: version, - Language: pkg.PHP, - Type: pkg.PhpComposerPkg, + Name: name, + Version: version, + Language: pkg.PHP, + Type: pkg.PhpComposerPkg, + MetadataType: pkg.PhpComposerJSONMetadataType, + Metadata: pkgMeta, }) } } diff --git a/syft/pkg/cataloger/php/parse_composer_lock_test.go b/syft/pkg/cataloger/php/parse_composer_lock_test.go index 6e14dcd7fe7..c0658aa51c4 100644 --- a/syft/pkg/cataloger/php/parse_composer_lock_test.go +++ b/syft/pkg/cataloger/php/parse_composer_lock_test.go @@ -11,16 +11,98 @@ import ( func TestParseComposerFileLock(t *testing.T) { expected := []*pkg.Package{ { - Name: "adoy/fastcgi-client", - Version: "1.0.2", - Language: pkg.PHP, - Type: pkg.PhpComposerPkg, + Name: "adoy/fastcgi-client", + Version: "1.0.2", + Language: pkg.PHP, + Type: pkg.PhpComposerPkg, + MetadataType: pkg.PhpComposerJSONMetadataType, + Metadata: pkg.PhpComposerJSONMetadata{ + Name: "adoy/fastcgi-client", + Version: "1.0.2", + Source: pkg.PhpComposerExternalReference{ + Type: "git", + URL: "https://github.com/adoy/PHP-FastCGI-Client.git", + Reference: "6d9a552f0206a1db7feb442824540aa6c55e5b27", + }, + Dist: pkg.PhpComposerExternalReference{ + Type: "zip", + URL: "https://api.github.com/repos/adoy/PHP-FastCGI-Client/zipball/6d9a552f0206a1db7feb442824540aa6c55e5b27", + Reference: "6d9a552f0206a1db7feb442824540aa6c55e5b27", + }, + Type: "library", + NotificationURL: "https://packagist.org/downloads/", + License: []string{ + "MIT", + }, + Authors: []pkg.PhpComposerAuthors{ + { + Name: "Pierrick Charron", + Email: "pierrick@adoy.net", + }, + }, + Description: "Lightweight, single file FastCGI client for PHP.", + Keywords: []string{ + "fastcgi", + "fcgi", + }, + Time: "2019-12-11T13:49:21+00:00", + }, }, { - Name: "alcaeus/mongo-php-adapter", - Version: "1.1.11", - Language: pkg.PHP, - Type: pkg.PhpComposerPkg, + Name: "alcaeus/mongo-php-adapter", + Version: "1.1.11", + Language: pkg.PHP, + Type: pkg.PhpComposerPkg, + MetadataType: pkg.PhpComposerJSONMetadataType, + Metadata: pkg.PhpComposerJSONMetadata{ + Name: "alcaeus/mongo-php-adapter", + Version: "1.1.11", + Source: pkg.PhpComposerExternalReference{ + Type: "git", + URL: "https://github.com/alcaeus/mongo-php-adapter.git", + Reference: "43b6add94c8b4cb9890d662cba4c0defde733dcf", + }, + Dist: pkg.PhpComposerExternalReference{ + Type: "zip", + URL: "https://api.github.com/repos/alcaeus/mongo-php-adapter/zipball/43b6add94c8b4cb9890d662cba4c0defde733dcf", + Reference: "43b6add94c8b4cb9890d662cba4c0defde733dcf", + }, + Require: map[string]string{ + "ext-ctype": "*", + "ext-hash": "*", + "ext-mongodb": "^1.2.0", + "mongodb/mongodb": "^1.0.1", + "php": "^5.6 || ^7.0", + }, + Provide: map[string]string{ + "ext-mongo": "1.6.14", + }, + RequireDev: map[string]string{ + "phpunit/phpunit": "^5.7.27 || ^6.0 || ^7.0", + "squizlabs/php_codesniffer": "^3.2", + }, + Type: "library", + NotificationURL: "https://packagist.org/downloads/", + License: []string{ + "MIT", + }, + Authors: []pkg.PhpComposerAuthors{ + { + Name: "alcaeus", + Email: "alcaeus@alcaeus.org", + }, + { + Name: "Olivier Lechevalier", + Email: "olivier.lechevalier@gmail.com", + }, + }, + Description: "Adapter to provide ext-mongo interface on top of mongo-php-libary", + Keywords: []string{ + "database", + "mongodb", + }, + Time: "2019-11-11T20:47:32+00:00", + }, }, } fixture, err := os.Open("test-fixtures/composer.lock") @@ -33,9 +115,8 @@ func TestParseComposerFileLock(t *testing.T) { if err != nil { t.Fatalf("failed to parse requirements: %+v", err) } - differences := deep.Equal(expected, actual) - if differences != nil { - t.Errorf("returned package list differed from expectation: %+v", differences) - } + for _, d := range deep.Equal(expected, actual) { + t.Errorf("diff: %+v", d) + } } diff --git a/syft/pkg/cataloger/php/parse_installed_json.go b/syft/pkg/cataloger/php/parse_installed_json.go index 178dcfa2bcb..3afa1f2fe89 100644 --- a/syft/pkg/cataloger/php/parse_installed_json.go +++ b/syft/pkg/cataloger/php/parse_installed_json.go @@ -12,19 +12,19 @@ import ( // Note: composer version 2 introduced a new structure for the installed.json file, so we support both type installedJSONComposerV2 struct { - Packages []Dependency `json:"packages"` + Packages []pkg.PhpComposerJSONMetadata `json:"packages"` } func (w *installedJSONComposerV2) UnmarshalJSON(data []byte) error { type compv2 struct { - Packages []Dependency `json:"packages"` + Packages []pkg.PhpComposerJSONMetadata `json:"packages"` } compv2er := new(compv2) err := json.Unmarshal(data, &compv2er) if err != nil { // If we had an err or, we may be dealing with a composer v.1 installed.json // which should be all arrays - var packages []Dependency + var packages []pkg.PhpComposerJSONMetadata err := json.Unmarshal(data, &packages) if err != nil { return err @@ -55,10 +55,12 @@ func parseInstalledJSON(_ string, reader io.Reader) ([]*pkg.Package, []artifact. version := pkgMeta.Version name := pkgMeta.Name packages = append(packages, &pkg.Package{ - Name: name, - Version: version, - Language: pkg.PHP, - Type: pkg.PhpComposerPkg, + Name: name, + Version: version, + Language: pkg.PHP, + Type: pkg.PhpComposerPkg, + MetadataType: pkg.PhpComposerJSONMetadataType, + Metadata: pkgMeta, }) } } diff --git a/syft/pkg/cataloger/php/parse_installed_json_test.go b/syft/pkg/cataloger/php/parse_installed_json_test.go index d553a51f409..9c925afce27 100644 --- a/syft/pkg/cataloger/php/parse_installed_json_test.go +++ b/syft/pkg/cataloger/php/parse_installed_json_test.go @@ -8,21 +8,118 @@ import ( "github.com/go-test/deep" ) -func TestParseInstalledJsonComposerV1(t *testing.T) { - expected := []*pkg.Package{ - { - Name: "asm89/stack-cors", - Version: "1.3.0", - Language: pkg.PHP, - Type: pkg.PhpComposerPkg, +var expectedInstalledJsonPackages = []*pkg.Package{ + { + Name: "asm89/stack-cors", + Version: "1.3.0", + Language: pkg.PHP, + Type: pkg.PhpComposerPkg, + MetadataType: pkg.PhpComposerJSONMetadataType, + Metadata: pkg.PhpComposerJSONMetadata{ + Name: "asm89/stack-cors", + Version: "1.3.0", + Source: pkg.PhpComposerExternalReference{ + Type: "git", + URL: "https://github.com/asm89/stack-cors.git", + Reference: "b9c31def6a83f84b4d4a40d35996d375755f0e08", + }, + Dist: pkg.PhpComposerExternalReference{ + Type: "zip", + URL: "https://api.github.com/repos/asm89/stack-cors/zipball/b9c31def6a83f84b4d4a40d35996d375755f0e08", + Reference: "b9c31def6a83f84b4d4a40d35996d375755f0e08", + }, + Require: map[string]string{ + "php": ">=5.5.9", + "symfony/http-foundation": "~2.7|~3.0|~4.0|~5.0", + "symfony/http-kernel": "~2.7|~3.0|~4.0|~5.0", + }, + RequireDev: map[string]string{ + "phpunit/phpunit": "^5.0 || ^4.8.10", + "squizlabs/php_codesniffer": "^2.3", + }, + Time: "2019-12-24T22:41:47+00:00", + Type: "library", + NotificationURL: "https://packagist.org/downloads/", + License: []string{ + "MIT", + }, + Authors: []pkg.PhpComposerAuthors{ + { + Name: "Alexander", + Email: "iam.asm89@gmail.com", + }, + }, + + Description: "Cross-origin resource sharing library and stack middleware", + Homepage: "https://github.com/asm89/stack-cors", + Keywords: []string{ + "cors", + "stack", + }, }, - { - Name: "behat/mink", - Version: "v1.8.1", - Language: pkg.PHP, - Type: pkg.PhpComposerPkg, + }, + { + Name: "behat/mink", + Version: "v1.8.1", + Language: pkg.PHP, + Type: pkg.PhpComposerPkg, + MetadataType: pkg.PhpComposerJSONMetadataType, + Metadata: pkg.PhpComposerJSONMetadata{ + Name: "behat/mink", + Version: "v1.8.1", + Source: pkg.PhpComposerExternalReference{ + Type: "git", + URL: "https://github.com/minkphp/Mink.git", + Reference: "07c6a9fe3fa98c2de074b25d9ed26c22904e3887", + }, + Dist: pkg.PhpComposerExternalReference{ + Type: "zip", + URL: "https://api.github.com/repos/minkphp/Mink/zipball/07c6a9fe3fa98c2de074b25d9ed26c22904e3887", + Reference: "07c6a9fe3fa98c2de074b25d9ed26c22904e3887", + }, + Require: map[string]string{ + "php": ">=5.3.1", + "symfony/css-selector": "^2.7|^3.0|^4.0|^5.0", + }, + RequireDev: map[string]string{ + "phpunit/phpunit": "^4.8.36 || ^5.7.27 || ^6.5.14 || ^7.5.20", + "symfony/debug": "^2.7|^3.0|^4.0", + "symfony/phpunit-bridge": "^3.4.38 || ^5.0.5", + }, + Suggest: map[string]string{ + "behat/mink-browserkit-driver": "extremely fast headless driver for Symfony\\Kernel-based apps (Sf2, Silex)", + "behat/mink-goutte-driver": "fast headless driver for any app without JS emulation", + "behat/mink-selenium2-driver": "slow, but JS-enabled driver for any app (requires Selenium2)", + "behat/mink-zombie-driver": "fast and JS-enabled headless driver for any app (requires node.js)", + "dmore/chrome-mink-driver": "fast and JS-enabled driver for any app (requires chromium or google chrome)", + }, + Time: "2020-03-11T15:45:53+00:00", + Type: "library", + NotificationURL: "https://packagist.org/downloads/", + License: []string{ + "MIT", + }, + Authors: []pkg.PhpComposerAuthors{ + { + Name: "Konstantin Kudryashov", + Email: "ever.zet@gmail.com", + Homepage: "http://everzet.com", + }, + }, + + Description: "Browser controller/emulator abstraction for PHP", + Homepage: "http://mink.behat.org/", + Keywords: []string{ + "browser", + "testing", + "web", + }, }, - } + }, +} + +func TestParseInstalledJsonComposerV1(t *testing.T) { + fixture, err := os.Open("test-fixtures/vendor/composer_1/installed.json") if err != nil { t.Fatalf("failed to open fixture: %+v", err) @@ -33,28 +130,13 @@ func TestParseInstalledJsonComposerV1(t *testing.T) { if err != nil { t.Fatalf("failed to parse requirements: %+v", err) } - differences := deep.Equal(expected, actual) + differences := deep.Equal(expectedInstalledJsonPackages, actual) if differences != nil { t.Errorf("returned package list differed from expectation: %+v", differences) } - } func TestParseInstalledJsonComposerV2(t *testing.T) { - expected := []*pkg.Package{ - { - Name: "asm89/stack-cors", - Version: "1.3.0", - Language: pkg.PHP, - Type: pkg.PhpComposerPkg, - }, - { - Name: "behat/mink", - Version: "v1.8.1", - Language: pkg.PHP, - Type: pkg.PhpComposerPkg, - }, - } fixture, err := os.Open("test-fixtures/vendor/composer_2/installed.json") if err != nil { t.Fatalf("failed to open fixture: %+v", err) @@ -65,7 +147,7 @@ func TestParseInstalledJsonComposerV2(t *testing.T) { if err != nil { t.Fatalf("failed to parse requirements: %+v", err) } - differences := deep.Equal(expected, actual) + differences := deep.Equal(expectedInstalledJsonPackages, actual) if differences != nil { t.Errorf("returned package list differed from expectation: %+v", differences) } diff --git a/syft/pkg/metadata.go b/syft/pkg/metadata.go index 3786d9fe506..a7021b4e3cb 100644 --- a/syft/pkg/metadata.go +++ b/syft/pkg/metadata.go @@ -5,6 +5,7 @@ type MetadataType string const ( // this is the full set of data shapes that can be represented within the pkg.Package.Metadata field + UnknownMetadataType MetadataType = "UnknownMetadata" ApkMetadataType MetadataType = "ApkMetadata" DpkgMetadataType MetadataType = "DpkgMetadata" @@ -16,6 +17,7 @@ const ( RustCargoPackageMetadataType MetadataType = "RustCargoPackageMetadata" KbPackageMetadataType MetadataType = "KbPackageMetadata" GolangBinMetadataType MetadataType = "GolangBinMetadata" + PhpComposerJSONMetadataType MetadataType = "PhpComposerJsonMetadata" ) var AllMetadataTypes = []MetadataType{ @@ -29,4 +31,5 @@ var AllMetadataTypes = []MetadataType{ RustCargoPackageMetadataType, KbPackageMetadataType, GolangBinMetadataType, + PhpComposerJSONMetadataType, } diff --git a/syft/pkg/php_composer_json_metadata.go b/syft/pkg/php_composer_json_metadata.go new file mode 100644 index 00000000000..3b3e88f4f48 --- /dev/null +++ b/syft/pkg/php_composer_json_metadata.go @@ -0,0 +1,67 @@ +package pkg + +import ( + "strings" + + "github.com/anchore/packageurl-go" +) + +// PhpComposerJSONMetadata represents information found from composer v1/v2 "installed.json" files as well as composer.lock files +type PhpComposerJSONMetadata struct { + Name string `json:"name"` + Version string `json:"version"` + Source PhpComposerExternalReference `json:"source"` + Dist PhpComposerExternalReference `json:"dist"` + Require map[string]string `json:"require,omitempty"` + Provide map[string]string `json:"provide,omitempty"` + RequireDev map[string]string `json:"require-dev,omitempty"` + Suggest map[string]string `json:"suggest,omitempty"` + Type string `json:"type,omitempty"` + NotificationURL string `json:"notification-url,omitempty"` + Bin []string `json:"bin,omitempty"` + License []string `json:"license,omitempty"` + Authors []PhpComposerAuthors `json:"authors,omitempty"` + Description string `json:"description,omitempty"` + Homepage string `json:"homepage,omitempty"` + Keywords []string `json:"keywords,omitempty"` + Time string `json:"time,omitempty"` +} + +type PhpComposerExternalReference struct { + Type string `json:"type"` + URL string `json:"url"` + Reference string `json:"reference"` + Shasum string `json:"shasum,omitempty"` +} + +type PhpComposerAuthors struct { + Name string `json:"name"` + Email string `json:"email,omitempty"` + Homepage string `json:"homepage,omitempty"` +} + +func (m PhpComposerJSONMetadata) PackageURL() string { + var name, vendor string + fields := strings.Split(m.Name, "/") + switch len(fields) { + case 0: + return "" + case 1: + name = m.Name + case 2: + vendor = fields[0] + name = fields[1] + default: + vendor = fields[0] + name = strings.Join(fields[1:], "-") + } + + pURL := packageurl.NewPackageURL( + packageurl.TypeComposer, + vendor, + name, + m.Version, + nil, + "") + return pURL.ToString() +} diff --git a/syft/pkg/php_composer_json_metadata_test.go b/syft/pkg/php_composer_json_metadata_test.go new file mode 100644 index 00000000000..c7a1acf8b1e --- /dev/null +++ b/syft/pkg/php_composer_json_metadata_test.go @@ -0,0 +1,51 @@ +package pkg + +import ( + "github.com/anchore/syft/syft/linux" + "github.com/sergi/go-diff/diffmatchpatch" + "testing" +) + +func TestPhpComposerJsonMetadata_pURL(t *testing.T) { + tests := []struct { + name string + distro *linux.Release + metadata PhpComposerJSONMetadata + expected string + }{ + { + name: "with extractable vendor", + metadata: PhpComposerJSONMetadata{ + Name: "ven/name", + Version: "1.0.1", + }, + expected: "pkg:composer/ven/name@1.0.1", + }, { + name: "name with slashes (invalid)", + metadata: PhpComposerJSONMetadata{ + Name: "ven/name/component", + Version: "1.0.1", + }, + expected: "pkg:composer/ven/name-component@1.0.1", + }, + { + name: "unknown vendor", + metadata: PhpComposerJSONMetadata{ + Name: "name", + Version: "1.0.1", + }, + expected: "pkg:composer/name@1.0.1", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + actual := test.metadata.PackageURL() + if actual != test.expected { + dmp := diffmatchpatch.New() + diffs := dmp.DiffMain(test.expected, actual, true) + t.Errorf("diff: %s", dmp.DiffPrettyText(diffs)) + } + }) + } +}