@eslint-sh/eslint-plugin 0.4.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +223 -17
- package/dist/command-harvest/builtins/bash.d.ts +36 -0
- package/dist/command-harvest/builtins/bash.js +98 -0
- package/dist/command-harvest/builtins/bats.d.ts +45 -0
- package/dist/command-harvest/builtins/bats.js +33 -0
- package/dist/command-harvest/builtins/index.d.ts +18 -0
- package/dist/command-harvest/builtins/index.js +39 -0
- package/dist/command-harvest/builtins/posix.d.ts +51 -0
- package/dist/command-harvest/builtins/posix.js +93 -0
- package/dist/command-harvest/harvest-commands.d.ts +66 -0
- package/dist/command-harvest/harvest-commands.js +97 -0
- package/dist/command-harvest/wrapper-table.d.ts +24 -0
- package/dist/command-harvest/wrapper-table.js +49 -0
- package/dist/configs/agent-authored.d.ts +41 -0
- package/dist/configs/agent-authored.js +33 -0
- package/dist/configs/bash-3-2.d.ts +28 -0
- package/dist/configs/bash-3-2.js +50 -0
- package/dist/configs/posix-portable.d.ts +40 -0
- package/dist/configs/posix-portable.js +52 -0
- package/dist/configs/recommended.d.ts +52 -0
- package/dist/configs/recommended.js +34 -0
- package/dist/declaration-conventions/comment-pragma.d.ts +33 -0
- package/dist/declaration-conventions/comment-pragma.js +153 -0
- package/dist/declaration-conventions/declaration-convention.d.ts +189 -0
- package/dist/declaration-conventions/declaration-convention.js +1 -0
- package/dist/declaration-conventions/read-declared-commands.d.ts +70 -0
- package/dist/declaration-conventions/read-declared-commands.js +128 -0
- package/dist/eslint-plugin-syntax-analyze.d.ts +23 -0
- package/dist/eslint-plugin-syntax.d.ts +27 -0
- package/dist/eslint-plugin.d.ts +1211 -52
- package/dist/index.d.ts +56 -8
- package/dist/index.js +53 -6
- package/dist/language/command-resolution.d.ts +124 -0
- package/dist/language/command-resolution.js +120 -0
- package/dist/language/end-of-input-marker.d.ts +29 -0
- package/dist/language/end-of-input-marker.js +88 -0
- package/dist/language/line-breaks.d.ts +18 -0
- package/dist/language/line-breaks.js +22 -0
- package/dist/language/lint-view-types.d.ts +104 -0
- package/dist/language/lint-view-types.js +1 -0
- package/dist/language/non-shell-file-guard.d.ts +35 -0
- package/dist/language/non-shell-file-guard.js +59 -0
- package/dist/language/selector-classes.d.ts +0 -1
- package/dist/language/selector-classes.js +0 -1
- package/dist/language/shell-language-options.d.ts +7 -1
- package/dist/language/shell-language-options.js +4 -4
- package/dist/language/shell-language.d.ts +10 -2
- package/dist/language/shell-language.js +59 -8
- package/dist/language/shell-source-code.d.ts +342 -5
- package/dist/language/shell-source-code.js +559 -30
- package/dist/language/source-coordinates.d.ts +13 -2
- package/dist/language/source-coordinates.js +68 -14
- package/dist/language/syntax-view.d.ts +60 -39
- package/dist/language/syntax-view.js +200 -45
- package/dist/language/word-resolution.d.ts +4 -3
- package/dist/language/word-resolution.js +4 -3
- package/dist/rules/compat/create-compat-rule.d.ts +59 -0
- package/dist/rules/compat/create-compat-rule.js +65 -0
- package/dist/rules/compat/declaration-options.d.ts +19 -0
- package/dist/rules/compat/declaration-options.js +110 -0
- package/dist/rules/compat/no-append-all-redirect.d.ts +13 -0
- package/dist/rules/compat/no-append-all-redirect.js +73 -0
- package/dist/rules/compat/no-associative-arrays.d.ts +12 -0
- package/dist/rules/compat/no-associative-arrays.js +39 -0
- package/dist/rules/compat/no-case-attributes.d.ts +12 -0
- package/dist/rules/compat/no-case-attributes.js +63 -0
- package/dist/rules/compat/no-case-fallthrough.d.ts +12 -0
- package/dist/rules/compat/no-case-fallthrough.js +67 -0
- package/dist/rules/compat/no-case-modification.d.ts +35 -0
- package/dist/rules/compat/no-case-modification.js +86 -0
- package/dist/rules/compat/no-coproc.d.ts +12 -0
- package/dist/rules/compat/no-coproc.js +47 -0
- package/dist/rules/compat/no-declare-g.d.ts +12 -0
- package/dist/rules/compat/no-declare-g.js +59 -0
- package/dist/rules/compat/no-double-bracket.d.ts +12 -0
- package/dist/rules/compat/no-double-bracket.js +52 -0
- package/dist/rules/compat/no-epoch-variables.d.ts +12 -0
- package/dist/rules/compat/no-epoch-variables.js +57 -0
- package/dist/rules/compat/no-fd-variable.d.ts +12 -0
- package/dist/rules/compat/no-fd-variable.js +48 -0
- package/dist/rules/compat/no-globstar.d.ts +12 -0
- package/dist/rules/compat/no-globstar.js +39 -0
- package/dist/rules/compat/no-lastpipe-semantics.d.ts +12 -0
- package/dist/rules/compat/no-lastpipe-semantics.js +53 -0
- package/dist/rules/compat/no-let.d.ts +12 -0
- package/dist/rules/compat/no-let.js +62 -0
- package/dist/rules/compat/no-mapfile.d.ts +13 -0
- package/dist/rules/compat/no-mapfile.js +122 -0
- package/dist/rules/compat/no-nameref.d.ts +12 -0
- package/dist/rules/compat/no-nameref.js +44 -0
- package/dist/rules/compat/no-negative-array-index.d.ts +13 -0
- package/dist/rules/compat/no-negative-array-index.js +123 -0
- package/dist/rules/compat/no-nounset-empty-array.d.ts +13 -0
- package/dist/rules/compat/no-nounset-empty-array.js +158 -0
- package/dist/rules/compat/no-parameter-transformation.d.ts +13 -0
- package/dist/rules/compat/no-parameter-transformation.js +123 -0
- package/dist/rules/compat/no-pipe-all.d.ts +13 -0
- package/dist/rules/compat/no-pipe-all.js +68 -0
- package/dist/rules/compat/no-printf-time-format.d.ts +12 -0
- package/dist/rules/compat/no-printf-time-format.js +99 -0
- package/dist/rules/compat/no-source.d.ts +13 -0
- package/dist/rules/compat/no-source.js +103 -0
- package/dist/rules/compat/no-test-v.d.ts +12 -0
- package/dist/rules/compat/no-test-v.js +280 -0
- package/dist/rules/compat/no-unicode-escape.d.ts +14 -0
- package/dist/rules/compat/no-unicode-escape.js +120 -0
- package/dist/rules/compat/no-wait-n.d.ts +12 -0
- package/dist/rules/compat/no-wait-n.js +73 -0
- package/dist/rules/compat/operator-range.d.ts +10 -0
- package/dist/rules/compat/operator-range.js +15 -0
- package/dist/rules/compat/posix-absent-command.d.ts +12 -0
- package/dist/rules/compat/posix-absent-command.js +134 -0
- package/dist/rules/compat/shopt-options.d.ts +9 -0
- package/dist/rules/compat/shopt-options.js +63 -0
- package/dist/rules/compat/version-gates.d.ts +76 -0
- package/dist/rules/compat/version-gates.js +197 -0
- package/dist/rules/create-rule.d.ts +91 -0
- package/dist/rules/create-rule.js +51 -0
- package/dist/rules/create-wrapper-rule.d.ts +166 -0
- package/dist/rules/create-wrapper-rule.js +407 -0
- package/dist/rules/define-shell-rule.d.ts +184 -0
- package/dist/rules/define-shell-rule.js +56 -0
- package/dist/rules/index.d.ts +26 -0
- package/dist/rules/index.js +88 -0
- package/dist/rules/max-script-complexity.d.ts +28 -0
- package/dist/rules/max-script-complexity.js +130 -0
- package/dist/rules/no-dynamic-invocation.d.ts +35 -0
- package/dist/rules/no-dynamic-invocation.js +117 -0
- package/dist/rules/no-eval.d.ts +13 -0
- package/dist/rules/no-eval.js +89 -0
- package/dist/rules/no-masked-exit-status.d.ts +21 -0
- package/dist/rules/no-masked-exit-status.js +667 -0
- package/dist/rules/no-secret-env-reads.d.ts +24 -0
- package/dist/rules/no-secret-env-reads.js +133 -0
- package/dist/rules/no-secret-flags.d.ts +32 -0
- package/dist/rules/no-secret-flags.js +162 -0
- package/dist/rules/no-unchecked-cd.d.ts +44 -0
- package/dist/rules/no-unchecked-cd.js +250 -0
- package/dist/rules/no-undeclared-commands.d.ts +20 -0
- package/dist/rules/no-undeclared-commands.js +115 -0
- package/dist/rules/no-unused-declarations.d.ts +23 -0
- package/dist/rules/no-unused-declarations.js +259 -0
- package/dist/rules/prefer-local.d.ts +16 -0
- package/dist/rules/prefer-local.js +249 -0
- package/dist/rules/quote-expansions.d.ts +13 -0
- package/dist/rules/quote-expansions.js +229 -0
- package/dist/rules/require-network-wrapper.d.ts +40 -0
- package/dist/rules/require-network-wrapper.js +244 -0
- package/dist/rules/require-shebang.d.ts +29 -0
- package/dist/rules/require-shebang.js +101 -0
- package/dist/rules/require-strict-mode.d.ts +52 -0
- package/dist/rules/require-strict-mode.js +177 -0
- package/dist/rules/require-tty-guard.d.ts +13 -0
- package/dist/rules/require-tty-guard.js +289 -0
- package/dist/rules/resolve-declaration-convention.d.ts +96 -0
- package/dist/rules/resolve-declaration-convention.js +109 -0
- package/dist/rules/resolve-network-wrapper.d.ts +19 -0
- package/dist/rules/resolve-network-wrapper.js +116 -0
- package/dist/rules/set-options.d.ts +109 -0
- package/dist/rules/set-options.js +201 -0
- package/dist/rules/shebang-interpreter.d.ts +18 -0
- package/dist/rules/shebang-interpreter.js +81 -0
- package/dist/rules/shebang-matches-dialect.d.ts +12 -0
- package/dist/rules/shebang-matches-dialect.js +69 -0
- package/dist/rules/transparent-wrappers.d.ts +52 -0
- package/dist/rules/transparent-wrappers.js +99 -0
- package/dist/rules/user-pattern.d.ts +63 -0
- package/dist/rules/user-pattern.js +184 -0
- package/dist/syntax/analyze.d.ts +19 -0
- package/dist/syntax/analyze.js +21 -0
- package/dist/syntax/index.d.ts +23 -0
- package/dist/syntax/index.js +25 -0
- package/dist/version.d.ts +0 -1
- package/dist/version.js +0 -1
- package/docs/compat.md +347 -0
- package/docs/custom-rule-tutorial.md +780 -34
- package/docs/declaration-conventions.md +478 -0
- package/docs/examples/audit-wrapper-pack.mjs +15 -0
- package/docs/examples/dep-line-convention.mjs +22 -0
- package/docs/examples/eslint.config.mjs +3 -2
- package/docs/examples/no-and-or-as-if-rule.mjs +61 -0
- package/docs/examples/no-backticks-rule-tester.mjs +106 -0
- package/docs/examples/no-backticks-rule.mjs +60 -0
- package/docs/examples/no-curl-in-functions-rule.mjs +53 -0
- package/docs/examples/no-function-keyword-rule.mjs +59 -0
- package/docs/examples/no-listed-commands-rule.ts +63 -0
- package/docs/examples/no-rm-rf-rule.mjs +26 -15
- package/docs/examples/no-unquoted-rm-variable-rule.ts +54 -0
- package/docs/examples/sidecar-convention.mjs +38 -0
- package/docs/examples/sidecar-snapshot-convention.mjs +26 -0
- package/docs/examples/sorted-declarations-rule.mjs +52 -0
- package/docs/reading-nodes.md +159 -0
- package/docs/rules/README.md +78 -0
- package/docs/rules/_template.md +83 -0
- package/docs/rules/compat/no-append-all-redirect.md +87 -0
- package/docs/rules/compat/no-associative-arrays.md +95 -0
- package/docs/rules/compat/no-case-attributes.md +82 -0
- package/docs/rules/compat/no-case-fallthrough.md +90 -0
- package/docs/rules/compat/no-case-modification.md +74 -0
- package/docs/rules/compat/no-coproc.md +76 -0
- package/docs/rules/compat/no-declare-g.md +80 -0
- package/docs/rules/compat/no-double-bracket.md +72 -0
- package/docs/rules/compat/no-epoch-variables.md +79 -0
- package/docs/rules/compat/no-fd-variable.md +78 -0
- package/docs/rules/compat/no-globstar.md +74 -0
- package/docs/rules/compat/no-lastpipe-semantics.md +73 -0
- package/docs/rules/compat/no-let.md +78 -0
- package/docs/rules/compat/no-mapfile.md +118 -0
- package/docs/rules/compat/no-nameref.md +81 -0
- package/docs/rules/compat/no-negative-array-index.md +87 -0
- package/docs/rules/compat/no-nounset-empty-array.md +103 -0
- package/docs/rules/compat/no-parameter-transformation.md +94 -0
- package/docs/rules/compat/no-pipe-all.md +85 -0
- package/docs/rules/compat/no-printf-time-format.md +75 -0
- package/docs/rules/compat/no-source.md +98 -0
- package/docs/rules/compat/no-test-v.md +82 -0
- package/docs/rules/compat/no-unicode-escape.md +91 -0
- package/docs/rules/compat/no-wait-n.md +85 -0
- package/docs/rules/max-script-complexity.md +109 -0
- package/docs/rules/no-dynamic-invocation.md +139 -0
- package/docs/rules/no-eval.md +106 -0
- package/docs/rules/no-masked-exit-status.md +239 -0
- package/docs/rules/no-secret-env-reads.md +173 -0
- package/docs/rules/no-secret-flags.md +169 -0
- package/docs/rules/no-unchecked-cd.md +146 -0
- package/docs/rules/no-undeclared-commands.md +141 -0
- package/docs/rules/no-unused-declarations.md +216 -0
- package/docs/rules/prefer-local.md +221 -0
- package/docs/rules/quote-expansions.md +157 -0
- package/docs/rules/require-network-wrapper.md +171 -0
- package/docs/rules/require-shebang.md +157 -0
- package/docs/rules/require-strict-mode.md +253 -0
- package/docs/rules/require-tty-guard.md +128 -0
- package/docs/rules/shebang-matches-dialect.md +120 -0
- package/docs/selectors.md +2 -2
- package/docs/shellcheck.md +86 -0
- package/docs/wrapper-rules.md +206 -0
- package/package.json +26 -20
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/language/selector-classes.d.ts.map +0 -1
- package/dist/language/selector-classes.js.map +0 -1
- package/dist/language/shell-language-options.d.ts.map +0 -1
- package/dist/language/shell-language-options.js.map +0 -1
- package/dist/language/shell-language.d.ts.map +0 -1
- package/dist/language/shell-language.js.map +0 -1
- package/dist/language/shell-source-code.d.ts.map +0 -1
- package/dist/language/shell-source-code.js.map +0 -1
- package/dist/language/source-coordinates.d.ts.map +0 -1
- package/dist/language/source-coordinates.js.map +0 -1
- package/dist/language/syntax-view.d.ts.map +0 -1
- package/dist/language/syntax-view.js.map +0 -1
- package/dist/language/word-resolution.d.ts.map +0 -1
- package/dist/language/word-resolution.js.map +0 -1
- package/dist/version.d.ts.map +0 -1
- package/dist/version.js.map +0 -1
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# `sh/no-dynamic-invocation`
|
|
2
|
+
|
|
3
|
+
Disallow commands whose executed command cannot be statically determined, so that every command
|
|
4
|
+
a script can run is readable from its source.
|
|
5
|
+
|
|
6
|
+
| Configs | Fixable | Languages |
|
|
7
|
+
| ------- | ------------ | --------- |
|
|
8
|
+
| `agent-authored` | not fixable | `sh/sh` |
|
|
9
|
+
|
|
10
|
+
## Rule details
|
|
11
|
+
|
|
12
|
+
To vouch for a script, a reviewer has to know every command it can run. A command word whose
|
|
13
|
+
value is only known at run time defeats that: `"$cmd" args`, `${tool} x`, `$(pick-tool) x`, and
|
|
14
|
+
`"${cmd[@]}"` can each run anything. So can a string of shell code handed to another shell:
|
|
15
|
+
`bash -c "$x"` and `sh -c '…'` run code that is a string to the reader, not commands.
|
|
16
|
+
|
|
17
|
+
The rule checks every simple command in the file, wherever it appears: top level, functions,
|
|
18
|
+
loops, conditionals, pipelines, and command and process substitutions. So in
|
|
19
|
+
`echo "$("$cmd")"`, the inner `"$cmd"` reports.
|
|
20
|
+
|
|
21
|
+
For each command, the rule finds the command that actually runs by following _transparent
|
|
22
|
+
wrappers_: commands that run the word after them. `time ls` runs `ls`, and
|
|
23
|
+
`sudo -u deploy "$prog"` runs `"$prog"`. The wrappers followed are:
|
|
24
|
+
|
|
25
|
+
- `command`, `exec`, `env`, `nohup`, `nice`, `time`, `timeout`, and `sudo`, with their own
|
|
26
|
+
options skipped (`timeout 10 ls`, `env FOO=1 ls`). `command -v name` only looks `name` up, so
|
|
27
|
+
it runs nothing else.
|
|
28
|
+
- Bash's `builtin`.
|
|
29
|
+
- Shells invoked as commands: `bash`, `sh`, `dash`, `ksh`, `mksh`, and `zsh`.
|
|
30
|
+
`bash ./build.sh` runs `./build.sh`. `bash -c STRING`, including a one-letter cluster such as
|
|
31
|
+
`bash -lc STRING`, runs an embedded string. `bash -s` and a bare `bash` read commands from
|
|
32
|
+
standard input, so the shell itself is the command.
|
|
33
|
+
- Any names you add with `allowWrappers`.
|
|
34
|
+
|
|
35
|
+
A command then reports in one of two ways:
|
|
36
|
+
|
|
37
|
+
- **`dynamicInvocation`**: the command that runs is not a static word. A word is static only when
|
|
38
|
+
its text is fully known from the source, with no parameter expansion, command substitution,
|
|
39
|
+
tilde, glob, or brace expansion. So `~/bin/tool` reports, because `~` depends on `$HOME`. An
|
|
40
|
+
option a wrapper doesn't recognize, such as `sudo -D /tmp rm x`, also reports: the rule never
|
|
41
|
+
guesses which word is the command.
|
|
42
|
+
- **`embeddedCommand`**: the command runs code embedded in a string, as with `bash -c …`,
|
|
43
|
+
`sh -c …`, or `env -S '…'`. It reports even when the string is a literal, because the string is
|
|
44
|
+
never checked as commands. Analyzing the string's contents is out of scope.
|
|
45
|
+
|
|
46
|
+
The rule is syntactic by design. It doesn't track values, so `cmd=ls; "$cmd"` reports. Aliases
|
|
47
|
+
need no handling: non-interactive shells don't expand them, so an alias name is an ordinary
|
|
48
|
+
static command word. Wrapper names match exactly, so `/bin/bash -c …` is a static command named
|
|
49
|
+
`/bin/bash`, not a shell wrapper.
|
|
50
|
+
|
|
51
|
+
It does not depend on `languageOptions`.
|
|
52
|
+
|
|
53
|
+
## Options
|
|
54
|
+
|
|
55
|
+
- `allowInFunctions` (`string[]`, default `[]`): names of dispatcher functions. A
|
|
56
|
+
`dynamicInvocation` inside a function with one of these names, at any nesting depth, does not
|
|
57
|
+
report. The same command outside that function still reports. `embeddedCommand` still reports
|
|
58
|
+
inside these functions. Each use of the dispatcher stays visible at its call sites.
|
|
59
|
+
- `allowWrappers` (`string[]`, default `[]`): extra transparent wrappers, added to the built-in
|
|
60
|
+
list above. The word after one is checked as the command that runs. An added wrapper has no
|
|
61
|
+
known options, so a flag right after it (`with-retry -n 3 curl`) makes the command unknown and
|
|
62
|
+
reports. A name already in the built-in list keeps its built-in option handling.
|
|
63
|
+
|
|
64
|
+
```js
|
|
65
|
+
// eslint.config.js
|
|
66
|
+
{
|
|
67
|
+
rules: {
|
|
68
|
+
'sh/no-dynamic-invocation': [
|
|
69
|
+
'error',
|
|
70
|
+
{ allowInFunctions: ['dispatch'], allowWrappers: ['with-retry'] },
|
|
71
|
+
],
|
|
72
|
+
},
|
|
73
|
+
}
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## Examples
|
|
77
|
+
|
|
78
|
+
### Incorrect
|
|
79
|
+
|
|
80
|
+
<!-- example: {"findings": 10, "messageIds": ["dynamicInvocation", "dynamicInvocation", "dynamicInvocation", "dynamicInvocation", "dynamicInvocation", "dynamicInvocation", "dynamicInvocation", "dynamicInvocation", "embeddedCommand", "embeddedCommand"]} -->
|
|
81
|
+
```sh
|
|
82
|
+
"$cmd" args # the command word is a parameter expansion
|
|
83
|
+
${tool} x # same, braced
|
|
84
|
+
$(pick-tool) x # the command is computed by a command substitution
|
|
85
|
+
"${cmd[@]}" # array splat: the first element is the command
|
|
86
|
+
exec "$@" # exec runs its first argument
|
|
87
|
+
sudo -u deploy "$prog" # sudo runs a dynamic target
|
|
88
|
+
echo "$("$cmd")" # a dynamic command nested in a substitution
|
|
89
|
+
~/bin/tool x # the path depends on $HOME
|
|
90
|
+
bash -c "$script" # embeddedCommand: a string of shell code
|
|
91
|
+
sh -c 'make test' # embeddedCommand, even for a literal string
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
With `{ allowWrappers: ['with-retry'] }`:
|
|
95
|
+
|
|
96
|
+
<!-- example: {"options": {"allowWrappers": ["with-retry"]}, "findings": 1, "messageIds": ["dynamicInvocation"]} -->
|
|
97
|
+
```sh
|
|
98
|
+
with-retry "$c" # the wrapped command is dynamic
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
### Correct
|
|
102
|
+
|
|
103
|
+
```sh
|
|
104
|
+
ls -la "$dir" # dynamic arguments are fine; the command is `ls`
|
|
105
|
+
time ls # time is transparent; it runs `ls`
|
|
106
|
+
nohup env FOO=1 make # wrappers are followed to `make`
|
|
107
|
+
command -v "$tool" # looks a name up; runs nothing
|
|
108
|
+
bash ./build.sh # runs the literal script `./build.sh`
|
|
109
|
+
alias ll='ls -l'
|
|
110
|
+
ll # an alias name is a static command word
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
With `{ allowInFunctions: ['dispatch'], allowWrappers: ['with-retry'] }`:
|
|
114
|
+
|
|
115
|
+
<!-- example: {"options": {"allowInFunctions": ["dispatch"], "allowWrappers": ["with-retry"]}} -->
|
|
116
|
+
```sh
|
|
117
|
+
dispatch() {
|
|
118
|
+
"$@" # allowed inside the dispatcher
|
|
119
|
+
}
|
|
120
|
+
with-retry curl https://example.com # with-retry runs `curl`
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
## When not to use it
|
|
124
|
+
|
|
125
|
+
Turn the rule off for scripts nobody needs to vouch for, where running a computed command is the
|
|
126
|
+
point, such as a generic task runner. Prefer `allowInFunctions` for one dispatcher function over
|
|
127
|
+
disabling the rule for the whole file, and prefer a line-level
|
|
128
|
+
`# eslint-disable-next-line sh/no-dynamic-invocation` with a reason over a file-level disable,
|
|
129
|
+
so new dynamic commands still report.
|
|
130
|
+
|
|
131
|
+
## Relationship to shellcheck
|
|
132
|
+
|
|
133
|
+
eslint-sh is a convention and contract linter: a policy layer, where shellcheck is the gotcha
|
|
134
|
+
layer. Run shellcheck beside it.
|
|
135
|
+
|
|
136
|
+
shellcheck has no equivalent. It treats a dynamic command word as legitimate shell, and only
|
|
137
|
+
warns about how it is written, such as unquoted expansions (SC2086) or a string used where an
|
|
138
|
+
array belongs (SC2089/SC2090). It can't express the policy that every command must be
|
|
139
|
+
statically enumerable, which is what makes a script reviewable as a whole.
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# `sh/no-eval`
|
|
2
|
+
|
|
3
|
+
Disallow `eval`, and `source` or `.` of a path that is not a static literal, so that every line
|
|
4
|
+
of code a script runs can be reviewed before it runs.
|
|
5
|
+
|
|
6
|
+
| Configs | Fixable | Languages |
|
|
7
|
+
| ------- | ------------ | --------- |
|
|
8
|
+
| `recommended`, `agent-authored` | not fixable | `sh/sh` |
|
|
9
|
+
|
|
10
|
+
## Rule details
|
|
11
|
+
|
|
12
|
+
A script you can review is a script whose code is all in front of you. Two constructs defeat
|
|
13
|
+
that by building the code to run at run time:
|
|
14
|
+
|
|
15
|
+
- **`eval`** re-parses its arguments as shell code when the line executes. The code a reviewer
|
|
16
|
+
reads is a string, not commands. That holds even for `eval "literal"`: the literal is a second
|
|
17
|
+
layer of shell parsing that no static tool, this linter included, treats as commands. So the
|
|
18
|
+
rule reports **every** `eval`, whatever its arguments.
|
|
19
|
+
- **`source <path>` and `. <path>`** run another file in the current shell. When the path is
|
|
20
|
+
only known at run time, a reviewer cannot tell which file, and so which code, runs.
|
|
21
|
+
|
|
22
|
+
The rule inspects every `CallExpr` (simple command), wherever it appears: top level, inside
|
|
23
|
+
functions, loops, conditionals, and command substitutions.
|
|
24
|
+
|
|
25
|
+
- **`eval`** (message `noEval`) is reported on the whole command. It is recognized as the
|
|
26
|
+
command that actually runs after transparent wrappers are followed, so `command eval …`,
|
|
27
|
+
`builtin eval …`, and `exec eval …` all report. `command -v eval` does not: it only looks
|
|
28
|
+
`eval` up.
|
|
29
|
+
- **`source` / `.`** (message `dynamicSource`) is reported on its path word when that word is not
|
|
30
|
+
static. A word is static only when its text is fully known from the source, with no parameter
|
|
31
|
+
expansion, command substitution, tilde, glob, or brace expansion. So `source ~/lib.sh` reports,
|
|
32
|
+
because `~` depends on `$HOME`. Words after the path are positional parameters for the sourced
|
|
33
|
+
file and are not checked. Like `eval`, `source` and `.` are recognized as the command that
|
|
34
|
+
actually runs after transparent wrappers are followed, so `command source "$p"` and
|
|
35
|
+
`builtin . "$p"` report exactly as `source "$p"` does, and `command source ./static.sh` is
|
|
36
|
+
clean. The path is the word right after the effective command.
|
|
37
|
+
|
|
38
|
+
The rule deliberately ignores:
|
|
39
|
+
|
|
40
|
+
- the word `eval` anywhere other than the command position (`echo eval`), and in comments;
|
|
41
|
+
- a command word that is itself dynamic (`"$cmd" args`), and command strings passed to another
|
|
42
|
+
shell (`bash -c "$x"`, `sh -c …`).
|
|
43
|
+
|
|
44
|
+
It does not depend on `languageOptions`.
|
|
45
|
+
|
|
46
|
+
## Options
|
|
47
|
+
|
|
48
|
+
This rule has no options.
|
|
49
|
+
|
|
50
|
+
```js
|
|
51
|
+
// eslint.config.js
|
|
52
|
+
{
|
|
53
|
+
rules: {
|
|
54
|
+
'sh/no-eval': 'error',
|
|
55
|
+
},
|
|
56
|
+
}
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Examples
|
|
60
|
+
|
|
61
|
+
### Incorrect
|
|
62
|
+
|
|
63
|
+
<!-- example: {"findings": 10, "messageIds": ["noEval", "noEval", "noEval", "noEval", "noEval", "dynamicSource", "dynamicSource", "dynamicSource", "dynamicSource", "dynamicSource"]} -->
|
|
64
|
+
```sh
|
|
65
|
+
eval "echo hi" # eval, even of a literal string
|
|
66
|
+
eval "$cmd" # eval of a value assembled at run time
|
|
67
|
+
command eval "$cmd" # eval reached through the `command` wrapper
|
|
68
|
+
builtin eval "$cmd" # eval reached through the `builtin` wrapper
|
|
69
|
+
exec eval "$cmd" # eval reached through the `exec` wrapper
|
|
70
|
+
source "$DIR/lib.sh" # the path depends on $DIR
|
|
71
|
+
. $(pick) # the path is computed by a command substitution
|
|
72
|
+
source ~/lib.sh # the path depends on $HOME
|
|
73
|
+
command source "$p" # source reached through the `command` wrapper
|
|
74
|
+
builtin . "$p" # `.` reached through the `builtin` wrapper
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
### Correct
|
|
78
|
+
|
|
79
|
+
```sh
|
|
80
|
+
source ./lib.sh # a literal relative path
|
|
81
|
+
. "/etc/profile" # a quoted literal path
|
|
82
|
+
source ./lib.sh "$1" # only the path word must be static
|
|
83
|
+
command source ./lib.sh # a literal path stays clean behind a wrapper
|
|
84
|
+
echo eval # `eval` is an argument here, not a command
|
|
85
|
+
command -v eval # looks `eval` up; never runs it
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
## When not to use it
|
|
89
|
+
|
|
90
|
+
Turn the rule off for a script whose purpose is to run generated code, for example a bootstrap
|
|
91
|
+
step that `eval`s the output of a tool like `ssh-agent -s` or `brew shellenv`, where the tool,
|
|
92
|
+
not the script, is the reviewed artifact. Prefer disabling it on that line
|
|
93
|
+
(`# eslint-disable-next-line sh/no-eval`) over disabling it for the whole file, so new uses
|
|
94
|
+
still report.
|
|
95
|
+
|
|
96
|
+
## Relationship to shellcheck
|
|
97
|
+
|
|
98
|
+
eslint-sh is a convention and contract linter: a policy layer, where shellcheck is the gotcha
|
|
99
|
+
layer. Run shellcheck beside it.
|
|
100
|
+
|
|
101
|
+
This rule is one of eslint-sh's two deliberate overlaps with shellcheck. shellcheck flags
|
|
102
|
+
`eval` only in specific misuse patterns, such as SC2294 (`eval` on an array negates the benefit
|
|
103
|
+
of arrays), and flags `source` of a non-constant path as SC1090 because shellcheck cannot follow
|
|
104
|
+
it. Both treat these as hazards to work around. This rule treats them as a policy: code that
|
|
105
|
+
can't be statically reviewed doesn't ship, so it reports every `eval`, including the uses
|
|
106
|
+
shellcheck accepts. If you run shellcheck and only want its narrower checks, disable this rule.
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
# `sh/no-masked-exit-status`
|
|
2
|
+
|
|
3
|
+
Disallow `local`, `declare`, `typeset`, `export` and `readonly` with a value taken from a command substitution, because the builtin's exit status replaces the command's.
|
|
4
|
+
|
|
5
|
+
| Configs | Fixable | Languages |
|
|
6
|
+
| ---------------- | ----------------------------------------- | --------- |
|
|
7
|
+
| `agent-authored` | suggestions (split into two statements) | `sh/sh` |
|
|
8
|
+
|
|
9
|
+
## Rule details
|
|
10
|
+
|
|
11
|
+
`local x=$(cmd)` runs `cmd`, then runs `local`, and the statement's exit status is the status of
|
|
12
|
+
`local`, which succeeds. A failing `cmd` therefore does not stop a script under `set -e`, and in
|
|
13
|
+
`local x=$(cmd) && next` the `next` runs anyway. `export X=$(cmd)`, `readonly X=$(cmd)`,
|
|
14
|
+
`declare x=$(cmd)` and `typeset x=$(cmd)` behave the same way. This is the most common way a
|
|
15
|
+
script that sets `set -euo pipefail` still swallows a failure, which is why
|
|
16
|
+
[`require-strict-mode`](./require-strict-mode.md) alone does not close the hole.
|
|
17
|
+
|
|
18
|
+
The rule reports a `local`, `declare`, `typeset`, `export` or `readonly` operand of the form
|
|
19
|
+
`name=value` whose value contains a command substitution, `$(…)` or backticks. The substitution
|
|
20
|
+
can be the whole value, part of a larger word (`export DIR=/opt/$(basename "$p")`), inside double
|
|
21
|
+
quotes, or inside a parameter expansion (`"${y:-$(z)}"`, `${y:$(n):1}`, `${a[$(i)]}`) or an
|
|
22
|
+
arithmetic expansion (`$(( $(count) + 1 ))`). In a plain assignment a failing substitution in any of
|
|
23
|
+
those places fails the assignment, so `set -e` stops the script in Bash, dash, zsh and ksh93; behind a
|
|
24
|
+
declaration builtin none of them does. The rule reads the builtins behind
|
|
25
|
+
`builtin` and `command` too. It reports once per operand, on the operand, and the message names the
|
|
26
|
+
builtin and the variable.
|
|
27
|
+
|
|
28
|
+
What the rule does not report:
|
|
29
|
+
|
|
30
|
+
- A value with no command substitution: `local x=$y`, `local x="${y:-z}"`, `local n=$((a + b))`.
|
|
31
|
+
- A declaration with no value: `local x`, `export X`, `readonly X`.
|
|
32
|
+
- A plain assignment: `x=$(cmd)`. Its exit status is the command's.
|
|
33
|
+
- The split forms: `local x; x=$(cmd)` and `X=$(cmd); export X`. `export X; X=$(cmd)` is not reported
|
|
34
|
+
either, but it exports the old value to the command, which the original did not.
|
|
35
|
+
- A process substitution used as a value (`local p=<(cmd)`). It expands to a path, so there is no
|
|
36
|
+
status to lose.
|
|
37
|
+
- An array value (`local -a a=($(cmd))`). shellcheck does not report SC2155 for it either.
|
|
38
|
+
|
|
39
|
+
**On purpose, and noisy for some code.** `export PATH="$(dirname "$0"):$PATH"` is reported. The
|
|
40
|
+
substitution's failure is hidden there like anywhere else, and the split
|
|
41
|
+
(`PATH="$(dirname "$0"):$PATH"` then `export PATH`) is safe because `export` creates no scope. To quiet
|
|
42
|
+
it, split the line as suggested, add an ESLint disable comment for the line, or set
|
|
43
|
+
`builtins: ["local"]` to stop checking `export`, `readonly`, `declare` and `typeset`.
|
|
44
|
+
|
|
45
|
+
The corpus the rule was measured on shows how much of the output is worth acting on. Of 40 evenly
|
|
46
|
+
spaced reports in eight open-source projects, 25 were real masked failures (`mktemp`, `get_*`
|
|
47
|
+
functions, `cd … && pwd`, `docker ls`) and 15 were low value: a `date`, a `$(cat)`, a command ending
|
|
48
|
+
in `|| true`, or a pipeline whose last stage already decides the status (`… | awk`, `| wc -l`). So
|
|
49
|
+
about one report in three is a correct finding that you may not want to fix. That is
|
|
50
|
+
the same noise as shellcheck's SC2155, which the rule matches on the shared constructs. In
|
|
51
|
+
`agent-authored` the rule is an `error`, because the preset's contract is that a failure stops
|
|
52
|
+
the script. A team that wants a gentler start can set it to `warn`, or use `onlyWithErrexit`, which
|
|
53
|
+
drops the findings in files and functions where errexit is not on.
|
|
54
|
+
|
|
55
|
+
**Suggestion.** There is no autofix. Splitting the declaration changes behavior on purpose: under
|
|
56
|
+
`set -e` a failure of `cmd` now stops the script. The editor suggestion declares the names first
|
|
57
|
+
and assigns each substituted one in its own statement. It keeps the builtin's flags, the
|
|
58
|
+
indentation and a trailing comment.
|
|
59
|
+
|
|
60
|
+
<!-- transform: {"kind": "suggestion"} -->
|
|
61
|
+
|
|
62
|
+
```sh
|
|
63
|
+
f() {
|
|
64
|
+
local -i n=$(count) # lines
|
|
65
|
+
}
|
|
66
|
+
# becomes
|
|
67
|
+
f() {
|
|
68
|
+
local -i n
|
|
69
|
+
n=$(count) # lines
|
|
70
|
+
}
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
With several operands, every name is declared first and then each substituted one is assigned:
|
|
74
|
+
`local APP="" REMOTE="$(fn-get-remote)" HOST=""` becomes `local APP="" REMOTE HOST=""` followed by
|
|
75
|
+
`REMOTE="$(fn-get-remote)"`. `export` goes the other way, because exporting first would give the
|
|
76
|
+
command the old value in its environment: `export X=$(cmd)` becomes `X=$(cmd)`, `export X`. When the
|
|
77
|
+
declaration forbids a later assignment (`readonly`, or `-r`),
|
|
78
|
+
the suggestion assigns first and makes the variable read-only afterwards:
|
|
79
|
+
`declare -r x=$(cmd)` becomes `declare x`, `x=$(cmd)`, `readonly x`.
|
|
80
|
+
|
|
81
|
+
The suggestion is withheld where the split would not mean the same thing, and the report stays:
|
|
82
|
+
|
|
83
|
+
- The value reads a name that the split moves before it. For `local`, `declare` and `typeset`
|
|
84
|
+
inside a function that includes the variable itself: `local P=$(echo "$P" | cut -d. -f1)`
|
|
85
|
+
would read the new, empty local after the split. `export PATH="$(…):$PATH"` is safe to split,
|
|
86
|
+
because `export` creates no new scope. For every builtin it also includes the other operands of
|
|
87
|
+
the same declaration. Any mention of such a name in the value counts, because a command can read a
|
|
88
|
+
variable by name: `local x=$([ -v x ] && echo set)`, `local x=$(declare -p x)` and
|
|
89
|
+
`export A=1 B=$(printenv A)` are withheld. So can an unrelated word, as in
|
|
90
|
+
`local name=$(git config user.name)`. Under `-i` the builtin evaluates each value as arithmetic,
|
|
91
|
+
so this reaches the values without a substitution too: `local -i a=$(cmd) b=a+1` is withheld. So
|
|
92
|
+
does `export`, which runs after the assignments: `export A=$(cmd) B=$A` is withheld. **Limit:** a
|
|
93
|
+
command that dumps every variable names none of them, so `local n=$(declare -p | wc -l)` and
|
|
94
|
+
`local n=$(set | wc -l)` keep their suggestion and see one more variable after the split.
|
|
95
|
+
- A scoped declaration of a variable the shell or its commands consult, such as `PATH`, `IFS`, `HOME`,
|
|
96
|
+
`LANG`, `TERM`, `PWD` or `TMPDIR`. `local PATH; PATH=$(cmd)` empties `PATH` first, so `cmd` is not found.
|
|
97
|
+
**Limit:** any other exported variable can be read by a command too, and that is not visible in the
|
|
98
|
+
script: after `export X=1`, a command in `local X=$(cmd)` that reads `X` sees a different environment
|
|
99
|
+
once the line is split. The same goes for `export A=$(c1) B=$(./tool)` when `A` is already exported:
|
|
100
|
+
`./tool` sees the new `A` after the split, and the old one before it.
|
|
101
|
+
- The value runs code the rule cannot read, and that code could read a name the split declares
|
|
102
|
+
earlier. That means the substitution runs `eval`, `source` or `.`, a command whose name is not
|
|
103
|
+
static (`$($cmd)`), or a function defined in the same file:
|
|
104
|
+
`g() { echo "$x"; }; f() { local x=$(g); }` is withheld, as is `local y=1 x=$(g)` when `g` reads
|
|
105
|
+
`y`. This applies where the split changes what is visible: `local`, `declare` or `typeset` inside a
|
|
106
|
+
function, a declaration with several operands, or one with any flag. A lone `export x=$(g)` keeps
|
|
107
|
+
its suggestion. **Limit:** a function defined in another file, such as one from a script that
|
|
108
|
+
is sourced, cannot be seen, so the rule cannot tell that a call to it reads the variable. The
|
|
109
|
+
suggestion may then change what that function sees. Read a suggestion before applying it.
|
|
110
|
+
- The statement has prefix assignments, as in `A=1 export x=$(cmd)`. The replacement covers the
|
|
111
|
+
whole command and would delete them.
|
|
112
|
+
- A flag changes what the split means or is not valid with an assignment. `declare -g x=$(cmd)`
|
|
113
|
+
writes the global, while the split writes the nearest local, so any `-g` is withheld (`-gx` too).
|
|
114
|
+
`local -x x=$(cmd)` would export the name to the command before it runs, so `-x` is withheld. After
|
|
115
|
+
`declare -n r`, the assignment in the split writes through `r` where the original rebinds it, so
|
|
116
|
+
`-n` is withheld on `local`, `declare` and `typeset`.
|
|
117
|
+
`-f`, `-F`, `-p` and flags the builtin does not have (`export -x`) are withheld as well, so the
|
|
118
|
+
suggestion never turns an error into an assignment. `local` outside a function is an error and
|
|
119
|
+
is also withheld. The flags kept are `-a -A -i -l -r -t -u` (`-n` for `export`).
|
|
120
|
+
- The declaration is not a standalone statement: part of an `&&`/`||` list or a pipeline (including
|
|
121
|
+
`[[ … ]] && local x=$(cmd)`), an `if` or `while` condition, negated, in the background, or
|
|
122
|
+
redirected.
|
|
123
|
+
- The builtin is reached through `builtin` or `command`.
|
|
124
|
+
- An operand is an append (`x+=$(cmd)`) or has a subscript (`a[1]=$(cmd)`), or `readonly` has flags.
|
|
125
|
+
|
|
126
|
+
Under `dialect: "posix"` the parser has no declaration clause and reads `local x=$(cmd)` as an
|
|
127
|
+
ordinary command; the rule reads it the same way and offers the same suggestion. Otherwise the rule
|
|
128
|
+
does not depend on `languageOptions`.
|
|
129
|
+
|
|
130
|
+
## Options
|
|
131
|
+
|
|
132
|
+
| Option | Type | Default | Effect |
|
|
133
|
+
| ----------------- | ---------- | ------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
134
|
+
| `onlyWithErrexit` | `boolean` | `false` | Report only where `errexit` is known to be on (below). |
|
|
135
|
+
| `allowShebangOnly` | `boolean` | `false` | With `onlyWithErrexit`, count options on the shebang line (`#!/bin/bash -e`, `#!/bin/sh -e`, `#!/usr/bin/env -S bash -e`) as enabled. Same meaning and default as the option of that name in [`require-strict-mode`](./require-strict-mode.md), and read by the same code. |
|
|
136
|
+
| `builtins` | `string[]` | `["local", "declare", "typeset", "export", "readonly"]` | Which declaration builtins to check, for example `["local"]` for teams that accept `export X=$(…)` at the top level. |
|
|
137
|
+
|
|
138
|
+
**Where `errexit` is on.** The rule reads the plain top-level `set` statements (`set -e`,
|
|
139
|
+
`set -euo pipefail`, `set -o errexit`, `set +e`) in order, with the same reading
|
|
140
|
+
`require-strict-mode` uses for each one. A statement is checked when they leave `errexit` on
|
|
141
|
+
before it. So `export x=$(cmd)` followed by `set -e` is not checked, and a `set -e` after five
|
|
142
|
+
statements counts for the code after it. Unlike `require-strict-mode` there is no five-statement
|
|
143
|
+
window, because the order is tracked. A function body is read at the point where it is defined, so
|
|
144
|
+
`set -e` before `f() { … }` covers `f`.
|
|
145
|
+
|
|
146
|
+
`allowShebangOnly` is off by default for the reason it is off in `require-strict-mode`: options on the
|
|
147
|
+
shebang line apply only when the script is run directly, and `bash script.sh` ignores them. Turn it
|
|
148
|
+
on if your scripts are always executed directly.
|
|
149
|
+
|
|
150
|
+
Under `dialect: "bats"` a statement written directly in an `@test` body, or directly in a top-level
|
|
151
|
+
`setup`, `teardown`, `setup_file` or `teardown_file` function, counts as on, because bats runs
|
|
152
|
+
those bodies with `errexit` on, until the body runs `set +e`. This is the reading `no-unchecked-cd`
|
|
153
|
+
uses. A declaration nested deeper in an `@test` (inside an `if` or a loop) falls back to the
|
|
154
|
+
file-level reading, so it is not checked.
|
|
155
|
+
|
|
156
|
+
What is not tracked: a `set` inside a function, a `{ … }` group, a condition or a loop; `set -e` in a
|
|
157
|
+
file that is sourced; `errexit` given on the command line (`bash -e script.sh`, `sh -e`); and a
|
|
158
|
+
`set -e` or `set +e` between the definition of a function and a call to it. So
|
|
159
|
+
`f() { local x=$(cmd); }; set -e; f` is not checked. A function called on the left of `||` runs with
|
|
160
|
+
`errexit` off, which is not followed either, so `set -e; f() { local x=$(cmd); echo ok; }; f || g` is
|
|
161
|
+
reported.
|
|
162
|
+
|
|
163
|
+
`onlyWithErrexit` does not apply where the script reads the status explicitly, because there the
|
|
164
|
+
status is always the builtin's. Those positions are the left side of `&&` or `||`, the right side of
|
|
165
|
+
one that is itself read (so `true && export x=$(false) && echo c` reports the `export`), and the last
|
|
166
|
+
command of an `if`, `elif`, `while` or `until` condition, including the middle of an `&&` chain
|
|
167
|
+
inside that condition (`if a && export x=$(false); then`). They are reported in every file. Status
|
|
168
|
+
read further away is not followed: the caller of a function (`f || echo failed`), the last command
|
|
169
|
+
of an `if` or `case` body, or a later `$?`.
|
|
170
|
+
|
|
171
|
+
```js
|
|
172
|
+
// eslint.config.js
|
|
173
|
+
{
|
|
174
|
+
rules: {
|
|
175
|
+
'sh/no-masked-exit-status': ['error', { onlyWithErrexit: true, builtins: ['local'] }],
|
|
176
|
+
},
|
|
177
|
+
}
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
## Examples
|
|
181
|
+
|
|
182
|
+
### Incorrect
|
|
183
|
+
|
|
184
|
+
<!-- example: {"findings": 6, "messageIds": ["maskedExitStatus", "maskedExitStatus", "maskedExitStatus", "maskedExitStatus", "maskedExitStatus", "maskedExitStatus"]} -->
|
|
185
|
+
|
|
186
|
+
```sh
|
|
187
|
+
f() {
|
|
188
|
+
local image=$(get_image "$1") # reported: local's status replaces get_image's
|
|
189
|
+
local dir="$(dirname "$1")" ver=1 # reported: the quoted form too
|
|
190
|
+
declare -r token=$(read_token) # reported: -r does not help
|
|
191
|
+
}
|
|
192
|
+
export LE_DIR="$(echo "$2" | sed 's|/$||')" # reported: export
|
|
193
|
+
readonly N_CACHE="$(mktemp -d)" # reported: readonly
|
|
194
|
+
[ -n "$x" ] && export Y=`date` # reported: backticks, after &&
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
### Correct
|
|
198
|
+
|
|
199
|
+
```sh
|
|
200
|
+
f() {
|
|
201
|
+
local image dir
|
|
202
|
+
image=$(get_image "$1") # the assignment keeps get_image's status
|
|
203
|
+
dir=$(dirname "$1")
|
|
204
|
+
local n=$((1 + 2)) y=$z # no command substitution
|
|
205
|
+
local -a files=($(ls)) # array values are not reported
|
|
206
|
+
local p=<(sort file) # a process substitution is a path
|
|
207
|
+
}
|
|
208
|
+
LE_DIR=$(pwd); export LE_DIR
|
|
209
|
+
readonly VERSION=1.2
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
## When not to use it
|
|
213
|
+
|
|
214
|
+
Turn the rule off, or set `onlyWithErrexit`, for scripts whose failures are never meant to stop
|
|
215
|
+
them and where the declaration idiom is accepted house style. `builtins: ["local"]` suits teams that
|
|
216
|
+
accept `export X=$(…)` at the top level and only want function variables split.
|
|
217
|
+
|
|
218
|
+
## Relationship to shellcheck
|
|
219
|
+
|
|
220
|
+
eslint-sh is a convention and contract linter: a policy layer, where shellcheck is the gotcha
|
|
221
|
+
layer. Run shellcheck beside it.
|
|
222
|
+
|
|
223
|
+
shellcheck reports this construct as SC2155 ("Declare and assign separately to avoid masking return
|
|
224
|
+
values") and offers no fix for it. The rule exists anyway for three reasons. `require-strict-mode`
|
|
225
|
+
makes `set -euo pipefail` a contract, and this construct is the most common way a script that
|
|
226
|
+
honours the contract still swallows a failure, so `agent-authored`, which requires strict mode,
|
|
227
|
+
closes the hole. eslint-sh can offer the split as an editor suggestion, which shellcheck cannot. And
|
|
228
|
+
`onlyWithErrexit` and `builtins` limit the policy to the files and builtins where the status matters.
|
|
229
|
+
|
|
230
|
+
It is not enabled in `recommended`, which is meant to run beside shellcheck, because the two would
|
|
231
|
+
report the same line twice. Where both run, disable SC2155 or this rule. The two agree on scalar
|
|
232
|
+
values for all five builtins. The rule reports more than shellcheck 0.11.0 in three places: a
|
|
233
|
+
substitution inside a parameter expansion operand, a substring offset, an array subscript or an
|
|
234
|
+
arithmetic expansion (`local x="${y:-$(cmd)}"`, `local n=$(( $(cmd) + 1 ))`), which shellcheck does
|
|
235
|
+
not look into although real shells mask their status the same way, and `local -r x=$(cmd)` or
|
|
236
|
+
`declare -r x=$(cmd)`, which shellcheck exempts. The rule's
|
|
237
|
+
suggestion handles the second by assigning first and then running `readonly`. It also reads the
|
|
238
|
+
`builtin` and `command` forms, and honours neither a `# shellcheck disable=SC2155` directive nor
|
|
239
|
+
`.shellcheckrc`; use an ESLint disable comment instead.
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
# `sh/no-secret-env-reads`
|
|
2
|
+
|
|
3
|
+
Disallow reading secret-shaped environment variables, such as `$GITHUB_TOKEN`, outside allowed sink commands.
|
|
4
|
+
|
|
5
|
+
| Configs | Fixable | Languages |
|
|
6
|
+
| ---------------- | ----------- | --------- |
|
|
7
|
+
| `agent-authored` (error) | not fixable | `sh/sh` |
|
|
8
|
+
|
|
9
|
+
## Rule details
|
|
10
|
+
|
|
11
|
+
Once a secret is expanded into a command's arguments, every user on the machine can read it
|
|
12
|
+
through `ps` and `/proc/<pid>/cmdline`, and it tends to land in logs and `set -x` traces. This
|
|
13
|
+
rule reports a read of an environment variable whose name looks like it holds a secret.
|
|
14
|
+
|
|
15
|
+
The rule inspects every parameter expansion: `$NAME`, `${NAME}`, and the forms with operators such
|
|
16
|
+
as `${NAME:-x}`. A read is reported when the variable's name matches a pattern. The
|
|
17
|
+
default pattern is `(_TOKEN|_SECRET|_PASSWORD|_KEY)$`. It is case-sensitive, so lower-case shell
|
|
18
|
+
locals such as `cache_key` are not matched.
|
|
19
|
+
|
|
20
|
+
Four things are not reported:
|
|
21
|
+
|
|
22
|
+
- **Assignments.** `API_KEY=value` writes the variable; it is not a read.
|
|
23
|
+
- **An assignment prefix of a command that runs.** `TOKEN=$GITHUB_TOKEN tool` expands the secret
|
|
24
|
+
into the child's environment, not its argv. This is the recommended pattern, and the message
|
|
25
|
+
suggests it. The read must sit directly in the prefix value: `T=$(echo "$GITHUB_TOKEN") tool`
|
|
26
|
+
reports, because the `echo` command reads it. A bare assignment with no command
|
|
27
|
+
(`TOKEN=$GITHUB_TOKEN`) and `export TOKEN=$GITHUB_TOKEN` still read the variable and report.
|
|
28
|
+
- **Expansions that never yield the value.** A length, `${#API_KEY}`, expands to a number. An
|
|
29
|
+
alternate value, `${API_KEY:+word}` or `${API_KEY+word}`, expands to `word` when the variable is
|
|
30
|
+
set and to nothing otherwise. Neither puts the secret in argv, logs, or traces. A read written
|
|
31
|
+
inside `word` is reported on its own: `${GH_TOKEN:+-H "Authorization: $GH_TOKEN"}` reports the
|
|
32
|
+
inner `$GH_TOKEN`.
|
|
33
|
+
- **Arguments of an allowed sink.** A command named in `allowedSinks` may read a secret. The
|
|
34
|
+
command is resolved through transparent wrappers (`sudo vault …` is `vault`), and a command run
|
|
35
|
+
by path matches by basename.
|
|
36
|
+
|
|
37
|
+
Matching is a name pattern, not secret detection.
|
|
38
|
+
|
|
39
|
+
### Expected false positives
|
|
40
|
+
|
|
41
|
+
The rule is a heuristic screen. Expect reports in these classes, and suppress them with a disable
|
|
42
|
+
comment that says why, narrow `patterns`, or list the command in `allowedSinks`:
|
|
43
|
+
|
|
44
|
+
- Names that end in a secret-shaped suffix but hold nothing secret, such as `CACHE_KEY`,
|
|
45
|
+
`PRIMARY_KEY`, or `SORT_KEY`.
|
|
46
|
+
- Presence checks such as `[ -n "${API_KEY:-}" ]`. The test command never reaches argv, but the
|
|
47
|
+
value is expanded, and a `set -x` trace prints it. `[ -n "${API_KEY+set}" ]` checks presence
|
|
48
|
+
without expanding the value.
|
|
49
|
+
- Reads that copy the secret into another variable (`KEY=$API_KEY`), which the rule cannot follow.
|
|
50
|
+
- Reads by a command you have already vetted but not listed in `allowedSinks`.
|
|
51
|
+
|
|
52
|
+
### Expected false negatives
|
|
53
|
+
|
|
54
|
+
The rule matches parameter expansions only. These reads are not reported:
|
|
55
|
+
|
|
56
|
+
- Arithmetic reads, such as `$((A_KEY+1))` and `(( A_KEY > 0 ))`, where the variable is named
|
|
57
|
+
without a `$`.
|
|
58
|
+
|
|
59
|
+
## Options
|
|
60
|
+
|
|
61
|
+
- `patterns` (`string[]`, at least one entry). Regular-expression sources tested against each
|
|
62
|
+
variable's name. They **replace** the default list. Sources are compiled case-sensitively. An
|
|
63
|
+
invalid source fails config validation.
|
|
64
|
+
|
|
65
|
+
Default: `["(_TOKEN|_SECRET|_PASSWORD|_KEY)$"]`.
|
|
66
|
+
|
|
67
|
+
- `allowedSinks` (`string[]`). Command names whose arguments may read a secret-shaped variable.
|
|
68
|
+
|
|
69
|
+
Default: `[]`.
|
|
70
|
+
|
|
71
|
+
```js
|
|
72
|
+
// eslint.config.js
|
|
73
|
+
{
|
|
74
|
+
rules: {
|
|
75
|
+
'sh/no-secret-env-reads': [
|
|
76
|
+
'error',
|
|
77
|
+
{ allowedSinks: ['vault', 'gh'], patterns: ['(_TOKEN|_SECRET|_PASSWORD|_KEY|_PAT)$'] },
|
|
78
|
+
],
|
|
79
|
+
},
|
|
80
|
+
}
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
### Patterns run against script-controlled text
|
|
84
|
+
|
|
85
|
+
`patterns` is tested against every variable name the linted script expands, and the script's author
|
|
86
|
+
chooses that text. When you lint scripts you do not control, a pattern with nested or overlapping
|
|
87
|
+
quantifiers can make the regular expression engine backtrack for a time that grows exponentially
|
|
88
|
+
with the name's length. Write anchored patterns with one level of repetition over a character class.
|
|
89
|
+
|
|
90
|
+
The rule rejects the most common accident: a pattern that repeats a group which itself repeats
|
|
91
|
+
(`*`, `+`, `{n,}` or `{n,m}` with `m > 1` applied to such a group). It is not a proof that an
|
|
92
|
+
accepted pattern is safe. Review every pattern you accept as if the guard were not there.
|
|
93
|
+
|
|
94
|
+
**What it does not catch.** These pass the check and can still be slow on a long name. Timings are
|
|
95
|
+
from Node 24 on a busy laptop and vary by machine; the shape of the growth is what matters.
|
|
96
|
+
|
|
97
|
+
- Alternation branches that overlap under a repetition grow exponentially. `^([A-Z]|[A-Z0-9])+_KEY$`
|
|
98
|
+
took about 0.4 s on a 24-character name, 1.2 s at 26 and 4.7 s at 28. `(a|a)+$` took 1.6 s at
|
|
99
|
+
24 characters and 6.4 s at 26. `^(A|AA)*$` is the same shape.
|
|
100
|
+
- Adjacent quantifiers that can match the same text grow polynomially. `.*.*.*x` took 0.5 s on a
|
|
101
|
+
255-character name and 0.9 s at 300; each extra `.*` raises the exponent. Nothing bounds the length
|
|
102
|
+
of a name or flag in a script, so a long enough one is slow.
|
|
103
|
+
|
|
104
|
+
**What it rejects that is in fact linear.** The check does not look at what the repeated group can
|
|
105
|
+
match, so it also refuses some patterns that are safe, such as `(a*b)*`,
|
|
106
|
+
`^(?:[a-z]+-)*[a-z]+$` and `^(?:SECRET|TOKEN|PASSWORD)(?:_[A-Z]+)*$`. A rewrite without the nesting
|
|
107
|
+
exists for some of these and not for others, so if you cannot rewrite a pattern exactly, loosen it to
|
|
108
|
+
the character class you mean. The last one can be rewritten exactly with a lookahead
|
|
109
|
+
(it accepts the same names; compared on every suffix of up to seven characters over `A`, `_`, `a`
|
|
110
|
+
and `1` after `SECRET`, `TOKEN` and `PASSWORD`):
|
|
111
|
+
|
|
112
|
+
```js
|
|
113
|
+
{ patterns: ['^(?:SECRET|TOKEN|PASSWORD)(?!.*_(?:_|$))(?:_[A-Z][A-Z_]*)?$'] }
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
**When you see the error.** The error is thrown from the rule's `create()`, which ESLint calls for
|
|
118
|
+
each file it lints, so the check does not run when the config is loaded. ESLint stops the run at the
|
|
119
|
+
first file it lints with the rule enabled and prints "Error while loading rule" followed by
|
|
120
|
+
"Occurred while linting <that file>" and a stack trace, and exits with code 2. A run that lints no
|
|
121
|
+
file with the rule enabled never sees it.
|
|
122
|
+
|
|
123
|
+
The accident the check is for, and its fix:
|
|
124
|
+
|
|
125
|
+
```js
|
|
126
|
+
// Rejected: `([A-Z]+_?)*` repeats a group that already repeats, so a long
|
|
127
|
+
// name that does not match can take seconds.
|
|
128
|
+
{ patterns: ['^([A-Z]+_?)*_KEY$'] }
|
|
129
|
+
|
|
130
|
+
// Safe rewrite: one repetition over a character class.
|
|
131
|
+
{ patterns: ['^[A-Z0-9_]*_KEY$'] }
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
## Examples
|
|
135
|
+
|
|
136
|
+
### Incorrect
|
|
137
|
+
|
|
138
|
+
<!-- example: {"findings": 3} -->
|
|
139
|
+
```sh
|
|
140
|
+
echo "$API_KEY" # expands the secret into argv and output
|
|
141
|
+
curl -H "Authorization: $AUTH_TOKEN" https://x # argv is world-readable
|
|
142
|
+
[[ -z $DB_PASSWORD ]] && exit 1 # a read, even though only tested
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
### Correct
|
|
146
|
+
|
|
147
|
+
```sh
|
|
148
|
+
TOKEN=$GITHUB_TOKEN tool # passed to the child's environment
|
|
149
|
+
API_KEY=abc123 # an assignment, not a read
|
|
150
|
+
echo "$cache_key" # not matched by the default pattern
|
|
151
|
+
[ -n "${API_KEY+set}" ] # checks presence without expanding the value
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
With `allowedSinks: ['vault']`:
|
|
155
|
+
|
|
156
|
+
<!-- example: {"options": {"allowedSinks": ["vault"]}} -->
|
|
157
|
+
```sh
|
|
158
|
+
vault login "$VAULT_TOKEN" # an allowed sink
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
## When not to use it
|
|
162
|
+
|
|
163
|
+
Turn it off when your scripts run in an environment where argv and logs are not observable by
|
|
164
|
+
others and you accept the exposure, or when another secret scanner already covers the same files.
|
|
165
|
+
|
|
166
|
+
## Relationship to shellcheck
|
|
167
|
+
|
|
168
|
+
eslint-sh is a convention and contract linter: a policy layer, where shellcheck is the gotcha
|
|
169
|
+
layer. Run shellcheck beside it.
|
|
170
|
+
|
|
171
|
+
shellcheck has no equivalent check. It reports shell mistakes, not credential handling, and it has
|
|
172
|
+
no notion of which variable names your project treats as secrets or which commands may receive
|
|
173
|
+
them. This rule enforces that policy.
|