@eslint-sh/eslint-plugin 0.5.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,133 @@
1
+ /**
2
+ * `sh/no-secret-env-reads`: a heuristic screen for reads of secret-shaped
3
+ * environment variables (`$GITHUB_TOKEN`, `${DB_PASSWORD}`) outside the
4
+ * commands a project has vetted to receive them.
5
+ *
6
+ * Once a secret is expanded into a command's arguments it is visible to every
7
+ * user on the machine (`ps`, `/proc/<pid>/cmdline`) and tends to end up in
8
+ * logs and `set -x` traces. The safe pattern is to hand the secret to a child
9
+ * process through that child's own environment, which expands in the parent
10
+ * but never appears in argv: `TOKEN=$GITHUB_TOKEN tool`.
11
+ *
12
+ * Scope, deliberately narrow:
13
+ *
14
+ * - Only reads are inspected: every parameter expansion (`$NAME`, `${NAME…}`)
15
+ * whose name matches a pattern. Assignments to such names (`API_KEY=…`)
16
+ * are not expansions, so they are never reported.
17
+ * - An expansion that never yields the value is not a read of the secret: a
18
+ * length (`${#NAME}`) expands to a number, and an alternate value
19
+ * (`${NAME:+word}`, `${NAME+word}`) expands to `word` or to nothing. Any
20
+ * read nested in `word` is its own expansion and is judged on its own.
21
+ * - A read that appears only inside the value of an assignment prefix of a
22
+ * command that runs (`TOKEN=$GITHUB_TOKEN tool`) is the recommended
23
+ * pattern and is clean. A read nested deeper (a command substitution in
24
+ * that value) belongs to the inner command and is judged there.
25
+ * - A read in the arguments of an allow-listed sink command is clean. The
26
+ * command is the nearest enclosing `CallExpr`, resolved through
27
+ * transparent wrappers by `ShellSourceCode#resolveInvokedCommand`.
28
+ * - Matching is a name pattern, not secret detection.
29
+ */
30
+ import { AlternateUnset, AlternateUnsetOrNull } from 'sh-ast/analyze';
31
+ import { createRule } from './create-rule.js';
32
+ import { compileUserPattern } from './user-pattern.js';
33
+ /**
34
+ * The default variable-name patterns, as regex sources. A variable whose name
35
+ * matches any of them is treated as holding a secret. Taken from the rule
36
+ * catalog's default for `no-secret-env-reads`. Case-sensitive on purpose:
37
+ * environment variables are upper-case by convention, and lower-case shell
38
+ * locals such as `cache_key` are not secrets.
39
+ */
40
+ const DEFAULT_PATTERNS = ['(_TOKEN|_SECRET|_PASSWORD|_KEY)$'];
41
+ /** The operators of `${NAME+word}` and `${NAME:+word}`, which expand `word`, never the value. */
42
+ const ALTERNATE_OPERATORS = new Set([AlternateUnset, AlternateUnsetOrNull]);
43
+ /**
44
+ * Whether the expansion `node` can produce the variable's value. A length
45
+ * (`${#NAME}`) produces a number and an alternate value (`${NAME:+word}`)
46
+ * produces `word`, so neither exposes the secret in argv, logs or traces.
47
+ */
48
+ function expandsValue(node) {
49
+ return !node.length && !ALTERNATE_OPERATORS.has(node.exp?.op);
50
+ }
51
+ /** The final path segment of `command`, so `/usr/bin/vault` matches `vault`. */
52
+ function basename(command) {
53
+ return command.slice(command.lastIndexOf('/') + 1);
54
+ }
55
+ /**
56
+ * The `sh/no-secret-env-reads` rule module, registered as
57
+ * `no-secret-env-reads`.
58
+ *
59
+ * @internal
60
+ */
61
+ export const noSecretEnvReads = createRule({
62
+ name: 'no-secret-env-reads',
63
+ meta: {
64
+ type: 'problem',
65
+ docs: {
66
+ description: 'Disallow reading secret-shaped environment variables (`*_TOKEN`, `*_SECRET`, …) outside allowed sink commands',
67
+ },
68
+ schema: [
69
+ {
70
+ type: 'object',
71
+ properties: {
72
+ patterns: {
73
+ type: 'array',
74
+ items: { type: 'string', format: 'regex' },
75
+ minItems: 1,
76
+ },
77
+ allowedSinks: {
78
+ type: 'array',
79
+ items: { type: 'string' },
80
+ uniqueItems: true,
81
+ },
82
+ },
83
+ additionalProperties: false,
84
+ },
85
+ ],
86
+ messages: {
87
+ secretEnvRead: "'{{name}}' looks like a secret, and expanding it here can expose it in `ps` output, logs, and traces. Pass it to the child process through its environment instead (`TOKEN=${{name}} tool`), or read it from stdin or a credential helper.",
88
+ },
89
+ },
90
+ create(context) {
91
+ const options = context.options[0];
92
+ const patterns = (options?.patterns ?? DEFAULT_PATTERNS).map((source, index) => compileUserPattern('sh/no-secret-env-reads', `patterns[${String(index)}]`, source, ''));
93
+ const allowedSinks = new Set(options?.allowedSinks ?? []);
94
+ const { sourceCode } = context;
95
+ const isSecretName = (name) => patterns.some((pattern) => pattern.test(name));
96
+ /** Whether the command that `callExpr` runs, wrappers followed, is an allowed sink. */
97
+ function isAllowedSink(callExpr) {
98
+ if (allowedSinks.size === 0)
99
+ return false;
100
+ const effective = sourceCode.resolveInvokedCommand(callExpr)?.effective;
101
+ if (!effective?.static)
102
+ return false;
103
+ return allowedSinks.has(effective.text) || allowedSinks.has(basename(effective.text));
104
+ }
105
+ /**
106
+ * Whether the read at `node` is permitted: it sits in the arguments of an
107
+ * allowed sink, or directly in an assignment prefix of a command that runs.
108
+ * The nearest enclosing `Assign` or `CallExpr` decides, so a read inside a
109
+ * command substitution is judged by the inner command, not the outer one.
110
+ */
111
+ function isPermittedRead(node) {
112
+ for (let up = sourceCode.getParent(node); up !== undefined; up = sourceCode.getParent(up)) {
113
+ if (up.type === 'CallExpr')
114
+ return isAllowedSink(up);
115
+ if (up.type === 'Assign') {
116
+ const owner = sourceCode.getParent(up);
117
+ return owner?.type === 'CallExpr' && owner.args.length > 0;
118
+ }
119
+ }
120
+ return false;
121
+ }
122
+ return {
123
+ ParamExp(node) {
124
+ const name = node.param?.value;
125
+ if (name === undefined || !isSecretName(name))
126
+ return;
127
+ if (!expandsValue(node) || isPermittedRead(node))
128
+ return;
129
+ context.report({ node, messageId: 'secretEnvRead', data: { name } });
130
+ },
131
+ };
132
+ },
133
+ });
@@ -0,0 +1,32 @@
1
+ import type { ShellNode } from '../language/syntax-view.js';
2
+ /**
3
+ * The rule's single options object.
4
+ *
5
+ * @internal
6
+ */
7
+ export interface NoSecretFlagsOptions {
8
+ /**
9
+ * Regex sources tested against each flag's name (the word up to, not
10
+ * including, its first `=`). Replaces {@link DEFAULT_PATTERNS} entirely.
11
+ */
12
+ readonly patterns?: readonly string[];
13
+ }
14
+ /**
15
+ * One secret-shaped flag found in a command's arguments.
16
+ *
17
+ * @internal
18
+ */
19
+ export interface SecretFlagFinding {
20
+ /** The flag's name as written, without `=` or value: `--token`. */
21
+ readonly flag: string;
22
+ /** The first word of the finding: the flag word. */
23
+ readonly start: ShellNode;
24
+ /** The last word of the finding: the flag word itself, or its separate value word. */
25
+ readonly end: ShellNode;
26
+ }
27
+ /**
28
+ * The `sh/no-secret-flags` rule module, registered as `no-secret-flags`.
29
+ *
30
+ * @internal
31
+ */
32
+ export declare const noSecretFlags: import("./define-shell-rule.js").ShellRule<[NoSecretFlagsOptions?], "secretFlag">;
@@ -0,0 +1,162 @@
1
+ /**
2
+ * `sh/no-secret-flags`: a heuristic screen for secrets written as literal
3
+ * command-line flag values (`--password=hunter2`, `--token abc`).
4
+ *
5
+ * A command's argv is readable by every user on the machine (`ps`,
6
+ * `/proc/<pid>/cmdline`), and a literal value in a script is also committed
7
+ * with it. The correct pattern is a dynamic value (`--token="$TOKEN"`), or not
8
+ * passing the secret through argv at all: stdin, a credential helper, or the
9
+ * child process's environment.
10
+ *
11
+ * Scope, deliberately narrow:
12
+ *
13
+ * - Only command arguments are inspected: the words of a `CallExpr` after its
14
+ * command word. Assignment values (`X="--token=abc"`), heredoc bodies,
15
+ * here-strings, redirect targets, and `[[ … ]]` operands are never argv
16
+ * words, so they are never matched.
17
+ * - Whether a word is static is answered only by
18
+ * `ShellSourceCode#resolveWordText`. A value is reported only when its
19
+ * whole word resolves statically; any expansion, substitution, glob, or
20
+ * other dynamic part makes it fine.
21
+ * - Matching is a name pattern, not secret detection. The default pattern
22
+ * stops matching when a letter directly follows the secret word, so
23
+ * `--author` is not `--auth`. It also excludes the `-stdin` and `-file`
24
+ * suffixes, which name the safe routes the message recommends
25
+ * (`--password-stdin`, `--token-file`). Other names that only look secret
26
+ * can still match; the `patterns` option replaces the list.
27
+ */
28
+ import { createRule } from './create-rule.js';
29
+ import { compileUserPattern } from './user-pattern.js';
30
+ /**
31
+ * The default flag-name patterns, as regex sources. A flag whose name matches
32
+ * any of them is treated as carrying a secret. Taken from the rule catalog's
33
+ * default for `no-secret-flags`.
34
+ *
35
+ * The one default pattern reads, left to right:
36
+ *
37
+ * - `(?!.*-(?:stdin|file)$)`: a name ending in `-stdin` or `-file` never
38
+ * matches. Those flags take the secret from stdin or name a file that holds
39
+ * it, which are the alternatives the rule recommends, and the word after
40
+ * `--password-stdin` is a positional argument, not a value.
41
+ * - `--?`: one or two leading dashes.
42
+ * - `(?:access-|auth-?)?`: an optional qualifier, so `--access-token`,
43
+ * `--auth-token`, and `--authtoken` match. Other qualifiers stay
44
+ * unmatched (`--my-token`), because a general prefix would also catch
45
+ * negations such as `--no-password`, which take no value.
46
+ * - `(?:password|passwd|token|secret|api-?key|auth(?:key|orization)?)`: the
47
+ * secret word. `authkey` (`tailscale up --authkey=…`) and `authorization`
48
+ * are spelled out because the next part would otherwise reject them.
49
+ * - `(?![a-z])`: no letter may directly follow the secret word, so a longer
50
+ * word that only starts with one (`--author`, `--tokens`, `--tokenizer`)
51
+ * does not match. A digit (`--password2`, `--token1`) or a further
52
+ * `-segment` (`--secret-key`, `--authorization-token`) still matches.
53
+ */
54
+ const DEFAULT_PATTERNS = [
55
+ '^(?!.*-(?:stdin|file)$)--?(?:access-|auth-?)?(?:password|passwd|token|secret|api-?key|auth(?:key|orization)?)(?![a-z])',
56
+ ];
57
+ /**
58
+ * The flags every pattern is compiled with. Case-insensitive, matching the
59
+ * default pattern's `/i`. No `u` flag: config validation checks each source
60
+ * with ESLint's JSON-schema `regex` format, which compiles without flags, and
61
+ * a few sources valid there are syntax errors under `u`. Adding `i` never
62
+ * turns a valid source into an invalid one, so a pattern that passed
63
+ * validation always compiles here.
64
+ */
65
+ const PATTERN_FLAGS = 'i';
66
+ /**
67
+ * Splits a static argument word into the flag name the patterns are tested
68
+ * against and the value written in the same word, if any. `--token=abc` is
69
+ * `{ name: '--token', value: 'abc' }`; `--token` is `{ name: '--token',
70
+ * value: undefined }`.
71
+ */
72
+ function splitFlagWord(text) {
73
+ const eq = text.indexOf('=');
74
+ return eq === -1 ? { name: text } : { name: text.slice(0, eq), value: text.slice(eq + 1) };
75
+ }
76
+ /**
77
+ * Whether a static word following a valueless flag is that flag's value. A
78
+ * word starting with `-` is another flag (or `-`/`--`, the stdin and
79
+ * end-of-options conventions), and an empty word passes nothing; neither is a
80
+ * literal secret.
81
+ */
82
+ function isLiteralValueWord(text) {
83
+ return text !== '' && !text.startsWith('-');
84
+ }
85
+ /**
86
+ * The `sh/no-secret-flags` rule module, registered as `no-secret-flags`.
87
+ *
88
+ * @internal
89
+ */
90
+ export const noSecretFlags = createRule({
91
+ name: 'no-secret-flags',
92
+ meta: {
93
+ type: 'problem',
94
+ docs: {
95
+ description: 'Disallow secret-shaped command-line flags (`--password=`, `--token`, …) with a literal value',
96
+ },
97
+ schema: [
98
+ {
99
+ type: 'object',
100
+ properties: {
101
+ patterns: {
102
+ type: 'array',
103
+ items: { type: 'string', format: 'regex' },
104
+ minItems: 1,
105
+ },
106
+ },
107
+ additionalProperties: false,
108
+ },
109
+ ],
110
+ messages: {
111
+ secretFlag: "'{{flag}}' passes a literal value on the command line, where any user can read it with `ps` and it is committed with the script. Read the secret from stdin, use a credential helper, or pass it to the child process through an environment variable instead.",
112
+ },
113
+ },
114
+ create(context) {
115
+ const sources = context.options[0]?.patterns ?? DEFAULT_PATTERNS;
116
+ const patterns = sources.map((source, index) => compileUserPattern('sh/no-secret-flags', `patterns[${String(index)}]`, source, PATTERN_FLAGS));
117
+ const isSecretFlag = (name) => patterns.some((pattern) => pattern.test(name));
118
+ const { sourceCode } = context;
119
+ /** Finds every secret-shaped flag with a literal value among `args` (argv, command word first). */
120
+ function findSecretFlags(args) {
121
+ const findings = [];
122
+ // `args[0]` is the command word, not an argument.
123
+ for (let i = 1; i < args.length; i++) {
124
+ const word = args.at(i);
125
+ if (word === undefined)
126
+ continue;
127
+ const text = sourceCode.resolveWordText(word);
128
+ if (text === null)
129
+ continue;
130
+ const { name, value } = splitFlagWord(text);
131
+ if (!isSecretFlag(name))
132
+ continue;
133
+ if (value !== undefined) {
134
+ // `--flag=value` in one word; an empty value passes nothing.
135
+ if (value !== '')
136
+ findings.push({ flag: name, start: word, end: word });
137
+ continue;
138
+ }
139
+ // `--flag value` across two words: the next word is the value when it
140
+ // is static and not itself a flag.
141
+ const next = args.at(i + 1);
142
+ const nextText = next === undefined ? null : sourceCode.resolveWordText(next);
143
+ if (next !== undefined && nextText !== null && isLiteralValueWord(nextText)) {
144
+ findings.push({ flag: name, start: word, end: next });
145
+ i++; // the value word is consumed; it is not a flag in its own right
146
+ }
147
+ }
148
+ return findings;
149
+ }
150
+ return {
151
+ CallExpr(node) {
152
+ for (const { flag, start, end } of findSecretFlags(node.args)) {
153
+ context.report({
154
+ loc: { start: start.loc.start, end: end.loc.end },
155
+ messageId: 'secretFlag',
156
+ data: { flag },
157
+ });
158
+ }
159
+ },
160
+ };
161
+ },
162
+ });
@@ -0,0 +1,44 @@
1
+ import type { ShellNode, ShellNodeOf } from '../language/syntax-view.js';
2
+ import type { ShellSourceCode } from '../language/shell-source-code.js';
3
+ /** The rule's single options object. @internal */
4
+ export interface NoUncheckedCdOptions {
5
+ /**
6
+ * When `true` (the default), a file whose prologue enables `errexit` is not
7
+ * checked: the shell already stops at a failed `cd`.
8
+ */
9
+ readonly ignoreWhenErrexit?: boolean;
10
+ }
11
+ /** Message ids this rule reports with. @internal */
12
+ export type NoUncheckedCdMessageId = 'uncheckedCd' | 'appendExit';
13
+ /**
14
+ * Whether the exit status of `stmt` is observed by the construct around it.
15
+ * Walks outward through the constructs that pass a status along unchanged
16
+ * (a list's right side, the last command of a group or subshell) and stops at
17
+ * the first one that tests it or discards it.
18
+ *
19
+ * @internal
20
+ */
21
+ export declare function isStatusChecked(stmt: ShellNodeOf<'Stmt'>, parentOf: (node: ShellNode) => ShellNode | undefined): boolean;
22
+ /**
23
+ * Whether bats' `errexit` is still in force when `stmt` runs, for a statement
24
+ * written directly in a bats `@test` body or top-level hook, or directly in a
25
+ * `( … )` subshell that is itself such a statement. `false` for every other
26
+ * position, and for a statement `errexit` ignores (negated or in the
27
+ * background).
28
+ *
29
+ * bats starts each test and hook with `errexit` on; the subshell inherits it,
30
+ * so a failed `cd` ends the subshell with a failing status, which fails the
31
+ * test. A command substitution `$( … )` is not covered: its failure is masked
32
+ * by the command it expands in. The state is read by walking the earlier
33
+ * statements of the same body in order, applying each plain `set` among them
34
+ * (`set +e`, `set +o errexit`, `set -e`). Only that straight-line reading is
35
+ * possible: a `set` inside an `if`, loop or function, in a file the test
36
+ * `load`s, or in the hook that ran before the test, is not seen.
37
+ *
38
+ * Callers must have established that the file is a `bats` file.
39
+ *
40
+ * @internal
41
+ */
42
+ export declare function batsErrexitIsOn(stmt: ShellNodeOf<'Stmt'>, parentOf: (node: ShellNode) => ShellNode | undefined, sourceCode: ShellSourceCode): boolean;
43
+ /** Built-in rule `sh/no-unchecked-cd`. @internal */
44
+ export declare const noUncheckedCd: import("./define-shell-rule.js").ShellRule<[NoUncheckedCdOptions], NoUncheckedCdMessageId>;
@@ -0,0 +1,250 @@
1
+ /**
2
+ * `sh/no-unchecked-cd`: a directory change whose failure nothing observes.
3
+ *
4
+ * When `cd` fails, the script keeps running in the directory it was already
5
+ * in, and every relative path after it silently means something else; the
6
+ * classic result is `cd "$build" && rm -rf *` written without the `&&`. The
7
+ * same holds for `pushd` and `popd`, which change the directory (and the
8
+ * directory stack) and fail the same way.
9
+ *
10
+ * A directory-changing command is **checked** when its exit status decides
11
+ * what runs next:
12
+ *
13
+ * - it is the left side of `&&` or `||` (`cd x || exit`, `cd x && make`);
14
+ * - it is the last command of an `if`, `elif`, `while` or `until` condition;
15
+ * - it is the right side of a list, or the last command of a `{ …; }` group
16
+ * or `( … )` subshell, whose own status is checked in one of these ways
17
+ * (`a && cd x || exit`, `{ cd x; } || exit`).
18
+ *
19
+ * Everything else is reported, including `cd` inside a subshell
20
+ * (`( cd x; make )`): a subshell keeps the script's directory intact but does
21
+ * not make the commands after a failed `cd` safe. A negated `! cd x` is only as
22
+ * checked as the position it sits in. Function bodies, loop bodies, `case`
23
+ * arms, pipelines and background jobs never check on their own.
24
+ *
25
+ * A file whose prologue enables `errexit` (the same reading `require-strict-mode`
26
+ * uses, via `prologueEnabledOptions`) aborts on a failed `cd` by itself, so the
27
+ * rule stays quiet there unless `ignoreWhenErrexit` is `false`.
28
+ *
29
+ * Under `dialect: "bats"` the bodies of `@test` blocks and of the file's
30
+ * top-level `setup`, `teardown`, `setup_file` and `teardown_file` functions
31
+ * run with `errexit` on (bats-core sets it itself), so a bare `cd` directly in
32
+ * one of them, or directly in a `( … )` subshell that is itself a statement of
33
+ * one, fails the test and is not reported. It is reported again once the body
34
+ * has turned `errexit` off with a plain `set +e` (or `set +o errexit`) before
35
+ * the `cd`, until a later plain `set -e` turns it back on. Any other function,
36
+ * any statement nested in an `if`, loop or `case`, a `$( … )` substitution, and
37
+ * any dialect other than `bats` is read as before. `run cd x` is unaffected:
38
+ * `run` is not a wrapper this rule follows, so the command it sees is `run`,
39
+ * not `cd`. See `batsErrexitIsOn` for what the rule cannot see.
40
+ *
41
+ * The command is recognized as the effective command after transparent
42
+ * wrappers, so `builtin cd x` and `command cd x` are read as `cd`.
43
+ *
44
+ * The suggestion appends ` || exit` to the statement. It is offered only for a
45
+ * plain statement in a command list: not when the `cd` is already part of an
46
+ * `&&`/`||`/pipeline (appending would change what the rest of the list does),
47
+ * negated, in the background, or carrying a here-document (the text after the
48
+ * statement is the document body, not shell code).
49
+ */
50
+ import { DEFAULT_TRANSPARENT_WRAPPERS } from 'sh-ast/analyze';
51
+ import { createRule } from './create-rule.js';
52
+ import { prologueEnabledOptions, setStatementChanges } from './require-strict-mode.js';
53
+ import { BUILTIN_WRAPPER } from './transparent-wrappers.js';
54
+ /** The commands that change the working directory and can fail doing so. */
55
+ const DIRECTORY_COMMANDS = new Set(['cd', 'pushd', 'popd']);
56
+ /** `BinaryCmd.op` values for `&&` and `||`; the list operators that test a status. */
57
+ const AND_OP = 11;
58
+ const OR_OP = 12;
59
+ /** Wrappers followed to find the command that actually runs (`builtin cd`). */
60
+ const TRANSPARENT_WRAPPERS = [
61
+ ...DEFAULT_TRANSPARENT_WRAPPERS,
62
+ BUILTIN_WRAPPER,
63
+ ];
64
+ /**
65
+ * The functions bats-core runs under `errexit` besides `@test` blocks: its
66
+ * per-test and per-file hooks.
67
+ *
68
+ * @see https://bats-core.readthedocs.io/en/stable/writing-tests.html#setup-and-teardown-pre--and-post-test-hooks
69
+ */
70
+ const BATS_ERREXIT_HOOKS = new Set([
71
+ 'setup',
72
+ 'teardown',
73
+ 'setup_file',
74
+ 'teardown_file',
75
+ ]);
76
+ /**
77
+ * Whether the exit status of `stmt` is observed by the construct around it.
78
+ * Walks outward through the constructs that pass a status along unchanged
79
+ * (a list's right side, the last command of a group or subshell) and stops at
80
+ * the first one that tests it or discards it.
81
+ *
82
+ * @internal
83
+ */
84
+ export function isStatusChecked(stmt, parentOf) {
85
+ if (stmt.background)
86
+ return false;
87
+ const parent = parentOf(stmt);
88
+ if (parent === undefined)
89
+ return false;
90
+ if (parent.type === 'BinaryCmd') {
91
+ if (parent.x === stmt)
92
+ return parent.op === AND_OP || parent.op === OR_OP;
93
+ // The right side's status is the whole list's status.
94
+ const listStmt = parentOf(parent);
95
+ return listStmt?.type === 'Stmt' && isStatusChecked(listStmt, parentOf);
96
+ }
97
+ if (parent.type === 'IfClause' || parent.type === 'WhileClause') {
98
+ return parent.cond.at(-1) === stmt;
99
+ }
100
+ if (parent.type === 'Block' || parent.type === 'Subshell') {
101
+ if (parent.stmts.at(-1) !== stmt)
102
+ return false;
103
+ const groupStmt = parentOf(parent);
104
+ return groupStmt?.type === 'Stmt' && isStatusChecked(groupStmt, parentOf);
105
+ }
106
+ return false;
107
+ }
108
+ /**
109
+ * Whether bats' `errexit` is still in force when `stmt` runs, for a statement
110
+ * written directly in a bats `@test` body or top-level hook, or directly in a
111
+ * `( … )` subshell that is itself such a statement. `false` for every other
112
+ * position, and for a statement `errexit` ignores (negated or in the
113
+ * background).
114
+ *
115
+ * bats starts each test and hook with `errexit` on; the subshell inherits it,
116
+ * so a failed `cd` ends the subshell with a failing status, which fails the
117
+ * test. A command substitution `$( … )` is not covered: its failure is masked
118
+ * by the command it expands in. The state is read by walking the earlier
119
+ * statements of the same body in order, applying each plain `set` among them
120
+ * (`set +e`, `set +o errexit`, `set -e`). Only that straight-line reading is
121
+ * possible: a `set` inside an `if`, loop or function, in a file the test
122
+ * `load`s, or in the hook that ran before the test, is not seen.
123
+ *
124
+ * Callers must have established that the file is a `bats` file.
125
+ *
126
+ * @internal
127
+ */
128
+ export function batsErrexitIsOn(stmt, parentOf, sourceCode) {
129
+ if (stmt.background || stmt.negated)
130
+ return false;
131
+ const container = parentOf(stmt);
132
+ let siblings;
133
+ let enabled;
134
+ if (container?.type === 'Block') {
135
+ const bodyStmt = parentOf(container);
136
+ const decl = bodyStmt === undefined ? undefined : parentOf(bodyStmt);
137
+ if (decl === undefined || !('body' in decl) || decl.body !== bodyStmt)
138
+ return false;
139
+ if (decl.type === 'FuncDecl') {
140
+ const declStmt = parentOf(decl);
141
+ const name = decl.name?.value;
142
+ if (name === undefined || !BATS_ERREXIT_HOOKS.has(name))
143
+ return false;
144
+ if (declStmt === undefined || parentOf(declStmt)?.type !== 'File')
145
+ return false;
146
+ }
147
+ siblings = container.stmts;
148
+ enabled = true;
149
+ }
150
+ else if (container?.type === 'Subshell') {
151
+ const subshellStmt = parentOf(container);
152
+ if (subshellStmt?.type !== 'Stmt' || subshellStmt.cmd !== container)
153
+ return false;
154
+ if (!batsErrexitIsOn(subshellStmt, parentOf, sourceCode))
155
+ return false;
156
+ siblings = container.stmts;
157
+ enabled = true;
158
+ }
159
+ else {
160
+ return false;
161
+ }
162
+ for (const earlier of siblings) {
163
+ if (earlier === stmt)
164
+ break;
165
+ for (const { option, enable } of setStatementChanges(earlier, sourceCode)) {
166
+ if (option === 'errexit')
167
+ enabled = enable;
168
+ }
169
+ }
170
+ return enabled;
171
+ }
172
+ /**
173
+ * The index just past the command and its redirections, excluding the
174
+ * statement's trailing separator (`;`, `&`), which `Stmt.range` includes.
175
+ */
176
+ function commandEnd(stmt) {
177
+ const parts = [...(stmt.cmd === undefined ? [] : [stmt.cmd]), ...stmt.redirs];
178
+ return Math.max(...parts.map((part) => part.range[1]));
179
+ }
180
+ /** Built-in rule `sh/no-unchecked-cd`. @internal */
181
+ export const noUncheckedCd = createRule({
182
+ name: 'no-unchecked-cd',
183
+ meta: {
184
+ type: 'suggestion',
185
+ docs: {
186
+ description: 'require the failure of `cd`, `pushd` and `popd` to be checked, unless the file runs with `set -e`',
187
+ },
188
+ hasSuggestions: true,
189
+ schema: [
190
+ {
191
+ type: 'object',
192
+ properties: { ignoreWhenErrexit: { type: 'boolean' } },
193
+ additionalProperties: false,
194
+ },
195
+ ],
196
+ defaultOptions: [{ ignoreWhenErrexit: true }],
197
+ messages: {
198
+ uncheckedCd: 'If `{{command}}` fails the script keeps running in the wrong directory. Check it, for example `{{command}} … || exit`.',
199
+ appendExit: 'Append ` || exit` so the script stops if `{{command}}` fails.',
200
+ },
201
+ },
202
+ create(context) {
203
+ const [options] = context.options;
204
+ const { sourceCode } = context;
205
+ let errexitInForce = false;
206
+ return {
207
+ File(node) {
208
+ errexitInForce =
209
+ options.ignoreWhenErrexit !== false &&
210
+ prologueEnabledOptions(node, sourceCode).has('errexit');
211
+ },
212
+ CallExpr(node) {
213
+ if (errexitInForce)
214
+ return;
215
+ const invoked = sourceCode.resolveInvokedCommand(node, {
216
+ transparentWrappers: TRANSPARENT_WRAPPERS,
217
+ });
218
+ if (invoked?.effective.static !== true || !DIRECTORY_COMMANDS.has(invoked.effective.text)) {
219
+ return;
220
+ }
221
+ const command = invoked.effective.text;
222
+ const stmt = sourceCode.getParent(node);
223
+ if (stmt?.type !== 'Stmt')
224
+ return;
225
+ const parentOf = (n) => sourceCode.getParent(n);
226
+ if (isStatusChecked(stmt, parentOf))
227
+ return;
228
+ if (sourceCode.dialect === 'bats' && batsErrexitIsOn(stmt, parentOf, sourceCode))
229
+ return;
230
+ const container = parentOf(stmt);
231
+ const hasHeredoc = stmt.redirs.some((r) => r.hdoc !== undefined);
232
+ const canAppend = !stmt.background && !stmt.negated && !hasHeredoc && container?.type !== 'BinaryCmd';
233
+ context.report({
234
+ node,
235
+ messageId: 'uncheckedCd',
236
+ data: { command },
237
+ suggest: canAppend
238
+ ? [
239
+ {
240
+ messageId: 'appendExit',
241
+ data: { command },
242
+ fix: (fixer) => fixer.insertTextAfterRange([0, commandEnd(stmt)], ' || exit'),
243
+ },
244
+ ]
245
+ : [],
246
+ });
247
+ },
248
+ };
249
+ },
250
+ });
@@ -0,0 +1,20 @@
1
+ /**
2
+ * The rule's single options object.
3
+ *
4
+ * @internal
5
+ */
6
+ export interface NoUndeclaredCommandsOptions {
7
+ /**
8
+ * Names of project-specific transparent wrappers (`with-retry`), added to
9
+ * the default wrapper table (see `harvestWrapperTable`). The word after a listed
10
+ * wrapper is the command it runs, so `with-retry curl` uses `curl` as well
11
+ * as `with-retry`.
12
+ */
13
+ readonly transparentWrappers?: readonly string[];
14
+ }
15
+ /**
16
+ * The `sh/no-undeclared-commands` rule module.
17
+ *
18
+ * @internal
19
+ */
20
+ export declare const noUndeclaredCommands: import("./define-shell-rule.js").ShellRule<[NoUndeclaredCommandsOptions?], "undeclaredCommand">;