From 56b59e8abbb891564bf03608b8150bceaeb60ded Mon Sep 17 00:00:00 2001 From: Teppei Fukuda Date: Tue, 9 Dec 2025 21:40:05 +0900 Subject: [PATCH] feat(php): add support for dev dependencies in Composer (#9910) --- docs/guide/coverage/language/php.md | 14 +- pkg/dependency/parser/php/composer/parse.go | 74 ++++--- .../parser/php/composer/parse_test.go | 32 +++ .../language/php/composer/composer.go | 19 +- .../language/php/composer/composer_test.go | 59 ++++++ .../testdata/composer/with-dev/composer.json | 8 + .../testdata/composer/with-dev/composer.lock | 190 ++++++++++++++++++ 7 files changed, 360 insertions(+), 36 deletions(-) create mode 100644 pkg/fanal/analyzer/language/php/composer/testdata/composer/with-dev/composer.json create mode 100644 pkg/fanal/analyzer/language/php/composer/testdata/composer/with-dev/composer.lock diff --git a/docs/guide/coverage/language/php.md b/docs/guide/coverage/language/php.md index 9fe38bf499..d2c8dbd78e 100644 --- a/docs/guide/coverage/language/php.md +++ b/docs/guide/coverage/language/php.md @@ -11,10 +11,10 @@ The following scanners are supported. The following table provides an outline of the features Trivy offers. -| Package manager | File | Transitive dependencies | Dev dependencies | [Dependency graph][dependency-graph] | Position | -|-----------------|----------------|:-----------------------:|:----------------:|:------------------------------------:|:--------:| -| Composer | composer.lock | ✓ | Excluded | ✓ | ✓ | -| Composer | installed.json | ✓ | Excluded | - | ✓ | +| Package manager | File | Transitive dependencies | Dev dependencies | [Dependency graph][dependency-graph] | Position | +|-----------------|----------------|:-----------------------:|:----------------------------------:|:------------------------------------:|:--------:| +| Composer | composer.lock | ✓ | [Excluded](#development-dependencies) | ✓ | ✓ | +| Composer | installed.json | ✓ | Excluded | - | ✓ | ## composer.lock In order to detect dependencies, Trivy searches for `composer.lock`. @@ -23,6 +23,12 @@ Trivy also supports dependency trees; however, to display an accurate tree, it n Since this information is not included in `composer.lock`, Trivy parses `composer.json`, which should be located next to `composer.lock`. If you want to see the dependency tree, please ensure that `composer.json` is present. +### Development dependencies +By default, Trivy doesn't report development dependencies (`packages-dev` in `composer.lock`). +Use the `--include-dev-deps` flag to include them. + +To correctly identify direct development dependencies, Trivy parses `require-dev` from `composer.json`, which should be located next to `composer.lock`. + ## installed.json Trivy also supports dependency detection for `installed.json` files. By default, you can find this file at `path_to_app/vendor/composer/installed.json`. diff --git a/pkg/dependency/parser/php/composer/parse.go b/pkg/dependency/parser/php/composer/parse.go index 9cec071525..b74f0d5285 100644 --- a/pkg/dependency/parser/php/composer/parse.go +++ b/pkg/dependency/parser/php/composer/parse.go @@ -17,7 +17,8 @@ import ( ) type LockFile struct { - Packages []packageInfo `json:"packages"` + Packages []packageInfo `json:"packages"` + PackagesDev []packageInfo `json:"packages-dev"` } type packageInfo struct { Name string `json:"name"` @@ -45,30 +46,11 @@ func (p *Parser) Parse(_ context.Context, r xio.ReadSeekerAt) ([]ftypes.Package, pkgs := make(map[string]ftypes.Package) foundDeps := make(map[string][]string) - for _, lpkg := range lockFile.Packages { - pkg := ftypes.Package{ - ID: dependency.ID(ftypes.Composer, lpkg.Name, lpkg.Version), - Name: lpkg.Name, - Version: lpkg.Version, - Relationship: ftypes.RelationshipUnknown, // composer.lock file doesn't have info about direct/indirect dependencies - Licenses: licenses(lpkg.License), - Locations: []ftypes.Location{ftypes.Location(lpkg.Location)}, - } - pkgs[pkg.Name] = pkg - var dependsOn []string - for depName := range lpkg.Require { - // Require field includes required php version, skip this - // Also skip PHP extensions - if depName == "php" || strings.HasPrefix(depName, "ext") { - continue - } - dependsOn = append(dependsOn, depName) // field uses range of versions, so later we will fill in the versions from the packages - } - if len(dependsOn) > 0 { - foundDeps[pkg.ID] = dependsOn - } - } + // Production packages are parsed first to ensure they take precedence + // when the same package exists in both "packages" and "packages-dev". + p.parseProdPackages(lockFile, pkgs, foundDeps) + p.parseDevPackages(lockFile, pkgs, foundDeps) // fill deps versions var deps ftypes.Dependencies @@ -95,6 +77,50 @@ func (p *Parser) Parse(_ context.Context, r xio.ReadSeekerAt) ([]ftypes.Package, return pkgSlice, deps, nil } +// parseProdPackages parses packages from the "packages" field in composer.lock. +func (p *Parser) parseProdPackages(lockFile LockFile, pkgs map[string]ftypes.Package, foundDeps map[string][]string) { + p.parsePackages(lockFile.Packages, false, pkgs, foundDeps) +} + +// parseDevPackages parses packages from the "packages-dev" field in composer.lock. +// Packages already present in pkgs (i.e., production packages) are skipped. +func (p *Parser) parseDevPackages(lockFile LockFile, pkgs map[string]ftypes.Package, foundDeps map[string][]string) { + p.parsePackages(lockFile.PackagesDev, true, pkgs, foundDeps) +} + +func (p *Parser) parsePackages(lockPkgs []packageInfo, isDev bool, pkgs map[string]ftypes.Package, foundDeps map[string][]string) { + for _, lpkg := range lockPkgs { + // Skip if the package already exists (production packages take precedence over dev packages) + if _, ok := pkgs[lpkg.Name]; ok { + continue + } + + pkg := ftypes.Package{ + ID: dependency.ID(ftypes.Composer, lpkg.Name, lpkg.Version), + Name: lpkg.Name, + Version: lpkg.Version, + Relationship: ftypes.RelationshipUnknown, // composer.lock file doesn't have info about direct/indirect dependencies + Licenses: licenses(lpkg.License), + Locations: []ftypes.Location{ftypes.Location(lpkg.Location)}, + Dev: isDev, + } + pkgs[pkg.Name] = pkg + + var dependsOn []string + for depName := range lpkg.Require { + // Require field includes required php version, skip this + // Also skip PHP extensions + if depName == "php" || strings.HasPrefix(depName, "ext") { + continue + } + dependsOn = append(dependsOn, depName) // field uses range of versions, so later we will fill in the versions from the packages + } + if len(dependsOn) > 0 { + foundDeps[pkg.ID] = dependsOn + } + } +} + // licenses returns slice of licenses from string, string with separators (`or`, `and`, etc.) or string array // cf. https://getcomposer.org/doc/04-schema.md#license func licenses(val any) []string { diff --git a/pkg/dependency/parser/php/composer/parse_test.go b/pkg/dependency/parser/php/composer/parse_test.go index 4ecf371d81..5222cc1d5f 100644 --- a/pkg/dependency/parser/php/composer/parse_test.go +++ b/pkg/dependency/parser/php/composer/parse_test.go @@ -54,6 +54,32 @@ var ( }, }, }, + { + ID: "pear/log@1.13.3", + Name: "pear/log", + Version: "1.13.3", + Dev: true, + Licenses: []string{"MIT"}, + Locations: []ftypes.Location{ + { + StartLine: 660, + EndLine: 719, + }, + }, + }, + { + ID: "pear/pear_exception@v1.0.2", + Name: "pear/pear_exception", + Version: "v1.0.2", + Dev: true, + Licenses: []string{"BSD-2-Clause"}, + Locations: []ftypes.Location{ + { + StartLine: 720, + EndLine: 778, + }, + }, + }, { ID: "psr/http-message@1.0.1", Name: "psr/http-message", @@ -132,6 +158,12 @@ var ( "ralouphie/getallheaders@3.0.3", }, }, + { + ID: "pear/log@1.13.3", + DependsOn: []string{ + "pear/pear_exception@v1.0.2", + }, + }, { ID: "symfony/polyfill-intl-idn@v1.27.0", DependsOn: []string{ diff --git a/pkg/fanal/analyzer/language/php/composer/composer.go b/pkg/fanal/analyzer/language/php/composer/composer.go index d5cd37f3bf..8eada9dfe7 100644 --- a/pkg/fanal/analyzer/language/php/composer/composer.go +++ b/pkg/fanal/analyzer/language/php/composer/composer.go @@ -106,7 +106,7 @@ func (a composerAnalyzer) parseComposerLock(ctx context.Context, path string, r func (a composerAnalyzer) mergeComposerJson(fsys fs.FS, dir string, app *types.Application) error { // Parse composer.json to identify the direct dependencies path := filepath.Join(dir, types.ComposerJson) - p, err := a.parseComposerJson(fsys, path) + cj, err := a.parseComposerJson(fsys, path) if errors.Is(err, fs.ErrNotExist) { // Assume all the packages are direct dependencies as it cannot identify them from composer.lock log.Debug("Unable to determine the direct dependencies, composer.json not found", log.FilePath(path)) @@ -117,7 +117,9 @@ func (a composerAnalyzer) mergeComposerJson(fsys fs.FS, dir string, app *types.A for i, pkg := range app.Packages { // Identify the direct/transitive dependencies - if _, ok := p[pkg.Name]; ok { + if _, ok := cj.Require[pkg.Name]; ok { + app.Packages[i].Relationship = types.RelationshipDirect + } else if _, ok := cj.RequireDev[pkg.Name]; ok { app.Packages[i].Relationship = types.RelationshipDirect } else { app.Packages[i].Indirect = true @@ -129,21 +131,22 @@ func (a composerAnalyzer) mergeComposerJson(fsys fs.FS, dir string, app *types.A } type composerJson struct { - Require map[string]string `json:"require"` + Require map[string]string `json:"require"` + RequireDev map[string]string `json:"require-dev"` } -func (a composerAnalyzer) parseComposerJson(fsys fs.FS, path string) (map[string]string, error) { +func (a composerAnalyzer) parseComposerJson(fsys fs.FS, path string) (composerJson, error) { // Parse composer.json f, err := fsys.Open(path) if err != nil { - return nil, xerrors.Errorf("file open error: %w", err) + return composerJson{}, xerrors.Errorf("file open error: %w", err) } defer func() { _ = f.Close() }() - jsonFile := composerJson{} + var jsonFile composerJson err = json.NewDecoder(f).Decode(&jsonFile) if err != nil { - return nil, xerrors.Errorf("json decode error: %w", err) + return composerJson{}, xerrors.Errorf("json decode error: %w", err) } - return jsonFile.Require, nil + return jsonFile, nil } diff --git a/pkg/fanal/analyzer/language/php/composer/composer_test.go b/pkg/fanal/analyzer/language/php/composer/composer_test.go index 0407d5068a..68fe5906df 100644 --- a/pkg/fanal/analyzer/language/php/composer/composer_test.go +++ b/pkg/fanal/analyzer/language/php/composer/composer_test.go @@ -151,6 +151,65 @@ func Test_composerAnalyzer_PostAnalyze(t *testing.T) { dir: "testdata/composer/sad", want: &analyzer.AnalysisResult{}, }, + { + name: "with dev dependencies", + dir: "testdata/composer/with-dev", + want: &analyzer.AnalysisResult{ + Applications: []types.Application{ + { + Type: types.Composer, + FilePath: "composer.lock", + Packages: types.Packages{ + { + ID: "pear/log@1.14.6", + Name: "pear/log", + Version: "1.14.6", + Dev: true, + Indirect: false, + Relationship: types.RelationshipDirect, + Licenses: []string{"MIT"}, + Locations: []types.Location{ + { + StartLine: 61, + EndLine: 121, + }, + }, + DependsOn: []string{"pear/pear_exception@v1.0.2"}, + }, + { + ID: "psr/log@1.1.4", + Name: "psr/log", + Version: "1.1.4", + Indirect: false, + Relationship: types.RelationshipDirect, + Licenses: []string{"MIT"}, + Locations: []types.Location{ + { + StartLine: 9, + EndLine: 58, + }, + }, + }, + { + ID: "pear/pear_exception@v1.0.2", + Name: "pear/pear_exception", + Version: "v1.0.2", + Dev: true, + Indirect: true, + Relationship: types.RelationshipIndirect, + Licenses: []string{"BSD-2-Clause"}, + Locations: []types.Location{ + { + StartLine: 122, + EndLine: 180, + }, + }, + }, + }, + }, + }, + }, + }, } for _, tt := range tests { diff --git a/pkg/fanal/analyzer/language/php/composer/testdata/composer/with-dev/composer.json b/pkg/fanal/analyzer/language/php/composer/testdata/composer/with-dev/composer.json new file mode 100644 index 0000000000..dfd478b82a --- /dev/null +++ b/pkg/fanal/analyzer/language/php/composer/testdata/composer/with-dev/composer.json @@ -0,0 +1,8 @@ +{ + "require": { + "psr/log": "^1.0" + }, + "require-dev": { + "pear/log": "^1.13" + } +} diff --git a/pkg/fanal/analyzer/language/php/composer/testdata/composer/with-dev/composer.lock b/pkg/fanal/analyzer/language/php/composer/testdata/composer/with-dev/composer.lock new file mode 100644 index 0000000000..9101629c47 --- /dev/null +++ b/pkg/fanal/analyzer/language/php/composer/testdata/composer/with-dev/composer.lock @@ -0,0 +1,190 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", + "This file is @generated automatically" + ], + "content-hash": "2c9e13a2460669ca09226814c0aefb51", + "packages": [ + { + "name": "psr/log", + "version": "1.1.4", + "source": { + "type": "git", + "url": "https://github.com/php-fig/log.git", + "reference": "d49695b909c3b7628b6289db5479a1c204601f11" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/log/zipball/d49695b909c3b7628b6289db5479a1c204601f11", + "reference": "d49695b909c3b7628b6289db5479a1c204601f11", + "shasum": "" + }, + "require": { + "php": ">=5.3.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Log\\": "Psr/Log/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for logging libraries", + "homepage": "https://github.com/php-fig/log", + "keywords": [ + "log", + "psr", + "psr-3" + ], + "support": { + "source": "https://github.com/php-fig/log/tree/1.1.4" + }, + "time": "2021-05-03T11:20:27+00:00" + } + ], + "packages-dev": [ + { + "name": "pear/log", + "version": "1.14.6", + "source": { + "type": "git", + "url": "https://github.com/pear/Log.git", + "reference": "e136d31ff6d5991e9707862f5fbfb97d40cd37a3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/pear/Log/zipball/e136d31ff6d5991e9707862f5fbfb97d40cd37a3", + "reference": "e136d31ff6d5991e9707862f5fbfb97d40cd37a3", + "shasum": "" + }, + "require": { + "pear/pear_exception": "1.0.1 || 1.0.2", + "php": ">=7.4" + }, + "require-dev": { + "phpunit/phpunit": "*", + "rector/rector": "*" + }, + "suggest": { + "pear/db": "Install optionally via your project's composer.json" + }, + "type": "library", + "autoload": { + "psr-0": { + "Log": "./" + }, + "exclude-from-classmap": [ + "/examples/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "include-path": [ + "" + ], + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jon Parise", + "email": "jon@php.net", + "homepage": "https://www.indelible.org/", + "role": "Developer" + } + ], + "description": "PEAR Logging Framework", + "homepage": "https://pear.github.io/Log/", + "keywords": [ + "log", + "logging" + ], + "support": { + "issues": "https://github.com/pear/Log/issues", + "source": "https://github.com/pear/Log" + }, + "time": "2025-07-27T00:25:20+00:00" + }, + { + "name": "pear/pear_exception", + "version": "v1.0.2", + "source": { + "type": "git", + "url": "https://github.com/pear/PEAR_Exception.git", + "reference": "b14fbe2ddb0b9f94f5b24cf08783d599f776fff0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/pear/PEAR_Exception/zipball/b14fbe2ddb0b9f94f5b24cf08783d599f776fff0", + "reference": "b14fbe2ddb0b9f94f5b24cf08783d599f776fff0", + "shasum": "" + }, + "require": { + "php": ">=5.2.0" + }, + "require-dev": { + "phpunit/phpunit": "<9" + }, + "type": "class", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "classmap": [ + "PEAR/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "include-path": [ + "." + ], + "license": [ + "BSD-2-Clause" + ], + "authors": [ + { + "name": "Helgi Thormar", + "email": "dufuz@php.net" + }, + { + "name": "Greg Beaver", + "email": "cellog@php.net" + } + ], + "description": "The PEAR Exception base class.", + "homepage": "https://github.com/pear/PEAR_Exception", + "keywords": [ + "exception" + ], + "support": { + "issues": "http://pear.php.net/bugs/search.php?cmd=display&package_name[]=PEAR_Exception", + "source": "https://github.com/pear/PEAR_Exception" + }, + "time": "2021-03-21T15:43:46+00:00" + } + ], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": {}, + "prefer-stable": false, + "prefer-lowest": false, + "platform": {}, + "platform-dev": {}, + "plugin-api-version": "2.9.0" +}