70bf21e064
Deploy (to testing) and Test Playground Preview Worker / Deploy Playground Preview Worker (testing) (push) Has been skipped
Deploy Workers Shared Staging / Deploy Workers Shared Staging (push) Failing after 0s
Prerelease / build (push) Has been skipped
Handle Changesets / Handle Changesets (push) Has been cancelled
Semgrep OSS scan / semgrep-oss (push) Has been cancelled
269 lines
7.7 KiB
JavaScript
269 lines
7.7 KiB
JavaScript
const UNSAFE_FUNCTIONS = [
|
|
"exec",
|
|
"execSync",
|
|
"spawn",
|
|
"spawnSync",
|
|
"execFile",
|
|
"execFileSync",
|
|
];
|
|
|
|
/**
|
|
* Package names for child_process module
|
|
*/
|
|
const CHILD_PROCESS_MODULES = ["child_process", "node:child_process"];
|
|
|
|
/**
|
|
* Check if a node is a template literal with expressions
|
|
*/
|
|
function isTemplateLiteralWithExpressions(node) {
|
|
return node.type === "TemplateLiteral" && node.expressions.length > 0;
|
|
}
|
|
|
|
/**
|
|
* Check if a node is a binary expression (string concatenation)
|
|
*/
|
|
function isBinaryExpression(node) {
|
|
return node.type === "BinaryExpression" && node.operator === "+";
|
|
}
|
|
|
|
/**
|
|
* Check if a call is to one of the unsafe functions from child_process
|
|
* Supports both CommonJS (require) and ES module (import) syntax
|
|
*/
|
|
function isUnsafeChildProcessCall(node, context) {
|
|
const sourceCode = context.sourceCode || context.getSourceCode();
|
|
const scope = sourceCode.getScope
|
|
? sourceCode.getScope(node)
|
|
: context.getScope();
|
|
|
|
// Check for direct require('child_process').exec(...) pattern
|
|
if (
|
|
node.callee.type === "MemberExpression" &&
|
|
node.callee.object.type === "CallExpression" &&
|
|
node.callee.object.callee.name === "require" &&
|
|
node.callee.object.arguments[0]?.type === "Literal" &&
|
|
CHILD_PROCESS_MODULES.includes(node.callee.object.arguments[0].value) &&
|
|
node.callee.property.type === "Identifier" &&
|
|
UNSAFE_FUNCTIONS.includes(node.callee.property.name)
|
|
) {
|
|
return node.callee.property.name;
|
|
}
|
|
|
|
// Check for imported functions: const { exec } = require('child_process') or import { exec } from 'child_process'
|
|
if (node.callee.type === "Identifier") {
|
|
// Walk up the scope chain to find if this identifier is from child_process
|
|
let currentScope = scope;
|
|
while (currentScope) {
|
|
const variable = currentScope.set.get(node.callee.name);
|
|
if (variable && variable.defs.length > 0) {
|
|
const def = variable.defs[0];
|
|
|
|
// Check for ES module import: import { execSync } from 'node:child_process'
|
|
// or import { execSync as run } from 'node:child_process'
|
|
if (
|
|
def.type === "ImportBinding" &&
|
|
def.parent?.type === "ImportDeclaration"
|
|
) {
|
|
const importSource = def.parent.source?.value;
|
|
if (CHILD_PROCESS_MODULES.includes(importSource)) {
|
|
// Check if it's one of the unsafe functions
|
|
// For aliased imports: import { execSync as run } - def.node.imported.name = "execSync"
|
|
// For regular imports: import { execSync } - def.node.local.name = "execSync"
|
|
const importedName =
|
|
def.node.imported?.name || def.node.local?.name;
|
|
if (importedName && UNSAFE_FUNCTIONS.includes(importedName)) {
|
|
return importedName;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check for CommonJS require()
|
|
if (
|
|
def.type === "Variable" &&
|
|
def.parent?.type === "VariableDeclaration" &&
|
|
def.node?.init
|
|
) {
|
|
const init = def.node.init;
|
|
// Check if it's from require('child_process')
|
|
if (
|
|
init.type === "CallExpression" &&
|
|
init.callee.name === "require" &&
|
|
init.arguments[0]?.type === "Literal" &&
|
|
CHILD_PROCESS_MODULES.includes(init.arguments[0].value)
|
|
) {
|
|
// For destructured requires, check the property name
|
|
// const { execSync: run } = require('child_process')
|
|
if (def.node.id?.type === "ObjectPattern") {
|
|
const prop = def.node.id.properties.find(
|
|
(p) => p.value?.name === node.callee.name
|
|
);
|
|
if (prop && UNSAFE_FUNCTIONS.includes(prop.key?.name)) {
|
|
return prop.key.name;
|
|
}
|
|
}
|
|
// For direct import: const execSync = require('child_process').execSync
|
|
if (UNSAFE_FUNCTIONS.includes(node.callee.name)) {
|
|
return node.callee.name;
|
|
}
|
|
}
|
|
// Check if it's from destructuring: const { exec } = require('child_process')
|
|
if (
|
|
init.type === "MemberExpression" &&
|
|
init.object.type === "CallExpression" &&
|
|
init.object.callee.name === "require" &&
|
|
init.object.arguments[0]?.type === "Literal" &&
|
|
CHILD_PROCESS_MODULES.includes(init.object.arguments[0].value)
|
|
) {
|
|
if (UNSAFE_FUNCTIONS.includes(node.callee.name)) {
|
|
return node.callee.name;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
currentScope = currentScope.upper;
|
|
}
|
|
}
|
|
|
|
// Check for MemberExpression: childProcess.exec(...)
|
|
if (
|
|
node.callee.type === "MemberExpression" &&
|
|
node.callee.property.type === "Identifier" &&
|
|
UNSAFE_FUNCTIONS.includes(node.callee.property.name)
|
|
) {
|
|
// This is a heuristic - we check if the object might be child_process
|
|
// We return the function name to trigger checking, even if we're not 100% sure
|
|
return node.callee.property.name;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Check if the options object has shell: true
|
|
*/
|
|
function hasShellOption(node) {
|
|
if (!node || node.type !== "ObjectExpression") {
|
|
return false;
|
|
}
|
|
|
|
return node.properties.some(
|
|
(prop) =>
|
|
prop.type === "Property" &&
|
|
prop.key.type === "Identifier" &&
|
|
prop.key.name === "shell" &&
|
|
prop.value.type === "Literal" &&
|
|
prop.value.value === true
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Get a helpful suggestion message based on the function being used
|
|
*/
|
|
function getSuggestion(functionName, hasTemplate, hasConcatenation) {
|
|
const patterns = [];
|
|
if (hasTemplate) {
|
|
patterns.push("template literal with expressions");
|
|
}
|
|
if (hasConcatenation) {
|
|
patterns.push("string concatenation");
|
|
}
|
|
|
|
const patternStr = patterns.join(" and ");
|
|
|
|
if (functionName.includes("exec")) {
|
|
return `Detected ${patternStr} in ${functionName}(). Use execFileSync() with an argument array instead to prevent command injection: execFileSync("command", ["arg1", "arg2"])`;
|
|
}
|
|
|
|
if (functionName.includes("spawn")) {
|
|
return `Detected ${patternStr} in ${functionName}(). Ensure you're using an argument array and avoid shell: true with user input.`;
|
|
}
|
|
|
|
return `Detected ${patternStr} in ${functionName}(). This may allow command injection. Use argument arrays instead of string interpolation.`;
|
|
}
|
|
|
|
export default {
|
|
meta: {
|
|
type: "error",
|
|
docs: {
|
|
description:
|
|
"Disallow template literals and string concatenation in shell command execution",
|
|
category: "Possible Security Vulnerability",
|
|
recommended: true,
|
|
url: "https://cwe.mitre.org/data/definitions/78.html",
|
|
},
|
|
messages: {
|
|
unsafeCommandExecution:
|
|
"Potential command injection vulnerability: {{message}}",
|
|
},
|
|
schema: [], // no options
|
|
},
|
|
|
|
create(context) {
|
|
return {
|
|
CallExpression(node) {
|
|
// Check if this is a call to a child_process function
|
|
const functionName = isUnsafeChildProcessCall(node, context);
|
|
if (!functionName) {
|
|
return;
|
|
}
|
|
|
|
// Check first argument (command string)
|
|
if (node.arguments.length === 0) {
|
|
return;
|
|
}
|
|
|
|
const commandArg = node.arguments[0];
|
|
let hasTemplate = false;
|
|
let hasConcatenation = false;
|
|
|
|
// Check for template literal with expressions
|
|
if (isTemplateLiteralWithExpressions(commandArg)) {
|
|
hasTemplate = true;
|
|
}
|
|
|
|
// Check for string concatenation
|
|
if (isBinaryExpression(commandArg)) {
|
|
hasConcatenation = true;
|
|
}
|
|
|
|
// For spawn/spawnSync, also check if shell: true is in options
|
|
if (
|
|
(functionName === "spawn" || functionName === "spawnSync") &&
|
|
node.arguments.length >= 3
|
|
) {
|
|
const options = node.arguments[2];
|
|
if (hasShellOption(options) && (hasTemplate || hasConcatenation)) {
|
|
context.report({
|
|
node,
|
|
messageId: "unsafeCommandExecution",
|
|
data: {
|
|
message: getSuggestion(
|
|
functionName,
|
|
hasTemplate,
|
|
hasConcatenation
|
|
),
|
|
},
|
|
});
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Report if we found unsafe patterns
|
|
if (hasTemplate || hasConcatenation) {
|
|
context.report({
|
|
node,
|
|
messageId: "unsafeCommandExecution",
|
|
data: {
|
|
message: getSuggestion(
|
|
functionName,
|
|
hasTemplate,
|
|
hasConcatenation
|
|
),
|
|
},
|
|
});
|
|
}
|
|
},
|
|
};
|
|
},
|
|
};
|