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

@@ -12,8 +12,8 @@ The following table provides an outline of the features Trivy offers.
| Package manager | File | Transitive dependencies | Dev dependencies | [Dependency graph][dependency-graph] | Position | | Package manager | File | Transitive dependencies | Dev dependencies | [Dependency graph][dependency-graph] | Position |
|-----------------|----------------|:-----------------------:|:----------------:|:------------------------------------:|:--------:| |-----------------|----------------|:-----------------------:|:----------------------------------:|:------------------------------------:|:--------:|
| Composer | composer.lock | ✓ | Excluded | ✓ | ✓ | | Composer | composer.lock | ✓ | [Excluded](#development-dependencies) | ✓ | ✓ |
| Composer | installed.json | ✓ | Excluded | - | ✓ | | Composer | installed.json | ✓ | Excluded | - | ✓ |
## composer.lock ## 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`. 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. 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 ## 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`. 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

@@ -18,6 +18,7 @@ import (
type LockFile struct { type LockFile struct {
Packages []packageInfo `json:"packages"` Packages []packageInfo `json:"packages"`
PackagesDev []packageInfo `json:"packages-dev"`
} }
type packageInfo struct { type packageInfo struct {
Name string `json:"name"` 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) pkgs := make(map[string]ftypes.Package)
foundDeps := make(map[string][]string) 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 // Production packages are parsed first to ensure they take precedence
for depName := range lpkg.Require { // when the same package exists in both "packages" and "packages-dev".
// Require field includes required php version, skip this p.parseProdPackages(lockFile, pkgs, foundDeps)
// Also skip PHP extensions p.parseDevPackages(lockFile, pkgs, foundDeps)
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
}
}
// fill deps versions // fill deps versions
var deps ftypes.Dependencies var deps ftypes.Dependencies
@@ -95,6 +77,50 @@ func (p *Parser) Parse(_ context.Context, r xio.ReadSeekerAt) ([]ftypes.Package,
return pkgSlice, deps, nil 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 // 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 // cf. https://getcomposer.org/doc/04-schema.md#license
func licenses(val any) []string { 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", ID: "psr/http-message@1.0.1",
Name: "psr/http-message", Name: "psr/http-message",
@@ -132,6 +158,12 @@ var (
"ralouphie/getallheaders@3.0.3", "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", ID: "symfony/polyfill-intl-idn@v1.27.0",
DependsOn: []string{ 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 { func (a composerAnalyzer) mergeComposerJson(fsys fs.FS, dir string, app *types.Application) error {
// Parse composer.json to identify the direct dependencies // Parse composer.json to identify the direct dependencies
path := filepath.Join(dir, types.ComposerJson) path := filepath.Join(dir, types.ComposerJson)
p, err := a.parseComposerJson(fsys, path) cj, err := a.parseComposerJson(fsys, path)
if errors.Is(err, fs.ErrNotExist) { if errors.Is(err, fs.ErrNotExist) {
// Assume all the packages are direct dependencies as it cannot identify them from composer.lock // 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)) 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 { for i, pkg := range app.Packages {
// Identify the direct/transitive dependencies // 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 app.Packages[i].Relationship = types.RelationshipDirect
} else { } else {
app.Packages[i].Indirect = true app.Packages[i].Indirect = true
@@ -130,20 +132,21 @@ func (a composerAnalyzer) mergeComposerJson(fsys fs.FS, dir string, app *types.A
type composerJson struct { 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 // Parse composer.json
f, err := fsys.Open(path) f, err := fsys.Open(path)
if err != nil { 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() }() defer func() { _ = f.Close() }()
jsonFile := composerJson{} var jsonFile composerJson
err = json.NewDecoder(f).Decode(&jsonFile) err = json.NewDecoder(f).Decode(&jsonFile)
if err != nil { 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", dir: "testdata/composer/sad",
want: &analyzer.AnalysisResult{}, 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 { 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"
}