* Resolve a `simple_expansion` ($VAR) node. Returns VAR_PLACEHOLDER if * resolvable, too-complex otherwise. * * @param insideString true when $VAR is inside a `string` node ("...$VAR...") * rather than a bare/concatenation argument. SAFE_ENV_VARS and unknown-value * tracked vars are only al
( node: Node, varScope: Map<string, string>, insideString: boolean, )
| 1935 | * value IS known. |
| 1936 | */ |
| 1937 | function resolveSimpleExpansion( |
| 1938 | node: Node, |
| 1939 | varScope: Map<string, string>, |
| 1940 | insideString: boolean, |
| 1941 | ): string | ParseForSecurityResult { |
| 1942 | let varName: string | null = null |
| 1943 | let isSpecial = false |
| 1944 | for (const c of node.children) { |
| 1945 | if (c?.type === 'variable_name') { |
| 1946 | varName = c.text |
| 1947 | break |
| 1948 | } |
| 1949 | if (c?.type === 'special_variable_name') { |
| 1950 | varName = c.text |
| 1951 | isSpecial = true |
| 1952 | break |
| 1953 | } |
| 1954 | } |
| 1955 | if (varName === null) return tooComplex(node) |
| 1956 | // Tracked vars: check stored value. Literal strings (VAR=/tmp) are |
| 1957 | // returned DIRECTLY so downstream path validation sees the real path. |
| 1958 | // Non-literal values (containing any placeholder — loop vars, $() output, |
| 1959 | // read vars, composites like `VAR="prefix$(cmd)"`) are ONLY safe inside |
| 1960 | // strings; as bare args they'd hide the runtime path/flag from validation. |
| 1961 | // |
| 1962 | // SECURITY: Returning the actual trackedValue (not a placeholder) is the |
| 1963 | // critical fix. `VAR=/etc && rm $VAR` → argv ['rm', '/etc'] → validatePath |
| 1964 | // correctly rejects. Previously returned a placeholder → validatePath saw |
| 1965 | // '__LOOP_STATIC__', resolved as cwd-relative → PASSED → bypass. |
| 1966 | const trackedValue = varScope.get(varName) |
| 1967 | if (trackedValue !== undefined) { |
| 1968 | if (containsAnyPlaceholder(trackedValue)) { |
| 1969 | // Non-literal: bare → reject, inside string → VAR_PLACEHOLDER |
| 1970 | // (walkString's solo-placeholder gate rejects `"$VAR"` alone). |
| 1971 | if (!insideString) return tooComplex(node) |
| 1972 | return VAR_PLACEHOLDER |
| 1973 | } |
| 1974 | // Pure literal (e.g. '/tmp', 'foo') — return it directly. Downstream |
| 1975 | // path validation / checkSemantics operate on the REAL value. |
| 1976 | // |
| 1977 | // SECURITY: For BARE args (not inside a string), bash word-splits on |
| 1978 | // $IFS and glob-expands the result. `VAR="-rf /" && rm $VAR` → bash |
| 1979 | // runs `rm -rf /` (two args); `VAR="/etc/*" && cat $VAR` → expands to |
| 1980 | // all files. Reject values containing IFS/glob chars unless in "...". |
| 1981 | // |
| 1982 | // SECURITY: Empty value as bare arg. Bash word-splitting on "" produces |
| 1983 | // ZERO fields — the expansion disappears. `V="" && $V eval x` → bash |
| 1984 | // runs `eval x` (our argv would be ["","eval","x"] with name="" — |
| 1985 | // every EVAL_LIKE/ZSH/keyword check misses). `V="" && ls $V /etc` → |
| 1986 | // bash runs `ls /etc`, our argv has a phantom "" shifting positions. |
| 1987 | // Inside "...": `"$V"` → bash produces one empty-string arg → our "" |
| 1988 | // is correct, keep allowing. |
| 1989 | if (!insideString) { |
| 1990 | if (trackedValue === '') return tooComplex(node) |
| 1991 | if (BARE_VAR_UNSAFE_RE.test(trackedValue)) return tooComplex(node) |
| 1992 | } |
| 1993 | return trackedValue |
| 1994 | } |
no test coverage detected