Skip to content

Commit c935edf

Browse files
chore(perf): use parsing for node utilities
Signed-off-by: Swarit Pandey <swarit@stepsecurity.io>
1 parent 201fb79 commit c935edf

7 files changed

Lines changed: 933 additions & 18 deletions

File tree

internal/detector/lockfile.go

Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
package detector
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"strings"
7+
)
8+
9+
// LockfilePackage represents a single resolved package from a lockfile.
10+
type LockfilePackage struct {
11+
Name string `json:"name"`
12+
Version string `json:"version"`
13+
Dev bool `json:"dev,omitempty"`
14+
}
15+
16+
// LockfileResult is the JSON structure produced when scanning via lockfile
17+
// instead of spawning a subprocess. It is base64-encoded into
18+
// NodeScanResult.RawStdoutBase64 so the backend can distinguish it from
19+
// raw CLI output via the "source" field.
20+
type LockfileResult struct {
21+
Source string `json:"source"` // "lockfile"
22+
LockfileFormat string `json:"lockfile_format"` // "npm-v3", "npm-v2", "npm-v1"
23+
LockfileVersion int `json:"lockfile_version"` // 1, 2, or 3
24+
Packages []LockfilePackage `json:"packages"`
25+
}
26+
27+
// ---------- npm package-lock.json parsing ----------
28+
29+
// npmLockfile is the top-level structure of package-lock.json.
30+
type npmLockfile struct {
31+
LockfileVersion int `json:"lockfileVersion"`
32+
Packages map[string]npmLockPkgV3 `json:"packages"` // v2/v3
33+
Dependencies map[string]npmLockDepV1 `json:"dependencies"` // v1
34+
}
35+
36+
// npmLockPkgV3 is a single entry in the v2/v3 "packages" map.
37+
type npmLockPkgV3 struct {
38+
Version string `json:"version"`
39+
Dev bool `json:"dev"`
40+
Optional bool `json:"optional"`
41+
Link bool `json:"link"`
42+
}
43+
44+
// npmLockDepV1 is a single entry in the v1 "dependencies" map.
45+
type npmLockDepV1 struct {
46+
Version string `json:"version"`
47+
Dev bool `json:"dev"`
48+
Dependencies map[string]npmLockDepV1 `json:"dependencies"`
49+
}
50+
51+
// ParseNPMLockfile parses a package-lock.json (or node_modules/.package-lock.json)
52+
// and returns a flat list of resolved packages.
53+
func ParseNPMLockfile(data []byte) (*LockfileResult, error) {
54+
var lock npmLockfile
55+
if err := json.Unmarshal(data, &lock); err != nil {
56+
return nil, fmt.Errorf("parse package-lock.json: %w", err)
57+
}
58+
59+
// v2/v3: use the "packages" map (preferred)
60+
if lock.LockfileVersion >= 2 || len(lock.Packages) > 0 {
61+
return parseNPMLockV3(lock)
62+
}
63+
64+
// v1: use the "dependencies" map
65+
if len(lock.Dependencies) > 0 {
66+
return parseNPMLockV1(lock)
67+
}
68+
69+
return &LockfileResult{
70+
Source: "lockfile",
71+
LockfileFormat: fmt.Sprintf("npm-v%d", lock.LockfileVersion),
72+
LockfileVersion: lock.LockfileVersion,
73+
Packages: nil,
74+
}, nil
75+
}
76+
77+
func parseNPMLockV3(lock npmLockfile) (*LockfileResult, error) {
78+
var pkgs []LockfilePackage
79+
for key, entry := range lock.Packages {
80+
if key == "" {
81+
continue // root project entry
82+
}
83+
name := extractPackageName(key)
84+
if name == "" {
85+
continue
86+
}
87+
pkgs = append(pkgs, LockfilePackage{
88+
Name: name,
89+
Version: entry.Version,
90+
Dev: entry.Dev,
91+
})
92+
}
93+
94+
format := "npm-v3"
95+
if lock.LockfileVersion == 2 {
96+
format = "npm-v2"
97+
}
98+
99+
return &LockfileResult{
100+
Source: "lockfile",
101+
LockfileFormat: format,
102+
LockfileVersion: lock.LockfileVersion,
103+
Packages: pkgs,
104+
}, nil
105+
}
106+
107+
// extractPackageName extracts the package name from a v2/v3 packages map key.
108+
// Keys look like "node_modules/express" or "node_modules/express/node_modules/qs"
109+
// or "node_modules/@scope/pkg".
110+
func extractPackageName(key string) string {
111+
normalized := strings.ReplaceAll(key, "\\", "/")
112+
// Find the last "node_modules/" segment
113+
const prefix = "node_modules/"
114+
idx := strings.LastIndex(normalized, prefix)
115+
if idx < 0 {
116+
return ""
117+
}
118+
return normalized[idx+len(prefix):]
119+
}
120+
121+
func parseNPMLockV1(lock npmLockfile) (*LockfileResult, error) {
122+
var pkgs []LockfilePackage
123+
flattenV1Deps(lock.Dependencies, &pkgs)
124+
return &LockfileResult{
125+
Source: "lockfile",
126+
LockfileFormat: "npm-v1",
127+
LockfileVersion: 1,
128+
Packages: pkgs,
129+
}, nil
130+
}
131+
132+
func flattenV1Deps(deps map[string]npmLockDepV1, out *[]LockfilePackage) {
133+
for name, dep := range deps {
134+
*out = append(*out, LockfilePackage{
135+
Name: name,
136+
Version: dep.Version,
137+
Dev: dep.Dev,
138+
})
139+
if len(dep.Dependencies) > 0 {
140+
flattenV1Deps(dep.Dependencies, out)
141+
}
142+
}
143+
}
144+
145+
// ---------- npm global packages via filesystem ----------
146+
147+
// NPMGlobalPrefix returns the npm global prefix directory by reading ~/.npmrc,
148+
// checking the PREFIX env var, or falling back to platform defaults.
149+
// This avoids running "npm config get prefix" as a subprocess.
150+
func NPMGlobalPrefix(homeDir, appDataDir, goos string, readFile func(string) ([]byte, error)) string {
151+
// 1. Check PREFIX env var (handled by caller if needed)
152+
153+
// 2. Parse ~/.npmrc for prefix= line
154+
npmrcPaths := []string{homeDir + "/.npmrc"}
155+
for _, rc := range npmrcPaths {
156+
data, err := readFile(rc)
157+
if err != nil {
158+
continue
159+
}
160+
if prefix := parseNpmrcPrefix(string(data)); prefix != "" {
161+
return prefix
162+
}
163+
}
164+
165+
// 3. Platform defaults
166+
switch goos {
167+
case "windows":
168+
if appDataDir != "" {
169+
return appDataDir + `\npm`
170+
}
171+
return ""
172+
default: // darwin, linux
173+
return "/usr/local"
174+
}
175+
}
176+
177+
// parseNpmrcPrefix extracts the "prefix" value from .npmrc INI content.
178+
func parseNpmrcPrefix(content string) string {
179+
for _, line := range strings.Split(content, "\n") {
180+
line = strings.TrimSpace(line)
181+
if strings.HasPrefix(line, "#") || strings.HasPrefix(line, ";") {
182+
continue
183+
}
184+
if strings.HasPrefix(line, "prefix=") {
185+
return strings.TrimSpace(strings.TrimPrefix(line, "prefix="))
186+
}
187+
if strings.HasPrefix(line, "prefix =") {
188+
return strings.TrimSpace(strings.TrimPrefix(line, "prefix ="))
189+
}
190+
}
191+
return ""
192+
}
193+
194+
// GlobalNodeModulesDir returns the path to global node_modules given a prefix.
195+
func GlobalNodeModulesDir(prefix, goos string) string {
196+
switch goos {
197+
case "windows":
198+
return prefix + `\node_modules`
199+
default:
200+
return prefix + "/lib/node_modules"
201+
}
202+
}

0 commit comments

Comments
 (0)