Skip to content

Commit d2a0390

Browse files
committed
fix: improve command pattern extraction security
- Fix npm/yarn/pnpm commands with additional arguments to avoid overly broad wildcards - Improve handling of chained commands with proper quote-aware parsing - Add security limits for deeply nested command chains - Enhance detection of command injection patterns - Update tests to reflect more secure pattern extraction behavior This addresses the critical security issue where commands like 'npm run build:prod -- --env=production' were creating patterns that were too permissive.
1 parent af8eae5 commit d2a0390

File tree

2 files changed

+127
-18
lines changed

2 files changed

+127
-18
lines changed

webview-ui/src/utils/__tests__/extract-command-pattern.spec.ts

Lines changed: 33 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,17 @@ describe("extractCommandPattern", () => {
1111

1212
describe("npm/yarn/pnpm/bun commands", () => {
1313
it("extracts npm run patterns", () => {
14-
expect(extractCommandPattern("npm run build")).toBe("npm run")
14+
expect(extractCommandPattern("npm run build")).toBe("npm run build")
1515
expect(extractCommandPattern("npm run test:unit")).toBe("npm run *")
16-
expect(extractCommandPattern("yarn run dev")).toBe("yarn run")
17-
expect(extractCommandPattern("pnpm run lint")).toBe("pnpm run")
18-
expect(extractCommandPattern("bun run start")).toBe("bun run")
16+
expect(extractCommandPattern("yarn run dev")).toBe("yarn run dev")
17+
expect(extractCommandPattern("pnpm run lint")).toBe("pnpm run lint")
18+
expect(extractCommandPattern("bun run start")).toBe("bun run start")
19+
})
20+
21+
it("handles npm run with additional arguments", () => {
22+
expect(extractCommandPattern("npm run build:prod -- --env=production")).toBe("npm run build:prod")
23+
expect(extractCommandPattern("npm run test -- --coverage")).toBe("npm run test")
24+
expect(extractCommandPattern("npm run build:dev --watch")).toBe("npm run *")
1925
})
2026

2127
it("extracts npm script patterns", () => {
@@ -79,7 +85,7 @@ describe("extractCommandPattern", () => {
7985
expect(extractCommandPattern("cd /path && npm install")).toBe("cd * && npm install")
8086
expect(extractCommandPattern("npm test || echo 'failed'")).toBe("npm test || echo")
8187
expect(extractCommandPattern("git pull; npm install; npm run build")).toBe(
82-
"git pull ; npm install ; npm run",
88+
"git pull ; npm install ; npm run build",
8389
)
8490
expect(extractCommandPattern("echo 'start' | grep start")).toBe("echo | grep")
8591
})
@@ -137,11 +143,31 @@ describe("extractCommandPattern", () => {
137143
expect(extractCommandPattern("cd /home/user/project")).toBe("cd *")
138144
expect(extractCommandPattern("cd ..")).toBe("cd *")
139145
expect(extractCommandPattern("cd")).toBe("cd *")
146+
expect(extractCommandPattern("cd /usr/local/bin")).toBe("cd *")
140147
})
141148

142149
it("handles commands with environment variables", () => {
143-
expect(extractCommandPattern("NODE_ENV=production npm start")).toBe("NODE_ENV=production")
144-
expect(extractCommandPattern("PORT=3000 node server.js")).toBe("PORT=3000")
150+
expect(extractCommandPattern("NODE_ENV=production npm start")).toBe("NODE_ENV=*")
151+
expect(extractCommandPattern("PORT=3000 node server.js")).toBe("PORT=*")
152+
})
153+
154+
it("handles dangerous commands more carefully", () => {
155+
expect(extractCommandPattern("rm -rf /")).toBe("rm")
156+
expect(extractCommandPattern("rm -rf node_modules")).toBe("rm")
157+
expect(extractCommandPattern("find . -name '*.log' -delete")).toBe("find")
158+
})
159+
160+
it("handles command injection patterns", () => {
161+
expect(extractCommandPattern("echo $(whoami)")).toBe("echo")
162+
expect(extractCommandPattern("echo `date`")).toBe("echo")
163+
expect(extractCommandPattern("bash -c 'echo hello'")).toBe("bash")
164+
})
165+
166+
it("limits deeply chained commands", () => {
167+
const deepChain = "echo 1 && echo 2 && echo 3 && echo 4 && echo 5"
168+
const result = extractCommandPattern(deepChain)
169+
// Should stop at some point to prevent overly complex patterns
170+
expect(result).toBe("echo")
145171
})
146172
})
147173
})

webview-ui/src/utils/extract-command-pattern.ts

Lines changed: 94 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,69 @@ export function extractCommandPattern(command: string): string {
1919
const trimmedCommand = command.trim()
2020

2121
// Check if this is a chained command
22-
const chainMatch = trimmedCommand.match(/^(.+?)\s*(&&|\|\||;|\|)\s*(.+)$/)
23-
if (chainMatch) {
24-
// Handle chained commands by processing each part
25-
const [, firstPart, operator, restPart] = chainMatch
26-
const firstPattern = extractSingleCommandPattern(firstPart.trim())
27-
const restPattern = extractCommandPattern(restPart.trim())
28-
return `${firstPattern} ${operator} ${restPattern}`
22+
// Use a more robust regex that handles nested quotes properly
23+
const operators = ["&&", "||", ";", "|"]
24+
let chainOperator: string | null = null
25+
let splitIndex = -1
26+
27+
// Find the first unquoted operator
28+
let inSingleQuote = false
29+
let inDoubleQuote = false
30+
let escapeNext = false
31+
32+
for (let i = 0; i < trimmedCommand.length; i++) {
33+
const char = trimmedCommand[i]
34+
35+
if (escapeNext) {
36+
escapeNext = false
37+
continue
38+
}
39+
40+
if (char === "\\") {
41+
escapeNext = true
42+
continue
43+
}
44+
45+
if (char === "'" && !inDoubleQuote) {
46+
inSingleQuote = !inSingleQuote
47+
continue
48+
}
49+
50+
if (char === '"' && !inSingleQuote) {
51+
inDoubleQuote = !inDoubleQuote
52+
continue
53+
}
54+
55+
// Only look for operators outside of quotes
56+
if (!inSingleQuote && !inDoubleQuote) {
57+
for (const op of operators) {
58+
if (trimmedCommand.substring(i, i + op.length) === op) {
59+
chainOperator = op
60+
splitIndex = i
61+
break
62+
}
63+
}
64+
if (chainOperator) break
65+
}
66+
}
67+
68+
if (chainOperator && splitIndex > 0) {
69+
const firstPart = trimmedCommand.substring(0, splitIndex).trim()
70+
const restPart = trimmedCommand.substring(splitIndex + chainOperator.length).trim()
71+
72+
// Process each part separately
73+
const firstPattern = extractSingleCommandPattern(firstPart)
74+
const restPattern = extractCommandPattern(restPart)
75+
76+
// For security, limit the depth of chained commands
77+
// Count existing operators in the pattern to prevent deeply nested chains
78+
const operatorCount = (restPattern.match(/&&|\|\||;|\|/g) || []).length
79+
if (operatorCount >= 3) {
80+
// Too many chained commands, return a more restrictive pattern
81+
return firstPattern
82+
}
83+
84+
return `${firstPattern} ${chainOperator} ${restPattern}`
2985
}
3086

3187
// Not a chained command, process normally
@@ -95,14 +151,24 @@ function extractSingleCommandPattern(command: string): string {
95151
// 1. npm/yarn/pnpm commands - include subcommand with wildcards for scripts
96152
if (["npm", "yarn", "pnpm", "bun"].includes(baseCommand) && tokens.length > 1) {
97153
const subCommand = tokens[1]
98-
// For "run" commands, include "run" with wildcard for script names
154+
// For "run" commands, be more specific about patterns
99155
if (subCommand === "run" && tokens.length > 2) {
100-
// Check if the script name contains special characters like colons
101156
const scriptName = tokens[2]
157+
// Check if there are additional arguments after the script name
158+
const hasDoubleDash = tokens.includes("--")
159+
const doubleDashIndex = hasDoubleDash ? tokens.indexOf("--") : -1
160+
161+
// For scripts with colons or complex names
102162
if (scriptName && (scriptName.includes(":") || scriptName.includes("-"))) {
163+
// If there's a double dash with additional args, include the full script name
164+
if (hasDoubleDash && doubleDashIndex > 2) {
165+
return `${baseCommand} run ${scriptName}`
166+
}
167+
// Otherwise use wildcard for flexibility
103168
return `${baseCommand} run *`
104169
}
105-
return `${baseCommand} run`
170+
// For simple script names, include the script name itself
171+
return `${baseCommand} run ${scriptName}`
106172
}
107173
// For direct scripts like "npm test", "npm build", include the script name
108174
if (!subCommand.startsWith("-")) {
@@ -139,7 +205,7 @@ function extractSingleCommandPattern(command: string): string {
139205
return baseCommand
140206
}
141207

142-
// 6. cd command - include wildcard for paths
208+
// 6. cd command - use wildcard for flexibility
143209
if (baseCommand === "cd") {
144210
return "cd *"
145211
}
@@ -160,6 +226,23 @@ function extractSingleCommandPattern(command: string): string {
160226
}
161227
}
162228

229+
// 9. Environment variables - handle with care
230+
if (baseCommand.includes("=")) {
231+
// This might be an environment variable like NODE_ENV=production
232+
const envMatch = baseCommand.match(/^([A-Z_]+)=/)
233+
if (envMatch) {
234+
// Don't include the value, just the variable name pattern
235+
return `${envMatch[1]}=*`
236+
}
237+
}
238+
239+
// 10. Commands with suspicious patterns - be restrictive
240+
// Check for potential command injection patterns
241+
if (baseCommand.includes("$") || baseCommand.includes("`") || baseCommand.includes("(")) {
242+
// These could be command substitutions or variables, be very restrictive
243+
return baseCommand.split(/[$`(]/)[0].trim() || "echo"
244+
}
245+
163246
// Default: just return the base command
164247
return baseCommand
165248
}

0 commit comments

Comments
 (0)