@eslint-sh/eslint-plugin 0.5.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +228 -17
- package/dist/command-harvest/builtins/bash.d.ts +36 -0
- package/dist/command-harvest/builtins/bash.js +98 -0
- package/dist/command-harvest/builtins/bats.d.ts +45 -0
- package/dist/command-harvest/builtins/bats.js +33 -0
- package/dist/command-harvest/builtins/index.d.ts +18 -0
- package/dist/command-harvest/builtins/index.js +39 -0
- package/dist/command-harvest/builtins/posix.d.ts +51 -0
- package/dist/command-harvest/builtins/posix.js +93 -0
- package/dist/command-harvest/harvest-commands.d.ts +66 -0
- package/dist/command-harvest/harvest-commands.js +97 -0
- package/dist/command-harvest/wrapper-table.d.ts +24 -0
- package/dist/command-harvest/wrapper-table.js +49 -0
- package/dist/configs/agent-authored.d.ts +41 -0
- package/dist/configs/agent-authored.js +33 -0
- package/dist/configs/bash-3-2.d.ts +28 -0
- package/dist/configs/bash-3-2.js +51 -0
- package/dist/configs/posix-portable.d.ts +40 -0
- package/dist/configs/posix-portable.js +53 -0
- package/dist/configs/recommended.d.ts +52 -0
- package/dist/configs/recommended.js +34 -0
- package/dist/declaration-conventions/comment-pragma.d.ts +33 -0
- package/dist/declaration-conventions/comment-pragma.js +153 -0
- package/dist/declaration-conventions/declaration-convention.d.ts +189 -0
- package/dist/declaration-conventions/declaration-convention.js +1 -0
- package/dist/declaration-conventions/read-declared-commands.d.ts +70 -0
- package/dist/declaration-conventions/read-declared-commands.js +128 -0
- package/dist/eslint-plugin-syntax-analyze.d.ts +23 -0
- package/dist/eslint-plugin-syntax.d.ts +27 -0
- package/dist/eslint-plugin.d.ts +1211 -52
- package/dist/index.d.ts +56 -8
- package/dist/index.js +53 -6
- package/dist/language/command-resolution.d.ts +124 -0
- package/dist/language/command-resolution.js +120 -0
- package/dist/language/end-of-input-marker.d.ts +29 -0
- package/dist/language/end-of-input-marker.js +88 -0
- package/dist/language/line-breaks.d.ts +18 -0
- package/dist/language/line-breaks.js +22 -0
- package/dist/language/lint-view-types.d.ts +104 -0
- package/dist/language/lint-view-types.js +1 -0
- package/dist/language/non-shell-file-guard.d.ts +35 -0
- package/dist/language/non-shell-file-guard.js +59 -0
- package/dist/language/selector-classes.d.ts +0 -1
- package/dist/language/selector-classes.js +0 -1
- package/dist/language/shell-language-options.d.ts +7 -1
- package/dist/language/shell-language-options.js +4 -4
- package/dist/language/shell-language.d.ts +10 -2
- package/dist/language/shell-language.js +59 -8
- package/dist/language/shell-source-code.d.ts +342 -5
- package/dist/language/shell-source-code.js +559 -30
- package/dist/language/source-coordinates.d.ts +13 -2
- package/dist/language/source-coordinates.js +68 -14
- package/dist/language/syntax-view.d.ts +60 -39
- package/dist/language/syntax-view.js +200 -45
- package/dist/language/word-resolution.d.ts +4 -3
- package/dist/language/word-resolution.js +4 -3
- package/dist/rules/compat/create-compat-rule.d.ts +59 -0
- package/dist/rules/compat/create-compat-rule.js +65 -0
- package/dist/rules/compat/declaration-options.d.ts +19 -0
- package/dist/rules/compat/declaration-options.js +110 -0
- package/dist/rules/compat/no-append-all-redirect.d.ts +13 -0
- package/dist/rules/compat/no-append-all-redirect.js +73 -0
- package/dist/rules/compat/no-associative-arrays.d.ts +12 -0
- package/dist/rules/compat/no-associative-arrays.js +39 -0
- package/dist/rules/compat/no-brace-sequence-padding-step.d.ts +15 -0
- package/dist/rules/compat/no-brace-sequence-padding-step.js +287 -0
- package/dist/rules/compat/no-case-attributes.d.ts +12 -0
- package/dist/rules/compat/no-case-attributes.js +63 -0
- package/dist/rules/compat/no-case-fallthrough.d.ts +12 -0
- package/dist/rules/compat/no-case-fallthrough.js +67 -0
- package/dist/rules/compat/no-case-modification.d.ts +35 -0
- package/dist/rules/compat/no-case-modification.js +86 -0
- package/dist/rules/compat/no-coproc.d.ts +12 -0
- package/dist/rules/compat/no-coproc.js +47 -0
- package/dist/rules/compat/no-declare-g.d.ts +12 -0
- package/dist/rules/compat/no-declare-g.js +59 -0
- package/dist/rules/compat/no-double-bracket.d.ts +12 -0
- package/dist/rules/compat/no-double-bracket.js +52 -0
- package/dist/rules/compat/no-epoch-variables.d.ts +12 -0
- package/dist/rules/compat/no-epoch-variables.js +57 -0
- package/dist/rules/compat/no-fd-variable.d.ts +12 -0
- package/dist/rules/compat/no-fd-variable.js +48 -0
- package/dist/rules/compat/no-globstar.d.ts +12 -0
- package/dist/rules/compat/no-globstar.js +39 -0
- package/dist/rules/compat/no-lastpipe-semantics.d.ts +12 -0
- package/dist/rules/compat/no-lastpipe-semantics.js +53 -0
- package/dist/rules/compat/no-let.d.ts +12 -0
- package/dist/rules/compat/no-let.js +62 -0
- package/dist/rules/compat/no-mapfile.d.ts +13 -0
- package/dist/rules/compat/no-mapfile.js +122 -0
- package/dist/rules/compat/no-nameref.d.ts +12 -0
- package/dist/rules/compat/no-nameref.js +44 -0
- package/dist/rules/compat/no-negative-array-index.d.ts +13 -0
- package/dist/rules/compat/no-negative-array-index.js +123 -0
- package/dist/rules/compat/no-nounset-empty-array.d.ts +13 -0
- package/dist/rules/compat/no-nounset-empty-array.js +158 -0
- package/dist/rules/compat/no-parameter-transformation.d.ts +13 -0
- package/dist/rules/compat/no-parameter-transformation.js +123 -0
- package/dist/rules/compat/no-pipe-all.d.ts +13 -0
- package/dist/rules/compat/no-pipe-all.js +68 -0
- package/dist/rules/compat/no-printf-time-format.d.ts +12 -0
- package/dist/rules/compat/no-printf-time-format.js +99 -0
- package/dist/rules/compat/no-source.d.ts +13 -0
- package/dist/rules/compat/no-source.js +103 -0
- package/dist/rules/compat/no-test-v.d.ts +12 -0
- package/dist/rules/compat/no-test-v.js +280 -0
- package/dist/rules/compat/no-unicode-escape.d.ts +14 -0
- package/dist/rules/compat/no-unicode-escape.js +120 -0
- package/dist/rules/compat/no-wait-n.d.ts +12 -0
- package/dist/rules/compat/no-wait-n.js +73 -0
- package/dist/rules/compat/operator-range.d.ts +10 -0
- package/dist/rules/compat/operator-range.js +15 -0
- package/dist/rules/compat/posix-absent-command.d.ts +12 -0
- package/dist/rules/compat/posix-absent-command.js +134 -0
- package/dist/rules/compat/shopt-options.d.ts +9 -0
- package/dist/rules/compat/shopt-options.js +63 -0
- package/dist/rules/compat/version-gates.d.ts +76 -0
- package/dist/rules/compat/version-gates.js +205 -0
- package/dist/rules/create-rule.d.ts +91 -0
- package/dist/rules/create-rule.js +51 -0
- package/dist/rules/create-wrapper-rule.d.ts +166 -0
- package/dist/rules/create-wrapper-rule.js +407 -0
- package/dist/rules/define-shell-rule.d.ts +184 -0
- package/dist/rules/define-shell-rule.js +56 -0
- package/dist/rules/index.d.ts +26 -0
- package/dist/rules/index.js +90 -0
- package/dist/rules/max-script-complexity.d.ts +28 -0
- package/dist/rules/max-script-complexity.js +130 -0
- package/dist/rules/no-dynamic-invocation.d.ts +35 -0
- package/dist/rules/no-dynamic-invocation.js +117 -0
- package/dist/rules/no-eval.d.ts +13 -0
- package/dist/rules/no-eval.js +89 -0
- package/dist/rules/no-masked-exit-status.d.ts +21 -0
- package/dist/rules/no-masked-exit-status.js +667 -0
- package/dist/rules/no-secret-env-reads.d.ts +24 -0
- package/dist/rules/no-secret-env-reads.js +133 -0
- package/dist/rules/no-secret-flags.d.ts +32 -0
- package/dist/rules/no-secret-flags.js +162 -0
- package/dist/rules/no-unchecked-cd.d.ts +44 -0
- package/dist/rules/no-unchecked-cd.js +250 -0
- package/dist/rules/no-undeclared-commands.d.ts +20 -0
- package/dist/rules/no-undeclared-commands.js +115 -0
- package/dist/rules/no-unused-declarations.d.ts +23 -0
- package/dist/rules/no-unused-declarations.js +259 -0
- package/dist/rules/prefer-local.d.ts +16 -0
- package/dist/rules/prefer-local.js +249 -0
- package/dist/rules/quote-expansions.d.ts +13 -0
- package/dist/rules/quote-expansions.js +229 -0
- package/dist/rules/require-network-wrapper.d.ts +40 -0
- package/dist/rules/require-network-wrapper.js +244 -0
- package/dist/rules/require-shebang.d.ts +29 -0
- package/dist/rules/require-shebang.js +101 -0
- package/dist/rules/require-strict-mode.d.ts +52 -0
- package/dist/rules/require-strict-mode.js +177 -0
- package/dist/rules/require-tty-guard.d.ts +13 -0
- package/dist/rules/require-tty-guard.js +318 -0
- package/dist/rules/resolve-declaration-convention.d.ts +96 -0
- package/dist/rules/resolve-declaration-convention.js +109 -0
- package/dist/rules/resolve-network-wrapper.d.ts +19 -0
- package/dist/rules/resolve-network-wrapper.js +116 -0
- package/dist/rules/set-options.d.ts +109 -0
- package/dist/rules/set-options.js +201 -0
- package/dist/rules/shebang-interpreter.d.ts +18 -0
- package/dist/rules/shebang-interpreter.js +81 -0
- package/dist/rules/shebang-matches-dialect.d.ts +12 -0
- package/dist/rules/shebang-matches-dialect.js +69 -0
- package/dist/rules/transparent-wrappers.d.ts +52 -0
- package/dist/rules/transparent-wrappers.js +99 -0
- package/dist/rules/user-pattern.d.ts +63 -0
- package/dist/rules/user-pattern.js +184 -0
- package/dist/syntax/analyze.d.ts +19 -0
- package/dist/syntax/analyze.js +21 -0
- package/dist/syntax/index.d.ts +23 -0
- package/dist/syntax/index.js +25 -0
- package/dist/version.d.ts +0 -1
- package/dist/version.js +0 -1
- package/docs/compat.md +354 -0
- package/docs/custom-rule-tutorial.md +780 -34
- package/docs/declaration-conventions.md +478 -0
- package/docs/examples/audit-wrapper-pack.mjs +15 -0
- package/docs/examples/dep-line-convention.mjs +22 -0
- package/docs/examples/eslint.config.mjs +3 -2
- package/docs/examples/no-and-or-as-if-rule.mjs +61 -0
- package/docs/examples/no-backticks-rule-tester.mjs +106 -0
- package/docs/examples/no-backticks-rule.mjs +60 -0
- package/docs/examples/no-curl-in-functions-rule.mjs +53 -0
- package/docs/examples/no-function-keyword-rule.mjs +59 -0
- package/docs/examples/no-listed-commands-rule.ts +63 -0
- package/docs/examples/no-rm-rf-rule.mjs +26 -15
- package/docs/examples/no-unquoted-rm-variable-rule.ts +54 -0
- package/docs/examples/sidecar-convention.mjs +38 -0
- package/docs/examples/sidecar-snapshot-convention.mjs +26 -0
- package/docs/examples/sorted-declarations-rule.mjs +52 -0
- package/docs/reading-nodes.md +159 -0
- package/docs/rules/README.md +79 -0
- package/docs/rules/_template.md +83 -0
- package/docs/rules/compat/no-append-all-redirect.md +87 -0
- package/docs/rules/compat/no-associative-arrays.md +95 -0
- package/docs/rules/compat/no-brace-sequence-padding-step.md +90 -0
- package/docs/rules/compat/no-case-attributes.md +82 -0
- package/docs/rules/compat/no-case-fallthrough.md +90 -0
- package/docs/rules/compat/no-case-modification.md +74 -0
- package/docs/rules/compat/no-coproc.md +76 -0
- package/docs/rules/compat/no-declare-g.md +80 -0
- package/docs/rules/compat/no-double-bracket.md +72 -0
- package/docs/rules/compat/no-epoch-variables.md +79 -0
- package/docs/rules/compat/no-fd-variable.md +78 -0
- package/docs/rules/compat/no-globstar.md +74 -0
- package/docs/rules/compat/no-lastpipe-semantics.md +73 -0
- package/docs/rules/compat/no-let.md +78 -0
- package/docs/rules/compat/no-mapfile.md +118 -0
- package/docs/rules/compat/no-nameref.md +81 -0
- package/docs/rules/compat/no-negative-array-index.md +87 -0
- package/docs/rules/compat/no-nounset-empty-array.md +103 -0
- package/docs/rules/compat/no-parameter-transformation.md +94 -0
- package/docs/rules/compat/no-pipe-all.md +85 -0
- package/docs/rules/compat/no-printf-time-format.md +75 -0
- package/docs/rules/compat/no-source.md +98 -0
- package/docs/rules/compat/no-test-v.md +82 -0
- package/docs/rules/compat/no-unicode-escape.md +91 -0
- package/docs/rules/compat/no-wait-n.md +85 -0
- package/docs/rules/max-script-complexity.md +109 -0
- package/docs/rules/no-dynamic-invocation.md +139 -0
- package/docs/rules/no-eval.md +106 -0
- package/docs/rules/no-masked-exit-status.md +239 -0
- package/docs/rules/no-secret-env-reads.md +173 -0
- package/docs/rules/no-secret-flags.md +169 -0
- package/docs/rules/no-unchecked-cd.md +146 -0
- package/docs/rules/no-undeclared-commands.md +141 -0
- package/docs/rules/no-unused-declarations.md +216 -0
- package/docs/rules/prefer-local.md +221 -0
- package/docs/rules/quote-expansions.md +157 -0
- package/docs/rules/require-network-wrapper.md +171 -0
- package/docs/rules/require-shebang.md +157 -0
- package/docs/rules/require-strict-mode.md +253 -0
- package/docs/rules/require-tty-guard.md +131 -0
- package/docs/rules/shebang-matches-dialect.md +120 -0
- package/docs/selectors.md +2 -2
- package/docs/shellcheck.md +87 -0
- package/docs/untrusted-code.md +440 -0
- package/docs/wrapper-rules.md +206 -0
- package/package.json +26 -20
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/language/selector-classes.d.ts.map +0 -1
- package/dist/language/selector-classes.js.map +0 -1
- package/dist/language/shell-language-options.d.ts.map +0 -1
- package/dist/language/shell-language-options.js.map +0 -1
- package/dist/language/shell-language.d.ts.map +0 -1
- package/dist/language/shell-language.js.map +0 -1
- package/dist/language/shell-source-code.d.ts.map +0 -1
- package/dist/language/shell-source-code.js.map +0 -1
- package/dist/language/source-coordinates.d.ts.map +0 -1
- package/dist/language/source-coordinates.js.map +0 -1
- package/dist/language/syntax-view.d.ts.map +0 -1
- package/dist/language/syntax-view.js.map +0 -1
- package/dist/language/word-resolution.d.ts.map +0 -1
- package/dist/language/word-resolution.js.map +0 -1
- package/dist/version.d.ts.map +0 -1
- package/dist/version.js.map +0 -1
|
@@ -0,0 +1,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.
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
# `sh/no-secret-flags`
|
|
2
|
+
|
|
3
|
+
Disallow secret-shaped command-line flags, such as `--password=` or `--token`, with a literal value.
|
|
4
|
+
|
|
5
|
+
| Configs | Fixable | Languages |
|
|
6
|
+
| ----------------------------------------- | ------------ | --------- |
|
|
7
|
+
| `recommended` (warn), `agent-authored` (error) | not fixable | `sh/sh` |
|
|
8
|
+
|
|
9
|
+
## Rule details
|
|
10
|
+
|
|
11
|
+
A command's arguments are visible to every user on the machine: `ps` and `/proc/<pid>/cmdline`
|
|
12
|
+
show them while the command runs. A secret written as a literal flag value is also committed with
|
|
13
|
+
the script. This rule reports a flag whose name looks like it carries a secret when its value is
|
|
14
|
+
written literally.
|
|
15
|
+
|
|
16
|
+
The rule inspects the arguments of every simple command (`CallExpr`), after the command word. It
|
|
17
|
+
recognizes two forms:
|
|
18
|
+
|
|
19
|
+
- One word: `--token=abc`. The flag name is the text before the first `=`, and the value is the
|
|
20
|
+
rest. An empty value (`--token=`) passes nothing and is not reported.
|
|
21
|
+
- Two words: `--token abc`. A flag word without `=` takes the next word as its value when that word
|
|
22
|
+
is static, non-empty, and doesn't start with `-`. So a flag followed by another flag
|
|
23
|
+
(`--auth --verbose`), by `-` (the usual "read from stdin" marker), or by nothing is valueless and
|
|
24
|
+
not reported.
|
|
25
|
+
|
|
26
|
+
A value is literal only when its whole word is static. Any parameter expansion, command
|
|
27
|
+
substitution, or other dynamic part makes the value fine: `--token="$TOKEN"` and
|
|
28
|
+
`--password=$(cat f)` are the correct pattern. Static-ness is decided by the plugin's shared word
|
|
29
|
+
resolution (`ShellSourceCode#resolveWordText`), so quoting that resolves to fixed text
|
|
30
|
+
(`"--password"='hunter2'`) is still literal.
|
|
31
|
+
|
|
32
|
+
Only command arguments are matched. Assignment values (`X="--token=abc"`, `export`, `local`),
|
|
33
|
+
heredoc bodies, here-strings, `[[ … ]]` operands, and `case` patterns are never reported. Commands
|
|
34
|
+
behind wrappers are still checked, because their flags are arguments of the same command:
|
|
35
|
+
`sudo mysql --password=hunter2` reports.
|
|
36
|
+
|
|
37
|
+
This is a heuristic screen on flag names, not secret detection. The default pattern matches a
|
|
38
|
+
flag whose name, after its dashes, is a secret word: `password`, `passwd`, `token`, `secret`,
|
|
39
|
+
`api-key` (or `apikey`), `auth`, `authkey`, or `authorization`. The word may follow an `access-`
|
|
40
|
+
or `auth` qualifier (`--access-token`, `--auth-token`, `--authtoken`). It may be followed by a
|
|
41
|
+
digit (`--password2`, `--token1`) or by another `-segment` (`--secret-key`,
|
|
42
|
+
`--authorization-token`). The pattern leaves two kinds of name alone:
|
|
43
|
+
|
|
44
|
+
- Names ending in `-stdin` or `-file`, such as `--password-stdin`, `--password-file`, and
|
|
45
|
+
`--token-file`. These flags read the secret from stdin or from a file, which are the alternatives
|
|
46
|
+
this rule recommends. In `docker login --password-stdin registry.example.com`, the next word is a
|
|
47
|
+
positional argument, not the flag's value.
|
|
48
|
+
- Names with a letter directly after the secret word, such as `--author` (`auth` + `or`),
|
|
49
|
+
`--tokens`, and `--tokenizer` (`token` + a letter). These are longer words that only start
|
|
50
|
+
with a secret word.
|
|
51
|
+
|
|
52
|
+
Other qualifiers aren't matched, so `--my-token` is not reported. A general prefix would also
|
|
53
|
+
match negations such as `--no-password`, which take no value. Add a project's own flag names
|
|
54
|
+
with the `patterns` option.
|
|
55
|
+
|
|
56
|
+
Expect some false positives: a name such as `--token-type` still matches. Suppress a single false
|
|
57
|
+
positive with a disable comment that says why, or narrow the `patterns` option for your project.
|
|
58
|
+
|
|
59
|
+
## Options
|
|
60
|
+
|
|
61
|
+
- `patterns` (`string[]`, at least one entry). Regular-expression sources tested against each flag's
|
|
62
|
+
name, without `=` or the value. They **replace** the default list. Each source is compiled
|
|
63
|
+
case-insensitively, like the default. An invalid source fails config validation with an error that
|
|
64
|
+
names the rule and the source.
|
|
65
|
+
|
|
66
|
+
Default: `["^(?!.*-(?:stdin|file)$)--?(?:access-|auth-?)?(?:password|passwd|token|secret|api-?key|auth(?:key|orization)?)(?![a-z])"]`.
|
|
67
|
+
|
|
68
|
+
```js
|
|
69
|
+
// eslint.config.js
|
|
70
|
+
{
|
|
71
|
+
rules: {
|
|
72
|
+
'sh/no-secret-flags': [
|
|
73
|
+
'error',
|
|
74
|
+
// Only these exact names, plus a project-specific flag.
|
|
75
|
+
{ patterns: ['^--?(password|token|api-?key)$', '^--vault-pin$'] },
|
|
76
|
+
],
|
|
77
|
+
},
|
|
78
|
+
}
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
### Patterns run against script-controlled text
|
|
82
|
+
|
|
83
|
+
`patterns` is tested against every flag name the linted script writes, and the script's author
|
|
84
|
+
chooses that text. When you lint scripts you do not control, a pattern with nested or overlapping
|
|
85
|
+
quantifiers can make the regular expression engine backtrack for a time that grows exponentially
|
|
86
|
+
with the name's length. Write anchored patterns with one level of repetition over a character class.
|
|
87
|
+
|
|
88
|
+
The rule rejects the most common accident: a pattern that repeats a group which itself repeats
|
|
89
|
+
(`*`, `+`, `{n,}` or `{n,m}` with `m > 1` applied to such a group). It is not a proof that an
|
|
90
|
+
accepted pattern is safe. Review every pattern you accept as if the guard were not there.
|
|
91
|
+
|
|
92
|
+
**What it does not catch.** These pass the check and can still be slow on a long name. Timings are
|
|
93
|
+
from Node 24 on a busy laptop and vary by machine; the shape of the growth is what matters.
|
|
94
|
+
|
|
95
|
+
- Alternation branches that overlap under a repetition grow exponentially. `^([A-Z]|[A-Z0-9])+_KEY$`
|
|
96
|
+
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
|
|
97
|
+
24 characters and 6.4 s at 26. `^(A|AA)*$` is the same shape.
|
|
98
|
+
- Adjacent quantifiers that can match the same text grow polynomially. `.*.*.*x` took 0.5 s on a
|
|
99
|
+
255-character name and 0.9 s at 300; each extra `.*` raises the exponent. Nothing bounds the length
|
|
100
|
+
of a name or flag in a script, so a long enough one is slow.
|
|
101
|
+
|
|
102
|
+
**What it rejects that is in fact linear.** The check does not look at what the repeated group can
|
|
103
|
+
match, so it also refuses some patterns that are safe, such as `(a*b)*`,
|
|
104
|
+
`^(?:[a-z]+-)*[a-z]+$` and `^(?:SECRET|TOKEN|PASSWORD)(?:_[A-Z]+)*$`. A rewrite without the nesting
|
|
105
|
+
exists for some of these and not for others, so if you cannot rewrite a pattern exactly, loosen it to
|
|
106
|
+
the character class you mean. The last one can be rewritten exactly with a lookahead
|
|
107
|
+
(it accepts the same names; compared on every suffix of up to seven characters over `A`, `_`, `a`
|
|
108
|
+
and `1` after `SECRET`, `TOKEN` and `PASSWORD`):
|
|
109
|
+
|
|
110
|
+
```js
|
|
111
|
+
{ patterns: ['^(?:SECRET|TOKEN|PASSWORD)(?!.*_(?:_|$))(?:_[A-Z][A-Z_]*)?$'] }
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
**When you see the error.** The error is thrown from the rule's `create()`, which ESLint calls for
|
|
116
|
+
each file it lints, so the check does not run when the config is loaded. ESLint stops the run at the
|
|
117
|
+
first file it lints with the rule enabled and prints "Error while loading rule" followed by
|
|
118
|
+
"Occurred while linting <that file>" and a stack trace, and exits with code 2. A run that lints no
|
|
119
|
+
file with the rule enabled never sees it.
|
|
120
|
+
|
|
121
|
+
The accident the check is for, and its fix:
|
|
122
|
+
|
|
123
|
+
```js
|
|
124
|
+
// Rejected: `([A-Z]+_?)*` repeats a group that already repeats, so a long
|
|
125
|
+
// name that does not match can take seconds.
|
|
126
|
+
{ patterns: ['^--([A-Z]+_?)*_KEY$'] }
|
|
127
|
+
|
|
128
|
+
// Safe rewrite: one repetition over a character class.
|
|
129
|
+
{ patterns: ['^--[A-Z0-9_]*_KEY$'] }
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
## Examples
|
|
133
|
+
|
|
134
|
+
### Incorrect
|
|
135
|
+
|
|
136
|
+
<!-- example: {"findings": 3} -->
|
|
137
|
+
```sh
|
|
138
|
+
curl --token=abc123 https://api.example.com # literal value in the same word
|
|
139
|
+
mysql --user=app --password hunter2 # literal value in the next word
|
|
140
|
+
sudo deploy --api-key='k-123' # quoting doesn't make it dynamic
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
### Correct
|
|
144
|
+
|
|
145
|
+
```sh
|
|
146
|
+
curl --token="$API_TOKEN" https://api.example.com # dynamic value
|
|
147
|
+
mysql --password="$(pass show db/app)" # command substitution
|
|
148
|
+
printf '%s' "$DB_PASSWORD" | db-cli --password - # read from stdin
|
|
149
|
+
printf '%s' "$REG_TOKEN" | docker login --password-stdin reg.example.com # a -stdin flag
|
|
150
|
+
db-cli --password-file=/run/secrets/db # a -file flag names a file
|
|
151
|
+
git commit --author="Jane <jane@example.com>" # --author is not --auth
|
|
152
|
+
mytool --auth --verbose # valueless flag
|
|
153
|
+
DB_PASSWORD="$DB_PASSWORD" db-cli # passed through the environment
|
|
154
|
+
X="--token=abc" # not a command argument
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
## When not to use it
|
|
158
|
+
|
|
159
|
+
Turn it off when literal flag values in your scripts are never real secrets, for example
|
|
160
|
+
throwaway fixtures for a local test service, or when another secret scanner already covers the same
|
|
161
|
+
files and you don't want duplicate reports.
|
|
162
|
+
|
|
163
|
+
## Relationship to shellcheck
|
|
164
|
+
|
|
165
|
+
eslint-sh is a convention and contract linter: a policy layer, where shellcheck is the gotcha
|
|
166
|
+
layer. Run shellcheck beside it.
|
|
167
|
+
|
|
168
|
+
shellcheck has no equivalent check. It reports shell mistakes, not credential handling, and it has
|
|
169
|
+
no way to configure which flag names your project treats as secrets. This rule enforces that policy.
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
# `sh/no-unchecked-cd`
|
|
2
|
+
|
|
3
|
+
Require the failure of `cd`, `pushd` and `popd` to be checked, so a script never carries on in the wrong directory.
|
|
4
|
+
|
|
5
|
+
| Configs | Fixable | Languages |
|
|
6
|
+
| ------------------------------- | ---------------------------- | --------- |
|
|
7
|
+
| `recommended`, `agent-authored` | suggestions (append `\|\| exit`) | `sh/sh` |
|
|
8
|
+
|
|
9
|
+
## Rule details
|
|
10
|
+
|
|
11
|
+
When `cd` fails, the script keeps running in the directory it was already in, and every relative
|
|
12
|
+
path after it silently means something else. `cd "$build"; rm -rf *` is the classic disaster.
|
|
13
|
+
`pushd` and `popd` change the directory (and the directory stack) and fail the same way.
|
|
14
|
+
|
|
15
|
+
A directory-changing command is **checked** when its exit status decides what runs next:
|
|
16
|
+
|
|
17
|
+
- it is the left side of `&&` or `||` (`cd x || exit`, `cd x || return`, `cd x && make`);
|
|
18
|
+
- it is the last command of an `if`, `elif`, `while` or `until` condition;
|
|
19
|
+
- it is the right side of a list, or the last command of a `{ …; }` group or `( … )` subshell,
|
|
20
|
+
whose own status is checked in one of these ways (`a && cd x || exit`, `{ cd x; } || exit`).
|
|
21
|
+
|
|
22
|
+
Everything else reports: a bare `cd`, `cd` in a function, loop or `case` body, a pipeline stage, a
|
|
23
|
+
background job, and `cd` inside a subshell (`( cd x; make )`). The subshell keeps the script's own
|
|
24
|
+
directory intact, but the commands after a failed `cd` inside it still run in the wrong place, so
|
|
25
|
+
it must be checked there too. A negated `! cd x` is only as checked as the position it sits in.
|
|
26
|
+
|
|
27
|
+
The command is recognized after transparent wrappers, so `builtin cd x` and `command cd x` are
|
|
28
|
+
read as `cd`.
|
|
29
|
+
|
|
30
|
+
**Files running under `set -e`.** A file whose prologue enables `errexit` stops at a failed `cd`
|
|
31
|
+
by itself, so the rule is quiet there. "Prologue" is read exactly as
|
|
32
|
+
[`require-strict-mode`](./require-strict-mode.md) reads it: the first five top-level statements,
|
|
33
|
+
tracked in order, so a later `set +e` undoes an earlier `set -e`, and a `set -e` outside that
|
|
34
|
+
window does not count. Set `ignoreWhenErrexit` to `false` to report anyway.
|
|
35
|
+
|
|
36
|
+
**Files run by bats.** With `languageOptions.dialect` set to `"bats"`, a bare `cd` (or `pushd` or
|
|
37
|
+
`popd`) written directly in the body of a `@test` block, or of a top-level `setup`, `teardown`,
|
|
38
|
+
`setup_file` or `teardown_file` function, is not reported: bats runs those bodies with `errexit`
|
|
39
|
+
on, so a failed `cd` fails the test. The same holds for a `cd` directly in a `( … )` subshell that
|
|
40
|
+
is itself a statement of such a body (`( cd "$dir"; make )`), because the subshell inherits
|
|
41
|
+
`errexit` and its failing status fails the test. A `$( … )` command substitution does not count: it
|
|
42
|
+
is reported as before.
|
|
43
|
+
|
|
44
|
+
The rule reads the body in order. After a plain `set +e` (or `set +o errexit`) in the same body,
|
|
45
|
+
`errexit` is off, and a later `cd` is reported again, because the test carries on after the failed
|
|
46
|
+
`cd`. A later plain `set -e` turns it back on, so `set +e; set -e; cd x` is not reported while
|
|
47
|
+
`set +e; set -e; set +e; cd x` is. A `cd` written before the `set +e` is still covered.
|
|
48
|
+
|
|
49
|
+
What the rule cannot tell: a `set +e` (or `shopt`, `eval 'set +e'`, `set "$flags"`) that is nested
|
|
50
|
+
in an `if`, loop or function, that lives in a file the test `load`s or `source`s or in a helper it
|
|
51
|
+
calls, or that ran in `setup` before the test starts. It reads each body on its own, assuming
|
|
52
|
+
`errexit` starts on, so none of these are seen, and a `cd` after one of them is not reported.
|
|
53
|
+
For example, bats runs `setup` in the same shell as the test, so after
|
|
54
|
+
`setup() { set +e; }` a bare `cd` in a `@test` has no `errexit` either, yet it is not reported.
|
|
55
|
+
Likewise a `cd` nested in an `if`, loop or `case` inside the body, a negated `! cd x`, a `cd` in
|
|
56
|
+
any other function of the same file, and a `cd` in a file read under another dialect (a `.sh` file)
|
|
57
|
+
are reported as usual. `run cd x` is not reported either, as before: `run` is not one of the
|
|
58
|
+
wrappers this rule follows, so the command it sees is `run`.
|
|
59
|
+
|
|
60
|
+
**Suggestion.** The editor suggestion appends ` || exit` after the command and its redirections.
|
|
61
|
+
|
|
62
|
+
<!-- transform: {"kind": "suggestion"} -->
|
|
63
|
+
|
|
64
|
+
```sh
|
|
65
|
+
cd "$build" > /dev/null
|
|
66
|
+
# becomes
|
|
67
|
+
cd "$build" > /dev/null || exit
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
It is not offered where appending would change the meaning: when the `cd` is already part of an
|
|
71
|
+
`&&`/`||` list or a pipeline, negated, in the background, or followed by a here-document.
|
|
72
|
+
|
|
73
|
+
The rule reads `languageOptions.dialect` only for the bats exemption above; no other option affects it.
|
|
74
|
+
|
|
75
|
+
## Options
|
|
76
|
+
|
|
77
|
+
| Option | Type | Default | Effect |
|
|
78
|
+
| ------------------- | --------- | ------- | --------------------------------------------------------------------------- |
|
|
79
|
+
| `ignoreWhenErrexit` | `boolean` | `true` | Skip files whose prologue enables `errexit` (for example `set -euo pipefail`). |
|
|
80
|
+
|
|
81
|
+
```js
|
|
82
|
+
// eslint.config.js
|
|
83
|
+
{
|
|
84
|
+
rules: {
|
|
85
|
+
'sh/no-unchecked-cd': ['error', { ignoreWhenErrexit: false }],
|
|
86
|
+
},
|
|
87
|
+
}
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
## Examples
|
|
91
|
+
|
|
92
|
+
### Incorrect
|
|
93
|
+
|
|
94
|
+
<!-- example: {"findings": 5} -->
|
|
95
|
+
|
|
96
|
+
```sh
|
|
97
|
+
cd "$build" # a failure goes unnoticed
|
|
98
|
+
rm -rf ./* # ...and this runs in the wrong directory
|
|
99
|
+
|
|
100
|
+
pushd /tmp # pushd and popd fail the same way
|
|
101
|
+
popd
|
|
102
|
+
|
|
103
|
+
( cd "$dir"; make ) # inside a subshell, still unchecked
|
|
104
|
+
|
|
105
|
+
f() {
|
|
106
|
+
cd "$1" # a function body does not check it
|
|
107
|
+
}
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
### Correct
|
|
111
|
+
|
|
112
|
+
```sh
|
|
113
|
+
cd "$build" || exit 1
|
|
114
|
+
cd "$build" || return
|
|
115
|
+
cd "$build" && make
|
|
116
|
+
|
|
117
|
+
if cd "$build"; then make; fi
|
|
118
|
+
while ! cd "$build"; do sleep 1; done
|
|
119
|
+
|
|
120
|
+
( cd "$dir" || exit; make )
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
With `errexit` in the prologue (the first five top-level statements), a bare `cd` is fine:
|
|
124
|
+
|
|
125
|
+
```sh
|
|
126
|
+
set -euo pipefail
|
|
127
|
+
cd "$build"
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
## When not to use it
|
|
131
|
+
|
|
132
|
+
Turn the rule off when every script already starts with `set -e` and you would rather not think
|
|
133
|
+
about the distinction (the default `ignoreWhenErrexit` already covers that), or when a project
|
|
134
|
+
deliberately tolerates a failed `cd`, such as `cd "$dir" 2>/dev/null; true`. Prefer writing the
|
|
135
|
+
`|| true` so the intent is explicit.
|
|
136
|
+
|
|
137
|
+
## Relationship to shellcheck
|
|
138
|
+
|
|
139
|
+
eslint-sh is a convention and contract linter: a policy layer, where shellcheck is the gotcha
|
|
140
|
+
layer. Run shellcheck beside it.
|
|
141
|
+
|
|
142
|
+
shellcheck overlaps with SC2164 ("Use `cd ... || exit` in case `cd` fails"), and this rule
|
|
143
|
+
enforces the same practice. It exists anyway because it offers a suggestion, covers `pushd` and
|
|
144
|
+
`popd`, can be turned off per file by `set -e` detection that follows the same prologue reading as
|
|
145
|
+
`require-strict-mode`, and is part of the shipped presets, so the policy applies without running
|
|
146
|
+
shellcheck. If you already run shellcheck with SC2164 enabled, the two agree on the common cases.
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
# `sh/no-undeclared-commands`
|
|
2
|
+
|
|
3
|
+
Require every external command a script uses to be declared, so that a script's dependencies
|
|
4
|
+
are stated up front instead of discovered when a command is missing.
|
|
5
|
+
|
|
6
|
+
| Configs | Fixable | Languages |
|
|
7
|
+
| ------- | ----------- | --------- |
|
|
8
|
+
| `agent-authored` (inert until a declaration convention is bound) | not fixable | `sh/sh` |
|
|
9
|
+
|
|
10
|
+
## Rule details
|
|
11
|
+
|
|
12
|
+
A script depends on every external command it runs, and any of them can be missing on the
|
|
13
|
+
machine it lands on. When your project states each script's dependencies, this rule keeps that
|
|
14
|
+
statement complete: it reports each command the script uses but does not declare.
|
|
15
|
+
|
|
16
|
+
The rule needs a **declaration convention**, which says how your project declares commands. Bind
|
|
17
|
+
one with `settings.sh.declarationConvention`. The plugin ships one, `commentPragma`, which reads
|
|
18
|
+
`# uses: jq curl` comments from a script's header. See
|
|
19
|
+
[Declaration conventions](../declaration-conventions.md) to write your own. **With no convention
|
|
20
|
+
bound, the rule reports nothing**, so turning it on is safe until your project picks a convention.
|
|
21
|
+
|
|
22
|
+
A command is **used** when the script runs it by a name known from the source. The rule looks
|
|
23
|
+
at every simple command in the file, wherever it is: the top level, function bodies, loops,
|
|
24
|
+
conditionals, pipelines, and command substitutions (`$(…)` and backquotes) in any position,
|
|
25
|
+
including arguments, assignment values, redirect targets, `for` lists, and `case` subjects.
|
|
26
|
+
|
|
27
|
+
- **Wrappers are followed to the command they run.** `sudo`, `env`, `nohup`, `nice`, `timeout`,
|
|
28
|
+
`time`, `command`, and `exec` run the command named after them, so that command is used. A
|
|
29
|
+
wrapper is also used itself when it is an external command: `sudo rsync -a . host:` uses both
|
|
30
|
+
`sudo` and `rsync`. `command` and `exec` are builtins, so `exec rsync …` uses only `rsync`.
|
|
31
|
+
`time` is a keyword in bash, so `time rsync …` uses only `rsync` there; in a `posix` file it
|
|
32
|
+
is the `time` utility and is used too. `command -v jq` only looks `jq` up, so it does not use
|
|
33
|
+
`jq`. In `bats` files, bats' `run` is a wrapper too, with its options (`-N`,
|
|
34
|
+
`!`, `--keep-empty-lines`, `--separate-stderr`, `--`), so `run ! curl x` uses `curl`.
|
|
35
|
+
- **Builtins don't count.** The builtin list depends on `languageOptions.dialect`. `bash` and
|
|
36
|
+
`bats` files use the bash builtins (`cd`, `echo`, `local`, `mapfile`, `source`, …). `bats`
|
|
37
|
+
files also leave out the helpers bats-core defines for every test file: `run`, `load`, `skip`,
|
|
38
|
+
`bats_pipe`, `bats_load_safe`, `bats_load_library`, `bats_require_minimum_version`, and
|
|
39
|
+
`bats_test_function`. Helpers from separate libraries, such as bats-assert's
|
|
40
|
+
`assert_success`, are not bats-core's and are still reported. `posix`
|
|
41
|
+
files use the POSIX special and regular built-ins plus `echo`, `printf`, `test` and `[`. So
|
|
42
|
+
`mapfile` is a command in a `posix` file. `mksh` and `zsh` files use the POSIX list for now,
|
|
43
|
+
so their own builtins (`print`, `whence`, `setopt`, …) are reported until they get lists of
|
|
44
|
+
their own.
|
|
45
|
+
- **Functions defined in the file don't count**, wherever they are defined, including after the
|
|
46
|
+
call or inside another function. A function named like a command (`curl() { … }`) shadows it.
|
|
47
|
+
- **Dynamic command words don't count.** `"$cmd" args`, `${tool} x`, and `$(pick) x` name no
|
|
48
|
+
command the rule can know. `sh/no-dynamic-invocation` reports them. A static wrapper in front
|
|
49
|
+
of one is still used: `sudo "$prog"` uses `sudo`.
|
|
50
|
+
- **Commands inside strings don't count.** `bash -c 'jq .'` uses `bash`, not `jq`.
|
|
51
|
+
|
|
52
|
+
Each undeclared command is reported once (message `undeclaredCommand`), at the word that names
|
|
53
|
+
it in its first use. The message says how to declare it, in the convention's own syntax when
|
|
54
|
+
the convention has a `declarationHint`. With `commentPragma`:
|
|
55
|
+
|
|
56
|
+
```text
|
|
57
|
+
'curl' is used but not declared. Add `# uses: curl` on a line below the shebang.
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
In a file without a shebang, the message says to add the line as the first line of the file
|
|
61
|
+
instead. A convention without a hint ends the message with the generic sentence
|
|
62
|
+
"Declare it with the project's declaration convention." A command run by path (`./build.sh`) is reported by that path.
|
|
63
|
+
|
|
64
|
+
## Options
|
|
65
|
+
|
|
66
|
+
- `transparentWrappers` (`string[]`, default `[]`): names of your project's own wrapper
|
|
67
|
+
commands, added to the built-in wrappers above. The word after a listed wrapper is the command
|
|
68
|
+
it runs, so with `['with-retry']`, `with-retry curl x` uses both `with-retry` and `curl`. A
|
|
69
|
+
listed wrapper takes no flags: in `with-retry -n 3 curl`, the rule stops at `-n` and does not
|
|
70
|
+
count `curl`. If the wrapper is a function defined in the same file, only the wrapped command
|
|
71
|
+
needs declaring. Your network wrapper doesn't need listing here when it is set in
|
|
72
|
+
`settings.sh.networkWrapper`: this rule looks through it, with the options its spec declares
|
|
73
|
+
(see [`sh/require-network-wrapper`](./require-network-wrapper.md#declaring-the-wrapper-once-settingsshnetworkwrapper)).
|
|
74
|
+
|
|
75
|
+
The convention is not a rule option. Bind it in `settings`, because ESLint copies rule options
|
|
76
|
+
with `structuredClone`, which cannot copy a convention's methods.
|
|
77
|
+
|
|
78
|
+
```js
|
|
79
|
+
// eslint.config.js
|
|
80
|
+
import sh, { commentPragma } from '@eslint-sh/eslint-plugin';
|
|
81
|
+
|
|
82
|
+
export default [
|
|
83
|
+
{
|
|
84
|
+
files: ['**/*.sh'],
|
|
85
|
+
plugins: { sh },
|
|
86
|
+
language: 'sh/sh',
|
|
87
|
+
settings: { sh: { declarationConvention: commentPragma } },
|
|
88
|
+
rules: {
|
|
89
|
+
'sh/no-undeclared-commands': ['error', { transparentWrappers: ['with-retry'] }],
|
|
90
|
+
},
|
|
91
|
+
},
|
|
92
|
+
];
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
## Examples
|
|
96
|
+
|
|
97
|
+
With `commentPragma` bound and `{ transparentWrappers: ['with-retry'] }`:
|
|
98
|
+
|
|
99
|
+
### Incorrect
|
|
100
|
+
|
|
101
|
+
<!-- example: {"convention": "commentPragma", "options": {"transparentWrappers": ["with-retry"]}, "findings": 6} -->
|
|
102
|
+
```sh
|
|
103
|
+
#!/usr/bin/env bash
|
|
104
|
+
# uses: jq
|
|
105
|
+
curl -s "$URL" | jq . # curl is not declared
|
|
106
|
+
echo "built $(date +%s)" # date, inside $(…), is not declared
|
|
107
|
+
sudo systemctl restart app # neither sudo nor systemctl is declared
|
|
108
|
+
with-retry git pull # git, run by the wrapper, is not declared (nor is with-retry)
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
### Correct
|
|
112
|
+
|
|
113
|
+
<!-- example: {"convention": "commentPragma", "options": {"transparentWrappers": ["with-retry"]}} -->
|
|
114
|
+
```sh
|
|
115
|
+
#!/usr/bin/env bash
|
|
116
|
+
# uses: curl jq date
|
|
117
|
+
log() { printf '%s\n' "$*"; } # a function defined here, using only builtins
|
|
118
|
+
log "fetching"
|
|
119
|
+
curl -s "$URL" | jq .
|
|
120
|
+
echo "built $(date +%s)"
|
|
121
|
+
cd /tmp && read -r answer # builtins need no declaration
|
|
122
|
+
"$cmd" --version # a dynamic command word: see sh/no-dynamic-invocation
|
|
123
|
+
command -v docker # looks docker up without running it
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
## When not to use it
|
|
127
|
+
|
|
128
|
+
Turn the rule off when your project doesn't track script dependencies, or tracks them somewhere
|
|
129
|
+
this rule can't read, such as a container image or a system package list. It is also noisy for
|
|
130
|
+
scripts that run many repository-local programs by path, since each path is reported until it is
|
|
131
|
+
declared.
|
|
132
|
+
|
|
133
|
+
## Relationship to shellcheck
|
|
134
|
+
|
|
135
|
+
eslint-sh is a convention and contract linter: a policy layer, where shellcheck is the gotcha
|
|
136
|
+
layer. Run shellcheck beside it.
|
|
137
|
+
|
|
138
|
+
shellcheck has no equivalent. It checks how a script uses commands, not which commands the
|
|
139
|
+
script may depend on, and it has no notion of a project's declaration convention to compare
|
|
140
|
+
against. A dependency manifest is project policy, which is the kind of rule shellcheck cannot be
|
|
141
|
+
configured to express.
|