feat(nodejs): add bun.lock parser (#8851)

Signed-off-by: Ashwani Kumar Kamal (sneaky-potato) <ashwanikamal.im421@gmail.com>
Co-authored-by: DmitriyLewen <dmitriy.lewen@smartforce.io>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Ashwani Kumar Kamal
2025-05-20 19:30:47 +05:30
committed by GitHub
parent c321fdfcdd
commit 1dcf81666f
7 changed files with 458 additions and 0 deletions

View File

@@ -0,0 +1,176 @@
package bun
import (
"fmt"
"io"
"sort"
"strings"
"github.com/go-json-experiment/json"
"github.com/go-json-experiment/json/jsontext"
"github.com/samber/lo"
"golang.org/x/xerrors"
"github.com/aquasecurity/trivy/pkg/dependency"
ftypes "github.com/aquasecurity/trivy/pkg/fanal/types"
"github.com/aquasecurity/trivy/pkg/set"
xio "github.com/aquasecurity/trivy/pkg/x/io"
xjson "github.com/aquasecurity/trivy/pkg/x/json"
)
type LockFile struct {
Packages map[string]ParsedPackage `json:"packages"`
Workspaces map[string]Workspace `json:"workspaces"`
LockfileVersion int `json:"lockfileVersion"`
}
type Workspace struct {
Name string `json:"name"`
Version string `json:"version"`
Dependencies map[string]string `json:"dependencies"`
DevDependencies map[string]string `json:"devDependencies"`
OptionalDependencies map[string]string `json:"optionalDependencies"`
PeerDependencies map[string]string `json:"peerDependencies"`
}
type ParsedPackage struct {
Identifier string
Meta map[string]any
xjson.Location
}
type Parser struct{}
func NewParser() *Parser {
return &Parser{}
}
func (p *ParsedPackage) UnmarshalJSON(data []byte) error {
var raw []jsontext.Value
if err := json.Unmarshal(data, &raw); err != nil {
return fmt.Errorf("expected package format: %w", err)
}
if len(raw) < 1 {
return fmt.Errorf("invalid package entry: not enough elements: %s", string(data))
}
if err := json.Unmarshal(raw[0], &p.Identifier); err != nil {
return err
}
if len(raw) > 2 {
if err := json.Unmarshal(raw[2], &p.Meta); err != nil {
return err
}
}
return nil
}
func (p *Parser) Parse(r xio.ReadSeekerAt) ([]ftypes.Package, []ftypes.Dependency, error) {
var lockFile LockFile
data, err := io.ReadAll(r)
if err != nil {
return nil, nil, xerrors.Errorf("file read error: %w", err)
}
if err = xjson.UnmarshalJSONC(data, &lockFile); err != nil {
return nil, nil, xerrors.Errorf("JSON decode error: %w", err)
}
pkgs := make(map[string]ftypes.Package, len(lockFile.Packages))
deps := make(map[string][]string)
prodDirectDeps := set.New[string]()
devDirectDeps := set.New[string]()
for _, ws := range lockFile.Workspaces {
prodDirectDeps.Append(lo.Keys(ws.Dependencies)...)
prodDirectDeps.Append(lo.Keys(ws.PeerDependencies)...)
prodDirectDeps.Append(lo.Keys(ws.OptionalDependencies)...)
devDirectDeps.Append(lo.Keys(ws.DevDependencies)...)
}
for pkgName, parsed := range lockFile.Packages {
pkgVersion := strings.TrimPrefix(parsed.Identifier, pkgName+"@")
if strings.HasPrefix(pkgVersion, "workspace") {
pkgVersion = lockFile.Workspaces[pkgName].Version
}
pkgId := packageID(pkgName, pkgVersion)
isDirect := prodDirectDeps.Contains(pkgName) || devDirectDeps.Contains(pkgName)
relationship := ftypes.RelationshipIndirect
if _, ok := lockFile.Workspaces[pkgName]; ok {
relationship = ftypes.RelationshipWorkspace
} else if isDirect {
relationship = ftypes.RelationshipDirect
}
newPkg := ftypes.Package{
ID: pkgId,
Name: pkgName,
Version: pkgVersion,
Relationship: relationship,
Dev: true, // Mark all dependencies as Dev. We will handle them later.
Locations: []ftypes.Location{ftypes.Location(parsed.Location)},
}
pkgs[pkgName] = newPkg
var dependsOn []string
if depMap, ok := parsed.Meta["dependencies"].(map[string]any); ok {
dependsOn = lo.Keys(depMap)
}
if len(dependsOn) > 0 {
sort.Strings(dependsOn)
deps[pkgName] = dependsOn
}
}
for _, pkg := range pkgs {
// Workspaces are always prod deps.
if pkg.Relationship == ftypes.RelationshipWorkspace {
pkg.Dev = false
pkgs[pkg.Name] = pkg
continue
}
if pkg.Relationship != ftypes.RelationshipDirect || !prodDirectDeps.Contains(pkg.Name) {
continue
}
walkProdPackages(pkg.Name, pkgs, deps, set.New[string]())
}
depSlice := lo.MapToSlice(deps, func(depName string, dependsOn []string) ftypes.Dependency {
id := pkgs[depName]
dependsOnIDs := make([]string, 0, len(dependsOn))
for _, d := range dependsOn {
dependsOnIDs = append(dependsOnIDs, pkgs[d].ID)
}
return ftypes.Dependency{
ID: id.ID,
DependsOn: dependsOnIDs,
}
})
pkgSlice := lo.Values(pkgs)
sort.Sort(ftypes.Packages(pkgSlice))
sort.Sort(ftypes.Dependencies(depSlice))
return pkgSlice, depSlice, nil
}
// walkProdPackages marks all packages in the dependency tree of the given package as prod packages (Dev == false).
func walkProdPackages(pkgName string, pkgs map[string]ftypes.Package, deps map[string][]string, visited set.Set[string]) {
if visited.Contains(pkgName) {
return
}
// Disable Dev field for prod pkgs.
pkg := pkgs[pkgName]
pkg.Dev = false
pkgs[pkgName] = pkg
visited.Append(pkgName)
for _, dep := range deps[pkgName] {
walkProdPackages(dep, pkgs, deps, visited)
}
}
func packageID(name, version string) string {
return dependency.ID(ftypes.Bun, name, version)
}

View File

@@ -0,0 +1,57 @@
package bun
import (
"os"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
ftypes "github.com/aquasecurity/trivy/pkg/fanal/types"
)
func TestParse(t *testing.T) {
tests := []struct {
name string
file string // Test input file
want []ftypes.Package
wantDeps []ftypes.Dependency
wantErr string
}{
{
name: "normal",
file: "testdata/bun_happy.lock",
want: normalPkgs,
wantDeps: normalDeps,
},
{
name: "invalid lockfile",
file: "testdata/bun_invalid.lock",
wantErr: "JSON decode error",
},
{
name: "multiple workspaces",
file: "testdata/bun_multiple_ws.lock",
want: multipleWsPkgs,
wantDeps: multipleWsDeps,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
f, err := os.Open(tt.file)
require.NoError(t, err)
defer f.Close()
got, deps, err := NewParser().Parse(f)
if tt.wantErr != "" {
assert.ErrorContains(t, err, tt.wantErr)
return
}
require.NoError(t, err)
assert.Equal(t, tt.want, got)
if tt.wantDeps != nil {
assert.Equal(t, tt.wantDeps, deps)
}
})
}
}

View File

@@ -0,0 +1,160 @@
package bun
import (
ftypes "github.com/aquasecurity/trivy/pkg/fanal/types"
)
var (
normalPkgs = []ftypes.Package{
{
ID: "@types/bun@1.2.13",
Name: "@types/bun",
Version: "1.2.13",
Relationship: ftypes.RelationshipDirect,
Dev: true,
Locations: ftypes.Locations{
{
StartLine: 18,
EndLine: 18,
},
},
},
{
ID: "typescript@5.8.3",
Name: "typescript",
Version: "5.8.3",
Relationship: ftypes.RelationshipDirect,
Dev: false,
Locations: ftypes.Locations{
{
StartLine: 24,
EndLine: 24,
},
},
},
{
ID: "zod@3.24.4",
Name: "zod",
Version: "3.24.4",
Relationship: ftypes.RelationshipDirect,
Dev: false,
Locations: ftypes.Locations{
{
StartLine: 28,
EndLine: 28,
},
},
},
{
ID: "@types/node@22.15.18",
Name: "@types/node",
Version: "22.15.18",
Relationship: ftypes.RelationshipIndirect,
Dev: true,
Locations: ftypes.Locations{
{
StartLine: 20,
EndLine: 20,
},
},
},
{
ID: "bun-types@1.2.13",
Name: "bun-types",
Version: "1.2.13",
Relationship: ftypes.RelationshipIndirect,
Dev: true,
Locations: ftypes.Locations{
{
StartLine: 22,
EndLine: 22,
},
},
},
{
ID: "undici-types@6.21.0",
Name: "undici-types",
Version: "6.21.0",
Relationship: ftypes.RelationshipIndirect,
Dev: false,
Locations: ftypes.Locations{
{
StartLine: 26,
EndLine: 26,
},
},
},
}
multipleWsPkgs = []ftypes.Package{
{
ID: "my-app@1.0.0",
Name: "my-app",
Version: "1.0.0",
Relationship: ftypes.RelationshipWorkspace,
Locations: ftypes.Locations{
{
StartLine: 27,
EndLine: 27,
},
},
},
{
ID: "my-lib@1.0.0",
Name: "my-lib",
Version: "1.0.0",
Relationship: ftypes.RelationshipWorkspace,
Locations: ftypes.Locations{
{
StartLine: 29,
EndLine: 29,
},
},
},
{
ID: "chalk@5.0.1",
Name: "chalk",
Version: "5.0.1",
Relationship: ftypes.RelationshipDirect,
Locations: ftypes.Locations{
{
StartLine: 23,
EndLine: 23,
},
},
},
{
ID: "lodash@4.17.21",
Name: "lodash",
Version: "4.17.21",
Relationship: ftypes.RelationshipDirect,
Locations: ftypes.Locations{
{
StartLine: 25,
EndLine: 25,
},
},
},
}
normalDeps = []ftypes.Dependency{
{
ID: "@types/bun@1.2.13",
DependsOn: []string{"bun-types@1.2.13"},
},
{
ID: "@types/node@22.15.18",
DependsOn: []string{"undici-types@6.21.0"},
},
{
ID: "bun-types@1.2.13",
DependsOn: []string{"@types/node@22.15.18"},
},
{
ID: "zod@3.24.4",
DependsOn: []string{"undici-types@6.21.0"},
},
}
multipleWsDeps = []ftypes.Dependency(nil)
)

View File

@@ -0,0 +1,30 @@
{
"lockfileVersion": 1,
"workspaces": {
"": {
"name": "buntest",
"devDependencies": {
"@types/bun": "latest",
},
"optionalDependencies": {
"zod": "^3.24.4",
},
"peerDependencies": {
"typescript": "^5",
},
},
},
"packages": {
"@types/bun": ["@types/bun@1.2.13", "", { "dependencies": { "bun-types": "1.2.13" } }, "sha512-u6vXep/i9VBxoJl3GjZsl/BFIsvML8DfVDO0RYLEwtSZSp981kEO1V5NwRcO1CPJ7AmvpbnDCiMKo3JvbDEjAg=="],
"@types/node": ["@types/node@22.15.18", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-v1DKRfUdyW+jJhZNEI1PYy29S2YRxMV5AOO/x/SjKmW0acCIOqmbj6Haf9eHAhsPmrhlHSxEhv/1WszcLWV4cg=="],
"bun-types": ["bun-types@1.2.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-rRjA1T6n7wto4gxhAO/ErZEtOXyEZEmnIHQfl0Dt1QQSB4QV0iP6BZ9/YB5fZaHFQ2dwHFrmPaRQ9GGMX01k9Q=="],
"typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="],
"undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
"zod": ["zod@3.24.4", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-OdqJE9UDRPwWsrHjLN2F8bPxvwJBK22EHLWtanu0LSYr5YqzsaaW3RMgmjwr8Rypg5k+meEJdSPXJZXE/yqOMg=="],
}
}

View File

@@ -0,0 +1,3 @@
{
"lockfileVersion": "not-a-number",
}

View File

@@ -0,0 +1,31 @@
{
"lockfileVersion": 1,
"workspaces": {
"": {
"name": "buntest",
},
"my-app": {
"name": "my-app",
"version": "1.0.0",
"dependencies": {
"lodash": "4.17.21",
},
},
"my-lib": {
"name": "my-lib",
"version": "1.0.0",
"dependencies": {
"chalk": "5.0.1",
},
},
},
"packages": {
"chalk": ["chalk@5.0.1", "", {}, "sha512-Fo07WOYGqMfCWHOzSXOt2CxDbC6skS/jO9ynEcmpANMoPrD+W1r1K6Vx7iNm+AQmETU1Xr2t+n8nzkV9t6xh3w=="],
"lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="],
"my-app": ["my-app@workspace:my-app"],
"my-lib": ["my-lib@workspace:my-lib"],
}
}

View File

@@ -64,6 +64,7 @@ const (
Composer LangType = "composer"
ComposerVendor LangType = "composer-vendor"
Npm LangType = "npm"
Bun LangType = "bun"
NuGet LangType = "nuget"
DotNetCore LangType = "dotnet-core"
PackagesProps LangType = "packages-props"