Skip to content

Commit 5c95d7c

Browse files
iseki0HundredBai
andcommitted
feat: SCA-293 support composer installed.json package detection
commit 6b6281c52561b343df0d7a5eb51f9b038ac38067 Author: hundredbai <yubaichao2008@126.com> Date: Fri Mar 6 16:00:27 2026 +0800 SCA-293 feat: support composer installed.json package detection Co-authored-by: hundredbai <yubaichao2008@126.com>
1 parent 14cb901 commit 5c95d7c

File tree

3 files changed

+162
-7
lines changed

3 files changed

+162
-7
lines changed

module/composer/composer.go

Lines changed: 55 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"go.uber.org/zap"
99
"io/fs"
1010
"path/filepath"
11+
"sort"
1112
"strings"
1213
)
1314

@@ -25,27 +26,48 @@ func (Inspector) String() string {
2526
}
2627

2728
func (Inspector) CheckDir(ctx context.Context, dir string) bool {
28-
return utils.IsFile(filepath.Join(dir, "composer.json"))
29+
return utils.IsFile(filepath.Join(dir, "composer.json")) ||
30+
utils.IsFile(filepath.Join(dir, "composer.lock")) ||
31+
utils.IsFile(filepath.Join(dir, "installed.json")) ||
32+
utils.IsFile(filepath.Join(dir, "vendor", "composer", "installed.json"))
2933
}
3034

3135
func (Inspector) InspectProject(ctx context.Context) error {
3236
logger := logctx.Use(ctx)
3337
task := model.UseInspectionTask(ctx)
3438
dir := task.Dir()
35-
manifest, e := readManifest(ctx, filepath.Join(dir, "composer.json"))
36-
if e != nil {
37-
return e
39+
manifestPath := filepath.Join(dir, "composer.json")
40+
modulePath := manifestPath
41+
manifest := &Manifest{}
42+
var e error
43+
if utils.IsFile(manifestPath) {
44+
manifest, e = readManifest(ctx, manifestPath)
45+
if e != nil {
46+
return e
47+
}
48+
} else {
49+
logger.Sugar().Infof("composer.json not found, fallback to installed/lock files. dir=%s", dir)
50+
modulePath = filepath.Join(dir, "installed.json")
51+
if !utils.IsFile(modulePath) {
52+
modulePath = filepath.Join(dir, "vendor", "composer", "installed.json")
53+
if !utils.IsFile(modulePath) {
54+
modulePath = filepath.Join(dir, "composer.lock")
55+
}
56+
}
3857
}
3958
module := &model.Module{
4059
PackageManager: "composer",
4160
ModuleName: manifest.Name,
4261
ModuleVersion: manifest.Version,
43-
ModulePath: filepath.Join(dir, "composer.json"),
62+
ModulePath: modulePath,
63+
}
64+
if module.ModuleName == "" {
65+
module.ModuleName = filepath.Base(dir)
4466
}
4567
lockfilePkgs := map[string]Package{}
4668

4769
{
48-
if !utils.IsPathExist(filepath.Join(dir, "composer.lock")) {
70+
if utils.IsFile(manifestPath) && !utils.IsPathExist(filepath.Join(dir, "composer.lock")) {
4971
logger.Info("composer.lock doesn't exists. Try to generate it")
5072
if e := doComposerInstall(context.TODO(), dir); e != nil {
5173
logger.Sugar().Warnf("Do composer install fail. %s", e.Error())
@@ -59,6 +81,18 @@ func (Inspector) InspectProject(ctx context.Context) error {
5981
if e != nil {
6082
logger.Sugar().Infof("Read composer lock file failed: %s", e.Error())
6183
}
84+
installedPaths := []string{
85+
filepath.Join(dir, "installed.json"),
86+
filepath.Join(dir, "vendor", "composer", "installed.json"),
87+
}
88+
for _, installedPath := range installedPaths {
89+
installedPkgs, ie := readComposerInstalledFile(installedPath)
90+
if ie != nil {
91+
logger.Sugar().Debugf("Read installed.json failed: %s", ie.Error())
92+
continue
93+
}
94+
pkgs = append(pkgs, installedPkgs...)
95+
}
6296
pkgs = append(pkgs, vendorScan(ctx, filepath.Join(dir, "vendor"))...)
6397
for _, it := range pkgs {
6498
if it.Version == "" || isVersionConstrain(it.Version) {
@@ -68,8 +102,22 @@ func (Inspector) InspectProject(ctx context.Context) error {
68102
}
69103
}
70104

105+
roots := map[string]string{}
71106
for _, requiredPkg := range manifest.Require {
72-
node := _buildDepTree(lockfilePkgs, map[string]struct{}{}, requiredPkg.Name, requiredPkg.Version)
107+
roots[requiredPkg.Name] = requiredPkg.Version
108+
}
109+
if len(roots) == 0 {
110+
for name := range lockfilePkgs {
111+
roots[name] = ""
112+
}
113+
}
114+
var rootNames []string
115+
for name := range roots {
116+
rootNames = append(rootNames, name)
117+
}
118+
sort.Strings(rootNames)
119+
for _, name := range rootNames {
120+
node := _buildDepTree(lockfilePkgs, map[string]struct{}{}, name, roots[name])
73121
if node != nil {
74122
module.Dependencies = append(module.Dependencies, *node)
75123
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
package composer
2+
3+
import (
4+
"testing"
5+
6+
"github.com/stretchr/testify/assert"
7+
"github.com/stretchr/testify/require"
8+
)
9+
10+
func TestParseComposerInstalled_ObjectSchema(t *testing.T) {
11+
data := []byte(`{
12+
"packages": [
13+
{
14+
"name": "symfony/polyfill-mbstring",
15+
"version": "v1.29.0",
16+
"require": {
17+
"php": ">=7.1"
18+
}
19+
},
20+
{
21+
"name": "guzzlehttp/guzzle",
22+
"version": "7.8.1",
23+
"require": {
24+
"php": "^7.2.5 || ^8.0",
25+
"psr/http-client": "^1.0"
26+
}
27+
}
28+
],
29+
"dev": false
30+
}`)
31+
32+
pkgs, err := parseComposerInstalled(data)
33+
require.NoError(t, err)
34+
require.Len(t, pkgs, 2)
35+
assert.Equal(t, "symfony/polyfill-mbstring", pkgs[0].Name)
36+
assert.Equal(t, "v1.29.0", pkgs[0].Version)
37+
assert.Contains(t, pkgs[0].Require, "php")
38+
assert.Equal(t, "guzzlehttp/guzzle", pkgs[1].Name)
39+
}
40+
41+
func TestParseComposerInstalled_ArraySchema(t *testing.T) {
42+
data := []byte(`[
43+
{
44+
"name": "monolog/monolog",
45+
"version": "3.5.0",
46+
"require": {
47+
"php": ">=8.1",
48+
"psr/log": "^2.0 || ^3.0"
49+
}
50+
}
51+
]`)
52+
53+
pkgs, err := parseComposerInstalled(data)
54+
require.NoError(t, err)
55+
require.Len(t, pkgs, 1)
56+
assert.Equal(t, "monolog/monolog", pkgs[0].Name)
57+
assert.Equal(t, "3.5.0", pkgs[0].Version)
58+
assert.Contains(t, pkgs[0].Require, "php")
59+
assert.Contains(t, pkgs[0].Require, "psr/log")
60+
}

module/composer/composer_lockfile.go

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,18 @@ func readComposerLockFile(path string) ([]Package, error) {
2222
return pkgs, nil
2323
}
2424

25+
func readComposerInstalledFile(path string) ([]Package, error) {
26+
lockFileData, e := utils.ReadFileLimited(path, _ComposerLockFileSizeLimit)
27+
if e != nil {
28+
return nil, errors.Wrap(e, "Read installed.json failed")
29+
}
30+
pkgs, e := parseComposerInstalled(lockFileData)
31+
if e != nil {
32+
return nil, errors.Wrap(e, "Parse installed.json failed")
33+
}
34+
return pkgs, nil
35+
}
36+
2537
func parseComposerLock(data []byte) ([]Package, error) {
2638
var j simplejson.JSON
2739
if e := json.Unmarshal(data, &j); e != nil {
@@ -43,6 +55,41 @@ func parseComposerLock(data []byte) ([]Package, error) {
4355
return pkgList, nil
4456
}
4557

58+
func parseComposerInstalled(data []byte) ([]Package, error) {
59+
var j simplejson.JSON
60+
if e := json.Unmarshal(data, &j); e != nil {
61+
return nil, errors.Wrap(e, "ParseComposerInstalled")
62+
}
63+
64+
// Composer installed.json has two common schemas:
65+
// 1) {"packages":[...], ...}
66+
// 2) [{...}, {...}]
67+
var packageNodes []*simplejson.JSON
68+
if arr := j.Get("packages").JSONArray(); len(arr) > 0 {
69+
packageNodes = arr
70+
} else if arr := j.JSONArray(); len(arr) > 0 {
71+
packageNodes = arr
72+
}
73+
74+
pkgList := make([]Package, 0, len(packageNodes))
75+
for _, pkg := range packageNodes {
76+
if pkg == nil {
77+
continue
78+
}
79+
p := Package{}
80+
p.Name = pkg.Get("name").String()
81+
p.Version = pkg.Get("version").String()
82+
if p.Name == "" || p.Version == "" {
83+
continue
84+
}
85+
for s := range pkg.Get("require").JSONMap() {
86+
p.Require = append(p.Require, s)
87+
}
88+
pkgList = append(pkgList, p)
89+
}
90+
return pkgList, nil
91+
}
92+
4693
func readManifest(ctx context.Context, path string) (*Manifest, error) {
4794
logger := logctx.Use(ctx)
4895
logger.Debug("readManifest", zap.String("path", path))

0 commit comments

Comments
 (0)