Skip to content

Commit ba8c71d

Browse files
feat(windows): add npm scanning
Signed-off-by: Swarit Pandey <swarit@stepsecurity.io>
1 parent e8c63df commit ba8c71d

10 files changed

Lines changed: 480 additions & 5 deletions

File tree

Makefile

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,17 @@ LDFLAGS := -s -w \
99
-X $(MODULE)/internal/buildinfo.ReleaseTag=$(TAG) \
1010
-X $(MODULE)/internal/buildinfo.ReleaseBranch=$(BRANCH)
1111

12-
.PHONY: build build-windows test lint clean smoke
12+
.PHONY: build build-windows deploy-windows test lint clean smoke
1313

1414
build:
1515
go build -trimpath -ldflags "$(LDFLAGS)" -o $(BINARY) ./cmd/stepsecurity-dev-machine-guard
1616

1717
build-windows:
1818
GOOS=windows GOARCH=amd64 go build -trimpath -ldflags "$(LDFLAGS)" -o $(BINARY).exe ./cmd/stepsecurity-dev-machine-guard
1919

20+
deploy-windows:
21+
@bash scripts/deploy-windows.sh $(DEPLOY_ARGS)
22+
2023
test:
2124
go test ./... -v -race -count=1
2225

Binary file not shown.

internal/detector/aicli.go

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -157,12 +157,25 @@ func (d *AICLIDetector) getVersion(ctx context.Context, spec cliToolSpec, binary
157157
if len(lines) > 0 {
158158
v := strings.TrimSpace(lines[0])
159159
if v != "" {
160-
return v
160+
return cleanVersionString(v)
161161
}
162162
}
163163
return "unknown"
164164
}
165165

166+
// cleanVersionString strips a leading tool name prefix from version output.
167+
// e.g. "codex-cli 0.118.0" -> "0.118.0", "aider 0.86.2" -> "0.86.2"
168+
func cleanVersionString(v string) string {
169+
parts := strings.Fields(v)
170+
for _, p := range parts {
171+
trimmed := strings.TrimLeft(p, "v")
172+
if len(trimmed) > 0 && trimmed[0] >= '0' && trimmed[0] <= '9' {
173+
return p
174+
}
175+
}
176+
return v
177+
}
178+
166179
func (d *AICLIDetector) findConfigDir(spec cliToolSpec, homeDir string) string {
167180
for _, dir := range spec.ConfigDirs {
168181
expanded := expandTilde(dir, homeDir)

internal/detector/nodepm_test.go

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package detector
22

33
import (
44
"context"
5+
"path/filepath"
56
"testing"
67

78
"github.com/step-security/dev-machine-guard/internal/executor"
@@ -51,6 +52,58 @@ func TestNodePMDetector_NoneFound(t *testing.T) {
5152
}
5253
}
5354

55+
func TestNodePMDetector_Windows_FindsNPM(t *testing.T) {
56+
mock := executor.NewMock()
57+
mock.SetGOOS("windows")
58+
mock.SetPath("npm", `C:\Program Files\nodejs\npm.cmd`)
59+
mock.SetCommand("10.2.0\n", "", 0, "npm", "--version")
60+
61+
det := NewNodePMDetector(mock)
62+
results := det.DetectManagers(context.Background())
63+
64+
if len(results) < 1 {
65+
t.Fatal("expected at least 1 package manager on Windows")
66+
}
67+
if results[0].Name != "npm" {
68+
t.Errorf("expected npm, got %s", results[0].Name)
69+
}
70+
if results[0].Version != "10.2.0" {
71+
t.Errorf("expected 10.2.0, got %s", results[0].Version)
72+
}
73+
if results[0].Path != `C:\Program Files\nodejs\npm.cmd` {
74+
t.Errorf("expected Windows path, got %s", results[0].Path)
75+
}
76+
}
77+
78+
func TestDetectProjectPM_Windows(t *testing.T) {
79+
// Note: filepath.Join is host-OS dependent; on macOS it uses "/" even for
80+
// Windows-style project dirs. We use filepath.Join here to match what
81+
// DetectProjectPM produces internally.
82+
projectDir := `C:\Users\dev\myapp`
83+
tests := []struct {
84+
name string
85+
lockFile string
86+
expected string
87+
}{
88+
{"npm lock", "package-lock.json", "npm"},
89+
{"yarn lock", "yarn.lock", "yarn"},
90+
{"pnpm lock", "pnpm-lock.yaml", "pnpm"},
91+
{"bun lock", "bun.lock", "bun"},
92+
}
93+
94+
for _, tt := range tests {
95+
t.Run(tt.name, func(t *testing.T) {
96+
mock := executor.NewMock()
97+
mock.SetGOOS("windows")
98+
mock.SetFile(filepath.Join(projectDir, tt.lockFile), []byte{})
99+
got := DetectProjectPM(mock, projectDir)
100+
if got != tt.expected {
101+
t.Errorf("expected %s, got %s", tt.expected, got)
102+
}
103+
})
104+
}
105+
}
106+
54107
func TestDetectProjectPM(t *testing.T) {
55108
tests := []struct {
56109
name string

internal/detector/nodescan.go

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -190,7 +190,7 @@ func (s *NodeScanner) ScanProjects(ctx context.Context, searchDirs []string) []m
190190
return nil
191191
}
192192
projectDir := filepath.Dir(path)
193-
if strings.Contains(projectDir, "/node_modules/") {
193+
if isInsideNodeModules(projectDir) {
194194
return nil
195195
}
196196
// Get modification time for sorting
@@ -322,3 +322,11 @@ func (s *NodeScanner) getOutput(ctx context.Context, binary string, args ...stri
322322
}
323323
return strings.TrimSpace(stdout)
324324
}
325+
326+
// isInsideNodeModules returns true if the path contains a node_modules component.
327+
// Uses strings.ReplaceAll instead of filepath.ToSlash so the check works
328+
// regardless of the host OS (important for cross-platform mock tests).
329+
func isInsideNodeModules(projectDir string) bool {
330+
normalized := strings.ReplaceAll(projectDir, "\\", "/")
331+
return strings.Contains(normalized, "/node_modules/")
332+
}

internal/detector/nodescan_test.go

Lines changed: 244 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,244 @@
1+
package detector
2+
3+
import (
4+
"context"
5+
"encoding/base64"
6+
"path/filepath"
7+
"testing"
8+
9+
"github.com/step-security/dev-machine-guard/internal/executor"
10+
"github.com/step-security/dev-machine-guard/internal/progress"
11+
)
12+
13+
func newTestScanner(exec *executor.Mock) *NodeScanner {
14+
log := progress.NewLogger(false)
15+
return NewNodeScanner(exec, log)
16+
}
17+
18+
func TestNodeScanner_ScanNPMGlobal(t *testing.T) {
19+
mock := executor.NewMock()
20+
mock.SetPath("npm", "/usr/local/bin/npm")
21+
mock.SetCommand("10.2.0\n", "", 0, "npm", "--version")
22+
mock.SetCommand("/usr/local\n", "", 0, "npm", "config", "get", "prefix")
23+
mock.SetCommand(`{"dependencies":{"express":{"version":"4.18.2"}}}`, "", 0, "npm", "list", "-g", "--json", "--depth=3")
24+
25+
scanner := newTestScanner(mock)
26+
results := scanner.ScanGlobalPackages(context.Background())
27+
28+
npmFound := false
29+
for _, r := range results {
30+
if r.PackageManager == "npm" {
31+
npmFound = true
32+
if r.ProjectPath != "/usr/local" {
33+
t.Errorf("expected ProjectPath /usr/local, got %s", r.ProjectPath)
34+
}
35+
if r.PMVersion != "10.2.0" {
36+
t.Errorf("expected PMVersion 10.2.0, got %s", r.PMVersion)
37+
}
38+
if r.ExitCode != 0 {
39+
t.Errorf("expected ExitCode 0, got %d", r.ExitCode)
40+
}
41+
decoded, _ := base64.StdEncoding.DecodeString(r.RawStdoutBase64)
42+
if len(decoded) == 0 {
43+
t.Error("expected non-empty RawStdoutBase64")
44+
}
45+
}
46+
}
47+
if !npmFound {
48+
t.Fatal("expected npm in global scan results")
49+
}
50+
}
51+
52+
func TestNodeScanner_ScanNPMGlobal_Windows(t *testing.T) {
53+
mock := executor.NewMock()
54+
mock.SetGOOS("windows")
55+
mock.SetPath("npm", `C:\Program Files\nodejs\npm.cmd`)
56+
mock.SetCommand("10.2.0\n", "", 0, "npm", "--version")
57+
// npm config get prefix returns a Windows-style path on real Windows.
58+
// The code stores it directly (no filepath.* processing), so the mock
59+
// value flows through unchanged.
60+
mock.SetCommand(`C:\Users\dev\AppData\Roaming\npm`+"\n", "", 0, "npm", "config", "get", "prefix")
61+
mock.SetCommand(`{"dependencies":{"express":{"version":"4.18.2"}}}`, "", 0, "npm", "list", "-g", "--json", "--depth=3")
62+
63+
scanner := newTestScanner(mock)
64+
results := scanner.ScanGlobalPackages(context.Background())
65+
66+
npmFound := false
67+
for _, r := range results {
68+
if r.PackageManager == "npm" {
69+
npmFound = true
70+
if r.ProjectPath != `C:\Users\dev\AppData\Roaming\npm` {
71+
t.Errorf("expected Windows npm prefix, got %s", r.ProjectPath)
72+
}
73+
if r.PMVersion != "10.2.0" {
74+
t.Errorf("expected PMVersion 10.2.0, got %s", r.PMVersion)
75+
}
76+
if r.ExitCode != 0 {
77+
t.Errorf("expected ExitCode 0, got %d", r.ExitCode)
78+
}
79+
}
80+
}
81+
if !npmFound {
82+
t.Fatal("expected npm in global scan results on Windows")
83+
}
84+
}
85+
86+
func TestNodeScanner_ScanYarnGlobal_Windows(t *testing.T) {
87+
mock := executor.NewMock()
88+
mock.SetGOOS("windows")
89+
mock.SetPath("yarn", `C:\Program Files\nodejs\yarn.cmd`)
90+
mock.SetCommand("1.22.19\n", "", 0, "yarn", "--version")
91+
mock.SetCommand(`C:\Users\dev\AppData\Local\Yarn\Data\global`+"\n", "", 0, "yarn", "global", "dir")
92+
// runShellCmd dispatches to cmd /c on Windows; platformShellQuote uses double quotes
93+
mock.SetCommand(`{"type":"tree","data":{"trees":[]}}`, "", 0,
94+
"cmd", "/c", `cd "C:\Users\dev\AppData\Local\Yarn\Data\global" && yarn list --json --depth=0`)
95+
96+
scanner := newTestScanner(mock)
97+
results := scanner.ScanGlobalPackages(context.Background())
98+
99+
yarnFound := false
100+
for _, r := range results {
101+
if r.PackageManager == "yarn" {
102+
yarnFound = true
103+
if r.ProjectPath != `C:\Users\dev\AppData\Local\Yarn\Data\global` {
104+
t.Errorf("expected Windows yarn global dir, got %s", r.ProjectPath)
105+
}
106+
if r.PMVersion != "1.22.19" {
107+
t.Errorf("expected PMVersion 1.22.19, got %s", r.PMVersion)
108+
}
109+
}
110+
}
111+
if !yarnFound {
112+
t.Fatal("expected yarn in global scan results on Windows")
113+
}
114+
}
115+
116+
func TestNodeScanner_ScanPnpmGlobal_Windows(t *testing.T) {
117+
mock := executor.NewMock()
118+
mock.SetGOOS("windows")
119+
mock.SetPath("pnpm", `C:\Users\dev\AppData\Local\pnpm\pnpm.cmd`)
120+
mock.SetCommand("9.1.0\n", "", 0, "pnpm", "--version")
121+
// pnpm root -g returns the global node_modules dir. The code calls
122+
// filepath.Dir on it. Since filepath.Dir is host-OS dependent, we use
123+
// forward slashes here so the test works on macOS hosts too.
124+
mock.SetCommand("C:/Users/dev/AppData/Local/pnpm/global/5/node_modules\n", "", 0, "pnpm", "root", "-g")
125+
mock.SetCommand(`{"dependencies":{"typescript":{"version":"5.4.0"}}}`, "", 0, "pnpm", "list", "-g", "--json", "--depth=3")
126+
127+
scanner := newTestScanner(mock)
128+
results := scanner.ScanGlobalPackages(context.Background())
129+
130+
pnpmFound := false
131+
for _, r := range results {
132+
if r.PackageManager == "pnpm" {
133+
pnpmFound = true
134+
// filepath.Dir strips the last component (node_modules)
135+
expected := "C:/Users/dev/AppData/Local/pnpm/global/5"
136+
if r.ProjectPath != expected {
137+
t.Errorf("expected ProjectPath %s, got %s", expected, r.ProjectPath)
138+
}
139+
if r.PMVersion != "9.1.0" {
140+
t.Errorf("expected PMVersion 9.1.0, got %s", r.PMVersion)
141+
}
142+
}
143+
}
144+
if !pnpmFound {
145+
t.Fatal("expected pnpm in global scan results on Windows")
146+
}
147+
}
148+
149+
func TestNodeScanner_ScanProject_Windows(t *testing.T) {
150+
mock := executor.NewMock()
151+
mock.SetGOOS("windows")
152+
mock.SetPath("npm", `C:\Program Files\nodejs\npm.cmd`)
153+
mock.SetCommand("10.2.0\n", "", 0, "npm", "--version")
154+
// DetectProjectPM uses filepath.Join which is host-dependent;
155+
// construct the mock file path the same way the code will.
156+
mock.SetFile(filepath.Join(`C:\Users\dev\myapp`, "package-lock.json"), []byte{})
157+
mock.SetCommand(`{"dependencies":{"lodash":{"version":"4.17.21"}}}`, "", 0,
158+
"cmd", "/c", `cd "C:\Users\dev\myapp" && npm ls --json --depth=3`)
159+
160+
scanner := newTestScanner(mock)
161+
result := scanner.scanProject(context.Background(), `C:\Users\dev\myapp`)
162+
163+
if result.PackageManager != "npm" {
164+
t.Errorf("expected npm, got %s", result.PackageManager)
165+
}
166+
if result.ProjectPath != `C:\Users\dev\myapp` {
167+
t.Errorf("expected project path C:\\Users\\dev\\myapp, got %s", result.ProjectPath)
168+
}
169+
if result.ExitCode != 0 {
170+
t.Errorf("expected ExitCode 0, got %d", result.ExitCode)
171+
}
172+
if result.PMVersion != "10.2.0" {
173+
t.Errorf("expected PMVersion 10.2.0, got %s", result.PMVersion)
174+
}
175+
decoded, _ := base64.StdEncoding.DecodeString(result.RawStdoutBase64)
176+
if len(decoded) == 0 {
177+
t.Error("expected non-empty RawStdoutBase64")
178+
}
179+
}
180+
181+
func TestNodeScanner_ScanProject_YarnBerry_Windows(t *testing.T) {
182+
mock := executor.NewMock()
183+
mock.SetGOOS("windows")
184+
mock.SetPath("yarn", `C:\Program Files\nodejs\yarn.cmd`)
185+
mock.SetCommand("4.1.0\n", "", 0, "yarn", "--version")
186+
// Use filepath.Join to construct mock file paths matching the code's behavior.
187+
projectDir := `C:\Users\dev\myapp`
188+
mock.SetFile(filepath.Join(projectDir, "yarn.lock"), []byte{})
189+
mock.SetFile(filepath.Join(projectDir, ".yarnrc.yml"), []byte{})
190+
mock.SetCommand(`{"name":"myapp","children":[]}`, "", 0,
191+
"cmd", "/c", `cd "C:\Users\dev\myapp" && yarn info --all --json`)
192+
193+
scanner := newTestScanner(mock)
194+
result := scanner.scanProject(context.Background(), projectDir)
195+
196+
if result.PackageManager != "yarn-berry" {
197+
t.Errorf("expected yarn-berry, got %s", result.PackageManager)
198+
}
199+
if result.PMVersion != "4.1.0" {
200+
t.Errorf("expected PMVersion 4.1.0, got %s", result.PMVersion)
201+
}
202+
if result.ExitCode != 0 {
203+
t.Errorf("expected ExitCode 0, got %d", result.ExitCode)
204+
}
205+
}
206+
207+
func TestNodeScanner_ScanGlobalPackages_NoneInstalled(t *testing.T) {
208+
mock := executor.NewMock()
209+
scanner := newTestScanner(mock)
210+
results := scanner.ScanGlobalPackages(context.Background())
211+
212+
if len(results) != 0 {
213+
t.Errorf("expected 0 results when no PMs installed, got %d", len(results))
214+
}
215+
}
216+
217+
func TestIsInsideNodeModules(t *testing.T) {
218+
tests := []struct {
219+
path string
220+
want bool
221+
}{
222+
// Unix-style paths
223+
{"/Users/dev/node_modules/foo", true},
224+
{"/Users/dev/myapp", false},
225+
{"/Users/dev/node_modules_backup/foo", false},
226+
{"/node_modules/", true},
227+
// Windows-style paths (backslashes)
228+
{`C:\Users\dev\node_modules\foo`, true},
229+
{`C:\Users\dev\myapp`, false},
230+
{`C:\node_modules\pkg`, true},
231+
{`\node_modules\`, true},
232+
// Edge cases
233+
{"node_modules", false},
234+
{"", false},
235+
}
236+
for _, tt := range tests {
237+
t.Run(tt.path, func(t *testing.T) {
238+
got := isInsideNodeModules(tt.path)
239+
if got != tt.want {
240+
t.Errorf("isInsideNodeModules(%q) = %v, want %v", tt.path, got, tt.want)
241+
}
242+
})
243+
}
244+
}

internal/output/html.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -169,7 +169,7 @@ const htmlTemplate = `<!DOCTYPE html>
169169
<div class="device-grid">
170170
<div class="field"><span class="field-label">Hostname</span><span class="field-value">{{.Device.Hostname}}</span></div>
171171
<div class="field"><span class="field-label">Serial</span><span class="field-value">{{.Device.SerialNumber}}</span></div>
172-
<div class="field"><span class="field-label">macOS</span><span class="field-value">{{.Device.OSVersion}}</span></div>
172+
<div class="field"><span class="field-label">{{if eq .Device.Platform "windows"}}Windows{{else}}macOS{{end}}</span><span class="field-value">{{.Device.OSVersion}}</span></div>
173173
<div class="field"><span class="field-label">User</span><span class="field-value">{{.Device.UserIdentity}}</span></div>
174174
</div>
175175

0 commit comments

Comments
 (0)