feat(php): add support for dev dependencies in Composer (#9910)

This commit is contained in:
Teppei Fukuda
2025-12-09 21:40:05 +09:00
committed by GitHub
parent f58826fb2a
commit 56b59e8abb
7 changed files with 360 additions and 36 deletions

View File

@@ -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`.

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,8 @@
{
"require": {
"psr/log": "^1.0"
},
"require-dev": {
"pear/log": "^1.13"
}
}

View File

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