@sikkuumiii/defuse 0.0.0-stage → 0.5.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/LICENSE +235 -0
- package/README.md +561 -2
- package/dist/src/cli.d.ts +20 -0
- package/dist/src/cli.js +619 -0
- package/dist/src/cli.js.map +1 -0
- package/dist/src/core/analyze.d.ts +178 -0
- package/dist/src/core/analyze.js +834 -0
- package/dist/src/core/analyze.js.map +1 -0
- package/dist/src/core/coverage.d.ts +87 -0
- package/dist/src/core/coverage.js +181 -0
- package/dist/src/core/coverage.js.map +1 -0
- package/dist/src/core/finding.d.ts +196 -0
- package/dist/src/core/finding.js +178 -0
- package/dist/src/core/finding.js.map +1 -0
- package/dist/src/core/licence.d.ts +50 -0
- package/dist/src/core/licence.js +56 -0
- package/dist/src/core/licence.js.map +1 -0
- package/dist/src/core/paths.d.ts +21 -0
- package/dist/src/core/paths.js +58 -0
- package/dist/src/core/paths.js.map +1 -0
- package/dist/src/core/score.d.ts +137 -0
- package/dist/src/core/score.js +232 -0
- package/dist/src/core/score.js.map +1 -0
- package/dist/src/dast/targets.d.ts +30 -0
- package/dist/src/dast/targets.js +79 -0
- package/dist/src/dast/targets.js.map +1 -0
- package/dist/src/dast/zap.d.ts +42 -0
- package/dist/src/dast/zap.js +184 -0
- package/dist/src/dast/zap.js.map +1 -0
- package/dist/src/engine/scan.d.ts +80 -0
- package/dist/src/engine/scan.js +129 -0
- package/dist/src/engine/scan.js.map +1 -0
- package/dist/src/engine/shapes.d.ts +47 -0
- package/dist/src/engine/shapes.js +328 -0
- package/dist/src/engine/shapes.js.map +1 -0
- package/dist/src/engine/walk.d.ts +60 -0
- package/dist/src/engine/walk.js +156 -0
- package/dist/src/engine/walk.js.map +1 -0
- package/dist/src/parse/astDump.d.ts +31 -0
- package/dist/src/parse/astDump.js +66 -0
- package/dist/src/parse/astDump.js.map +1 -0
- package/dist/src/parse/languages.d.ts +70 -0
- package/dist/src/parse/languages.js +174 -0
- package/dist/src/parse/languages.js.map +1 -0
- package/dist/src/parse/location.d.ts +17 -0
- package/dist/src/parse/location.js +38 -0
- package/dist/src/parse/location.js.map +1 -0
- package/dist/src/parse/node-grammars.d.ts +12 -0
- package/dist/src/parse/node-grammars.js +35 -0
- package/dist/src/parse/node-grammars.js.map +1 -0
- package/dist/src/parse/parser.d.ts +126 -0
- package/dist/src/parse/parser.js +202 -0
- package/dist/src/parse/parser.js.map +1 -0
- package/dist/src/parse/tree-store.d.ts +139 -0
- package/dist/src/parse/tree-store.js +236 -0
- package/dist/src/parse/tree-store.js.map +1 -0
- package/dist/src/platform/dependency-rule.d.ts +27 -0
- package/dist/src/platform/dependency-rule.js +21 -0
- package/dist/src/platform/dependency-rule.js.map +1 -0
- package/dist/src/platform/deps.d.ts +86 -0
- package/dist/src/platform/deps.js +304 -0
- package/dist/src/platform/deps.js.map +1 -0
- package/dist/src/platform/php-facts.d.ts +49 -0
- package/dist/src/platform/php-facts.js +67 -0
- package/dist/src/platform/php-facts.js.map +1 -0
- package/dist/src/platform/runtime-version.d.ts +78 -0
- package/dist/src/platform/runtime-version.js +573 -0
- package/dist/src/platform/runtime-version.js.map +1 -0
- package/dist/src/report/colors.d.ts +50 -0
- package/dist/src/report/colors.js +97 -0
- package/dist/src/report/colors.js.map +1 -0
- package/dist/src/report/coverage-table.d.ts +90 -0
- package/dist/src/report/coverage-table.js +291 -0
- package/dist/src/report/coverage-table.js.map +1 -0
- package/dist/src/report/html-report.d.ts +66 -0
- package/dist/src/report/html-report.js +489 -0
- package/dist/src/report/html-report.js.map +1 -0
- package/dist/src/report/human.d.ts +44 -0
- package/dist/src/report/human.js +575 -0
- package/dist/src/report/human.js.map +1 -0
- package/dist/src/report/json.d.ts +24 -0
- package/dist/src/report/json.js +178 -0
- package/dist/src/report/json.js.map +1 -0
- package/dist/src/report/mark.d.ts +17 -0
- package/dist/src/report/mark.js +39 -0
- package/dist/src/report/mark.js.map +1 -0
- package/dist/src/report/pricing.d.ts +33 -0
- package/dist/src/report/pricing.js +343 -0
- package/dist/src/report/pricing.js.map +1 -0
- package/dist/src/report/sarif.d.ts +39 -0
- package/dist/src/report/sarif.js +219 -0
- package/dist/src/report/sarif.js.map +1 -0
- package/dist/src/report/site.d.ts +87 -0
- package/dist/src/report/site.js +742 -0
- package/dist/src/report/site.js.map +1 -0
- package/dist/src/rules/code-injection.d.ts +47 -0
- package/dist/src/rules/code-injection.js +326 -0
- package/dist/src/rules/code-injection.js.map +1 -0
- package/dist/src/rules/command-injection.d.ts +30 -0
- package/dist/src/rules/command-injection.js +286 -0
- package/dist/src/rules/command-injection.js.map +1 -0
- package/dist/src/rules/contract.d.ts +198 -0
- package/dist/src/rules/contract.js +39 -0
- package/dist/src/rules/contract.js.map +1 -0
- package/dist/src/rules/format-string.d.ts +46 -0
- package/dist/src/rules/format-string.js +146 -0
- package/dist/src/rules/format-string.js.map +1 -0
- package/dist/src/rules/hardcoded-secret.d.ts +38 -0
- package/dist/src/rules/hardcoded-secret.js +440 -0
- package/dist/src/rules/hardcoded-secret.js.map +1 -0
- package/dist/src/rules/lib/guards.d.ts +168 -0
- package/dist/src/rules/lib/guards.js +1179 -0
- package/dist/src/rules/lib/guards.js.map +1 -0
- package/dist/src/rules/lib/html-safety.d.ts +75 -0
- package/dist/src/rules/lib/html-safety.js +755 -0
- package/dist/src/rules/lib/html-safety.js.map +1 -0
- package/dist/src/rules/lib/local-build.d.ts +59 -0
- package/dist/src/rules/lib/local-build.js +320 -0
- package/dist/src/rules/lib/local-build.js.map +1 -0
- package/dist/src/rules/lib/strings.d.ts +208 -0
- package/dist/src/rules/lib/strings.js +1970 -0
- package/dist/src/rules/lib/strings.js.map +1 -0
- package/dist/src/rules/path-traversal.d.ts +33 -0
- package/dist/src/rules/path-traversal.js +96 -0
- package/dist/src/rules/path-traversal.js.map +1 -0
- package/dist/src/rules/registry.d.ts +19 -0
- package/dist/src/rules/registry.js +77 -0
- package/dist/src/rules/registry.js.map +1 -0
- package/dist/src/rules/sql-injection.d.ts +36 -0
- package/dist/src/rules/sql-injection.js +299 -0
- package/dist/src/rules/sql-injection.js.map +1 -0
- package/dist/src/rules/ssrf.d.ts +39 -0
- package/dist/src/rules/ssrf.js +111 -0
- package/dist/src/rules/ssrf.js.map +1 -0
- package/dist/src/rules/unbounded-copy.d.ts +37 -0
- package/dist/src/rules/unbounded-copy.js +147 -0
- package/dist/src/rules/unbounded-copy.js.map +1 -0
- package/dist/src/rules/unsafe-deserialization.d.ts +43 -0
- package/dist/src/rules/unsafe-deserialization.js +276 -0
- package/dist/src/rules/unsafe-deserialization.js.map +1 -0
- package/dist/src/rules/weak-hash.d.ts +37 -0
- package/dist/src/rules/weak-hash.js +197 -0
- package/dist/src/rules/weak-hash.js.map +1 -0
- package/dist/src/rules/xss.d.ts +38 -0
- package/dist/src/rules/xss.js +527 -0
- package/dist/src/rules/xss.js.map +1 -0
- package/dist/src/taint/content-type.d.ts +19 -0
- package/dist/src/taint/content-type.js +20 -0
- package/dist/src/taint/content-type.js.map +1 -0
- package/dist/src/taint/dictionaries.d.ts +23 -0
- package/dist/src/taint/dictionaries.js +1778 -0
- package/dist/src/taint/dictionaries.js.map +1 -0
- package/dist/src/taint/fold.d.ts +59 -0
- package/dist/src/taint/fold.js +1177 -0
- package/dist/src/taint/fold.js.map +1 -0
- package/dist/src/taint/java-scope.d.ts +61 -0
- package/dist/src/taint/java-scope.js +212 -0
- package/dist/src/taint/java-scope.js.map +1 -0
- package/dist/src/taint/local-map.d.ts +79 -0
- package/dist/src/taint/local-map.js +603 -0
- package/dist/src/taint/local-map.js.map +1 -0
- package/dist/src/taint/project.d.ts +172 -0
- package/dist/src/taint/project.js +617 -0
- package/dist/src/taint/project.js.map +1 -0
- package/dist/src/taint/tracer.d.ts +265 -0
- package/dist/src/taint/tracer.js +3817 -0
- package/dist/src/taint/tracer.js.map +1 -0
- package/dist/src/taint/types.d.ts +484 -0
- package/dist/src/taint/types.js +47 -0
- package/dist/src/taint/types.js.map +1 -0
- package/dist/src/taint/visibility.d.ts +25 -0
- package/dist/src/taint/visibility.js +96 -0
- package/dist/src/taint/visibility.js.map +1 -0
- package/docs/examples/defuse.json +20 -0
- package/docs/examples/dvwa-urls.json +14 -0
- package/docs/examples/github-code-scanning.yml +54 -0
- package/docs/examples/tiny.js +4 -0
- package/package.json +73 -4
- package/src/cli.ts +747 -0
- package/src/core/analyze.ts +1072 -0
- package/src/core/coverage.ts +269 -0
- package/src/core/finding.ts +311 -0
- package/src/core/licence.ts +61 -0
- package/src/core/paths.ts +59 -0
- package/src/core/score.ts +353 -0
- package/src/dast/targets.ts +121 -0
- package/src/dast/zap.ts +246 -0
- package/src/engine/scan.ts +200 -0
- package/src/engine/shapes.ts +348 -0
- package/src/engine/walk.ts +196 -0
- package/src/parse/astDump.ts +85 -0
- package/src/parse/languages.ts +232 -0
- package/src/parse/location.ts +44 -0
- package/src/parse/node-grammars.ts +41 -0
- package/src/parse/parser.ts +262 -0
- package/src/parse/tree-store.ts +301 -0
- package/src/platform/dependency-rule.ts +33 -0
- package/src/platform/deps.ts +374 -0
- package/src/platform/php-facts.ts +85 -0
- package/src/platform/runtime-version.ts +710 -0
- package/src/report/colors.ts +107 -0
- package/src/report/coverage-table.ts +359 -0
- package/src/report/html-report.ts +601 -0
- package/src/report/human.ts +811 -0
- package/src/report/json.ts +189 -0
- package/src/report/mark.ts +47 -0
- package/src/report/pricing.ts +374 -0
- package/src/report/sarif.ts +235 -0
- package/src/report/site.ts +760 -0
- package/src/rules/code-injection.ts +346 -0
- package/src/rules/command-injection.ts +296 -0
- package/src/rules/contract.ts +220 -0
- package/src/rules/format-string.ts +158 -0
- package/src/rules/hardcoded-secret.ts +485 -0
- package/src/rules/lib/guards.ts +1235 -0
- package/src/rules/lib/html-safety.ts +793 -0
- package/src/rules/lib/local-build.ts +329 -0
- package/src/rules/lib/strings.ts +2034 -0
- package/src/rules/path-traversal.ts +101 -0
- package/src/rules/registry.ts +90 -0
- package/src/rules/sql-injection.ts +311 -0
- package/src/rules/ssrf.ts +118 -0
- package/src/rules/unbounded-copy.ts +162 -0
- package/src/rules/unsafe-deserialization.ts +291 -0
- package/src/rules/weak-hash.ts +226 -0
- package/src/rules/xss.ts +572 -0
- package/src/taint/content-type.ts +20 -0
- package/src/taint/dictionaries.ts +1855 -0
- package/src/taint/fold.ts +1206 -0
- package/src/taint/java-scope.ts +200 -0
- package/src/taint/local-map.ts +594 -0
- package/src/taint/project.ts +774 -0
- package/src/taint/tracer.ts +4298 -0
- package/src/taint/types.ts +511 -0
- package/src/taint/visibility.ts +100 -0
- package/tsconfig.json +61 -0
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CODE INJECTION (CWE-94 / CWE-95, OWASP A05:2025 - Injection)
|
|
3
|
+
*
|
|
4
|
+
* WHAT THE BUG IS, in plain language:
|
|
5
|
+
* The other injection rules are about a string being read as instructions by
|
|
6
|
+
* something ELSE - a database, a shell, a browser. This one is about a string
|
|
7
|
+
* being read as instructions by YOUR OWN PROGRAM, in your own language, with
|
|
8
|
+
* every privilege your process has.
|
|
9
|
+
*
|
|
10
|
+
* const total = eval(req.body.preTax);
|
|
11
|
+
*
|
|
12
|
+
* The author wanted arithmetic. What they built is a remote code execution
|
|
13
|
+
* endpoint: whatever the request body says, the server runs. No escaping helps,
|
|
14
|
+
* because there is nothing to escape - the whole string IS the program.
|
|
15
|
+
*
|
|
16
|
+
* It is the shortest path from "user input" to "attacker owns the machine" in
|
|
17
|
+
* any language that has it, and it is startlingly common in code that meant to
|
|
18
|
+
* be clever about configuration, formulas, filters or plugins.
|
|
19
|
+
*
|
|
20
|
+
* THE FIX is always the same shape: stop evaluating, start parsing. A maths
|
|
21
|
+
* expression wants an expression parser. A filter wants a fixed vocabulary of
|
|
22
|
+
* allowed operations. A plugin wants a registry of known functions looked up by
|
|
23
|
+
* name. All three are more code than `eval` and none of them can be talked into
|
|
24
|
+
* running `rm -rf`.
|
|
25
|
+
*
|
|
26
|
+
* WHY IT NEEDED A NEW SINK KIND:
|
|
27
|
+
* `command` is a shell; `code` is the interpreter you are already inside. A
|
|
28
|
+
* shell-quoting sanitiser (`escapeshellarg`, `shlex.quote`) does nothing here,
|
|
29
|
+
* and the engine has to know that, which is exactly what kind-scoping is for.
|
|
30
|
+
*
|
|
31
|
+
* WHY IT EXISTS AT ALL:
|
|
32
|
+
* Three independent test files pointed at the same hole on the same day -
|
|
33
|
+
* OWASP NodeGoat's `eval(req.body.preTax)`, a Python file's
|
|
34
|
+
* `eval(expression, safe_globals)`, and a TypeScript app's
|
|
35
|
+
* `new RegExp(req.query.filter)`. In every one the tracer had already found the
|
|
36
|
+
* source and followed it; there was simply nothing at the far end to hand it
|
|
37
|
+
* to. The engine could see these all along.
|
|
38
|
+
*
|
|
39
|
+
* WHAT THIS RULE DELIBERATELY DOES NOT COVER:
|
|
40
|
+
* ReDoS - `new RegExp(userInput)` - is a real bug and was one of the three that
|
|
41
|
+
* prompted this rule, but it is CWE-1333 and an AVAILABILITY problem: a
|
|
42
|
+
* catastrophically backtracking pattern hangs the process, it does not run the
|
|
43
|
+
* attacker's code. Filing it under CWE-94 would put the wrong number on a real
|
|
44
|
+
* finding, so it is left out and named here rather than quietly folded in.
|
|
45
|
+
*/
|
|
46
|
+
|
|
47
|
+
import type { LanguageId } from '../parse/languages.js';
|
|
48
|
+
import { asCall, type Rule, type RuleHit } from './contract.js';
|
|
49
|
+
import { analyzeStringExpression, constantProof } from './lib/strings.js';
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Calls that hand a string to the language's own interpreter.
|
|
53
|
+
*
|
|
54
|
+
* `setTimeout` and `setInterval` are NOT here, and the omission is deliberate.
|
|
55
|
+
* They only execute a string argument - `setTimeout("alert(1)", 100)` - and the
|
|
56
|
+
* overwhelmingly common form passes a FUNCTION. Reporting them on shape would
|
|
57
|
+
* light up every debounce and every retry in every codebase. They are listed as
|
|
58
|
+
* taint sinks instead, where the engine knows the value came from a request and
|
|
59
|
+
* a request cannot deliver a function.
|
|
60
|
+
*/
|
|
61
|
+
const EVALUATORS: Record<LanguageId, readonly string[]> = {
|
|
62
|
+
javascript: ['eval', 'Function', 'execScript', 'runInNewContext', 'runInThisContext'],
|
|
63
|
+
typescript: ['eval', 'Function', 'execScript', 'runInNewContext', 'runInThisContext'],
|
|
64
|
+
// `compile` produces a code object that only `exec` can run, but it is the
|
|
65
|
+
// same decision one step earlier, and reporting it points at the real line.
|
|
66
|
+
python: ['eval', 'exec', 'compile'],
|
|
67
|
+
// `assert($x)` evaluates its argument as PHP when handed a string. It is a
|
|
68
|
+
// debugging aid that became an RCE primitive, removed in PHP 8 - which means
|
|
69
|
+
// the codebases that still use it are the old ones nobody is watching.
|
|
70
|
+
php: ['eval', 'assert', 'create_function'],
|
|
71
|
+
// ScriptEngine.eval("...") runs JavaScript inside the JVM.
|
|
72
|
+
java: ['eval'],
|
|
73
|
+
// Go has no eval. There is no interpreter at runtime to hand a string to, so
|
|
74
|
+
// the shape this rule detects cannot occur, and an empty list is the honest
|
|
75
|
+
// answer rather than a rule that never fires but implies coverage.
|
|
76
|
+
go: [],
|
|
77
|
+
// C has no interpreter to hand a string to. `dlopen`/`dlsym` load code, but
|
|
78
|
+
// from a PATH rather than from a string, which is a different bug (untrusted
|
|
79
|
+
// search path) and not one this rule models. Empty on purpose, and the
|
|
80
|
+
// coverage matrix says so rather than leaving a blank.
|
|
81
|
+
c: [],
|
|
82
|
+
cpp: [],
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Evaluators that are BUILT-IN FUNCTIONS, and are therefore only themselves
|
|
87
|
+
* when called with nothing in front of them.
|
|
88
|
+
*
|
|
89
|
+
* Scanning Django found 163 code-injection findings and 153 were this mistake:
|
|
90
|
+
*
|
|
91
|
+
* compiler.compile(value) Django's own SQL compiler x82
|
|
92
|
+
* re.compile(pattern) a regular expression x21
|
|
93
|
+
* self.compile(node) somebody's method x20
|
|
94
|
+
* x.eval(...) somebody else's method x25
|
|
95
|
+
*
|
|
96
|
+
* `compile`, `eval` and `exec` are builtins. `re.compile` shares three letters
|
|
97
|
+
* with the dangerous one and nothing else. Matching the bare method name meant
|
|
98
|
+
* every object in Python with a `.compile()` or `.eval()` method - and there are
|
|
99
|
+
* a lot of them, because those are natural names for "turn this into a runnable
|
|
100
|
+
* thing" in any DSL, ORM or template engine.
|
|
101
|
+
*
|
|
102
|
+
* Not on this list: vm.runInNewContext, which is a method and is supposed to
|
|
103
|
+
* have `vm` in front of it, and Java's ScriptEngine.eval, which is handled by
|
|
104
|
+
* JAVA_ENGINE below.
|
|
105
|
+
*/
|
|
106
|
+
const BARE_BUILTINS: ReadonlySet<string> = new Set([
|
|
107
|
+
'eval',
|
|
108
|
+
'exec',
|
|
109
|
+
'compile',
|
|
110
|
+
'execScript',
|
|
111
|
+
'assert',
|
|
112
|
+
'create_function',
|
|
113
|
+
'Function',
|
|
114
|
+
]);
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Receivers that ARE evaluators, and so keep the finding despite BARE_BUILTINS.
|
|
118
|
+
*
|
|
119
|
+
* The bare-only rule was too blunt on its own: OWASP's dvna has
|
|
120
|
+
*
|
|
121
|
+
* mathjs.eval(req.body.eqn)
|
|
122
|
+
*
|
|
123
|
+
* which is a genuine remote-code-execution sink - mathjs's `eval` has its own
|
|
124
|
+
* CVEs - and it has a receiver, so the first version of this guard silently
|
|
125
|
+
* dropped it. That is the exact trade this rule has to get right: `re.compile`
|
|
126
|
+
* and `compiler.compile` are somebody's method, `mathjs.eval` and `window.eval`
|
|
127
|
+
* are the interpreter. The difference is WHICH object, not whether there is one.
|
|
128
|
+
*/
|
|
129
|
+
const EVALUATOR_RECEIVERS: ReadonlySet<string> = new Set([
|
|
130
|
+
'mathjs',
|
|
131
|
+
'math',
|
|
132
|
+
'vm',
|
|
133
|
+
'safeEval',
|
|
134
|
+
'eval5',
|
|
135
|
+
'builtins',
|
|
136
|
+
'__builtins__',
|
|
137
|
+
]);
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Receivers that are the GLOBAL OBJECT, and therefore only in JavaScript.
|
|
141
|
+
*
|
|
142
|
+
* `self` was the expensive one. In a browser - and especially in a web worker,
|
|
143
|
+
* where it is the only name for the global scope - `self.eval(src)` IS the
|
|
144
|
+
* interpreter, so it belonged on the allowlist. In Python `self` is the
|
|
145
|
+
* instance, and `self.eval(expr)` is a method somebody wrote.
|
|
146
|
+
*
|
|
147
|
+
* Scanning pandas reported 42 code injections and seventeen were `self.eval` -
|
|
148
|
+
* pandas' own `DataFrame.eval`, a public API that parses a restricted
|
|
149
|
+
* expression grammar and cannot reach the interpreter. The allowlist was
|
|
150
|
+
* correct; applying a JavaScript fact to Python was not. `window`, `global`
|
|
151
|
+
* and `globalThis` move here for the same reason, even though no other language
|
|
152
|
+
* happened to collide with them yet.
|
|
153
|
+
*/
|
|
154
|
+
const JS_GLOBAL_RECEIVERS: ReadonlySet<string> = new Set([
|
|
155
|
+
'window',
|
|
156
|
+
'global',
|
|
157
|
+
'globalThis',
|
|
158
|
+
'self',
|
|
159
|
+
]);
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Is this dangerous-looking name actually bound to something else in this file?
|
|
163
|
+
*
|
|
164
|
+
* pandas/core/computation/ops.py, with the import one line above the call:
|
|
165
|
+
*
|
|
166
|
+
* from pandas.core.computation.eval import eval
|
|
167
|
+
* res = eval(self, local_dict=env, engine=engine, parser=parser)
|
|
168
|
+
*
|
|
169
|
+
* `eval`, `exec` and `compile` are only dangerous because of what the
|
|
170
|
+
* interpreter binds those names to. An explicit import rebinds them, and this
|
|
171
|
+
* scanner is holding the very file that says so. Reporting it anyway was
|
|
172
|
+
* matching a name rather than a function.
|
|
173
|
+
*
|
|
174
|
+
* Deliberately narrow: only an explicit import of the same name counts. A
|
|
175
|
+
* reassignment (`eval = my_evaluator`) or a `import *` that happens to shadow
|
|
176
|
+
* it is NOT detected, and is a documented gap rather than a guess.
|
|
177
|
+
*/
|
|
178
|
+
function isImportedName(source: string, name: string): boolean {
|
|
179
|
+
const n = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
180
|
+
// from x.y import (a, eval, b) / from x import eval as e / import eval
|
|
181
|
+
return (
|
|
182
|
+
new RegExp(`^[ \\t]*from[ \\t]+[\\w.]+[ \\t]+import[ \\t]*\\(?[^\\n]*\\b${n}\\b`, 'm').test(
|
|
183
|
+
source,
|
|
184
|
+
) || new RegExp(`^[ \\t]*import[ \\t]+${n}\\b`, 'm').test(source)
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Receivers that make `eval` unambiguous in Java, where the bare name is far
|
|
190
|
+
* too common. `engine.eval(script)` is the JSR-223 scripting API.
|
|
191
|
+
*/
|
|
192
|
+
const JAVA_ENGINE = /engine|script|nashorn|graal|interpreter/i;
|
|
193
|
+
|
|
194
|
+
export const codeInjectionRule: Rule = {
|
|
195
|
+
id: 'code-injection',
|
|
196
|
+
name: 'User input evaluated as program code',
|
|
197
|
+
cwe: 'CWE-94',
|
|
198
|
+
owasp: 'A05:2025 Injection',
|
|
199
|
+
severity: 'critical',
|
|
200
|
+
summary: 'A string is run as program code; if any of it comes from outside, the sender decides what your server does.',
|
|
201
|
+
fix:
|
|
202
|
+
'Never eval input: parse it instead (a maths parser, a fixed list of allowed commands, or a lookup table).',
|
|
203
|
+
explanation:
|
|
204
|
+
'Passing a string to eval (or exec, Function, assert) tells your own language ' +
|
|
205
|
+
'to run it as a program, with every privilege the process has. If any part of ' +
|
|
206
|
+
'that string came from a request, the request decides what your server does. ' +
|
|
207
|
+
'There is nothing to escape here - the string IS the program - so the fix is ' +
|
|
208
|
+
'always to stop evaluating and start parsing: an expression parser for maths, ' +
|
|
209
|
+
'a fixed vocabulary for filters, a lookup table for plugins.',
|
|
210
|
+
limitations:
|
|
211
|
+
'UNVERIFIED (signature-based): we confirmed a value reaches an evaluator and ' +
|
|
212
|
+
'that it is not a fixed literal. We did NOT confirm it is attacker-controlled - ' +
|
|
213
|
+
'the data-flow pass is what upgrades this to flow-verified, and evaluating your ' +
|
|
214
|
+
'own constant is harmless. DELIBERATE GAP: setTimeout and setInterval accept a ' +
|
|
215
|
+
'string and run it, but almost always receive a function, so they are taint ' +
|
|
216
|
+
'sinks only and are never reported on shape alone. ALSO NOT COVERED: ReDoS - ' +
|
|
217
|
+
'new RegExp(userInput) - is a real bug but it is CWE-1333 and an availability ' +
|
|
218
|
+
'problem rather than code execution, so filing it here would put the wrong ' +
|
|
219
|
+
'identifier on a real finding. Go is not covered because it has no runtime ' +
|
|
220
|
+
'evaluator for the shape to occur in.',
|
|
221
|
+
shapes: ['call'],
|
|
222
|
+
support: {
|
|
223
|
+
javascript: {
|
|
224
|
+
status: 'implemented',
|
|
225
|
+
note: 'Covers eval, new Function, and the vm module\'s runInNewContext/runInThisContext. setTimeout/setInterval with a string are taint sinks only, because the common form passes a function and reporting on shape would flag every debounce in the codebase.',
|
|
226
|
+
},
|
|
227
|
+
typescript: {
|
|
228
|
+
status: 'implemented',
|
|
229
|
+
note: 'Identical to JavaScript.',
|
|
230
|
+
},
|
|
231
|
+
python: {
|
|
232
|
+
status: 'implemented',
|
|
233
|
+
note: 'Covers eval, exec and compile. NOT covered: __import__ with a built name, and the ast.literal_eval / eval confusion - literal_eval is safe and is deliberately absent.',
|
|
234
|
+
},
|
|
235
|
+
java: {
|
|
236
|
+
status: 'partial',
|
|
237
|
+
note: 'PARTIAL: ScriptEngine.eval only, and only on a receiver whose name looks like a script engine (engine, script, nashorn, graal). `eval` is a common method name in Java, so an engine stored under an unrelated name is missed rather than guessed at.',
|
|
238
|
+
},
|
|
239
|
+
php: {
|
|
240
|
+
status: 'implemented',
|
|
241
|
+
note: 'Covers eval, assert with a string argument, and create_function. assert() evaluating strings was removed in PHP 8, so a hit here also tells you the file predates that.',
|
|
242
|
+
},
|
|
243
|
+
go: {
|
|
244
|
+
status: 'not-implemented',
|
|
245
|
+
note: 'NOT IMPLEMENTED: Go has no runtime evaluator - no eval, no exec of source, no scripting engine in the standard library. The shape this rule detects cannot occur, so a Go entry would imply coverage of a risk that does not exist.',
|
|
246
|
+
},
|
|
247
|
+
c: {
|
|
248
|
+
status: 'not-implemented',
|
|
249
|
+
note:
|
|
250
|
+
'C has no interpreter to hand a string to. dlopen/dlsym load code, but from a PATH rather than from a string - that is an untrusted-search-path weakness, which this rule does not model. Empty on purpose rather than half-filled.',
|
|
251
|
+
},
|
|
252
|
+
cpp: {
|
|
253
|
+
status: 'not-implemented',
|
|
254
|
+
note:
|
|
255
|
+
'C++ has no interpreter to hand a string to either. Embedded scripting engines (Lua, V8, Python-C-API) genuinely do evaluate strings and are NOT modelled - a tainted string reaching luaL_dostring or v8::Script::Compile is missed entirely.',
|
|
256
|
+
},
|
|
257
|
+
},
|
|
258
|
+
check(shape, ctx): RuleHit | null {
|
|
259
|
+
const language = ctx.language;
|
|
260
|
+
const call = asCall(shape);
|
|
261
|
+
if (!call) return null;
|
|
262
|
+
|
|
263
|
+
if (!EVALUATORS[language].includes(call.calleeName)) return null;
|
|
264
|
+
|
|
265
|
+
// Java's `eval` needs a receiver that looks like a scripting engine.
|
|
266
|
+
if (language === 'java' && !JAVA_ENGINE.test(call.receiverText)) return null;
|
|
267
|
+
|
|
268
|
+
// Everywhere else, a builtin with something in front of it is somebody
|
|
269
|
+
// else's method that happens to share the name.
|
|
270
|
+
const isJs = language === 'javascript' || language === 'typescript';
|
|
271
|
+
if (language !== 'java' && BARE_BUILTINS.has(call.calleeName) && call.receiverText !== '') {
|
|
272
|
+
const owner = call.receiverText.split(/[.\s([]/).filter(Boolean).pop() ?? call.receiverText;
|
|
273
|
+
const known = EVALUATOR_RECEIVERS.has(owner) || (isJs && JS_GLOBAL_RECEIVERS.has(owner));
|
|
274
|
+
if (!known) return null;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// A builtin the file has explicitly imported over is not the builtin.
|
|
278
|
+
if (BARE_BUILTINS.has(call.calleeName) && isImportedName(ctx.file.source, call.calleeName)) {
|
|
279
|
+
return null;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// PHP's assert() is only dangerous with a STRING argument; assert($a > $b)
|
|
283
|
+
// is an ordinary boolean check and is not evaluated as source.
|
|
284
|
+
const argument = call.args[0];
|
|
285
|
+
if (!argument) return null;
|
|
286
|
+
if (language === 'php' && call.calleeName === 'assert') {
|
|
287
|
+
if (argument.type !== 'string' && argument.type !== 'encapsed_string') {
|
|
288
|
+
// A variable could still hold a string, so we do not drop it outright -
|
|
289
|
+
// but a visible comparison is provably not source text.
|
|
290
|
+
if (argument.type === 'binary_expression') return null;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/*
|
|
295
|
+
* `exec(compile(source, name, "exec"))` is ONE decision, and this rule saw
|
|
296
|
+
* two: once for the `exec` and once for the `compile` inside it. Flask's
|
|
297
|
+
* own config loader produced a duplicate pair on the same line.
|
|
298
|
+
*
|
|
299
|
+
* `compile` earns its place in the list because it is sometimes reached
|
|
300
|
+
* alone, but when its result is being handed straight to an evaluator the
|
|
301
|
+
* outer call is the honest place to report - it is where the code actually
|
|
302
|
+
* runs, and one line of source should not become two findings.
|
|
303
|
+
*/
|
|
304
|
+
if (call.calleeName === 'compile') {
|
|
305
|
+
let ancestor = call.node.parent;
|
|
306
|
+
for (let depth = 0; ancestor && depth < 4; depth++) {
|
|
307
|
+
const text = ancestor.text ?? '';
|
|
308
|
+
if (/\b(eval|exec)\s*\(/.test(text) && ancestor.type !== 'call' && ancestor.type !== 'call_expression') {
|
|
309
|
+
ancestor = ancestor.parent;
|
|
310
|
+
continue;
|
|
311
|
+
}
|
|
312
|
+
if (
|
|
313
|
+
(ancestor.type === 'call' || ancestor.type === 'call_expression') &&
|
|
314
|
+
/^\s*(eval|exec)\s*\(/.test(text)
|
|
315
|
+
) {
|
|
316
|
+
return null;
|
|
317
|
+
}
|
|
318
|
+
ancestor = ancestor.parent;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
const built = analyzeStringExpression(argument, language);
|
|
323
|
+
// `eval("1 + 1")` is a constant. Nobody can put anything inside it.
|
|
324
|
+
if (!built.isDynamic) return null;
|
|
325
|
+
// `bar` written in a branch that cannot run, every live write a literal:
|
|
326
|
+
// the same proof the SQL rule withdraws on, with the same receipt.
|
|
327
|
+
const proof = constantProof(argument, built.dynamicParts, language, { staticMethod: ctx.staticMethod, javaTypeInScan: ctx.javaTypeInScan });
|
|
328
|
+
if (proof) {
|
|
329
|
+
if (proof.reasoned) ctx.noteClean?.('code-injection', argument, proof.reason);
|
|
330
|
+
return null;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
return {
|
|
334
|
+
node: call.node,
|
|
335
|
+
message: `Value passed to ${call.calleeName}() is executed as ${language === 'php' ? 'PHP' : language} code`,
|
|
336
|
+
reasoning:
|
|
337
|
+
`\`${call.calleeName}()\` hands its argument to the interpreter this program is ` +
|
|
338
|
+
`already running inside, so whatever the string says, the process does - with ` +
|
|
339
|
+
`every permission it has. The value here is ${built.mechanism === 'opaque' ? 'a runtime value' : `built at runtime via ${built.mechanism}`}, ` +
|
|
340
|
+
`so its text is not fixed at the time you are reading this line. Escaping cannot ` +
|
|
341
|
+
`help, because the whole string is the program. Replace the evaluation with a ` +
|
|
342
|
+
`parser for the thing you actually wanted: an expression evaluator for arithmetic, ` +
|
|
343
|
+
`a fixed set of allowed operations for filters, a lookup table for plugin names.`,
|
|
344
|
+
};
|
|
345
|
+
},
|
|
346
|
+
};
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OS COMMAND INJECTION (CWE-78, OWASP A05:2025 - Injection)
|
|
3
|
+
*
|
|
4
|
+
* WHAT THE BUG IS, in plain language:
|
|
5
|
+
* Some functions hand a string to the operating system's SHELL - the same
|
|
6
|
+
* program that runs your terminal. The shell has its own punctuation with
|
|
7
|
+
* special meaning: `;` ends a command and starts another, `|` pipes output,
|
|
8
|
+
* `&&` chains, and backticks or `$( )` run a command and paste its output.
|
|
9
|
+
*
|
|
10
|
+
* exec("ping -c 1 " + host)
|
|
11
|
+
*
|
|
12
|
+
* If `host` is `example.com; rm -rf /`, the shell sees TWO commands and runs
|
|
13
|
+
* both. Same disease as SQL injection: data got mixed into instructions.
|
|
14
|
+
*
|
|
15
|
+
* THE FIX:
|
|
16
|
+
* Don't use a shell. Every language has a way to launch a program with its
|
|
17
|
+
* arguments as a LIST rather than a sentence - `execFile`/`spawn` without
|
|
18
|
+
* `shell: true` in Node, `subprocess.run([...])` without `shell=True` in
|
|
19
|
+
* Python, `exec.Command("ping", "-c", "1", host)` in Go. When arguments are a
|
|
20
|
+
* list, `;` is just a character in a string. Nothing parses it.
|
|
21
|
+
*
|
|
22
|
+
* WHY THIS RULE IS FUSSIER THAN THE SQL ONE:
|
|
23
|
+
* `spawn("ls", [dir])` is safe, `spawn("ls " + dir, {shell:true})` is not, and
|
|
24
|
+
* they are the same function. Where the safety depends on an options object we
|
|
25
|
+
* check for it textually and say so; where we cannot tell, we stay quiet rather
|
|
26
|
+
* than guess. A scanner that cries wolf on every subprocess call gets muted,
|
|
27
|
+
* and a muted scanner finds nothing.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import type { Node } from 'web-tree-sitter';
|
|
31
|
+
import type { LanguageId } from '../parse/languages.js';
|
|
32
|
+
import { asCall, type Rule, type RuleHit } from './contract.js';
|
|
33
|
+
import { analyzeStringExpression, constantProof } from './lib/strings.js';
|
|
34
|
+
import { followLocalBuild } from './lib/local-build.js';
|
|
35
|
+
import { partsAreGuarded } from './lib/guards.js';
|
|
36
|
+
|
|
37
|
+
/** Calls that ALWAYS go through a shell. Dynamic argument = report it. */
|
|
38
|
+
const SHELL_SINKS: Record<LanguageId, readonly string[]> = {
|
|
39
|
+
javascript: ['exec', 'execSync'],
|
|
40
|
+
typescript: ['exec', 'execSync'],
|
|
41
|
+
python: ['system', 'popen', 'getoutput', 'getstatusoutput'],
|
|
42
|
+
java: ['exec'], // Runtime.getRuntime().exec(String) splits naively - still injectable
|
|
43
|
+
go: [],
|
|
44
|
+
// Every one of these hands the string to /bin/sh. `exec` is PHP's own
|
|
45
|
+
// function, unrelated to the JS/Java `exec` above - it shares the name only.
|
|
46
|
+
php: ['system', 'exec', 'shell_exec', 'passthru', 'popen', 'proc_open', 'pcntl_exec'],
|
|
47
|
+
/*
|
|
48
|
+
* C's shell calls, and the cleanest sink list in this table.
|
|
49
|
+
*
|
|
50
|
+
* There is no ambiguity to scope away here. `system()` runs /bin/sh, always,
|
|
51
|
+
* and has done since the 1970s; it has no safe overload, no options object,
|
|
52
|
+
* and no parameterised form to be confused with - which is exactly the thing
|
|
53
|
+
* that makes the SQL and deserialisation lists so fiddly elsewhere.
|
|
54
|
+
*
|
|
55
|
+
* The `execlp`/`execvp` variants search PATH, and `execl`/`execv` do not,
|
|
56
|
+
* but neither family uses a shell: they take an argument VECTOR, so a
|
|
57
|
+
* semicolon in an argument is a semicolon, not a command separator. They are
|
|
58
|
+
* the CORRECT fix for a system() call and are deliberately absent from this
|
|
59
|
+
* list - see safe/c-format-constant.c, which asserts execv stays quiet.
|
|
60
|
+
*
|
|
61
|
+
* The one exception is execl with an explicit shell: `execlp("sh", "sh",
|
|
62
|
+
* "-c", tainted, ...)`. That IS a shell, so `execlp` and `execvp` appear in
|
|
63
|
+
* CONDITIONAL_SINKS below, gated on a shell program being visible.
|
|
64
|
+
*/
|
|
65
|
+
c: ['system', 'popen'],
|
|
66
|
+
cpp: ['system', 'popen'],
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Calls that MAY go through a shell depending on an option.
|
|
71
|
+
* We only report these when the shell-enabling option is visible in the call.
|
|
72
|
+
*/
|
|
73
|
+
const CONDITIONAL_SINKS: Record<LanguageId, readonly string[]> = {
|
|
74
|
+
javascript: ['spawn', 'spawnSync', 'execFile', 'execFileSync'],
|
|
75
|
+
typescript: ['spawn', 'spawnSync', 'execFile', 'execFileSync'],
|
|
76
|
+
python: ['run', 'call', 'check_call', 'check_output', 'Popen'],
|
|
77
|
+
java: ['ProcessBuilder', 'start'],
|
|
78
|
+
go: ['Command', 'CommandContext'],
|
|
79
|
+
// PHP has no safe-by-default process API to make conditional, so this list is
|
|
80
|
+
// empty and every shell call above is reported unconditionally.
|
|
81
|
+
php: [],
|
|
82
|
+
// exec* takes an argument vector and is SAFE - unless the program it runs is
|
|
83
|
+
// itself a shell, which turns the next argument back into a command.
|
|
84
|
+
c: ['execl', 'execlp', 'execv', 'execvp', 'execle', 'execve'],
|
|
85
|
+
cpp: ['execl', 'execlp', 'execv', 'execvp', 'execle', 'execve'],
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const SHELL_OPTION: Record<LanguageId, RegExp> = {
|
|
89
|
+
javascript: /shell\s*:\s*true/,
|
|
90
|
+
typescript: /shell\s*:\s*true/,
|
|
91
|
+
python: /shell\s*=\s*True/,
|
|
92
|
+
java: /\b(sh|bash|cmd\.exe|powershell)\b/,
|
|
93
|
+
// Unused: PHP's CONDITIONAL_SINKS list is empty, so nothing consults this.
|
|
94
|
+
// It exists because the type demands an entry for every language, and an
|
|
95
|
+
// entry that never matches is more honest than one that pretends to.
|
|
96
|
+
php: /(?!)/,
|
|
97
|
+
// Only a shell PROGRAM makes exec.Command dangerous. `exec.Command("ping",
|
|
98
|
+
// "-c", "1", host)` is genuinely safe, so matching on "-c" alone would be a
|
|
99
|
+
// false positive on correct code.
|
|
100
|
+
go: /["'`](sh|bash|zsh|cmd\.exe|powershell)["'`]/,
|
|
101
|
+
// Same reasoning as Go: only a shell PROGRAM makes an exec* call injectable.
|
|
102
|
+
// `execv("/bin/ping", args)` with a tainted arg is genuinely safe and must
|
|
103
|
+
// stay quiet, so the program name is what this matches - not the "-c".
|
|
104
|
+
c: /["'](\/bin\/)?(sh|bash|zsh|ksh|dash)["']/,
|
|
105
|
+
cpp: /["'](\/bin\/)?(sh|bash|zsh|ksh|dash)["']/,
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
function nodeText(node: Node): string {
|
|
109
|
+
return (node.text ?? '').replace(/\s+/g, ' ').trim();
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export const commandInjectionRule: Rule = {
|
|
113
|
+
id: 'command-injection',
|
|
114
|
+
name: 'Shell command built from a dynamic string',
|
|
115
|
+
cwe: 'CWE-78',
|
|
116
|
+
owasp: 'A05:2025 Injection',
|
|
117
|
+
severity: 'critical',
|
|
118
|
+
summary: 'A shell command is built from pieces; if any piece comes from outside, it can add a command of its own.',
|
|
119
|
+
fix:
|
|
120
|
+
'Run the program with an argument list and no shell, e.g. subprocess.run(["ls", path]) or execFile("ls", [path]).',
|
|
121
|
+
explanation:
|
|
122
|
+
'A shell reads punctuation like ; | && ` and $() as instructions. If a command ' +
|
|
123
|
+
'string is glued together from pieces, anything spliced in can end your command ' +
|
|
124
|
+
'and start a new one of the attacker\'s choosing, running as your process. ' +
|
|
125
|
+
'The fix is to launch the program with its arguments as a list instead of a ' +
|
|
126
|
+
'sentence, so no shell ever parses the input.',
|
|
127
|
+
limitations:
|
|
128
|
+
'UNVERIFIED (signature-based): we confirmed a shell-executing call receives a ' +
|
|
129
|
+
'dynamically built string - either built inside the call, or built in a local ' +
|
|
130
|
+
'variable earlier in the same function and followed back to it. We did NOT confirm ' +
|
|
131
|
+
'the spliced value is attacker-controlled. A command variable written inside an if, ' +
|
|
132
|
+
'a loop or a switch is not followed, and for list-style APIs we judged shell usage ' +
|
|
133
|
+
'from the visible text of the call only - an options object built elsewhere is ' +
|
|
134
|
+
'invisible to us. A command built right inside the call from values that are ' +
|
|
135
|
+
'PROVABLY fixed - by the same constant proof, and with the same limits, as the SQL ' +
|
|
136
|
+
'rule\'s, including a static Java helper in another file - is withdrawn and listed ' +
|
|
137
|
+
'as proved clean with the reason. Java only: in every other language this rule ' +
|
|
138
|
+
'does not use the proof and reports as before. A command assembled on an earlier ' +
|
|
139
|
+
'line does not get that proof either.',
|
|
140
|
+
shapes: ['call'],
|
|
141
|
+
support: {
|
|
142
|
+
javascript: {
|
|
143
|
+
status: 'implemented',
|
|
144
|
+
note: 'child_process exec/execSync always reported; spawn/execFile reported only when `shell: true` appears in the call text. A command built in a local variable before the call (`const cmd = "ls " + x; exec(cmd)`) is followed back to its build; one written inside a branch or loop, or passed in as a parameter, is not. An options object assembled in another variable is missed, because we read the shell option from the visible text of the call.',
|
|
145
|
+
},
|
|
146
|
+
typescript: {
|
|
147
|
+
status: 'implemented',
|
|
148
|
+
note: 'Identical to JavaScript.',
|
|
149
|
+
},
|
|
150
|
+
python: {
|
|
151
|
+
status: 'implemented',
|
|
152
|
+
note: 'os.system/os.popen always reported; subprocess.* reported only when `shell=True` is visible in the call. A command built in a local variable before the call is followed back to its build, including `+=` extensions after it; one written inside a branch or loop, or passed in as a parameter, is not.',
|
|
153
|
+
},
|
|
154
|
+
java: {
|
|
155
|
+
status: 'partial',
|
|
156
|
+
note: 'PARTIAL: Runtime.exec(String) and ProcessBuilder are detected when the command string is built at the call site, or in a straight-line assignment earlier in the same method. Java code commonly builds commands with StringBuilder across several statements, or picks the shell per operating system inside an if/else; the signature pass follows neither, and the taint engine tracks variables rather than the interior of objects, so a StringBuilder is not followed there either.',
|
|
157
|
+
},
|
|
158
|
+
go: {
|
|
159
|
+
status: 'partial',
|
|
160
|
+
note: 'PARTIAL: exec.Command is only reported when the call itself mentions a shell ("sh", "-c"). exec.Command("ping", host) is genuinely safe, so reporting every exec.Command would be noise - but that also means a shell wrapper built elsewhere is missed. The command string handed to `sh -c` is followed back to a local build (`c := "ls " + x`, fmt.Sprintf) when it was written in straight-line code.',
|
|
161
|
+
},
|
|
162
|
+
php: {
|
|
163
|
+
status: 'implemented',
|
|
164
|
+
note: 'Covers system, exec, shell_exec, passthru, popen, proc_open and pcntl_exec. Unlike Go and Java there is no conditional case to weigh: every one of these hands the string to a shell, so a dynamic argument is always reported.',
|
|
165
|
+
},
|
|
166
|
+
c: {
|
|
167
|
+
status: 'implemented',
|
|
168
|
+
note:
|
|
169
|
+
'system() and popen() hand the string to /bin/sh with no safe overload to be confused with, which makes this the least ambiguous sink list in the tool. exec* takes an argument VECTOR and is reported ONLY when the program being run is itself a shell - execv("/bin/ping", args) with a tainted argument is genuinely safe and stays quiet. The build-then-run idiom is followed through the buffer at both levels. The tracer follows a recognised source into sprintf/strcat and out through system(). The signature pass - which until build-then-run.c saw only `system(buf)`, a bare name, and so reported NOTHING for any C command injection whose source it did not recognise - now reads how the buffer was filled: sprintf/snprintf (reading the format string, so %d is not a splice and %s is), strcpy then strcat, an initialised array then strcat. A buffer written inside an if, a loop or a switch is not followed, and says nothing.',
|
|
170
|
+
},
|
|
171
|
+
cpp: {
|
|
172
|
+
status: 'implemented',
|
|
173
|
+
note:
|
|
174
|
+
'Every C entry applies unchanged, since C++ inherits the whole libc surface and real code still uses it. Namespace-qualified calls (std::system) and method calls on objects are recognised in addition. std::system() takes a const char*, so every C++ shell call ends in .c_str() or .data(); those are looked through, and a std::string built with +, += or append() before the call is followed back to its build - under the same straight-line-only condition as C.',
|
|
175
|
+
},
|
|
176
|
+
},
|
|
177
|
+
check(shape, ctx): RuleHit | null {
|
|
178
|
+
const call = asCall(shape);
|
|
179
|
+
if (!call) return null;
|
|
180
|
+
const language = ctx.language;
|
|
181
|
+
|
|
182
|
+
/*
|
|
183
|
+
* A SHELL FUNCTION THAT WAS RENAMED IS STILL A SHELL FUNCTION.
|
|
184
|
+
*
|
|
185
|
+
* import { exec } from "child_process";
|
|
186
|
+
* const execAsync = promisify(exec);
|
|
187
|
+
* await execAsync(command);
|
|
188
|
+
*
|
|
189
|
+
* That is CVE-2025-53107 - twenty-three vulnerable files - and this rule
|
|
190
|
+
* found none of them, because the list holds `exec` and the call says
|
|
191
|
+
* `execAsync`. The near-identical CVE-2025-59046 WAS caught, purely because
|
|
192
|
+
* that author wrote `const exec = promisify(execCb)` and aliased back to a
|
|
193
|
+
* name we already knew. Two CVEs, and the only difference was luck.
|
|
194
|
+
*
|
|
195
|
+
* `promisify` is the standard Node idiom for exactly these APIs, so the
|
|
196
|
+
* aliased form is the normal one, not an exotic one. File-local only: an
|
|
197
|
+
* alias exported from another module is still missed.
|
|
198
|
+
*/
|
|
199
|
+
const aliasOf = (called: string): string | null => {
|
|
200
|
+
const declaration = new RegExp(
|
|
201
|
+
`\\b${called.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*=\\s*([^;\\n]+)`,
|
|
202
|
+
).exec(ctx.file.source);
|
|
203
|
+
if (!declaration?.[1]) return null;
|
|
204
|
+
const initialiser = declaration[1];
|
|
205
|
+
if (!/\bpromisify\s*\(|require\s*\(|child_process/.test(initialiser)) return null;
|
|
206
|
+
for (const known of [...SHELL_SINKS[language], ...CONDITIONAL_SINKS[language]]) {
|
|
207
|
+
if (known !== called && new RegExp(`\\b${known}\\b`).test(initialiser)) return known;
|
|
208
|
+
}
|
|
209
|
+
return null;
|
|
210
|
+
};
|
|
211
|
+
const resolved =
|
|
212
|
+
SHELL_SINKS[language].includes(call.calleeName) ||
|
|
213
|
+
CONDITIONAL_SINKS[language].includes(call.calleeName)
|
|
214
|
+
? call.calleeName
|
|
215
|
+
: (aliasOf(call.calleeName) ?? call.calleeName);
|
|
216
|
+
|
|
217
|
+
const always = SHELL_SINKS[language].includes(resolved);
|
|
218
|
+
const conditional = CONDITIONAL_SINKS[language].includes(resolved);
|
|
219
|
+
if (!always && !conditional) return null;
|
|
220
|
+
|
|
221
|
+
// For the conditional family, require visible evidence that a shell is used.
|
|
222
|
+
const wholeCall = nodeText(call.node);
|
|
223
|
+
if (conditional && !always && !SHELL_OPTION[language].test(wholeCall)) return null;
|
|
224
|
+
|
|
225
|
+
for (const arg of call.args) {
|
|
226
|
+
let built = analyzeStringExpression(arg, language);
|
|
227
|
+
let builtOnLine: number | null = null;
|
|
228
|
+
/*
|
|
229
|
+
* A bare variable tells us nothing about a command ON ITS OWN - "you
|
|
230
|
+
* passed a variable to exec" is not actionable, and the rule has always
|
|
231
|
+
* skipped it. What it never did was ask the next question: where was that
|
|
232
|
+
* variable built? `cmd = "ls " + name` one line earlier is the same
|
|
233
|
+
* finding as `exec("ls " + name)`, and in C - which cannot build a string
|
|
234
|
+
* inside a call's brackets at all - it is the ONLY form a command
|
|
235
|
+
* injection ever takes. lib/local-build.ts says what it follows and,
|
|
236
|
+
* more importantly, where it gives up.
|
|
237
|
+
*/
|
|
238
|
+
if (built.mechanism === 'opaque' && built.literalText.length === 0) {
|
|
239
|
+
const followed = followLocalBuild(arg, language);
|
|
240
|
+
if (!followed) continue;
|
|
241
|
+
built = followed.expression;
|
|
242
|
+
builtOnLine = followed.line;
|
|
243
|
+
}
|
|
244
|
+
if (!built.isDynamic) continue;
|
|
245
|
+
if (built.mechanism === 'opaque' && built.literalText.length === 0) continue;
|
|
246
|
+
/*
|
|
247
|
+
* ASSEMBLED FROM FIXED PIECES IS NOT ASSEMBLED FROM INPUT. The SQL rule
|
|
248
|
+
* has asked this since the constant proof existed; this rule never did,
|
|
249
|
+
* so `exec(cmd + bar)` with both halves provably fixed was reported -
|
|
250
|
+
* 31 of BenchmarkJava's command-injection false positives, where `cmd`
|
|
251
|
+
* comes from a helper in another file (StaticHelperCommand.java). When
|
|
252
|
+
* the proof needed reasoning it leaves a receipt, as the SQL rule's does.
|
|
253
|
+
*
|
|
254
|
+
* Only for a command written right here in the call. One assembled on an
|
|
255
|
+
* earlier line (builtOnLine) is read at the call, and a name's value at
|
|
256
|
+
* the call can differ from its value where the command was built - so
|
|
257
|
+
* that case keeps the guess.
|
|
258
|
+
*
|
|
259
|
+
* And only in Java for now. The first two rounds of an agent trying to
|
|
260
|
+
* break the proof (tests/probes/) found most of its holes outside Java, and the
|
|
261
|
+
* command rule had never leaned on it in any language; it starts where
|
|
262
|
+
* it has been tested hardest. Elsewhere this rule reports as before.
|
|
263
|
+
*/
|
|
264
|
+
if (builtOnLine === null && language === 'java') {
|
|
265
|
+
const proof = constantProof(arg, built.dynamicParts, language, { staticMethod: ctx.staticMethod, javaTypeInScan: ctx.javaTypeInScan });
|
|
266
|
+
if (proof) {
|
|
267
|
+
if (proof.reasoned) ctx.noteClean?.('command-injection', arg, proof.reason);
|
|
268
|
+
continue;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
/*
|
|
272
|
+
* A VALIDATION GUARD. Inside `if (is_numeric($octet[0]) && ...)` the value
|
|
273
|
+
* provably holds no shell metacharacter - it is digits. This is DVWA's own
|
|
274
|
+
* fix in exec/impossible.php, and reporting it was the seventh time this
|
|
275
|
+
* project punished a corrected file, and the last one still showing in the
|
|
276
|
+
* instrument panel. lib/guards.ts documents why the predicate list is tiny.
|
|
277
|
+
*/
|
|
278
|
+
if (partsAreGuarded(arg, built.dynamicParts, language, 'command')) continue;
|
|
279
|
+
|
|
280
|
+
const callLine = arg.startPosition.row + 1;
|
|
281
|
+
const where = builtOnLine === null || builtOnLine === callLine ? '' : ` on line ${builtOnLine}`;
|
|
282
|
+
return {
|
|
283
|
+
node: arg,
|
|
284
|
+
message: `Command passed to ${call.calleeText}() is built with ${built.mechanism}${where}`,
|
|
285
|
+
reasoning:
|
|
286
|
+
`\`${call.calleeText}()\` executes its argument through a shell` +
|
|
287
|
+
`${conditional && !always ? ' (a shell option is set in this call)' : ''}. ` +
|
|
288
|
+
`The command string is not fixed - it was assembled via ${built.mechanism}${where}, ` +
|
|
289
|
+
`splicing in ${built.dynamicParts.slice(0, 3).map((p) => `\`${p}\``).join(', ') || 'a runtime value'}. ` +
|
|
290
|
+
`Shell metacharacters (; | && \` $()) inside that value would be executed as ` +
|
|
291
|
+
`separate commands.`,
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
return null;
|
|
295
|
+
},
|
|
296
|
+
};
|