@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.
Files changed (256) hide show
  1. package/README.md +223 -17
  2. package/dist/command-harvest/builtins/bash.d.ts +36 -0
  3. package/dist/command-harvest/builtins/bash.js +98 -0
  4. package/dist/command-harvest/builtins/bats.d.ts +45 -0
  5. package/dist/command-harvest/builtins/bats.js +33 -0
  6. package/dist/command-harvest/builtins/index.d.ts +18 -0
  7. package/dist/command-harvest/builtins/index.js +39 -0
  8. package/dist/command-harvest/builtins/posix.d.ts +51 -0
  9. package/dist/command-harvest/builtins/posix.js +93 -0
  10. package/dist/command-harvest/harvest-commands.d.ts +66 -0
  11. package/dist/command-harvest/harvest-commands.js +97 -0
  12. package/dist/command-harvest/wrapper-table.d.ts +24 -0
  13. package/dist/command-harvest/wrapper-table.js +49 -0
  14. package/dist/configs/agent-authored.d.ts +41 -0
  15. package/dist/configs/agent-authored.js +33 -0
  16. package/dist/configs/bash-3-2.d.ts +28 -0
  17. package/dist/configs/bash-3-2.js +50 -0
  18. package/dist/configs/posix-portable.d.ts +40 -0
  19. package/dist/configs/posix-portable.js +52 -0
  20. package/dist/configs/recommended.d.ts +52 -0
  21. package/dist/configs/recommended.js +34 -0
  22. package/dist/declaration-conventions/comment-pragma.d.ts +33 -0
  23. package/dist/declaration-conventions/comment-pragma.js +153 -0
  24. package/dist/declaration-conventions/declaration-convention.d.ts +189 -0
  25. package/dist/declaration-conventions/declaration-convention.js +1 -0
  26. package/dist/declaration-conventions/read-declared-commands.d.ts +70 -0
  27. package/dist/declaration-conventions/read-declared-commands.js +128 -0
  28. package/dist/eslint-plugin-syntax-analyze.d.ts +23 -0
  29. package/dist/eslint-plugin-syntax.d.ts +27 -0
  30. package/dist/eslint-plugin.d.ts +1211 -52
  31. package/dist/index.d.ts +56 -8
  32. package/dist/index.js +53 -6
  33. package/dist/language/command-resolution.d.ts +124 -0
  34. package/dist/language/command-resolution.js +120 -0
  35. package/dist/language/end-of-input-marker.d.ts +29 -0
  36. package/dist/language/end-of-input-marker.js +88 -0
  37. package/dist/language/line-breaks.d.ts +18 -0
  38. package/dist/language/line-breaks.js +22 -0
  39. package/dist/language/lint-view-types.d.ts +104 -0
  40. package/dist/language/lint-view-types.js +1 -0
  41. package/dist/language/non-shell-file-guard.d.ts +35 -0
  42. package/dist/language/non-shell-file-guard.js +59 -0
  43. package/dist/language/selector-classes.d.ts +0 -1
  44. package/dist/language/selector-classes.js +0 -1
  45. package/dist/language/shell-language-options.d.ts +7 -1
  46. package/dist/language/shell-language-options.js +4 -4
  47. package/dist/language/shell-language.d.ts +10 -2
  48. package/dist/language/shell-language.js +59 -8
  49. package/dist/language/shell-source-code.d.ts +342 -5
  50. package/dist/language/shell-source-code.js +559 -30
  51. package/dist/language/source-coordinates.d.ts +13 -2
  52. package/dist/language/source-coordinates.js +68 -14
  53. package/dist/language/syntax-view.d.ts +60 -39
  54. package/dist/language/syntax-view.js +200 -45
  55. package/dist/language/word-resolution.d.ts +4 -3
  56. package/dist/language/word-resolution.js +4 -3
  57. package/dist/rules/compat/create-compat-rule.d.ts +59 -0
  58. package/dist/rules/compat/create-compat-rule.js +65 -0
  59. package/dist/rules/compat/declaration-options.d.ts +19 -0
  60. package/dist/rules/compat/declaration-options.js +110 -0
  61. package/dist/rules/compat/no-append-all-redirect.d.ts +13 -0
  62. package/dist/rules/compat/no-append-all-redirect.js +73 -0
  63. package/dist/rules/compat/no-associative-arrays.d.ts +12 -0
  64. package/dist/rules/compat/no-associative-arrays.js +39 -0
  65. package/dist/rules/compat/no-case-attributes.d.ts +12 -0
  66. package/dist/rules/compat/no-case-attributes.js +63 -0
  67. package/dist/rules/compat/no-case-fallthrough.d.ts +12 -0
  68. package/dist/rules/compat/no-case-fallthrough.js +67 -0
  69. package/dist/rules/compat/no-case-modification.d.ts +35 -0
  70. package/dist/rules/compat/no-case-modification.js +86 -0
  71. package/dist/rules/compat/no-coproc.d.ts +12 -0
  72. package/dist/rules/compat/no-coproc.js +47 -0
  73. package/dist/rules/compat/no-declare-g.d.ts +12 -0
  74. package/dist/rules/compat/no-declare-g.js +59 -0
  75. package/dist/rules/compat/no-double-bracket.d.ts +12 -0
  76. package/dist/rules/compat/no-double-bracket.js +52 -0
  77. package/dist/rules/compat/no-epoch-variables.d.ts +12 -0
  78. package/dist/rules/compat/no-epoch-variables.js +57 -0
  79. package/dist/rules/compat/no-fd-variable.d.ts +12 -0
  80. package/dist/rules/compat/no-fd-variable.js +48 -0
  81. package/dist/rules/compat/no-globstar.d.ts +12 -0
  82. package/dist/rules/compat/no-globstar.js +39 -0
  83. package/dist/rules/compat/no-lastpipe-semantics.d.ts +12 -0
  84. package/dist/rules/compat/no-lastpipe-semantics.js +53 -0
  85. package/dist/rules/compat/no-let.d.ts +12 -0
  86. package/dist/rules/compat/no-let.js +62 -0
  87. package/dist/rules/compat/no-mapfile.d.ts +13 -0
  88. package/dist/rules/compat/no-mapfile.js +122 -0
  89. package/dist/rules/compat/no-nameref.d.ts +12 -0
  90. package/dist/rules/compat/no-nameref.js +44 -0
  91. package/dist/rules/compat/no-negative-array-index.d.ts +13 -0
  92. package/dist/rules/compat/no-negative-array-index.js +123 -0
  93. package/dist/rules/compat/no-nounset-empty-array.d.ts +13 -0
  94. package/dist/rules/compat/no-nounset-empty-array.js +158 -0
  95. package/dist/rules/compat/no-parameter-transformation.d.ts +13 -0
  96. package/dist/rules/compat/no-parameter-transformation.js +123 -0
  97. package/dist/rules/compat/no-pipe-all.d.ts +13 -0
  98. package/dist/rules/compat/no-pipe-all.js +68 -0
  99. package/dist/rules/compat/no-printf-time-format.d.ts +12 -0
  100. package/dist/rules/compat/no-printf-time-format.js +99 -0
  101. package/dist/rules/compat/no-source.d.ts +13 -0
  102. package/dist/rules/compat/no-source.js +103 -0
  103. package/dist/rules/compat/no-test-v.d.ts +12 -0
  104. package/dist/rules/compat/no-test-v.js +280 -0
  105. package/dist/rules/compat/no-unicode-escape.d.ts +14 -0
  106. package/dist/rules/compat/no-unicode-escape.js +120 -0
  107. package/dist/rules/compat/no-wait-n.d.ts +12 -0
  108. package/dist/rules/compat/no-wait-n.js +73 -0
  109. package/dist/rules/compat/operator-range.d.ts +10 -0
  110. package/dist/rules/compat/operator-range.js +15 -0
  111. package/dist/rules/compat/posix-absent-command.d.ts +12 -0
  112. package/dist/rules/compat/posix-absent-command.js +134 -0
  113. package/dist/rules/compat/shopt-options.d.ts +9 -0
  114. package/dist/rules/compat/shopt-options.js +63 -0
  115. package/dist/rules/compat/version-gates.d.ts +76 -0
  116. package/dist/rules/compat/version-gates.js +197 -0
  117. package/dist/rules/create-rule.d.ts +91 -0
  118. package/dist/rules/create-rule.js +51 -0
  119. package/dist/rules/create-wrapper-rule.d.ts +166 -0
  120. package/dist/rules/create-wrapper-rule.js +407 -0
  121. package/dist/rules/define-shell-rule.d.ts +184 -0
  122. package/dist/rules/define-shell-rule.js +56 -0
  123. package/dist/rules/index.d.ts +26 -0
  124. package/dist/rules/index.js +88 -0
  125. package/dist/rules/max-script-complexity.d.ts +28 -0
  126. package/dist/rules/max-script-complexity.js +130 -0
  127. package/dist/rules/no-dynamic-invocation.d.ts +35 -0
  128. package/dist/rules/no-dynamic-invocation.js +117 -0
  129. package/dist/rules/no-eval.d.ts +13 -0
  130. package/dist/rules/no-eval.js +89 -0
  131. package/dist/rules/no-masked-exit-status.d.ts +21 -0
  132. package/dist/rules/no-masked-exit-status.js +667 -0
  133. package/dist/rules/no-secret-env-reads.d.ts +24 -0
  134. package/dist/rules/no-secret-env-reads.js +133 -0
  135. package/dist/rules/no-secret-flags.d.ts +32 -0
  136. package/dist/rules/no-secret-flags.js +162 -0
  137. package/dist/rules/no-unchecked-cd.d.ts +44 -0
  138. package/dist/rules/no-unchecked-cd.js +250 -0
  139. package/dist/rules/no-undeclared-commands.d.ts +20 -0
  140. package/dist/rules/no-undeclared-commands.js +115 -0
  141. package/dist/rules/no-unused-declarations.d.ts +23 -0
  142. package/dist/rules/no-unused-declarations.js +259 -0
  143. package/dist/rules/prefer-local.d.ts +16 -0
  144. package/dist/rules/prefer-local.js +249 -0
  145. package/dist/rules/quote-expansions.d.ts +13 -0
  146. package/dist/rules/quote-expansions.js +229 -0
  147. package/dist/rules/require-network-wrapper.d.ts +40 -0
  148. package/dist/rules/require-network-wrapper.js +244 -0
  149. package/dist/rules/require-shebang.d.ts +29 -0
  150. package/dist/rules/require-shebang.js +101 -0
  151. package/dist/rules/require-strict-mode.d.ts +52 -0
  152. package/dist/rules/require-strict-mode.js +177 -0
  153. package/dist/rules/require-tty-guard.d.ts +13 -0
  154. package/dist/rules/require-tty-guard.js +289 -0
  155. package/dist/rules/resolve-declaration-convention.d.ts +96 -0
  156. package/dist/rules/resolve-declaration-convention.js +109 -0
  157. package/dist/rules/resolve-network-wrapper.d.ts +19 -0
  158. package/dist/rules/resolve-network-wrapper.js +116 -0
  159. package/dist/rules/set-options.d.ts +109 -0
  160. package/dist/rules/set-options.js +201 -0
  161. package/dist/rules/shebang-interpreter.d.ts +18 -0
  162. package/dist/rules/shebang-interpreter.js +81 -0
  163. package/dist/rules/shebang-matches-dialect.d.ts +12 -0
  164. package/dist/rules/shebang-matches-dialect.js +69 -0
  165. package/dist/rules/transparent-wrappers.d.ts +52 -0
  166. package/dist/rules/transparent-wrappers.js +99 -0
  167. package/dist/rules/user-pattern.d.ts +63 -0
  168. package/dist/rules/user-pattern.js +184 -0
  169. package/dist/syntax/analyze.d.ts +19 -0
  170. package/dist/syntax/analyze.js +21 -0
  171. package/dist/syntax/index.d.ts +23 -0
  172. package/dist/syntax/index.js +25 -0
  173. package/dist/version.d.ts +0 -1
  174. package/dist/version.js +0 -1
  175. package/docs/compat.md +347 -0
  176. package/docs/custom-rule-tutorial.md +780 -34
  177. package/docs/declaration-conventions.md +478 -0
  178. package/docs/examples/audit-wrapper-pack.mjs +15 -0
  179. package/docs/examples/dep-line-convention.mjs +22 -0
  180. package/docs/examples/eslint.config.mjs +3 -2
  181. package/docs/examples/no-and-or-as-if-rule.mjs +61 -0
  182. package/docs/examples/no-backticks-rule-tester.mjs +106 -0
  183. package/docs/examples/no-backticks-rule.mjs +60 -0
  184. package/docs/examples/no-curl-in-functions-rule.mjs +53 -0
  185. package/docs/examples/no-function-keyword-rule.mjs +59 -0
  186. package/docs/examples/no-listed-commands-rule.ts +63 -0
  187. package/docs/examples/no-rm-rf-rule.mjs +26 -15
  188. package/docs/examples/no-unquoted-rm-variable-rule.ts +54 -0
  189. package/docs/examples/sidecar-convention.mjs +38 -0
  190. package/docs/examples/sidecar-snapshot-convention.mjs +26 -0
  191. package/docs/examples/sorted-declarations-rule.mjs +52 -0
  192. package/docs/reading-nodes.md +159 -0
  193. package/docs/rules/README.md +78 -0
  194. package/docs/rules/_template.md +83 -0
  195. package/docs/rules/compat/no-append-all-redirect.md +87 -0
  196. package/docs/rules/compat/no-associative-arrays.md +95 -0
  197. package/docs/rules/compat/no-case-attributes.md +82 -0
  198. package/docs/rules/compat/no-case-fallthrough.md +90 -0
  199. package/docs/rules/compat/no-case-modification.md +74 -0
  200. package/docs/rules/compat/no-coproc.md +76 -0
  201. package/docs/rules/compat/no-declare-g.md +80 -0
  202. package/docs/rules/compat/no-double-bracket.md +72 -0
  203. package/docs/rules/compat/no-epoch-variables.md +79 -0
  204. package/docs/rules/compat/no-fd-variable.md +78 -0
  205. package/docs/rules/compat/no-globstar.md +74 -0
  206. package/docs/rules/compat/no-lastpipe-semantics.md +73 -0
  207. package/docs/rules/compat/no-let.md +78 -0
  208. package/docs/rules/compat/no-mapfile.md +118 -0
  209. package/docs/rules/compat/no-nameref.md +81 -0
  210. package/docs/rules/compat/no-negative-array-index.md +87 -0
  211. package/docs/rules/compat/no-nounset-empty-array.md +103 -0
  212. package/docs/rules/compat/no-parameter-transformation.md +94 -0
  213. package/docs/rules/compat/no-pipe-all.md +85 -0
  214. package/docs/rules/compat/no-printf-time-format.md +75 -0
  215. package/docs/rules/compat/no-source.md +98 -0
  216. package/docs/rules/compat/no-test-v.md +82 -0
  217. package/docs/rules/compat/no-unicode-escape.md +91 -0
  218. package/docs/rules/compat/no-wait-n.md +85 -0
  219. package/docs/rules/max-script-complexity.md +109 -0
  220. package/docs/rules/no-dynamic-invocation.md +139 -0
  221. package/docs/rules/no-eval.md +106 -0
  222. package/docs/rules/no-masked-exit-status.md +239 -0
  223. package/docs/rules/no-secret-env-reads.md +173 -0
  224. package/docs/rules/no-secret-flags.md +169 -0
  225. package/docs/rules/no-unchecked-cd.md +146 -0
  226. package/docs/rules/no-undeclared-commands.md +141 -0
  227. package/docs/rules/no-unused-declarations.md +216 -0
  228. package/docs/rules/prefer-local.md +221 -0
  229. package/docs/rules/quote-expansions.md +157 -0
  230. package/docs/rules/require-network-wrapper.md +171 -0
  231. package/docs/rules/require-shebang.md +157 -0
  232. package/docs/rules/require-strict-mode.md +253 -0
  233. package/docs/rules/require-tty-guard.md +128 -0
  234. package/docs/rules/shebang-matches-dialect.md +120 -0
  235. package/docs/selectors.md +2 -2
  236. package/docs/shellcheck.md +86 -0
  237. package/docs/wrapper-rules.md +206 -0
  238. package/package.json +26 -20
  239. package/dist/index.d.ts.map +0 -1
  240. package/dist/index.js.map +0 -1
  241. package/dist/language/selector-classes.d.ts.map +0 -1
  242. package/dist/language/selector-classes.js.map +0 -1
  243. package/dist/language/shell-language-options.d.ts.map +0 -1
  244. package/dist/language/shell-language-options.js.map +0 -1
  245. package/dist/language/shell-language.d.ts.map +0 -1
  246. package/dist/language/shell-language.js.map +0 -1
  247. package/dist/language/shell-source-code.d.ts.map +0 -1
  248. package/dist/language/shell-source-code.js.map +0 -1
  249. package/dist/language/source-coordinates.d.ts.map +0 -1
  250. package/dist/language/source-coordinates.js.map +0 -1
  251. package/dist/language/syntax-view.d.ts.map +0 -1
  252. package/dist/language/syntax-view.js.map +0 -1
  253. package/dist/language/word-resolution.d.ts.map +0 -1
  254. package/dist/language/word-resolution.js.map +0 -1
  255. package/dist/version.d.ts.map +0 -1
  256. 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.