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