core/quality/eslint/no-shell-argument-injection.eslint.rule.ts

core/quality/eslint/no-shell-argument-injection.eslint.rule.ts is a file in GovLab Quality. 51 lines of code and 9 definitions.

import { asNode, identName, propKeyName } from "#core/selectors/syntax.selector";
import type { Rule } from "eslint";
import type { SyntaxNode } from "#types/syntax.types";
import { govlabMeta } from "#core/factories/eslint.factory";

const CHILD_PROCESS_SPAWNERS = new Set(["spawn", "spawnSync", "execFile", "execFileSync"]);

const calleeName = function calleeName(callee: SyntaxNode | undefined): string | null {
    if (callee?.type === "Identifier") {
        return identName(callee);
    }
    return callee?.type === "MemberExpression" ? identName(callee.property) : null;
};

const isTruthyLiteral = function isTruthyLiteral(value: SyntaxNode | null): boolean {
    return value?.type === "Literal" && value.value !== false && value.value !== "" && value.value !== null;
};

const ARGS_ARGUMENT_INDEX = 2;

const hasShellOption = function hasShellOption(node: SyntaxNode): boolean {
    return (
        node.type === "ObjectExpression" &&
        (node.properties ?? []).some(
            (property) =>
                property.type === "Property" &&
                propKeyName(property) === "shell" &&
                isTruthyLiteral(asNode(property.value)),
        )
    );
};

export default {
    create(context: Rule.RuleContext): Rule.RuleListener {
        const onCall = (node: Rule.Node): void => {
            const call = asNode(node);
            if (call === null || !CHILD_PROCESS_SPAWNERS.has(calleeName(call.callee) ?? "")) {
                return;
            }
            const args = call.arguments ?? [];
            if (args.findIndex(hasShellOption) >= ARGS_ARGUMENT_INDEX) {
                context.report({ messageId: "shellArgs", node });
            }
        };
        return Object.fromEntries([["CallExpression", onCall]]);
    },
    meta: govlabMeta({
        canonical: ["injection"],
        description:
            "A child-process spawner must not combine a separate argument array with a truthy shell option — the arguments are concatenated into the shell command line unescaped, so any dynamic argument becomes a command-injection vector",
        messages: {
            shellArgs:
                "A child process is spawned with a separate argument array AND a truthy shell option, so the arguments are concatenated into the shell command line unescaped — any dynamic argument is a command-injection vector. Drop the shell option so the argument array is handed to the executable directly (never parsed by a shell); if a shell command line is genuinely required, pass ONE command string built only from trusted literals, never interpolated untrusted input.",
        },
        ruleId: "no_shell_argument_injection",
    }),
} satisfies Rule.RuleModule;