@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,440 @@
|
|
|
1
|
+
# Linting code you don't control
|
|
2
|
+
|
|
3
|
+
eslint-sh is often a policy gate: CI lints a pull request, an agent's output, or a vendored
|
|
4
|
+
repository whose author does not own the policy. In that setting, the author controls the scripts
|
|
5
|
+
under review and every other file in the checkout. This page covers what that lets them change, how
|
|
6
|
+
to run the lint from a directory outside those files, so that they can't change its configuration
|
|
7
|
+
or its exit code, and what a passing run still doesn't tell you:
|
|
8
|
+
|
|
9
|
+
- [What a script's comments can switch off](#what-a-scripts-comments-can-switch-off)
|
|
10
|
+
- [A policy config that ignores them](#a-policy-config-that-ignores-them)
|
|
11
|
+
- [Run ESLint from outside the checkout](#run-eslint-from-outside-the-checkout)
|
|
12
|
+
- [Declaration conventions that read files](#declaration-conventions-that-read-files)
|
|
13
|
+
- [Which files get linted](#which-files-get-linted)
|
|
14
|
+
- [What this does not cover](#what-this-does-not-cover)
|
|
15
|
+
|
|
16
|
+
Everything below was run against ESLint 10.2.0, the oldest version this plugin's peer range allows,
|
|
17
|
+
and against 10.7.0.
|
|
18
|
+
|
|
19
|
+
## What a script's comments can switch off
|
|
20
|
+
|
|
21
|
+
ESLint's inline comments work in shell scripts the same way they do in JavaScript, with `#` as the
|
|
22
|
+
comment marker. With the default `linterOptions`, a script can silence any rule for itself, and
|
|
23
|
+
neither `sh/recommended` nor `sh/agent-authored` changes that. Each of these scripts lints with no
|
|
24
|
+
`sh/no-eval` report under `sh.configs.recommended`:
|
|
25
|
+
|
|
26
|
+
```sh
|
|
27
|
+
#!/bin/bash
|
|
28
|
+
# eslint-disable-next-line sh/no-eval
|
|
29
|
+
eval "$x"
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
| Comment | Effect |
|
|
33
|
+
| -------------------------------------------- | -------------------------------------------- |
|
|
34
|
+
| `# eslint-disable-next-line sh/no-eval` | Silences the rule on the next line. |
|
|
35
|
+
| `eval "$x" # eslint-disable-line sh/no-eval` | Silences the rule on its own line. |
|
|
36
|
+
| `# eslint-disable` … `# eslint-enable` | Silences every rule between the two. |
|
|
37
|
+
| `# eslint sh/no-eval: off` | Turns the rule off for the whole file. |
|
|
38
|
+
|
|
39
|
+
The `eslint` prefix is the default `languageOptions.directivePrefix`. A config that sets it, for
|
|
40
|
+
example to `shellcheck-sh`, makes `# shellcheck-sh-disable-next-line sh/no-eval` the working form
|
|
41
|
+
and turns `# eslint-disable-next-line` into an ordinary comment. The prefix is chosen by the config,
|
|
42
|
+
not by the script, and it does not stop directives, it only renames them.
|
|
43
|
+
|
|
44
|
+
## A policy config that ignores them
|
|
45
|
+
|
|
46
|
+
Set `linterOptions.noInlineConfig` in the config you lint with. The `globalIgnores` line and the
|
|
47
|
+
`checkout/` in `files` belong to [the recipe](#run-eslint-from-outside-the-checkout), which explains
|
|
48
|
+
them.
|
|
49
|
+
|
|
50
|
+
```js
|
|
51
|
+
// policy/eslint.config.mjs
|
|
52
|
+
import { defineConfig, globalIgnores } from 'eslint/config';
|
|
53
|
+
import sh from '@eslint-sh/eslint-plugin';
|
|
54
|
+
|
|
55
|
+
export default defineConfig([
|
|
56
|
+
globalIgnores(['!**/node_modules/', 'checkout/.git/']),
|
|
57
|
+
{
|
|
58
|
+
files: ['**/*.sh', '**/*.bash', 'checkout/**/bin/?*'],
|
|
59
|
+
extends: [sh.configs.recommended],
|
|
60
|
+
linterOptions: { noInlineConfig: true },
|
|
61
|
+
},
|
|
62
|
+
]);
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
`noInlineConfig: true` makes ESLint ignore every comment in the table above, whatever the
|
|
66
|
+
`directivePrefix`. The rule reports as if the comment were not there, and ESLint adds a warning with
|
|
67
|
+
no rule id at the comment itself. For the script above, with its other report:
|
|
68
|
+
|
|
69
|
+
```text
|
|
70
|
+
1:1 error Enable strict mode with `set -euo pipefail` within the first 5 statements; not enabled: errexit, nounset, pipefail sh/require-strict-mode
|
|
71
|
+
2:1 warning '# eslint-disable-next-line sh/no-eval' has no effect because you have 'noInlineConfig' setting in your config
|
|
72
|
+
3:1 error `eval` runs text as shell code at run time, so the code it executes cannot be statically reviewed. Write the commands directly instead sh/no-eval
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
The plugin returns its directives to ESLint through the language's `getDisableDirectives` and
|
|
76
|
+
`getInlineConfigNodes` hooks, and ESLint applies `noInlineConfig` to them as it does to
|
|
77
|
+
JavaScript comments. Under `--max-warnings 0`, that warning fails the
|
|
78
|
+
run on its own: a contributed script that contains any directive fails the gate, even one that would
|
|
79
|
+
have suppressed nothing. If you would rather let those through, run without `--max-warnings 0` and
|
|
80
|
+
read the warnings, but then no other warning fails the run either (see the
|
|
81
|
+
[continuous integration exit codes](https://github.com/mike-north/eslint-sh/blob/main/packages/eslint-sh/README.md#continuous-integration)).
|
|
82
|
+
|
|
83
|
+
**If you accept directives instead.** `linterOptions.reportUnusedDisableDirectives` sets the
|
|
84
|
+
severity of a disable comment that suppressed nothing. ESLint 10's default is `"warn"`; `"error"`
|
|
85
|
+
fails the run on one:
|
|
86
|
+
|
|
87
|
+
```js
|
|
88
|
+
// policy/eslint.config.mjs, directives allowed
|
|
89
|
+
import { defineConfig, globalIgnores } from 'eslint/config';
|
|
90
|
+
import sh from '@eslint-sh/eslint-plugin';
|
|
91
|
+
|
|
92
|
+
export default defineConfig([
|
|
93
|
+
globalIgnores(['!**/node_modules/', 'checkout/.git/']),
|
|
94
|
+
{
|
|
95
|
+
files: ['**/*.sh', '**/*.bash', 'checkout/**/bin/?*'],
|
|
96
|
+
extends: [sh.configs.recommended],
|
|
97
|
+
linterOptions: { reportUnusedDisableDirectives: 'error' },
|
|
98
|
+
},
|
|
99
|
+
]);
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
This keeps stale directives out of the tree, but it is not a gate: the `# eslint-disable-next-line
|
|
103
|
+
sh/no-eval` above still silences `sh/no-eval`, because it did suppress something. Setting both
|
|
104
|
+
options is allowed, but `reportUnusedDisableDirectives` then reports nothing, because
|
|
105
|
+
`noInlineConfig` has already discarded every directive.
|
|
106
|
+
|
|
107
|
+
## Run ESLint from outside the checkout
|
|
108
|
+
|
|
109
|
+
`linterOptions` only helps if the contributor can't replace the config, ESLint or the plugin, and
|
|
110
|
+
can't change the result some other way. ESLint finds several of its inputs in the directory it runs
|
|
111
|
+
in, and Node looks through that directory's parents for packages. When that directory is the
|
|
112
|
+
checkout, every one of these belongs to the contributor:
|
|
113
|
+
|
|
114
|
+
- **The config.** `eslint .` without `--config` imports the `eslint.config.mjs` in the directory it
|
|
115
|
+
runs in, as JavaScript, with your job's environment variables. ESLint 10 also loads an
|
|
116
|
+
`eslint.config.*` from a subdirectory for the files under it, so a nested config in the checkout
|
|
117
|
+
runs, and replaces your policy for its directory, even if your config sits at the root.
|
|
118
|
+
- **Where the config's imports resolve.** `import sh from '@eslint-sh/eslint-plugin'` resolves
|
|
119
|
+
from the config file's own location. A copy of your config placed inside the checkout imports
|
|
120
|
+
the checkout's `node_modules`.
|
|
121
|
+
- **Which ESLint runs.** `npx eslint` in the checkout runs the checkout's
|
|
122
|
+
`node_modules/.bin/eslint`.
|
|
123
|
+
- **The formatter.** ESLint resolves a formatter name, including the default `stylish`, as the
|
|
124
|
+
package `eslint-formatter-stylish` from the directory it runs in and then from each of its parents,
|
|
125
|
+
with or without `--config`. That package is code in your job. It can print what it likes and set
|
|
126
|
+
the exit code.
|
|
127
|
+
- **The suppressions file.** ESLint applies `eslint-suppressions.json` from the directory it runs in
|
|
128
|
+
whenever that file exists, with no flag, and neither `--config` nor `noInlineConfig` affects it.
|
|
129
|
+
An error listed in the file is hidden, and the run exits 0.
|
|
130
|
+
- **The cache file.** With `--cache`, ESLint reads `.eslintcache` from the directory it runs in and
|
|
131
|
+
reuses the results recorded there. The default `--cache-strategy metadata` trusts a recorded size
|
|
132
|
+
and modification time. `--cache-strategy content` doesn't help either, because the hash it
|
|
133
|
+
compares against is in the same file.
|
|
134
|
+
- **Your job's own relative paths.** A relative entry in `PATH`, or a relative file in
|
|
135
|
+
`NODE_OPTIONS`, resolves against the directory ESLint runs in, so either can run a file from the
|
|
136
|
+
checkout. A version manager may read its settings from there too: `nodenv` runs ESLint under the
|
|
137
|
+
Node version named in a `.node-version` in the checkout or any parent. Other version managers were
|
|
138
|
+
not tested.
|
|
139
|
+
|
|
140
|
+
Installing the checkout is a separate hazard: `npm ci` and `npm install` run the package's lifecycle
|
|
141
|
+
scripts, such as `preinstall`. The lint needs none of the checkout's dependencies, so don't install
|
|
142
|
+
them.
|
|
143
|
+
|
|
144
|
+
The recipe is to run ESLint somewhere else and pass the checkout as an argument. Keep the policy
|
|
145
|
+
where the contributor can't change it in the same change, such as a separate repository or a path
|
|
146
|
+
your CI provides, with `eslint` and `@eslint-sh/eslint-plugin` installed there from its own lockfile.
|
|
147
|
+
If `policy` is a link to such a path, the rule below applies to the parents of its real location
|
|
148
|
+
too, and the guards don't look there.
|
|
149
|
+
Put it beside the checkout, in a directory your job creates and the contributor can't write to.
|
|
150
|
+
Neither that directory nor any of its parents may be inside the contributor's files: a CI job often
|
|
151
|
+
starts in a clone of the repository under review, so don't create it there. The recipe refuses a
|
|
152
|
+
`node_modules` in a parent, but it has no guard for the rest of that rule, such as a `.node-version`
|
|
153
|
+
there.
|
|
154
|
+
|
|
155
|
+
```text
|
|
156
|
+
job-directory/
|
|
157
|
+
policy/ eslint.config.mjs and its own node_modules
|
|
158
|
+
checkout/ the code under review
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
Create `checkout` yourself, so that nothing of the contributor's lands beside it. From a repository:
|
|
162
|
+
|
|
163
|
+
```sh
|
|
164
|
+
git clone URL checkout
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
From a tarball, keep the input archive outside `job-directory` (here, `../x.tar`) and unpack only
|
|
168
|
+
into `checkout`. Keep every path as archived: the policy covers files directly inside any `bin/`
|
|
169
|
+
directory under `checkout`, so wrappers and siblings need no special handling.
|
|
170
|
+
|
|
171
|
+
```sh
|
|
172
|
+
[ ! -e checkout ] && [ ! -L checkout ] || { printf 'checkout already exists\n' >&2; exit 1; }
|
|
173
|
+
mkdir checkout && tar -xf ../x.tar -C checkout || exit 1
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
Stop the job if this fails: a failed `tar` can leave only some of the files. Don't strip path
|
|
177
|
+
components or move files out of a wrapper; stripping can silently discard top-level files.
|
|
178
|
+
|
|
179
|
+
Then, from `job-directory`, not from inside the checkout:
|
|
180
|
+
|
|
181
|
+
```sh
|
|
182
|
+
[ "$(ls -A | wc -l)" -eq 2 ] && [ -d policy ] && [ -d checkout ] || {
|
|
183
|
+
printf 'the job directory must hold only checkout and policy\n' >&2
|
|
184
|
+
exit 1
|
|
185
|
+
}
|
|
186
|
+
dir=$(pwd -P)
|
|
187
|
+
while :; do
|
|
188
|
+
if [ -e "$dir/node_modules" ]; then
|
|
189
|
+
printf 'node_modules in or above the job directory: %s\n' "$dir" >&2
|
|
190
|
+
exit 1
|
|
191
|
+
fi
|
|
192
|
+
[ "$dir" != / ] || break
|
|
193
|
+
dir=$(dirname "$dir")
|
|
194
|
+
done
|
|
195
|
+
policy/node_modules/.bin/eslint \
|
|
196
|
+
--config policy/eslint.config.mjs \
|
|
197
|
+
--max-warnings 0 \
|
|
198
|
+
checkout
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
- The working directory is what closes the list above. `eslint-suppressions.json`, `.eslintcache`
|
|
202
|
+
and any relative path in your job's environment resolve in `job-directory`, the formatter name
|
|
203
|
+
resolves there and in its parents, and the checkout's copies are never consulted.
|
|
204
|
+
- The first guard refuses anything else in `job-directory`, because ESLint reads its inputs from
|
|
205
|
+
there: a tarball unpacked here rather than as above can leave an `eslint-suppressions.json` beside
|
|
206
|
+
the checkout. It can't catch such a tarball writing into `policy/`, which only unpacking elsewhere
|
|
207
|
+
prevents.
|
|
208
|
+
- The second guard refuses a `node_modules` in `job-directory` or any parent, because Node looks
|
|
209
|
+
there for the formatter and for a package ESLint's own dependencies ask for on every run, whatever
|
|
210
|
+
`--format` says, and a package found there runs in your job and can set the exit code. If it
|
|
211
|
+
refuses a `node_modules` of your own, such as one in your home directory, make the job directory
|
|
212
|
+
with `mktemp -d` instead.
|
|
213
|
+
- `--config` loads only the policy, and its imports resolve from `policy/node_modules`.
|
|
214
|
+
- The checkout has to be under the working directory. With `--config`, ESLint matches the config's
|
|
215
|
+
`files` and `ignores` against the directory it runs in and treats anything outside it as ignored:
|
|
216
|
+
running from inside `policy/` with `../checkout`, or from elsewhere with an absolute path, exits 2
|
|
217
|
+
with `all of the files matching the glob pattern ... are ignored`. The policy's
|
|
218
|
+
`checkout/**/bin/?*` matches files directly in a `bin` at any depth under `checkout`.
|
|
219
|
+
- `!**/node_modules/` in `globalIgnores` turns off ESLint's default ignore of `**/node_modules/`.
|
|
220
|
+
Without it, a script under any directory named `node_modules`, at any depth, is not linted, and
|
|
221
|
+
the contributor can commit a directory of that name anywhere. A wrapper named `node_modules`
|
|
222
|
+
or starting with a dot is covered too. `node_modules/.bin` is not a directory named `bin`:
|
|
223
|
+
regular files there need a `.sh` or `.bash` extension to match; its usual symlinks fail the check.
|
|
224
|
+
- `checkout/.git/` in `globalIgnores` keeps the lint out of the clone's repository data, because
|
|
225
|
+
ESLint's default ignores only a `.git` in the directory it runs in, and a branch called
|
|
226
|
+
`fix/deploy.sh` is a file there that matches `files`.
|
|
227
|
+
- `--max-warnings 0` makes warnings, including the "has no effect" warnings above, fail the run.
|
|
228
|
+
The exit codes are the ones in the
|
|
229
|
+
[continuous integration table](https://github.com/mike-north/eslint-sh/blob/main/packages/eslint-sh/README.md#continuous-integration).
|
|
230
|
+
- Leave `--cache` off. The recipe needs no cache, and if you add one, it must not come from the
|
|
231
|
+
checkout or from anything else the contributor could write.
|
|
232
|
+
|
|
233
|
+
To find what was left, this command was run with every `fs` call, module load and child process
|
|
234
|
+
logged, against a checkout holding each ESLint input named on this page and a marker-writing copy
|
|
235
|
+
of every package in the policy's install. Under the checkout, ESLint only listed directories and read the
|
|
236
|
+
files it lints. It loaded no module from there and started no process. That is a result for the
|
|
237
|
+
ESLint versions above, not a promise from ESLint; this page's tests repeat the trace on the version
|
|
238
|
+
the repository uses.
|
|
239
|
+
|
|
240
|
+
The workflow that runs this command is part of the gate too: keep it, like the policy, where the
|
|
241
|
+
contributor's change can't edit it. Nothing of the contributor's should have run on the machine
|
|
242
|
+
before it, either: an install, build or test run in the same job can change any file the job can
|
|
243
|
+
write, including the policy.
|
|
244
|
+
|
|
245
|
+
## Declaration conventions that read files
|
|
246
|
+
|
|
247
|
+
A [`DeclarationConvention`](./declaration-conventions.md) is code in your config, and one that reads
|
|
248
|
+
files reads whatever path it is given. When linting code you don't control, read only paths you
|
|
249
|
+
control: a file built from the policy config's own location (`new URL('./commands.txt',
|
|
250
|
+
import.meta.url)`), or data in the config itself. Don't read a file located from the linted script's
|
|
251
|
+
path, such as the per-script sidecars in
|
|
252
|
+
[declarations outside the script](./declaration-conventions.md#declarations-outside-the-script) or
|
|
253
|
+
`scriptSidecarCommands()` in the repository's
|
|
254
|
+
[container-entrypoint example pack](https://github.com/mike-north/eslint-sh/tree/main/examples/container-entrypoint-pack):
|
|
255
|
+
in a checkout under review, every file next to a script belongs to the contributor.
|
|
256
|
+
|
|
257
|
+
## Which files get linted
|
|
258
|
+
|
|
259
|
+
ESLint lints the files the policy's `files` patterns match, and the contributor chooses file names.
|
|
260
|
+
With the policy above, a checkout with a clean `ok.sh` and these scripts, each starting `#!/bin/bash`
|
|
261
|
+
and containing `eval "$x"`, exits 0 with no report from the recipe:
|
|
262
|
+
|
|
263
|
+
- `ci/run` and `deploy`: no extension, outside any `bin/`.
|
|
264
|
+
- `build.SH`: the patterns are case-sensitive.
|
|
265
|
+
- `x.zsh`: the extension is not in the patterns.
|
|
266
|
+
- `script.sh.txt`: the pattern needs the name to end in `.sh`.
|
|
267
|
+
|
|
268
|
+
Naming a script differently is enough to avoid being linted. Widening the patterns does not close
|
|
269
|
+
this, because any list of names is a list the contributor can step around. What you can do is
|
|
270
|
+
check the checkout for files that start with a shell shebang and that the patterns do not match,
|
|
271
|
+
and fail the job when there are any. Run this from `job-directory`, before the lint, from a workflow
|
|
272
|
+
the contributor can't edit:
|
|
273
|
+
|
|
274
|
+
```sh
|
|
275
|
+
[ -d checkout ] && [ ! -L checkout ] || { printf 'no checkout directory here\n' >&2; exit 2; }
|
|
276
|
+
status=0
|
|
277
|
+
while IFS= read -r -d '' file; do
|
|
278
|
+
if [ -L "$file" ] || [ ! -f "$file" ] || [ ! -r "$file" ]; then
|
|
279
|
+
printf 'symlink, special or unreadable file: %q\n' "$file" >&2
|
|
280
|
+
status=1
|
|
281
|
+
continue
|
|
282
|
+
fi
|
|
283
|
+
case "$file" in
|
|
284
|
+
*.sh | *.bash) continue ;;
|
|
285
|
+
esac
|
|
286
|
+
# The policy covers direct children of every bin, regardless of wrappers.
|
|
287
|
+
case "${file%/*}" in
|
|
288
|
+
*/bin) continue ;;
|
|
289
|
+
esac
|
|
290
|
+
if head -c 1024 "$file" | head -n 1 | LC_ALL=C grep -Eiq '^#!.*sh'; then
|
|
291
|
+
printf 'not linted, but has a shell shebang: %q\n' "$file" >&2
|
|
292
|
+
status=1
|
|
293
|
+
fi
|
|
294
|
+
done < <(find checkout -path checkout/.git -type d -prune -o ! -type d -print0)
|
|
295
|
+
[ "$status" -eq 0 ] || exit "$status"
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
This needs `bash` (it uses `read -d` and process substitution). It exits non-zero when it finds
|
|
299
|
+
something and otherwise falls through, so it can sit above the recipe in one script or run as its
|
|
300
|
+
own step. Its first line exits 2 without a real `checkout` directory, because a failing `find`
|
|
301
|
+
wouldn't fail the check, and a check run from the wrong directory would pass every time. The first
|
|
302
|
+
`case` covers the extensions; the second checks the parent directory, so it covers direct children
|
|
303
|
+
of every `bin/` without accidentally accepting `bin/sub/run`. Keep these checks in sync with the
|
|
304
|
+
policy's `files`.
|
|
305
|
+
|
|
306
|
+
To rename `checkout`, change it everywhere. Left behind in the guards or the recipe's argument, the
|
|
307
|
+
old name stops the run, and in `globalIgnores` it can only add failures. The policy's `files`, the
|
|
308
|
+
`case` and the `find` must change together: if only `files` is left behind, `.sh` files are still
|
|
309
|
+
linted, so nothing looks wrong, but `bin/` silently isn't.
|
|
310
|
+
|
|
311
|
+
The check looks everywhere in the checkout except a top-level `.git` directory, including under
|
|
312
|
+
`node_modules`. The lint skips that directory too, and only a tarball can put files of its own there,
|
|
313
|
+
so don't run anything from it. The check names every file in the example above except `ok.sh`, and it
|
|
314
|
+
fails on any symlink, special file or file it can't read (see [symlinks](#what-this-does-not-cover)),
|
|
315
|
+
so an installed `node_modules`, whose `.bin` holds symlinks, fails it too. A directory it can't read
|
|
316
|
+
only makes `find` print an error, and the recipe then exits 2 on it. It flags any first line starting `#!` that contains `sh` in any
|
|
317
|
+
case, so `#!/usr/local/bin/publish` fails it as well. When it fails, either fix the name or extend
|
|
318
|
+
the policy's `files` to cover the file.
|
|
319
|
+
|
|
320
|
+
It uses `find` where `git ls-files` would be shorter, because git reads the checkout's own repository
|
|
321
|
+
configuration, which a checkout you did not clone yourself can set, and that configuration can run
|
|
322
|
+
commands. Don't run git in such a checkout, including to list changed files.
|
|
323
|
+
|
|
324
|
+
It is not a complete answer. A script with no shebang, outside every `bin/` and without a `.sh` or
|
|
325
|
+
`.bash` extension, passes both the lint and this check, even if something runs it as `sh deploy` or
|
|
326
|
+
`source`s it. So does one whose
|
|
327
|
+
shebang doesn't contain `sh`, such as a wrapper with another name. The check narrows what a
|
|
328
|
+
contributor can hide; it does not make the set of linted files equal to the set of scripts the
|
|
329
|
+
project runs.
|
|
330
|
+
|
|
331
|
+
The lint sees the files on disk, not the commit, so run it on a file system that keeps every name
|
|
332
|
+
apart, such as Linux's: on macOS's default, a commit with both `A.sh` and `a.sh` clones to one file,
|
|
333
|
+
and only that file is linted. The same goes for names that differ only in Unicode normal form,
|
|
334
|
+
which collide on APFS even on a case-sensitive volume. Lint what you will run, too. A plain clone leaves a submodule's directory empty, so use
|
|
335
|
+
`git clone --recurse-submodules` if anything later fetches submodules. And the contributor's
|
|
336
|
+
`.gitattributes` can mark a file `export-ignore`, which keeps it out of a `git archive` tarball, so
|
|
337
|
+
a tarball can lint clean while a clone of the same commit holds the file.
|
|
338
|
+
|
|
339
|
+
An honest repository can also fail the recipe. ESLint lints `*.js`, `*.mjs` and `*.cjs` by default,
|
|
340
|
+
whatever the policy's `files` say, and `checkout/**/bin/?*` covers everything directly in any `bin/`
|
|
341
|
+
under `checkout`, including vendored binaries. Those files are linted as shell, and can fail with
|
|
342
|
+
parse errors or shell policy findings. A JavaScript file with a disable comment, a JavaScript file
|
|
343
|
+
that ESLint's default parser rejects, or a
|
|
344
|
+
Python script in `bin/` each exits 1. So does any JavaScript file directly in `bin/`, however valid,
|
|
345
|
+
with a message telling you to add a `files` entry that is already there, and an empty `bin/.gitkeep`.
|
|
346
|
+
A checkout with nothing to lint exits 2 with `all of the files matching the glob pattern ... are
|
|
347
|
+
ignored`. These failures are closed rather than open. The policy's `files` and `ignores` are yours
|
|
348
|
+
to adjust, as long as the check's `case` patterns stay the same as what you leave linted.
|
|
349
|
+
|
|
350
|
+
## What this does not cover
|
|
351
|
+
|
|
352
|
+
Run this way, ESLint reads the checkout's files only as text to lint. That is all the recipe does.
|
|
353
|
+
|
|
354
|
+
**A clean lint is not evidence that a script is harmless.** Exit 0 means your policy's rules found
|
|
355
|
+
nothing in the files your policy matched. The rules look for patterns; they do not work out what a
|
|
356
|
+
script does. Under `sh.configs.recommended`, `eval "$1"` is an error and `bash -c "$1"`, which also
|
|
357
|
+
runs its argument as code, exits 0 with no report. Passing text to a shell another way, or
|
|
358
|
+
downloading a file and running it, passes too. Don't take a clean lint as a reason to run a script
|
|
359
|
+
you haven't reviewed.
|
|
360
|
+
|
|
361
|
+
**It is not a sandbox.** Linting parses the contributor's files in the job's own process, with the
|
|
362
|
+
job's environment variables and file access. The linter does not execute the scripts under review:
|
|
363
|
+
a script that touches a file at its top level, in `$(...)`, in `<(...)`, in an `eval` string, or
|
|
364
|
+
in a `source`d file created none of the test's marker files. But that is not isolation. If a leaked
|
|
365
|
+
secret or a parser fault in the job is the concern, run the lint in a container or a CI job that has
|
|
366
|
+
no secrets and no network access it does not need. Give the job a time limit: a named pipe called
|
|
367
|
+
`f.sh`, which a tarball can deliver though git can't, hung the run until it was killed. This page
|
|
368
|
+
did not test pathological inputs exhaustively.
|
|
369
|
+
|
|
370
|
+
**The report contains the contributor's text.** ESLint prints file names as they are, and rule
|
|
371
|
+
messages quote parts of the script. A file name containing newlines can print lines that look like
|
|
372
|
+
a clean summary. The exit code doesn't depend on any of that text, so gate on the exit code, and
|
|
373
|
+
treat the report as untrusted wherever you display it.
|
|
374
|
+
|
|
375
|
+
**`noInlineConfig` does not cover a script's own declarations.** `noInlineConfig` ignores ESLint's
|
|
376
|
+
comments. It leaves alone anything a `DeclarationConvention` reads out of the script. With
|
|
377
|
+
`commentPragma` bound, `sh/no-undeclared-commands` at `error`, and `noInlineConfig: true`, this
|
|
378
|
+
script has two reports, for `curl` and for `sh`:
|
|
379
|
+
|
|
380
|
+
```sh
|
|
381
|
+
#!/bin/bash
|
|
382
|
+
set -euo pipefail
|
|
383
|
+
curl https://example.com/install | sh
|
|
384
|
+
```
|
|
385
|
+
|
|
386
|
+
and this one, whose header declares them itself, has none and the run exits 0:
|
|
387
|
+
|
|
388
|
+
```sh
|
|
389
|
+
#!/bin/bash
|
|
390
|
+
# uses: curl sh
|
|
391
|
+
set -euo pipefail
|
|
392
|
+
curl https://example.com/install | sh
|
|
393
|
+
```
|
|
394
|
+
|
|
395
|
+
Don't bind `commentPragma`, or any convention that reads declarations from the script, in a policy
|
|
396
|
+
for code you don't control. To use the declaration rules as a gate, take the declarations from the
|
|
397
|
+
policy: a convention that returns a list written in the config, which the script cannot add to.
|
|
398
|
+
|
|
399
|
+
```js
|
|
400
|
+
// policy/eslint.config.mjs
|
|
401
|
+
const allowed = ['jq', 'git'];
|
|
402
|
+
|
|
403
|
+
/** Declarations come from this config, never from the script. */
|
|
404
|
+
const policyDeclarations = {
|
|
405
|
+
declaredCommands: () =>
|
|
406
|
+
allowed.map((name) => ({
|
|
407
|
+
kind: 'described',
|
|
408
|
+
name,
|
|
409
|
+
description: 'the allowed commands in policy/eslint.config.mjs',
|
|
410
|
+
})),
|
|
411
|
+
};
|
|
412
|
+
|
|
413
|
+
// In the config entry: settings: { sh: { declarationConvention: policyDeclarations } },
|
|
414
|
+
```
|
|
415
|
+
|
|
416
|
+
With that convention, the script with the `# uses: curl sh` header is reported for `curl` and `sh`,
|
|
417
|
+
the same as the one without it.
|
|
418
|
+
|
|
419
|
+
**A relative sidecar path resolves against the working directory.** With the recipe, that is
|
|
420
|
+
`job-directory`. With the example pack's `sidecarCommands('commands.txt')` in the policy and no such
|
|
421
|
+
file in `job-directory`, the config fails to load and ESLint exits 2, whatever the checkout holds.
|
|
422
|
+
A path that reaches into the checkout hands over the list: with
|
|
423
|
+
`sidecarCommands('checkout/commands.txt')` and that file listing `curl` and `sh`, the script above
|
|
424
|
+
exits 0. The same config with the absolute path of a file in your policy,
|
|
425
|
+
`sidecarCommands('/ci/policy/commands.txt')`, reports both commands. Give a convention an absolute
|
|
426
|
+
path to a file outside the checkout, or build it from `import.meta.url` as
|
|
427
|
+
[above](#declaration-conventions-that-read-files), or use no sidecar.
|
|
428
|
+
|
|
429
|
+
**A symlink in the checkout can point outside it.** A file symlink whose name matches the policy's
|
|
430
|
+
`files`, or ESLint's default JavaScript patterns, is followed: ESLint lints the file it points at,
|
|
431
|
+
wherever that is. A `leak.sh` linking to a
|
|
432
|
+
text file outside the checkout printed that file's parse error, such as ``Parsing error: `if` must
|
|
433
|
+
be followed by a statement list``, and a link to a script outside the checkout printed rule
|
|
434
|
+
messages naming its unquoted variables and their line numbers. If the report is posted where the
|
|
435
|
+
contributor can read it, that leaks a little of whatever the link points to, and text inside an
|
|
436
|
+
unquoted `$(...)` prints verbatim. Other symlinked directories are not followed: files behind a link
|
|
437
|
+
to a directory were not linted or reported. A link to a directory or to nothing stops the run with
|
|
438
|
+
exit 2 if its own name matches `files` or ESLint's default JavaScript patterns, and is skipped
|
|
439
|
+
otherwise. The check above rejects every symlink under the checkout, including one named
|
|
440
|
+
`node_modules` or `.git`, which closes the file case when it runs before the lint.
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
# Wrapper rules
|
|
2
|
+
|
|
3
|
+
Many projects require some commands to run through a wrapper: network calls through a retry
|
|
4
|
+
helper, infrastructure changes through an audit logger, deploys through a lock. These are
|
|
5
|
+
"command X must be run through wrapper Y" policies. `createWrapperRule` builds a rule that
|
|
6
|
+
enforces one, and `sh/require-network-wrapper` is the built-in rule made with it.
|
|
7
|
+
|
|
8
|
+
Which commands need a wrapper, and which wrapper, is your project's policy. This plugin ships no
|
|
9
|
+
wrapper name. You bind both, either in a rule pack with `createWrapperRule` or in your config
|
|
10
|
+
with [`sh/require-network-wrapper`](./rules/require-network-wrapper.md)'s `wrapper` option.
|
|
11
|
+
|
|
12
|
+
## `createWrapperRule`
|
|
13
|
+
|
|
14
|
+
```ts
|
|
15
|
+
import type { RuleDefinition } from '@eslint/core';
|
|
16
|
+
import type { WrapperSpec } from '@eslint-sh/eslint-plugin';
|
|
17
|
+
|
|
18
|
+
interface WrapperRuleConfig {
|
|
19
|
+
/** Command names that must be run through the wrapper. */
|
|
20
|
+
readonly commands: readonly string[];
|
|
21
|
+
/** The wrapper: a name, or a WrapperSpec that declares its options. */
|
|
22
|
+
readonly wrapper: string | WrapperSpec;
|
|
23
|
+
/** Listed commands that count only with some subcommands, keyed by command name. */
|
|
24
|
+
readonly subcommands?: Readonly<Record<string, WrapperRuleSubcommands>>;
|
|
25
|
+
/** Invocations that only act locally and are not reported, keyed by the command as the message names it. */
|
|
26
|
+
readonly localInvocations?: Readonly<Record<string, WrapperRuleLocalInvocation>>;
|
|
27
|
+
/** Overrides the rule's meta.docs.url and meta.docs.description. */
|
|
28
|
+
readonly docs?: { readonly url?: string; readonly description?: string };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** The command's own options before the subcommand use WrapperSpec's flag fields. */
|
|
32
|
+
interface WrapperRuleSubcommands
|
|
33
|
+
extends Pick<WrapperSpec, 'noArgFlags' | 'argFlags' | 'stopsChainFlags'> {
|
|
34
|
+
/** The subcommands that make an invocation count. */
|
|
35
|
+
readonly names: readonly string[];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface WrapperRuleLocalInvocation {
|
|
39
|
+
/** Complete argument lists (after the command and its subcommand) that are local. */
|
|
40
|
+
readonly argLists?: readonly (readonly string[])[];
|
|
41
|
+
/** Local when the first operand is a static word starting with one of `localPrefixes`. */
|
|
42
|
+
readonly localOperand?: Pick<WrapperSpec, 'noArgFlags' | 'argFlags'> & {
|
|
43
|
+
readonly localPrefixes: readonly string[];
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function createWrapperRule(config: WrapperRuleConfig): RuleDefinition;
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
The returned rule:
|
|
51
|
+
|
|
52
|
+
- declares `meta.languages: ["sh/sh"]`, so ESLint applies it only to files linted with this
|
|
53
|
+
plugin's shell language;
|
|
54
|
+
- takes no options, because the config is bound when the rule is made;
|
|
55
|
+
- reports message id `unwrappedCommand` on each command that should be wrapped and is not, naming
|
|
56
|
+
the command and the wrapper: `` `kubectl` must be run through `audit-run`. ``
|
|
57
|
+
- has a `meta.docs.url` pointing at this page and a `meta.docs.description` naming the commands
|
|
58
|
+
and the wrapper, unless `docs` overrides them. Set `docs.url` to your pack's own documentation,
|
|
59
|
+
so the link in a lint report leads your users to your policy.
|
|
60
|
+
|
|
61
|
+
`createWrapperRule` throws a `TypeError` when the config is malformed: no `commands` array, an
|
|
62
|
+
empty wrapper name, a `WrapperSpec` with no `names`, a subcommand entry with no `names`, or a
|
|
63
|
+
`docs.url` or `docs.description` that is not a non-empty string, or a malformed `localInvocations`
|
|
64
|
+
entry.
|
|
65
|
+
|
|
66
|
+
## Example: a rule pack
|
|
67
|
+
|
|
68
|
+
This pack requires `kubectl` and `terraform apply`/`terraform destroy` to be run through its
|
|
69
|
+
`audit-run` wrapper. `audit-run` takes a `--ticket <id>` option, and terraform's `-no-color` may
|
|
70
|
+
come before its subcommand. This file ships with the package as
|
|
71
|
+
`docs/examples/audit-wrapper-pack.mjs`, and CI runs it.
|
|
72
|
+
|
|
73
|
+
```mjs
|
|
74
|
+
/**
|
|
75
|
+
* A rule pack that requires infrastructure CLIs to be run through its
|
|
76
|
+
* `audit-run` wrapper. From `../wrapper-rules.md`, and run in CI.
|
|
77
|
+
*/
|
|
78
|
+
import { createWrapperRule } from '@eslint-sh/eslint-plugin';
|
|
79
|
+
|
|
80
|
+
const requireAudit = createWrapperRule({
|
|
81
|
+
commands: ['kubectl', 'terraform'],
|
|
82
|
+
wrapper: { names: ['audit-run'], argFlags: ['--ticket'] },
|
|
83
|
+
subcommands: {
|
|
84
|
+
terraform: { names: ['apply', 'destroy'], noArgFlags: ['-no-color'] },
|
|
85
|
+
},
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
export default { rules: { 'require-audit': requireAudit } };
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Register the pack beside this plugin and turn the rule on:
|
|
92
|
+
|
|
93
|
+
```js
|
|
94
|
+
// eslint.config.js
|
|
95
|
+
import sh from '@eslint-sh/eslint-plugin';
|
|
96
|
+
import acme from './audit-wrapper-pack.mjs';
|
|
97
|
+
|
|
98
|
+
export default [
|
|
99
|
+
{
|
|
100
|
+
files: ['**/*.sh'],
|
|
101
|
+
plugins: { sh, acme },
|
|
102
|
+
language: 'sh/sh',
|
|
103
|
+
rules: { 'acme/require-audit': 'error' },
|
|
104
|
+
},
|
|
105
|
+
];
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
```sh
|
|
109
|
+
kubectl apply -f app.yaml # reported: kubectl is not wrapped
|
|
110
|
+
terraform -no-color apply # reported as `terraform apply`
|
|
111
|
+
audit-run --ticket OPS-1 kubectl apply -f a.yaml # clean
|
|
112
|
+
terraform plan # clean: plan is not a listed subcommand
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
## What counts as wrapped
|
|
116
|
+
|
|
117
|
+
Every command in the file is checked: top level, in functions, in pipelines, in conditionals,
|
|
118
|
+
and inside command substitutions (`$(…)`).
|
|
119
|
+
|
|
120
|
+
For each command, the rule follows its command word through transparent wrappers, the way
|
|
121
|
+
`ShellSourceCode.resolveInvokedCommand` does. The default wrappers (`command`, `exec`, `env`,
|
|
122
|
+
`nohup`, `nice`, `time`, `timeout`, `sudo`) are followed, and so is your wrapper. The result is a
|
|
123
|
+
chain, outermost first: `retry sudo curl x` runs `retry`, then `sudo`, then `curl`.
|
|
124
|
+
|
|
125
|
+
- A listed command is **wrapped** when your wrapper appears earlier in its chain. So
|
|
126
|
+
`retry curl x` and `retry sudo curl x` are wrapped, and so is `sudo retry curl x`.
|
|
127
|
+
- `sudo curl x` is **not** wrapped: `sudo` is not your wrapper.
|
|
128
|
+
- A command substitution is a separate command. In `retry echo "$(curl x)"`, `retry` wraps `echo`,
|
|
129
|
+
and the `curl` inside `$(…)` is reported.
|
|
130
|
+
- `retry echo; curl x` is two commands, and `curl x` is reported.
|
|
131
|
+
|
|
132
|
+
### Declaring the wrapper's options
|
|
133
|
+
|
|
134
|
+
A wrapper given as a string is assumed to take no options of its own, so the word after it is
|
|
135
|
+
the command it runs. When your wrapper has options, give a `WrapperSpec` (from `sh-ast/analyze`,
|
|
136
|
+
re-exported by this package) that declares them:
|
|
137
|
+
|
|
138
|
+
```js
|
|
139
|
+
wrapper: { names: ['retry'], argFlags: ['-n'] } // retry -n 3 curl x → curl, wrapped
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
`noArgFlags` lists options without an operand and `argFlags` options with one. An `argFlags`
|
|
143
|
+
option is recognized as `-n 3` and `-n3`, and a `--long` one also as `--long=value`. Every listed
|
|
144
|
+
`names` entry counts as the wrapper. A wrapper string that names one of the default wrappers,
|
|
145
|
+
such as `timeout`, uses that wrapper's known option shape (`timeout 10 curl x`).
|
|
146
|
+
|
|
147
|
+
### What is never guessed
|
|
148
|
+
|
|
149
|
+
The rule reports only what it can determine from the source:
|
|
150
|
+
|
|
151
|
+
- A dynamic command word (`"$client" x`) is not a listed command.
|
|
152
|
+
- When the chain reaches an option your wrapper does not declare, the command it runs is unknown,
|
|
153
|
+
and nothing is reported. Declare the option to have the command checked.
|
|
154
|
+
- When a command restricted to subcommands has an undeclared option or a dynamic word before
|
|
155
|
+
its subcommand (`git "$op"`), the subcommand is unknown, and nothing is reported.
|
|
156
|
+
- Command strings run by another shell (`bash -c 'curl x'`) are text, not commands, and are not
|
|
157
|
+
checked.
|
|
158
|
+
|
|
159
|
+
### Functions the file defines
|
|
160
|
+
|
|
161
|
+
A call to a function the file defines is not reported, wherever the function is defined (its
|
|
162
|
+
definition may come after the call). `curl() { retry command curl "$@"; }` followed by `curl x`
|
|
163
|
+
runs the function, not the external `curl`. This matches how
|
|
164
|
+
[`sh/no-undeclared-commands`](./rules/README.md) treats in-file functions. Only the command word
|
|
165
|
+
itself is looked up as a function. A real invocation inside the function (`command curl x`), a
|
|
166
|
+
call behind another wrapper (`sudo curl x`), and a call by path (`/usr/bin/curl x`) run the
|
|
167
|
+
external command and are still reported.
|
|
168
|
+
|
|
169
|
+
### Local-only invocations
|
|
170
|
+
|
|
171
|
+
Some invocations of a listed command never leave the machine. `localInvocations` names them,
|
|
172
|
+
keyed by the command as the message names it (`curl`, or `git clone` for a command with a
|
|
173
|
+
subcommand), so the factory itself knows no command:
|
|
174
|
+
|
|
175
|
+
- `argLists` are complete argument lists that are local. They match exactly:
|
|
176
|
+
`{ curl: { argLists: [['--version'], ['--help']] } }` allows `curl --version` and `curl --help`
|
|
177
|
+
but still reports `curl --version https://example.com`, which makes the request.
|
|
178
|
+
- `localOperand` makes the invocation local when its first operand, found after the declared
|
|
179
|
+
`noArgFlags` and `argFlags` (a `--` ends the options), is a static word starting with one of
|
|
180
|
+
`localPrefixes`. `{ 'git clone': { localOperand: { localPrefixes: ['./', '../', '/', 'file://'] } } }`
|
|
181
|
+
allows `git clone ./x` and `git clone --depth 1 /srv/x.git`. Only the operand decides: words
|
|
182
|
+
after it (`git clone ./x "$dest"`), and the value of a declared option (`--depth "$n"`), may be
|
|
183
|
+
dynamic. An operand that is dynamic, or that follows an option the entry does not declare, is
|
|
184
|
+
not known to be local and is reported. The whole argument list up to a literal `--` is read, not
|
|
185
|
+
only up to the operand, because options may follow operands (`git clone ./x --recurse-submodules`):
|
|
186
|
+
an option the entry does not declare anywhere before `--` makes the invocation not local, and
|
|
187
|
+
words after `--` are operands. Leave an option out of the entry when it can make the
|
|
188
|
+
command reach elsewhere (`--recurse-submodules`).
|
|
189
|
+
|
|
190
|
+
Anything that is not provably local is reported.
|
|
191
|
+
|
|
192
|
+
### Commands run by path
|
|
193
|
+
|
|
194
|
+
A command word that contains a slash is matched by its basename, so running a command by path
|
|
195
|
+
cannot bypass the policy. `/usr/bin/curl x` and `./bin/curl x` are `curl`, and the message names
|
|
196
|
+
`curl`. The same holds for wrappers: `/usr/local/bin/retry curl x` is `curl` run through `retry`,
|
|
197
|
+
with `retry`'s declared options, and `/usr/bin/sudo curl x` is `curl` run through `sudo`. Only the
|
|
198
|
+
whole basename is compared: `/usr/bin/curly` is not `curl`.
|
|
199
|
+
|
|
200
|
+
### Subcommands
|
|
201
|
+
|
|
202
|
+
`subcommands` restricts a listed command to some subcommands. The subcommand is the word the
|
|
203
|
+
command runs after its own options, located the way a wrapped command is located: options in
|
|
204
|
+
`noArgFlags` and `argFlags` are skipped, and an option in `stopsChainFlags` means no subcommand
|
|
205
|
+
runs. The message names the command with its subcommand: `` `git fetch` must be run through
|
|
206
|
+
`retry`. ``
|