@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,1179 @@
|
|
|
1
|
+
const GUARD_LANGUAGES = new Set(['php', 'python', 'javascript', 'typescript']);
|
|
2
|
+
/** Does this language have any checks this module recognises? */
|
|
3
|
+
export const guardsApply = (language) => GUARD_LANGUAGES.has(language);
|
|
4
|
+
/**
|
|
5
|
+
* Predicates whose truth proves what the value can contain. Deliberately
|
|
6
|
+
* short. Adding a name here silences findings, so a candidate has to survive
|
|
7
|
+
* the question: "can a value this accepts contain a quote, a semicolon, an
|
|
8
|
+
* angle bracket or a dot-dot-slash?"
|
|
9
|
+
*
|
|
10
|
+
* is_numeric accepts "1e5", "-1" and " 1" - no metacharacter in any of them.
|
|
11
|
+
* ctype_xdigit accepts "dead" and "beef", which are words, not digits.
|
|
12
|
+
*/
|
|
13
|
+
const VALIDATORS = {
|
|
14
|
+
php: {
|
|
15
|
+
is_numeric: 'digits', is_int: 'digits', is_integer: 'digits', is_long: 'digits',
|
|
16
|
+
is_float: 'digits', is_double: 'digits', ctype_digit: 'digits',
|
|
17
|
+
ctype_alnum: 'word', ctype_alpha: 'word', ctype_xdigit: 'word',
|
|
18
|
+
},
|
|
19
|
+
python: {
|
|
20
|
+
// Method form: `page.isdigit()`. The receiver is the checked value.
|
|
21
|
+
isdigit: 'digits', isnumeric: 'digits', isdecimal: 'digits',
|
|
22
|
+
isalnum: 'word', isalpha: 'word',
|
|
23
|
+
},
|
|
24
|
+
javascript: { isInteger: 'digits', isFinite: 'digits', isSafeInteger: 'digits' },
|
|
25
|
+
typescript: { isInteger: 'digits', isFinite: 'digits', isSafeInteger: 'digits' },
|
|
26
|
+
};
|
|
27
|
+
/** Calls that hand back the value with its letters' case changed - and nothing else. */
|
|
28
|
+
const CASE_FOLDS = new Set(['strtolower', 'strtoupper', 'mb_strtolower', 'mb_strtoupper', 'toLowerCase', 'toUpperCase', 'lower', 'upper']);
|
|
29
|
+
const SAFE_KINDS = {
|
|
30
|
+
// No quote, semicolon, angle bracket, dot-dot-slash or `--`. Not a URL host,
|
|
31
|
+
// though: "2130706433" is 127.0.0.1 to most URL parsers.
|
|
32
|
+
digits: { kinds: ['sql', 'command', 'xss', 'deserialization', 'code', 'format', 'path'], insideQuotesOnly: [] },
|
|
33
|
+
// A bare word is a column name in SQL (`WHERE id = id`) and a name in code,
|
|
34
|
+
// so SQL counts only inside quotes and code not at all.
|
|
35
|
+
word: { kinds: ['sql', 'command', 'xss', 'format', 'path'], insideQuotesOnly: ['sql'] },
|
|
36
|
+
// The attacker only chooses which of the programmer's own values arrives.
|
|
37
|
+
fixed: {
|
|
38
|
+
kinds: ['sql', 'command', 'xss', 'deserialization', 'code', 'ssrf', 'format', 'overflow', 'path'],
|
|
39
|
+
insideQuotesOnly: [],
|
|
40
|
+
},
|
|
41
|
+
// One Python string literal with no quote of its kind inside: eval/exec can
|
|
42
|
+
// only build a str from it. Says nothing for SQL, a shell or a page.
|
|
43
|
+
'quoted-literal': { kinds: ['code'], insideQuotesOnly: [] },
|
|
44
|
+
};
|
|
45
|
+
/** Which sinks a fact makes safe, and which of those only when the value lands inside quotes. */
|
|
46
|
+
export const guardClears = (fact) => {
|
|
47
|
+
const base = SAFE_KINDS[fact.strength];
|
|
48
|
+
// `/^(localhost|internal)$/` also passes "localhost\n": fine in a query or a
|
|
49
|
+
// page, not something to vouch for inside a URL.
|
|
50
|
+
if (fact.newlineTail)
|
|
51
|
+
return { kinds: base.kinds.filter((k) => k !== 'ssrf' && k !== 'overflow'), insideQuotesOnly: base.insideQuotesOnly };
|
|
52
|
+
return base;
|
|
53
|
+
};
|
|
54
|
+
/** One sentence for the path: what the check proved. */
|
|
55
|
+
export function describeFact(fact) {
|
|
56
|
+
const line = fact.check.startPosition.row + 1;
|
|
57
|
+
const snippet = (fact.check.text ?? '').replace(/\s+/g, ' ').trim();
|
|
58
|
+
const shown = snippet.length > 70 ? `${snippet.slice(0, 67)}...` : snippet;
|
|
59
|
+
const lets = fact.strength === 'quoted-literal'
|
|
60
|
+
? 'only one quoted string literal can pass, so evaluating it only builds a string'
|
|
61
|
+
: fact.strength === 'digits'
|
|
62
|
+
? 'only digits can pass'
|
|
63
|
+
: fact.strength === 'word'
|
|
64
|
+
? 'only letters, digits and underscores can pass'
|
|
65
|
+
: `only ${fact.values && fact.values.length > 0 ? fact.values.slice(0, 5).map((v) => `'${v}'`).join(', ') : 'fixed values'}` +
|
|
66
|
+
`${fact.values && fact.values.length > 5 ? ' (and others)' : ''} can pass` +
|
|
67
|
+
`${fact.caseFolded ? ', in any letter case' : ''}`;
|
|
68
|
+
const tail = fact.newlineTail ? ' (and one line break after it, which `$` allows)' : '';
|
|
69
|
+
return `checked by \`${shown}\` on line ${line}: ${lets}${tail}`;
|
|
70
|
+
}
|
|
71
|
+
/* ------------------------------------------------------------------------ */
|
|
72
|
+
/* Text */
|
|
73
|
+
/* ------------------------------------------------------------------------ */
|
|
74
|
+
/**
|
|
75
|
+
* The same expression written two ways must compare equal. DVWA writes
|
|
76
|
+
* `$_REQUEST[ 'ip' ]` with spaces and reads it back the same way, but a check
|
|
77
|
+
* written `$_GET["id"]` and a read written `$_GET['id']` are one value too.
|
|
78
|
+
*/
|
|
79
|
+
export function normalizeExpression(raw) {
|
|
80
|
+
let out = '';
|
|
81
|
+
let quote = null;
|
|
82
|
+
for (let i = 0; i < raw.length; i++) {
|
|
83
|
+
const c = raw[i];
|
|
84
|
+
if (quote) {
|
|
85
|
+
out += c === '"' ? "'" : c;
|
|
86
|
+
if (c === '\\') {
|
|
87
|
+
out += raw[i + 1] ?? '';
|
|
88
|
+
i++;
|
|
89
|
+
}
|
|
90
|
+
else if (c === quote) {
|
|
91
|
+
quote = null;
|
|
92
|
+
}
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
if (c === '"' || c === "'") {
|
|
96
|
+
quote = c;
|
|
97
|
+
out += "'";
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
if (/\s/.test(c))
|
|
101
|
+
continue;
|
|
102
|
+
out += c;
|
|
103
|
+
}
|
|
104
|
+
return out;
|
|
105
|
+
}
|
|
106
|
+
const firstNamed = (node) => node?.namedChildren.find((c) => c !== null) ?? null;
|
|
107
|
+
const namedKids = (node) => node ? node.namedChildren.filter((c) => c !== null) : [];
|
|
108
|
+
const contains = (container, node) => container.startIndex <= node.startIndex && container.endIndex >= node.endIndex;
|
|
109
|
+
const lastSegment = (text) => {
|
|
110
|
+
const trimmed = text.trim();
|
|
111
|
+
const cut = Math.max(trimmed.lastIndexOf('.'), trimmed.lastIndexOf('->'), trimmed.lastIndexOf('::'));
|
|
112
|
+
if (cut === -1)
|
|
113
|
+
return trimmed;
|
|
114
|
+
return trimmed.slice(cut + (trimmed.startsWith('->', cut) || trimmed.startsWith('::', cut) ? 2 : 1));
|
|
115
|
+
};
|
|
116
|
+
/** The arguments of a call, unwrapped from PHP's `argument` nodes. */
|
|
117
|
+
function callArgs(call) {
|
|
118
|
+
const list = call.childForFieldName('arguments') ?? namedKids(call).find((c) => c.type === 'argument_list' || c.type === 'arguments') ?? null;
|
|
119
|
+
return namedKids(list)
|
|
120
|
+
.filter((a) => a.type !== 'comment')
|
|
121
|
+
.map((a) => (a.type === 'argument' ? firstNamed(a) ?? a : a));
|
|
122
|
+
}
|
|
123
|
+
const CALL_TYPES = new Set(['function_call_expression', 'member_call_expression', 'scoped_call_expression', 'call_expression', 'call']);
|
|
124
|
+
/** `strtolower($x)` -> `$x`; anything else -> null. */
|
|
125
|
+
function caseFoldedArgument(node) {
|
|
126
|
+
if (!CALL_TYPES.has(node.type))
|
|
127
|
+
return null;
|
|
128
|
+
const callee = node.childForFieldName('function') ?? node.childForFieldName('name');
|
|
129
|
+
const name = lastSegment(callee?.text ?? '');
|
|
130
|
+
if (!CASE_FOLDS.has(name) && !CASE_FOLDS.has(name.toLowerCase()))
|
|
131
|
+
return null;
|
|
132
|
+
const args = callArgs(node);
|
|
133
|
+
if (args.length === 1)
|
|
134
|
+
return args[0] ?? null;
|
|
135
|
+
// Method form: `ext.toLowerCase()`, `ext.lower()`.
|
|
136
|
+
if (args.length === 0 && callee && (callee.type === 'member_expression' || callee.type === 'attribute')) {
|
|
137
|
+
return callee.childForFieldName('object');
|
|
138
|
+
}
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* The value of a literal, or null when it is not one we can read exactly.
|
|
143
|
+
* Interpolation, escapes we do not decode and heredocs all answer null - and
|
|
144
|
+
* null only ever means a check is NOT counted.
|
|
145
|
+
*/
|
|
146
|
+
function literalOf(node, language) {
|
|
147
|
+
if (!node)
|
|
148
|
+
return null;
|
|
149
|
+
const raw = node.text ?? '';
|
|
150
|
+
if (node.type === 'parenthesized_expression')
|
|
151
|
+
return literalOf(firstNamed(node), language);
|
|
152
|
+
if (node.type === 'integer' || node.type === 'number' || node.type === 'float')
|
|
153
|
+
return { kind: 'number', value: raw };
|
|
154
|
+
if (node.type === 'boolean' || node.type === 'true' || node.type === 'false')
|
|
155
|
+
return { kind: 'bool', value: raw.toLowerCase() };
|
|
156
|
+
if (node.type === 'null' || node.type === 'none')
|
|
157
|
+
return { kind: 'null', value: '' };
|
|
158
|
+
if (language === 'php') {
|
|
159
|
+
if (node.type === 'string' && raw.startsWith("'") && raw.endsWith("'")) {
|
|
160
|
+
return { kind: 'string', value: raw.slice(1, -1).replace(/\\([\\'])/g, '$1') };
|
|
161
|
+
}
|
|
162
|
+
if (node.type === 'encapsed_string' && raw.startsWith('"') && raw.endsWith('"')) {
|
|
163
|
+
if (namedKids(node).some((c) => c.type !== 'string_content' && c.type !== 'string_value' && c.type !== 'escape_sequence'))
|
|
164
|
+
return null;
|
|
165
|
+
const body = raw.slice(1, -1);
|
|
166
|
+
if (/\$[A-Za-z_{]/.test(body))
|
|
167
|
+
return null;
|
|
168
|
+
if (/\\(?:[nrtvef0-7]|x[0-9A-Fa-f]|u\{)/.test(body))
|
|
169
|
+
return null;
|
|
170
|
+
return { kind: 'string', value: body.replace(/\\([\\$"])/g, '$1') };
|
|
171
|
+
}
|
|
172
|
+
return null;
|
|
173
|
+
}
|
|
174
|
+
if (language === 'javascript' || language === 'typescript') {
|
|
175
|
+
if (node.type === 'string' || node.type === 'template_string') {
|
|
176
|
+
if (namedKids(node).some((c) => c.type !== 'string_fragment'))
|
|
177
|
+
return null;
|
|
178
|
+
return { kind: 'string', value: raw.slice(1, -1) };
|
|
179
|
+
}
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
if (language === 'python' && node.type === 'string') {
|
|
183
|
+
const kids = namedKids(node);
|
|
184
|
+
if (kids.some((c) => c.type === 'interpolation'))
|
|
185
|
+
return null;
|
|
186
|
+
const start = kids.find((c) => c.type === 'string_start')?.text ?? '';
|
|
187
|
+
const prefix = start.replace(/['"]+$/, '').toLowerCase();
|
|
188
|
+
if (prefix.includes('f'))
|
|
189
|
+
return null;
|
|
190
|
+
const content = kids.filter((c) => c.type === 'string_content').map((c) => c.text ?? '').join('');
|
|
191
|
+
if (prefix.includes('r'))
|
|
192
|
+
return { kind: 'string', value: content };
|
|
193
|
+
// Escapes Python decodes are refused; ones it leaves alone (`\d`) are kept.
|
|
194
|
+
if (/\\[\\'"abfnrtv0-7xNuU\n]/.test(content.replace(/\\\\/g, '')))
|
|
195
|
+
return null;
|
|
196
|
+
return { kind: 'string', value: content.replace(/\\\\/g, '\\') };
|
|
197
|
+
}
|
|
198
|
+
return null;
|
|
199
|
+
}
|
|
200
|
+
/** PHP's idea of a numeric string - the ones `==` compares as numbers. */
|
|
201
|
+
const PHP_NUMERIC_STRING = /^\s*[+-]?(\d+(\.\d*)?|\.\d+)([eE][+-]?\d+)?\s*$/;
|
|
202
|
+
/* ------------------------------------------------------------------------ */
|
|
203
|
+
/* Regular expressions */
|
|
204
|
+
/* ------------------------------------------------------------------------ */
|
|
205
|
+
const precedingBackslashes = (s, at) => {
|
|
206
|
+
let n = 0;
|
|
207
|
+
for (let i = at - 1; i >= 0 && s[i] === '\\'; i--)
|
|
208
|
+
n++;
|
|
209
|
+
return n;
|
|
210
|
+
};
|
|
211
|
+
const sameRangeGroup = (lo, hi) => (/[0-9]/.test(lo) && /[0-9]/.test(hi)) || (/[a-z]/.test(lo) && /[a-z]/.test(hi)) || (/[A-Z]/.test(lo) && /[A-Z]/.test(hi));
|
|
212
|
+
/**
|
|
213
|
+
* What a whole-value regular expression lets through: 'digits', 'word',
|
|
214
|
+
* 'fixed' (a list of literal words, `/^(asc|desc)$/`), or null when anything
|
|
215
|
+
* else could pass - and null is the answer for everything this does not
|
|
216
|
+
* understand.
|
|
217
|
+
*
|
|
218
|
+
* The body may only be digits, letters, underscores, `\d`, `\w`, classes of
|
|
219
|
+
* those, groups, alternation INSIDE a group, and quantifiers. Top-level `|`
|
|
220
|
+
* is refused because it splits the anchors: `/^1|2$/` is "starts with 1" OR
|
|
221
|
+
* "ends with 2", and "1; rm -rf /" starts with 1.
|
|
222
|
+
*
|
|
223
|
+
* `$` lets one trailing newline through in PHP and Python (not in
|
|
224
|
+
* JavaScript, and not with PHP's D flag). A lone newline at the very end
|
|
225
|
+
* cannot open a quote or a tag, and is accepted - but it is recorded, so a
|
|
226
|
+
* fixed list is not vouched for inside a URL (see guardClears).
|
|
227
|
+
*/
|
|
228
|
+
export function regexAllowList(source, options) {
|
|
229
|
+
const flags = options.flags ?? '';
|
|
230
|
+
// Multi-line makes ^ and $ match at every line; extended mode changes what
|
|
231
|
+
// whitespace and # mean. Either way the anchors no longer pin the value.
|
|
232
|
+
if (/[mx]/.test(flags))
|
|
233
|
+
return null;
|
|
234
|
+
const php = options.language === 'php';
|
|
235
|
+
let body = source;
|
|
236
|
+
if (body.startsWith('^'))
|
|
237
|
+
body = body.slice(1);
|
|
238
|
+
else if (php && body.startsWith('\\A'))
|
|
239
|
+
body = body.slice(2);
|
|
240
|
+
else if (!options.implicitStart && !(php && flags.includes('A')))
|
|
241
|
+
return null;
|
|
242
|
+
const endsWith = (token) => body.endsWith(token) && precedingBackslashes(body, body.length - token.length) % 2 === 0;
|
|
243
|
+
const javascript = options.language === 'javascript' || options.language === 'typescript';
|
|
244
|
+
let newlineTail = false;
|
|
245
|
+
if (endsWith('$')) {
|
|
246
|
+
body = body.slice(0, -1);
|
|
247
|
+
newlineTail = !javascript && !(php && flags.includes('D'));
|
|
248
|
+
}
|
|
249
|
+
else if (!javascript && endsWith('\\Z')) {
|
|
250
|
+
// PCRE's and Python's \Z both allow the same one line break as `$`.
|
|
251
|
+
body = body.slice(0, -2);
|
|
252
|
+
newlineTail = true;
|
|
253
|
+
}
|
|
254
|
+
else if (!javascript && endsWith('\\z')) {
|
|
255
|
+
body = body.slice(0, -2);
|
|
256
|
+
}
|
|
257
|
+
else if (!options.implicitEnd)
|
|
258
|
+
return null;
|
|
259
|
+
const tail = newlineTail ? { newlineTail: true } : {};
|
|
260
|
+
// A list of literal words: the value is one of them.
|
|
261
|
+
const list = /^\((?:\?:)?([A-Za-z0-9_]+(?:\|[A-Za-z0-9_]+)*)\)$/.exec(body) ?? /^([A-Za-z0-9_]+)$/.exec(body);
|
|
262
|
+
if (list) {
|
|
263
|
+
const values = list[1].split('|');
|
|
264
|
+
return { strength: 'fixed', values, ...(flags.includes('i') ? { caseFolded: true } : {}), ...tail };
|
|
265
|
+
}
|
|
266
|
+
let letters = false;
|
|
267
|
+
let depth = 0;
|
|
268
|
+
let i = 0;
|
|
269
|
+
while (i < body.length) {
|
|
270
|
+
const c = body[i];
|
|
271
|
+
if (c === '\\') {
|
|
272
|
+
const e = body[i + 1];
|
|
273
|
+
if (e === 'd') {
|
|
274
|
+
i += 2;
|
|
275
|
+
continue;
|
|
276
|
+
}
|
|
277
|
+
if (e === 'w') {
|
|
278
|
+
letters = true;
|
|
279
|
+
i += 2;
|
|
280
|
+
continue;
|
|
281
|
+
}
|
|
282
|
+
return null;
|
|
283
|
+
}
|
|
284
|
+
if (c === '[') {
|
|
285
|
+
let j = i + 1;
|
|
286
|
+
if (body[j] === '^' || body[j] === ']')
|
|
287
|
+
return null;
|
|
288
|
+
let any = false;
|
|
289
|
+
while (j < body.length && body[j] !== ']') {
|
|
290
|
+
const k = body[j];
|
|
291
|
+
if (k === '[' && php && body[j + 1] === ':') {
|
|
292
|
+
const posix = /^\[:(digit|alnum|alpha|upper|lower|word|xdigit):\]/.exec(body.slice(j));
|
|
293
|
+
if (!posix)
|
|
294
|
+
return null;
|
|
295
|
+
if (posix[1] !== 'digit')
|
|
296
|
+
letters = true;
|
|
297
|
+
j += posix[0].length;
|
|
298
|
+
any = true;
|
|
299
|
+
continue;
|
|
300
|
+
}
|
|
301
|
+
if (k === '\\') {
|
|
302
|
+
const e = body[j + 1];
|
|
303
|
+
if (e === 'd') {
|
|
304
|
+
j += 2;
|
|
305
|
+
any = true;
|
|
306
|
+
continue;
|
|
307
|
+
}
|
|
308
|
+
if (e === 'w') {
|
|
309
|
+
letters = true;
|
|
310
|
+
j += 2;
|
|
311
|
+
any = true;
|
|
312
|
+
continue;
|
|
313
|
+
}
|
|
314
|
+
return null;
|
|
315
|
+
}
|
|
316
|
+
if (!/[A-Za-z0-9_]/.test(k))
|
|
317
|
+
return null;
|
|
318
|
+
if (body[j + 1] === '-' && body[j + 2] !== undefined && body[j + 2] !== ']') {
|
|
319
|
+
const hi = body[j + 2];
|
|
320
|
+
if (!sameRangeGroup(k, hi) || k > hi)
|
|
321
|
+
return null;
|
|
322
|
+
if (!/[0-9]/.test(k))
|
|
323
|
+
letters = true;
|
|
324
|
+
j += 3;
|
|
325
|
+
any = true;
|
|
326
|
+
continue;
|
|
327
|
+
}
|
|
328
|
+
if (!/[0-9]/.test(k))
|
|
329
|
+
letters = true;
|
|
330
|
+
j++;
|
|
331
|
+
any = true;
|
|
332
|
+
}
|
|
333
|
+
if (body[j] !== ']' || !any)
|
|
334
|
+
return null;
|
|
335
|
+
i = j + 1;
|
|
336
|
+
continue;
|
|
337
|
+
}
|
|
338
|
+
if (c === '(') {
|
|
339
|
+
if (body[i + 1] === '?') {
|
|
340
|
+
if (body[i + 2] !== ':')
|
|
341
|
+
return null; // lookarounds, inline flags, named groups
|
|
342
|
+
i += 3;
|
|
343
|
+
}
|
|
344
|
+
else {
|
|
345
|
+
i++;
|
|
346
|
+
}
|
|
347
|
+
depth++;
|
|
348
|
+
continue;
|
|
349
|
+
}
|
|
350
|
+
if (c === ')') {
|
|
351
|
+
if (depth === 0)
|
|
352
|
+
return null;
|
|
353
|
+
depth--;
|
|
354
|
+
i++;
|
|
355
|
+
continue;
|
|
356
|
+
}
|
|
357
|
+
if (c === '|') {
|
|
358
|
+
if (depth === 0)
|
|
359
|
+
return null;
|
|
360
|
+
i++;
|
|
361
|
+
continue;
|
|
362
|
+
}
|
|
363
|
+
if (c === '+' || c === '*' || c === '?') {
|
|
364
|
+
i++;
|
|
365
|
+
continue;
|
|
366
|
+
}
|
|
367
|
+
if (c === '{') {
|
|
368
|
+
const q = /^\{\d+(,\d*)?\}/.exec(body.slice(i));
|
|
369
|
+
if (!q)
|
|
370
|
+
return null;
|
|
371
|
+
i += q[0].length;
|
|
372
|
+
continue;
|
|
373
|
+
}
|
|
374
|
+
if (/[0-9]/.test(c)) {
|
|
375
|
+
i++;
|
|
376
|
+
continue;
|
|
377
|
+
}
|
|
378
|
+
if (/[A-Za-z_]/.test(c)) {
|
|
379
|
+
letters = true;
|
|
380
|
+
i++;
|
|
381
|
+
continue;
|
|
382
|
+
}
|
|
383
|
+
return null;
|
|
384
|
+
}
|
|
385
|
+
if (depth !== 0)
|
|
386
|
+
return null;
|
|
387
|
+
return { strength: letters ? 'word' : 'digits', ...tail };
|
|
388
|
+
}
|
|
389
|
+
/** `/^\d+$/i` written as a PHP string: delimiters, then flags. */
|
|
390
|
+
function phpPattern(literal) {
|
|
391
|
+
const open = literal[0];
|
|
392
|
+
if (!open || /[A-Za-z0-9\\\s]/.test(open))
|
|
393
|
+
return null;
|
|
394
|
+
const close = { '(': ')', '[': ']', '{': '}', '<': '>' }[open] ?? open;
|
|
395
|
+
const end = literal.lastIndexOf(close);
|
|
396
|
+
if (end <= 0)
|
|
397
|
+
return null;
|
|
398
|
+
const flags = literal.slice(end + 1);
|
|
399
|
+
if (!/^[A-Za-z]*$/.test(flags))
|
|
400
|
+
return null;
|
|
401
|
+
return { source: literal.slice(1, end), flags };
|
|
402
|
+
}
|
|
403
|
+
/* ------------------------------------------------------------------------ */
|
|
404
|
+
/* Conditions */
|
|
405
|
+
/* ------------------------------------------------------------------------ */
|
|
406
|
+
const EMPTY = { whenTrue: new Map(), whenFalse: new Map() };
|
|
407
|
+
const RANK = { 'quoted-literal': 0, word: 1, digits: 2, fixed: 3 };
|
|
408
|
+
const classOfValues = (values) => values.every((v) => /^[0-9]*$/.test(v)) ? 'digits' : values.every((v) => /^[A-Za-z0-9_]*$/.test(v)) ? 'word' : null;
|
|
409
|
+
/** Both facts hold - keep the stronger. */
|
|
410
|
+
function stronger(a, b) {
|
|
411
|
+
return RANK[b.strength] > RANK[a.strength] ? b : a;
|
|
412
|
+
}
|
|
413
|
+
/** One of the two holds - keep only what both promise. */
|
|
414
|
+
function weaker(a, b, at) {
|
|
415
|
+
if (a.strength === 'fixed' && b.strength === 'fixed') {
|
|
416
|
+
return {
|
|
417
|
+
strength: 'fixed',
|
|
418
|
+
values: [...new Set([...(a.values ?? []), ...(b.values ?? [])])],
|
|
419
|
+
...(a.caseFolded || b.caseFolded ? { caseFolded: true } : {}),
|
|
420
|
+
...(a.newlineTail || b.newlineTail ? { newlineTail: true } : {}),
|
|
421
|
+
check: at,
|
|
422
|
+
};
|
|
423
|
+
}
|
|
424
|
+
// Not a character class: "word OR quoted literal" promises nothing either does.
|
|
425
|
+
if (a.strength === 'quoted-literal' || b.strength === 'quoted-literal') {
|
|
426
|
+
return a.strength === b.strength ? { strength: a.strength, check: at } : null;
|
|
427
|
+
}
|
|
428
|
+
const asCharset = (f) => (f.strength === 'fixed' ? classOfValues(f.values ?? ['\u0000']) : f.strength);
|
|
429
|
+
const x = asCharset(a);
|
|
430
|
+
const y = asCharset(b);
|
|
431
|
+
if (!x || !y)
|
|
432
|
+
return null;
|
|
433
|
+
return { strength: RANK[x] <= RANK[y] ? x : y, ...(a.newlineTail || b.newlineTail ? { newlineTail: true } : {}), check: at };
|
|
434
|
+
}
|
|
435
|
+
function joinStrong(a, b) {
|
|
436
|
+
const out = new Map(a);
|
|
437
|
+
for (const [k, f] of b)
|
|
438
|
+
out.set(k, out.has(k) ? stronger(out.get(k), f) : f);
|
|
439
|
+
return out;
|
|
440
|
+
}
|
|
441
|
+
function meetWeak(a, b, at) {
|
|
442
|
+
const out = new Map();
|
|
443
|
+
for (const [k, f] of a) {
|
|
444
|
+
const g = b.get(k);
|
|
445
|
+
if (!g)
|
|
446
|
+
continue;
|
|
447
|
+
const w = weaker(f, g, at);
|
|
448
|
+
if (w)
|
|
449
|
+
out.set(k, w);
|
|
450
|
+
}
|
|
451
|
+
return out;
|
|
452
|
+
}
|
|
453
|
+
const facts1 = (subject, fact, check) => {
|
|
454
|
+
const out = new Map();
|
|
455
|
+
if (subject?.text)
|
|
456
|
+
out.set(normalizeExpression(subject.text), { ...fact, check });
|
|
457
|
+
return out;
|
|
458
|
+
};
|
|
459
|
+
const operatorOf = (node) => {
|
|
460
|
+
const field = node.childForFieldName('operator')?.text;
|
|
461
|
+
if (field)
|
|
462
|
+
return field.trim();
|
|
463
|
+
// Python's comparison operators are anonymous children: `in`, `not in`, `is not`.
|
|
464
|
+
return node.children
|
|
465
|
+
.filter((c) => c !== null && !c.isNamed)
|
|
466
|
+
.map((c) => c.text)
|
|
467
|
+
.join(' ')
|
|
468
|
+
.trim();
|
|
469
|
+
};
|
|
470
|
+
/** A check written as a call: validators, regex matches, list membership. */
|
|
471
|
+
function callFacts(call, language) {
|
|
472
|
+
const callee = call.childForFieldName('function') ?? call.childForFieldName('name');
|
|
473
|
+
const calleeText = callee?.text ?? '';
|
|
474
|
+
// PHP lets a builtin be called fully qualified: `\preg_match(...)`.
|
|
475
|
+
const name = lastSegment(calleeText).replace(/^\\+/, '');
|
|
476
|
+
const args = callArgs(call);
|
|
477
|
+
const empty = new Map();
|
|
478
|
+
// Validators: is_numeric($x), ctype_digit($x), Number.isInteger(x), page.isdigit()
|
|
479
|
+
const validators = VALIDATORS[language] ?? {};
|
|
480
|
+
const lookup = language === 'php' ? name.toLowerCase() : name;
|
|
481
|
+
if (Object.prototype.hasOwnProperty.call(validators, lookup)) {
|
|
482
|
+
const strength = validators[lookup];
|
|
483
|
+
if (args.length >= 1)
|
|
484
|
+
return facts1(args[0] ?? null, { strength }, call);
|
|
485
|
+
if (callee && (callee.type === 'attribute' || callee.type === 'member_expression')) {
|
|
486
|
+
return facts1(callee.childForFieldName('object'), { strength }, call);
|
|
487
|
+
}
|
|
488
|
+
return empty;
|
|
489
|
+
}
|
|
490
|
+
if (language === 'php') {
|
|
491
|
+
const lower = name.toLowerCase();
|
|
492
|
+
if (lower === 'preg_match' && args.length >= 2 && args.length <= 4) {
|
|
493
|
+
const pattern = literalOf(args[0], language);
|
|
494
|
+
const parsed = pattern?.kind === 'string' ? phpPattern(pattern.value) : null;
|
|
495
|
+
const allowed = parsed ? regexAllowList(parsed.source, { flags: parsed.flags, language }) : null;
|
|
496
|
+
return allowed ? facts1(args[1] ?? null, allowed, call) : empty;
|
|
497
|
+
}
|
|
498
|
+
if (lower === 'in_array' && (args.length === 2 || args.length === 3)) {
|
|
499
|
+
const strict = args.length === 3 && (args[2]?.text ?? '').toLowerCase() === 'true';
|
|
500
|
+
const values = arrayLiteralValues(args[1] ?? null, language, strict);
|
|
501
|
+
if (!values)
|
|
502
|
+
return empty;
|
|
503
|
+
return membershipFacts(args[0] ?? null, values, call);
|
|
504
|
+
}
|
|
505
|
+
return empty;
|
|
506
|
+
}
|
|
507
|
+
if (language === 'javascript' || language === 'typescript') {
|
|
508
|
+
if (!callee || callee.type !== 'member_expression')
|
|
509
|
+
return empty;
|
|
510
|
+
const object = callee.childForFieldName('object');
|
|
511
|
+
const property = callee.childForFieldName('property')?.text ?? '';
|
|
512
|
+
// /^\d+$/.test(x)
|
|
513
|
+
if (property === 'test' && object?.type === 'regex' && args.length === 1) {
|
|
514
|
+
const source = object.childForFieldName('pattern')?.text ?? '';
|
|
515
|
+
const flags = object.childForFieldName('flags')?.text ?? '';
|
|
516
|
+
const allowed = regexAllowList(source, { flags, language });
|
|
517
|
+
return allowed ? facts1(args[0] ?? null, allowed, call) : empty;
|
|
518
|
+
}
|
|
519
|
+
// x.match(/^\d+$/)
|
|
520
|
+
if (property === 'match' && args.length === 1 && args[0]?.type === 'regex') {
|
|
521
|
+
const source = args[0].childForFieldName('pattern')?.text ?? '';
|
|
522
|
+
const flags = args[0].childForFieldName('flags')?.text ?? '';
|
|
523
|
+
const allowed = regexAllowList(source, { flags, language });
|
|
524
|
+
return allowed ? facts1(object, allowed, call) : empty;
|
|
525
|
+
}
|
|
526
|
+
// ['jpg', 'png'].includes(x) - includes() is strict equality.
|
|
527
|
+
if (property === 'includes' && object?.type === 'array' && args.length === 1) {
|
|
528
|
+
const values = arrayLiteralValues(object, language, true);
|
|
529
|
+
return values ? membershipFacts(args[0] ?? null, values, call) : empty;
|
|
530
|
+
}
|
|
531
|
+
return empty;
|
|
532
|
+
}
|
|
533
|
+
if (language === 'python') {
|
|
534
|
+
// re.fullmatch(r'\d+', x) / re.match(r'\d+$', x)
|
|
535
|
+
if ((calleeText === 're.fullmatch' || calleeText === 're.match') && (args.length === 2 || args.length === 3)) {
|
|
536
|
+
if (args.length === 3 && !/^re\.(I|IGNORECASE|A|ASCII|U|UNICODE)$/.test((args[2]?.text ?? '').trim()))
|
|
537
|
+
return empty;
|
|
538
|
+
const pattern = literalOf(args[0], language);
|
|
539
|
+
if (pattern?.kind !== 'string')
|
|
540
|
+
return empty;
|
|
541
|
+
const allowed = regexAllowList(pattern.value, {
|
|
542
|
+
language,
|
|
543
|
+
implicitStart: true,
|
|
544
|
+
implicitEnd: calleeText === 're.fullmatch',
|
|
545
|
+
// Python spells case-insensitive as an argument, not a suffix.
|
|
546
|
+
...(args.length === 3 && /^re\.(I|IGNORECASE)$/.test((args[2]?.text ?? '').trim()) ? { flags: 'i' } : {}),
|
|
547
|
+
});
|
|
548
|
+
return allowed ? facts1(args[1] ?? null, allowed, call) : empty;
|
|
549
|
+
}
|
|
550
|
+
return empty;
|
|
551
|
+
}
|
|
552
|
+
return empty;
|
|
553
|
+
}
|
|
554
|
+
/** The values of an array literal of strings - or null if any element is not one. */
|
|
555
|
+
function arrayLiteralValues(node, language, strict) {
|
|
556
|
+
if (!node)
|
|
557
|
+
return null;
|
|
558
|
+
const ok = new Set(['array_creation_expression', 'array', 'list', 'tuple', 'set']);
|
|
559
|
+
if (!ok.has(node.type))
|
|
560
|
+
return null;
|
|
561
|
+
const values = [];
|
|
562
|
+
for (const element of namedKids(node)) {
|
|
563
|
+
if (element.type === 'comment')
|
|
564
|
+
continue;
|
|
565
|
+
// PHP wraps each element; a `key => value` pair is refused.
|
|
566
|
+
const inner = element.type === 'array_element_initializer' ? (namedKids(element).length === 1 ? firstNamed(element) : null) : element;
|
|
567
|
+
const lit = literalOf(inner, language);
|
|
568
|
+
if (!lit)
|
|
569
|
+
return null;
|
|
570
|
+
if (lit.kind === 'string') {
|
|
571
|
+
// Loose comparison turns "1e0" == "1" true; only exact strings are safe there.
|
|
572
|
+
if (!strict && language === 'php' && PHP_NUMERIC_STRING.test(lit.value))
|
|
573
|
+
return null;
|
|
574
|
+
values.push(lit.value);
|
|
575
|
+
continue;
|
|
576
|
+
}
|
|
577
|
+
// A number compared loosely is PHP 7's `"abc" == 0`. Strictly, a request
|
|
578
|
+
// string never equals one, so the branch is dead - harmless to count.
|
|
579
|
+
if (strict && lit.kind === 'number') {
|
|
580
|
+
values.push(lit.value);
|
|
581
|
+
continue;
|
|
582
|
+
}
|
|
583
|
+
return null;
|
|
584
|
+
}
|
|
585
|
+
return values.length > 0 ? values : null;
|
|
586
|
+
}
|
|
587
|
+
function membershipFacts(subject, values, check) {
|
|
588
|
+
if (!subject)
|
|
589
|
+
return new Map();
|
|
590
|
+
const folded = caseFoldedArgument(subject);
|
|
591
|
+
if (folded)
|
|
592
|
+
return facts1(folded, { strength: 'fixed', values, caseFolded: true }, check);
|
|
593
|
+
return facts1(subject, { strength: 'fixed', values }, check);
|
|
594
|
+
}
|
|
595
|
+
/**
|
|
596
|
+
* Comparisons. Two families:
|
|
597
|
+
*
|
|
598
|
+
* CHECK == 1, CHECK > 0, CHECK != 0 ... the result of a check, compared
|
|
599
|
+
* $x === 'png', 'png' == $x, $x != 'a' the value against a literal
|
|
600
|
+
*
|
|
601
|
+
* The first family is where preg_match bites: it returns 1, 0, or FALSE on an
|
|
602
|
+
* error (bad UTF-8 under /u, a backtrack limit). `!== 0` and `=== false`
|
|
603
|
+
* treat that error as a pass, so they are not counted at all.
|
|
604
|
+
*/
|
|
605
|
+
function comparisonOutcome(node, language, depth) {
|
|
606
|
+
const operator = operatorOf(node);
|
|
607
|
+
const left = node.childForFieldName('left') ?? namedKids(node)[0] ?? null;
|
|
608
|
+
const right = node.childForFieldName('right') ?? namedKids(node)[1] ?? null;
|
|
609
|
+
if (!left || !right || namedKids(node).length > 2)
|
|
610
|
+
return EMPTY;
|
|
611
|
+
const leftLit = literalOf(left, language);
|
|
612
|
+
const rightLit = literalOf(right, language);
|
|
613
|
+
// Python: `m is not None` / `m is None`
|
|
614
|
+
if (language === 'python' && (operator === 'is not' || operator === 'is') && rightLit?.kind === 'null') {
|
|
615
|
+
const inner = outcomeOf(left, language, depth + 1);
|
|
616
|
+
return operator === 'is not' ? inner : { whenTrue: inner.whenFalse, whenFalse: inner.whenTrue };
|
|
617
|
+
}
|
|
618
|
+
// The result of a check, compared to a constant.
|
|
619
|
+
const lit = rightLit ?? leftLit;
|
|
620
|
+
const other = rightLit ? left : right;
|
|
621
|
+
if (lit && (lit.kind === 'number' || lit.kind === 'bool')) {
|
|
622
|
+
const v = lit.value;
|
|
623
|
+
const isOne = v === '1' || v === 'true';
|
|
624
|
+
const isZero = v === '0' || v === 'false';
|
|
625
|
+
const flipped = !rightLit; // literal on the left: `0 < x` reads the other way
|
|
626
|
+
const op = flipped ? { '>': '<', '<': '>', '>=': '<=', '<=': '>=' }[operator] ?? operator : operator;
|
|
627
|
+
const positive = ((op === '==' || op === '===') && isOne) ||
|
|
628
|
+
((op === '!=' || op === '<>') && isZero) ||
|
|
629
|
+
(op === '>' && v === '0') ||
|
|
630
|
+
(op === '>=' && v === '1');
|
|
631
|
+
const negative = (op === '==' && isZero) ||
|
|
632
|
+
((op === '!=' || op === '<>' || op === '!==') && isOne) ||
|
|
633
|
+
(op === '<' && v === '1');
|
|
634
|
+
if (positive || negative) {
|
|
635
|
+
const inner = outcomeOf(other, language, depth + 1);
|
|
636
|
+
if (inner.whenTrue.size > 0 || inner.whenFalse.size > 0) {
|
|
637
|
+
return positive ? inner : { whenTrue: inner.whenFalse, whenFalse: inner.whenTrue };
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
// Membership: `x in ('a', 'b')`, `x not in [...]`
|
|
642
|
+
if (language === 'python' && (operator === 'in' || operator === 'not in')) {
|
|
643
|
+
const values = arrayLiteralValues(right, language, true);
|
|
644
|
+
if (!values)
|
|
645
|
+
return EMPTY;
|
|
646
|
+
const facts = membershipFacts(left, values, node);
|
|
647
|
+
return operator === 'in' ? { whenTrue: facts, whenFalse: new Map() } : { whenTrue: new Map(), whenFalse: facts };
|
|
648
|
+
}
|
|
649
|
+
// The value against a literal.
|
|
650
|
+
const equal = operator === '===' || operator === '==';
|
|
651
|
+
const notEqual = operator === '!==' || operator === '!=' || operator === '<>';
|
|
652
|
+
if (!equal && !notEqual)
|
|
653
|
+
return EMPTY;
|
|
654
|
+
if (!lit || lit.kind === 'null' || lit.kind === 'bool')
|
|
655
|
+
return EMPTY;
|
|
656
|
+
const strict = operator === '===' || operator === '!==' || language === 'python';
|
|
657
|
+
if (!strict) {
|
|
658
|
+
// Loose equality is exact only against a non-numeric string.
|
|
659
|
+
if (lit.kind !== 'string')
|
|
660
|
+
return EMPTY;
|
|
661
|
+
if (language === 'php' && PHP_NUMERIC_STRING.test(lit.value))
|
|
662
|
+
return EMPTY;
|
|
663
|
+
if ((language === 'javascript' || language === 'typescript') && (lit.value === '' || !Number.isNaN(Number(lit.value))))
|
|
664
|
+
return EMPTY;
|
|
665
|
+
}
|
|
666
|
+
if (literalOf(other, language))
|
|
667
|
+
return EMPTY;
|
|
668
|
+
const facts = membershipFacts(other, [lit.value], node);
|
|
669
|
+
return equal ? { whenTrue: facts, whenFalse: new Map() } : { whenTrue: new Map(), whenFalse: facts };
|
|
670
|
+
}
|
|
671
|
+
/** `'` or `"` written as a one-character Python literal, in any of its spellings. */
|
|
672
|
+
const QUOTE_LITERALS = { [`"'"`]: "'", [`'\\''`]: "'", [`'"'`]: '"', [`"\\""`]: '"' };
|
|
673
|
+
/**
|
|
674
|
+
* `s.startswith(Q) and s.endswith(Q) and Q not in s[1:-1]`, or its negation
|
|
675
|
+
* `not s.startswith(Q) or not s.endswith(Q) or Q in s[1:-1]` (true side leaves).
|
|
676
|
+
*
|
|
677
|
+
* Where it holds, s is exactly one Q-quoted literal with no Q inside, so
|
|
678
|
+
* eval/exec can only produce a str (or a SyntaxError). BenchmarkPython fences
|
|
679
|
+
* eval and exec with it; the engine proved 7 of those safe cases "vulnerable".
|
|
680
|
+
*
|
|
681
|
+
* Recognised WHOLE or not at all - each part alone proves nothing - and on one
|
|
682
|
+
* subject with one quote character.
|
|
683
|
+
*/
|
|
684
|
+
function quotedLiteralFence(node) {
|
|
685
|
+
const operator = operatorOf(node).toLowerCase();
|
|
686
|
+
if (operator !== 'and' && operator !== 'or')
|
|
687
|
+
return null;
|
|
688
|
+
const operands = [];
|
|
689
|
+
const flatten = (n) => {
|
|
690
|
+
if (!n)
|
|
691
|
+
return;
|
|
692
|
+
if (n.type === 'parenthesized_expression')
|
|
693
|
+
return flatten(firstNamed(n));
|
|
694
|
+
if (n.type === 'boolean_operator' && operatorOf(n).toLowerCase() === operator) {
|
|
695
|
+
flatten(n.childForFieldName('left'));
|
|
696
|
+
flatten(n.childForFieldName('right'));
|
|
697
|
+
}
|
|
698
|
+
else
|
|
699
|
+
operands.push(n);
|
|
700
|
+
};
|
|
701
|
+
flatten(node);
|
|
702
|
+
// `and`: every operand true on the true side. `or`: every operand false on the false side.
|
|
703
|
+
const need = operator === 'and';
|
|
704
|
+
const seen = new Map(); // `${subject}|${quote}` -> parts
|
|
705
|
+
for (let operand of operands) {
|
|
706
|
+
let truth = need;
|
|
707
|
+
while (operand.type === 'not_operator' || operand.type === 'parenthesized_expression') {
|
|
708
|
+
if (operand.type === 'not_operator')
|
|
709
|
+
truth = !truth;
|
|
710
|
+
const inner = operand.childForFieldName('argument') ?? firstNamed(operand);
|
|
711
|
+
if (!inner)
|
|
712
|
+
break;
|
|
713
|
+
operand = inner;
|
|
714
|
+
}
|
|
715
|
+
let part = null;
|
|
716
|
+
let subject = '';
|
|
717
|
+
let quote;
|
|
718
|
+
if (operand.type === 'call' && truth) {
|
|
719
|
+
const callee = operand.childForFieldName('function');
|
|
720
|
+
const method = callee?.type === 'attribute' ? callee.childForFieldName('attribute')?.text : undefined;
|
|
721
|
+
const args = callArgs(operand);
|
|
722
|
+
quote = args.length === 1 ? QUOTE_LITERALS[args[0]?.text ?? ''] : undefined;
|
|
723
|
+
if ((method === 'startswith' || method === 'endswith') && quote) {
|
|
724
|
+
part = method;
|
|
725
|
+
subject = normalizeExpression(callee?.childForFieldName('object')?.text ?? '');
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
else if (operand.type === 'comparison_operator') {
|
|
729
|
+
const op = operatorOf(operand);
|
|
730
|
+
const [left, right] = namedKids(operand);
|
|
731
|
+
quote = QUOTE_LITERALS[left?.text ?? ''];
|
|
732
|
+
// `Q in s[1:-1]` must come out false; `Q not in s[1:-1]` true.
|
|
733
|
+
if (quote && right?.type === 'subscript' && ((op === 'in' && !truth) || (op === 'not in' && truth))) {
|
|
734
|
+
if (normalizeExpression(right.text.slice(right.childForFieldName('value')?.text.length ?? 0)) === '[1:-1]') {
|
|
735
|
+
part = 'inner';
|
|
736
|
+
subject = normalizeExpression(right.childForFieldName('value')?.text ?? '');
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
if (!part || !subject || !quote)
|
|
741
|
+
continue;
|
|
742
|
+
const key = `${subject}|${quote}`;
|
|
743
|
+
if (!seen.has(key))
|
|
744
|
+
seen.set(key, new Set());
|
|
745
|
+
seen.get(key).add(part);
|
|
746
|
+
}
|
|
747
|
+
for (const [key, parts] of seen) {
|
|
748
|
+
if (parts.size !== 3)
|
|
749
|
+
continue;
|
|
750
|
+
const facts = new Map([[key.slice(0, key.lastIndexOf('|')), { strength: 'quoted-literal', check: node }]]);
|
|
751
|
+
return need ? { whenTrue: facts, whenFalse: new Map() } : { whenTrue: new Map(), whenFalse: facts };
|
|
752
|
+
}
|
|
753
|
+
return null;
|
|
754
|
+
}
|
|
755
|
+
const outcomeCache = new WeakMap();
|
|
756
|
+
/** What is known about values when this condition came out true, and when false. */
|
|
757
|
+
function outcomeOf(node, language, depth = 0) {
|
|
758
|
+
if (!node || depth > 40)
|
|
759
|
+
return EMPTY;
|
|
760
|
+
let perTree = outcomeCache.get(node.tree);
|
|
761
|
+
if (!perTree) {
|
|
762
|
+
perTree = new Map();
|
|
763
|
+
outcomeCache.set(node.tree, perTree);
|
|
764
|
+
}
|
|
765
|
+
const cached = perTree.get(node.id);
|
|
766
|
+
if (cached)
|
|
767
|
+
return cached;
|
|
768
|
+
const result = computeOutcome(node, language, depth);
|
|
769
|
+
perTree.set(node.id, result);
|
|
770
|
+
return result;
|
|
771
|
+
}
|
|
772
|
+
function computeOutcome(node, language, depth) {
|
|
773
|
+
switch (node.type) {
|
|
774
|
+
case 'parenthesized_expression':
|
|
775
|
+
return outcomeOf(firstNamed(node), language, depth + 1);
|
|
776
|
+
case 'unary_op_expression': // PHP
|
|
777
|
+
case 'unary_expression': { // JS
|
|
778
|
+
const operator = node.childForFieldName('operator')?.text ?? (node.text ?? '').trim().charAt(0);
|
|
779
|
+
if (operator.trim() !== '!')
|
|
780
|
+
return EMPTY;
|
|
781
|
+
const inner = outcomeOf(node.childForFieldName('argument') ?? firstNamed(node), language, depth + 1);
|
|
782
|
+
return { whenTrue: inner.whenFalse, whenFalse: inner.whenTrue };
|
|
783
|
+
}
|
|
784
|
+
case 'not_operator': { // Python
|
|
785
|
+
const inner = outcomeOf(node.childForFieldName('argument') ?? firstNamed(node), language, depth + 1);
|
|
786
|
+
return { whenTrue: inner.whenFalse, whenFalse: inner.whenTrue };
|
|
787
|
+
}
|
|
788
|
+
case 'binary_expression':
|
|
789
|
+
case 'boolean_operator':
|
|
790
|
+
case 'comparison_operator': {
|
|
791
|
+
const fence = language === 'python' ? quotedLiteralFence(node) : null;
|
|
792
|
+
if (fence)
|
|
793
|
+
return fence;
|
|
794
|
+
const operator = operatorOf(node).toLowerCase();
|
|
795
|
+
if (operator === '&&' || operator === 'and') {
|
|
796
|
+
const a = outcomeOf(node.childForFieldName('left'), language, depth + 1);
|
|
797
|
+
const b = outcomeOf(node.childForFieldName('right'), language, depth + 1);
|
|
798
|
+
return { whenTrue: joinStrong(a.whenTrue, b.whenTrue), whenFalse: meetWeak(a.whenFalse, b.whenFalse, node) };
|
|
799
|
+
}
|
|
800
|
+
if (operator === '||' || operator === 'or') {
|
|
801
|
+
const a = outcomeOf(node.childForFieldName('left'), language, depth + 1);
|
|
802
|
+
const b = outcomeOf(node.childForFieldName('right'), language, depth + 1);
|
|
803
|
+
return { whenTrue: meetWeak(a.whenTrue, b.whenTrue, node), whenFalse: joinStrong(a.whenFalse, b.whenFalse) };
|
|
804
|
+
}
|
|
805
|
+
return comparisonOutcome(node, language, depth);
|
|
806
|
+
}
|
|
807
|
+
default:
|
|
808
|
+
if (CALL_TYPES.has(node.type))
|
|
809
|
+
return { whenTrue: callFacts(node, language), whenFalse: new Map() };
|
|
810
|
+
return EMPTY;
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
/* ------------------------------------------------------------------------ */
|
|
814
|
+
/* Where a fact holds, and whether the value is written while it does */
|
|
815
|
+
/* ------------------------------------------------------------------------ */
|
|
816
|
+
const FUNCTION_TYPES = new Set([
|
|
817
|
+
'function_definition', 'method_declaration', 'anonymous_function_creation_expression', 'anonymous_function',
|
|
818
|
+
'arrow_function', 'function_declaration', 'function_expression', 'method_definition',
|
|
819
|
+
'generator_function_declaration', 'generator_function', 'lambda',
|
|
820
|
+
]);
|
|
821
|
+
const IF_TYPES = new Set(['if_statement']);
|
|
822
|
+
const ALTERNATIVE_TYPES = new Set(['else_if_clause', 'else_clause', 'elif_clause']);
|
|
823
|
+
const TERNARY_TYPES = new Set(['conditional_expression', 'ternary_expression']);
|
|
824
|
+
const BLOCK_TYPES = new Set(['compound_statement', 'statement_block', 'block', 'program', 'module']);
|
|
825
|
+
const EXIT_TYPES = new Set(['exit_statement', 'return_statement', 'throw_statement', 'raise_statement', 'break_statement', 'continue_statement']);
|
|
826
|
+
const WRITE_TYPES = new Set([
|
|
827
|
+
'assignment_expression', 'augmented_assignment_expression', 'reference_assignment_expression',
|
|
828
|
+
'variable_declarator', 'assignment', 'augmented_assignment', 'named_expression', 'update_expression',
|
|
829
|
+
]);
|
|
830
|
+
const writesCache = new WeakMap();
|
|
831
|
+
function writesIn(region) {
|
|
832
|
+
let perTree = writesCache.get(region.tree);
|
|
833
|
+
if (!perTree) {
|
|
834
|
+
perTree = new Map();
|
|
835
|
+
writesCache.set(region.tree, perTree);
|
|
836
|
+
}
|
|
837
|
+
const cached = perTree.get(region.id);
|
|
838
|
+
if (cached)
|
|
839
|
+
return cached;
|
|
840
|
+
const targets = [];
|
|
841
|
+
let anything = false;
|
|
842
|
+
const visit = (n) => {
|
|
843
|
+
if (WRITE_TYPES.has(n.type)) {
|
|
844
|
+
const target = n.childForFieldName('left') ?? n.childForFieldName('name') ?? n.childForFieldName('argument') ?? firstNamed(n);
|
|
845
|
+
if (target?.text) {
|
|
846
|
+
if (target.type === 'dynamic_variable_name' || target.text.startsWith('$$'))
|
|
847
|
+
anything = true;
|
|
848
|
+
targets.push(normalizeExpression(target.text));
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
if (n.type === 'foreach_statement') {
|
|
852
|
+
// Everything bound by `as $k => $v`, which sits between the iterable and the body.
|
|
853
|
+
const kids = namedKids(n);
|
|
854
|
+
for (const k of kids.slice(1)) {
|
|
855
|
+
if (k.type === 'compound_statement' || k.type === 'colon_block')
|
|
856
|
+
continue;
|
|
857
|
+
targets.push(normalizeExpression(k.text ?? ''));
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
if (n.type === 'as_pattern') {
|
|
861
|
+
// Python `with open(p) as f`, `except E as e`: the name after `as`.
|
|
862
|
+
const alias = namedKids(n).pop();
|
|
863
|
+
if (alias?.text)
|
|
864
|
+
targets.push(normalizeExpression(alias.text));
|
|
865
|
+
}
|
|
866
|
+
if (n.type === 'for_in_statement' || n.type === 'for_statement') {
|
|
867
|
+
const left = n.childForFieldName('left');
|
|
868
|
+
if (left?.text)
|
|
869
|
+
targets.push(normalizeExpression(left.text));
|
|
870
|
+
}
|
|
871
|
+
if (n.type === 'global_declaration' || n.type === 'static_variable_declaration' || n.type === 'global_statement' || n.type === 'nonlocal_statement') {
|
|
872
|
+
targets.push(normalizeExpression(n.text ?? ''));
|
|
873
|
+
}
|
|
874
|
+
if (n.type === 'function_call_expression') {
|
|
875
|
+
const name = (n.childForFieldName('function')?.text ?? '').toLowerCase();
|
|
876
|
+
const args = callArgs(n);
|
|
877
|
+
if (name === 'extract' || (name === 'parse_str' && args.length < 2))
|
|
878
|
+
anything = true;
|
|
879
|
+
// Out-parameters PHP writes through a reference.
|
|
880
|
+
const outIndex = { parse_str: 1, preg_match: 2, preg_match_all: 2, settype: 0, sscanf: 2 };
|
|
881
|
+
const at = outIndex[name];
|
|
882
|
+
if (at !== undefined)
|
|
883
|
+
for (const a of args.slice(at))
|
|
884
|
+
targets.push(normalizeExpression(a.text ?? ''));
|
|
885
|
+
}
|
|
886
|
+
for (const c of namedKids(n))
|
|
887
|
+
visit(c);
|
|
888
|
+
};
|
|
889
|
+
visit(region);
|
|
890
|
+
const result = { targets, anything };
|
|
891
|
+
perTree.set(region.id, result);
|
|
892
|
+
return result;
|
|
893
|
+
}
|
|
894
|
+
const isIdentChar = (c) => c !== undefined && /[\w$]/.test(c);
|
|
895
|
+
/** Does a write to `target` change the value written `text`? */
|
|
896
|
+
function touches(target, text) {
|
|
897
|
+
if (target === text)
|
|
898
|
+
return true;
|
|
899
|
+
for (const sep of ['[', '->', '.']) {
|
|
900
|
+
if (text.startsWith(target + sep) || target.startsWith(text + sep))
|
|
901
|
+
return true;
|
|
902
|
+
}
|
|
903
|
+
// `[$a, $b] = ...`, `list($a) = ...`, `global $a;`, `obj.x = ...` - a whole
|
|
904
|
+
// token inside the target counts. Over-cautious by design.
|
|
905
|
+
let from = 0;
|
|
906
|
+
while (true) {
|
|
907
|
+
const at = target.indexOf(text, from);
|
|
908
|
+
if (at === -1)
|
|
909
|
+
return false;
|
|
910
|
+
if (!isIdentChar(target[at - 1]) || text.startsWith('$')) {
|
|
911
|
+
if (!isIdentChar(target[at + text.length]))
|
|
912
|
+
return true;
|
|
913
|
+
}
|
|
914
|
+
from = at + 1;
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
function writtenIn(regions, text) {
|
|
918
|
+
for (const region of regions) {
|
|
919
|
+
const w = writesIn(region);
|
|
920
|
+
if (w.anything)
|
|
921
|
+
return true;
|
|
922
|
+
if (w.targets.some((t) => touches(t, text)))
|
|
923
|
+
return true;
|
|
924
|
+
}
|
|
925
|
+
return false;
|
|
926
|
+
}
|
|
927
|
+
/** The then-branch: PHP calls it `body`, JavaScript and Python `consequence`. */
|
|
928
|
+
const thenBranch = (statement) => statement.childForFieldName('consequence') ?? statement.childForFieldName('body');
|
|
929
|
+
/** Does this branch always leave - its last statement an exit, return, throw, break or continue? */
|
|
930
|
+
function alwaysLeaves(branch) {
|
|
931
|
+
if (!branch)
|
|
932
|
+
return false;
|
|
933
|
+
const last = BLOCK_TYPES.has(branch.type) || branch.type === 'colon_block' ? namedKids(branch).filter((k) => k.type !== 'comment').pop() ?? null : branch;
|
|
934
|
+
if (!last)
|
|
935
|
+
return false;
|
|
936
|
+
if (EXIT_TYPES.has(last.type))
|
|
937
|
+
return true;
|
|
938
|
+
// PHP: `die(...)`, `exit(...)` and `throw` parse inside an expression statement.
|
|
939
|
+
const inner = last.type === 'expression_statement' ? firstNamed(last) : null;
|
|
940
|
+
if (inner?.type === 'throw_expression')
|
|
941
|
+
return true;
|
|
942
|
+
if (inner?.type === 'exit_statement')
|
|
943
|
+
return true;
|
|
944
|
+
if (inner?.type === 'function_call_expression') {
|
|
945
|
+
const name = (inner.childForFieldName('function')?.text ?? '').toLowerCase();
|
|
946
|
+
if (name === 'die' || name === 'exit')
|
|
947
|
+
return true;
|
|
948
|
+
}
|
|
949
|
+
return false;
|
|
950
|
+
}
|
|
951
|
+
function add(into, facts, regions) {
|
|
952
|
+
for (const [text, fact] of facts) {
|
|
953
|
+
if (writtenIn(regions, text))
|
|
954
|
+
continue;
|
|
955
|
+
const had = into.get(text);
|
|
956
|
+
into.set(text, had ? stronger(had, fact) : fact);
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
/**
|
|
960
|
+
* Every value a check proves something about, at this node. Keys are
|
|
961
|
+
* normalised expression texts (see normalizeExpression).
|
|
962
|
+
*/
|
|
963
|
+
export function guardFactsAt(node, language) {
|
|
964
|
+
const facts = new Map();
|
|
965
|
+
if (!GUARD_LANGUAGES.has(language))
|
|
966
|
+
return facts;
|
|
967
|
+
let child = node;
|
|
968
|
+
let current = node.parent;
|
|
969
|
+
while (current) {
|
|
970
|
+
if (FUNCTION_TYPES.has(current.type))
|
|
971
|
+
break;
|
|
972
|
+
if (IF_TYPES.has(current.type)) {
|
|
973
|
+
const condition = current.childForFieldName('condition');
|
|
974
|
+
const then = thenBranch(current);
|
|
975
|
+
if (condition) {
|
|
976
|
+
const outcome = outcomeOf(condition, language);
|
|
977
|
+
if (then && then.id === child.id) {
|
|
978
|
+
add(facts, outcome.whenTrue, [current]);
|
|
979
|
+
}
|
|
980
|
+
else {
|
|
981
|
+
const alternatives = namedKids(current).filter((k) => ALTERNATIVE_TYPES.has(k.type));
|
|
982
|
+
if (alternatives.some((a) => a.id === child.id)) {
|
|
983
|
+
add(facts, outcome.whenFalse, [current]);
|
|
984
|
+
for (const alt of alternatives) {
|
|
985
|
+
const altCondition = alt.childForFieldName('condition');
|
|
986
|
+
if (alt.id === child.id) {
|
|
987
|
+
const altBody = thenBranch(alt);
|
|
988
|
+
if (altCondition && altBody && contains(altBody, node)) {
|
|
989
|
+
add(facts, outcomeOf(altCondition, language).whenTrue, [current]);
|
|
990
|
+
}
|
|
991
|
+
break;
|
|
992
|
+
}
|
|
993
|
+
if (altCondition)
|
|
994
|
+
add(facts, outcomeOf(altCondition, language).whenFalse, [current]);
|
|
995
|
+
}
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
if (TERNARY_TYPES.has(current.type)) {
|
|
1001
|
+
const condition = current.childForFieldName('condition');
|
|
1002
|
+
const yes = current.childForFieldName('consequence') ?? current.childForFieldName('body');
|
|
1003
|
+
const no = current.childForFieldName('alternative');
|
|
1004
|
+
if (condition) {
|
|
1005
|
+
const outcome = outcomeOf(condition, language);
|
|
1006
|
+
if (yes && yes.id === child.id)
|
|
1007
|
+
add(facts, outcome.whenTrue, [current]);
|
|
1008
|
+
else if (no && no.id === child.id)
|
|
1009
|
+
add(facts, outcome.whenFalse, [current]);
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
// An earlier `if (!CHECK) { exit; }` in the same block.
|
|
1013
|
+
if (BLOCK_TYPES.has(current.type)) {
|
|
1014
|
+
const siblings = namedKids(current);
|
|
1015
|
+
const index = siblings.findIndex((s) => s.id === child.id);
|
|
1016
|
+
for (let i = 0; i < index; i++) {
|
|
1017
|
+
const s = siblings[i];
|
|
1018
|
+
if (!IF_TYPES.has(s.type))
|
|
1019
|
+
continue;
|
|
1020
|
+
const condition = s.childForFieldName('condition');
|
|
1021
|
+
if (!condition || !alwaysLeaves(thenBranch(s)))
|
|
1022
|
+
continue;
|
|
1023
|
+
add(facts, outcomeOf(condition, language).whenFalse, siblings.slice(i));
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
child = current;
|
|
1027
|
+
current = current.parent;
|
|
1028
|
+
}
|
|
1029
|
+
return facts;
|
|
1030
|
+
}
|
|
1031
|
+
/* ------------------------------------------------------------------------ */
|
|
1032
|
+
/* The signature rules' entry point */
|
|
1033
|
+
/* ------------------------------------------------------------------------ */
|
|
1034
|
+
/** A fact a signature rule may use for this sink: word needs quotes for SQL, and a text rule does not know them. */
|
|
1035
|
+
const usableFor = (fact, kind) => {
|
|
1036
|
+
const clears = SAFE_KINDS[fact.strength];
|
|
1037
|
+
return clears.kinds.includes(kind) && !clears.insideQuotesOnly.includes(kind);
|
|
1038
|
+
};
|
|
1039
|
+
/**
|
|
1040
|
+
* Are ALL the spliced parts of this expression proven safe by a check?
|
|
1041
|
+
*
|
|
1042
|
+
* Every part must be covered. That is deliberate and it is the lesson from the
|
|
1043
|
+
* Jenkins escaper bug, where a sanitiser covering ONE value in an expression
|
|
1044
|
+
* was allowed to vouch for a DIFFERENT value beside it. Here, one unchecked
|
|
1045
|
+
* part means the whole expression still reports:
|
|
1046
|
+
*
|
|
1047
|
+
* if (is_numeric($port)) { shell_exec('nc ' . $host . ' ' . $port); }
|
|
1048
|
+
* ^^^^^ never checked
|
|
1049
|
+
*
|
|
1050
|
+
* One level of local indirection is followed, because the shape that matters
|
|
1051
|
+
* rebuilds the value inside the branch:
|
|
1052
|
+
*
|
|
1053
|
+
* $target = $octet[0] . '.' . $octet[1]; <- every piece checked here
|
|
1054
|
+
* shell_exec('ping ' . $target); <- and used here
|
|
1055
|
+
*
|
|
1056
|
+
* A name counts when its value here certainly comes from one assignment: the
|
|
1057
|
+
* last plain `name = ...` before this point, sitting directly in a block that
|
|
1058
|
+
* also holds this point, with no other write to the name from that assignment
|
|
1059
|
+
* to the end of the statement this point is in (so a loop cannot bring a
|
|
1060
|
+
* different value round). And every piece of that one value was checked where
|
|
1061
|
+
* it was assigned. DVWA writes `$target` twice - once from the request, once
|
|
1062
|
+
* rebuilt from checked octets - so "assigned once in the function" was too
|
|
1063
|
+
* strict, and "assigned somewhere in the branch" (the first version) never
|
|
1064
|
+
* asked whether a later write replaced it.
|
|
1065
|
+
*/
|
|
1066
|
+
export function partsAreGuarded(node, dynamicParts, language, kind) {
|
|
1067
|
+
if (dynamicParts.length === 0)
|
|
1068
|
+
return false;
|
|
1069
|
+
if (!GUARD_LANGUAGES.has(language))
|
|
1070
|
+
return false;
|
|
1071
|
+
const facts = guardFactsAt(node, language);
|
|
1072
|
+
for (const raw of dynamicParts) {
|
|
1073
|
+
const part = normalizeExpression(raw);
|
|
1074
|
+
const fact = facts.get(part);
|
|
1075
|
+
if (fact && usableFor(fact, kind))
|
|
1076
|
+
continue;
|
|
1077
|
+
if (!definedFromCheckedParts(node, part, language, kind))
|
|
1078
|
+
return false;
|
|
1079
|
+
}
|
|
1080
|
+
return true;
|
|
1081
|
+
}
|
|
1082
|
+
function definedFromCheckedParts(node, name, language, kind) {
|
|
1083
|
+
if (!/^\$?[A-Za-z_][\w]*$/.test(name))
|
|
1084
|
+
return false;
|
|
1085
|
+
let scope = node.parent;
|
|
1086
|
+
while (scope && !FUNCTION_TYPES.has(scope.type) && scope.parent)
|
|
1087
|
+
scope = scope.parent;
|
|
1088
|
+
if (!scope)
|
|
1089
|
+
return false;
|
|
1090
|
+
// Every write to the name in the function, in source order.
|
|
1091
|
+
const writes = [];
|
|
1092
|
+
const visit = (n) => {
|
|
1093
|
+
if (WRITE_TYPES.has(n.type)) {
|
|
1094
|
+
const target = n.childForFieldName('left') ?? n.childForFieldName('name') ?? n.childForFieldName('argument');
|
|
1095
|
+
if (target?.text && touches(normalizeExpression(target.text), name))
|
|
1096
|
+
writes.push(n);
|
|
1097
|
+
}
|
|
1098
|
+
for (const c of namedKids(n))
|
|
1099
|
+
visit(c);
|
|
1100
|
+
};
|
|
1101
|
+
visit(scope);
|
|
1102
|
+
const before = writes.filter((w) => w.endIndex <= node.startIndex);
|
|
1103
|
+
const definition = before[before.length - 1];
|
|
1104
|
+
if (!definition)
|
|
1105
|
+
return false;
|
|
1106
|
+
const target = definition.childForFieldName('left') ?? definition.childForFieldName('name');
|
|
1107
|
+
const value = definition.childForFieldName('right') ?? definition.childForFieldName('value');
|
|
1108
|
+
const plain = definition.type === 'assignment_expression' || definition.type === 'assignment' || definition.type === 'variable_declarator';
|
|
1109
|
+
if (!plain || !value || !target || normalizeExpression(target.text ?? '') !== name)
|
|
1110
|
+
return false;
|
|
1111
|
+
// The assignment's statement must sit directly in a block that holds this point.
|
|
1112
|
+
let statement = definition;
|
|
1113
|
+
while (statement.parent && !BLOCK_TYPES.has(statement.parent.type))
|
|
1114
|
+
statement = statement.parent;
|
|
1115
|
+
const block = statement.parent;
|
|
1116
|
+
if (!block || !contains(block, node) || statement.endIndex > node.startIndex)
|
|
1117
|
+
return false;
|
|
1118
|
+
let pointStatement = node;
|
|
1119
|
+
while (pointStatement.parent && pointStatement.parent.id !== block.id)
|
|
1120
|
+
pointStatement = pointStatement.parent;
|
|
1121
|
+
if (pointStatement.parent?.id !== block.id)
|
|
1122
|
+
return false;
|
|
1123
|
+
// Nothing else writes it - by assignment, foreach, global, out-parameter or
|
|
1124
|
+
// extract() - between the assignment and the end of this point's statement.
|
|
1125
|
+
if (writes.some((w) => w.id !== definition.id && w.startIndex > definition.startIndex && w.startIndex < pointStatement.endIndex))
|
|
1126
|
+
return false;
|
|
1127
|
+
const between = namedKids(block).filter((s) => s.startIndex >= statement.endIndex && s.startIndex <= pointStatement.startIndex);
|
|
1128
|
+
for (const s of between) {
|
|
1129
|
+
const w = writesIn(s);
|
|
1130
|
+
if (w.anything || w.targets.some((t) => touches(t, name)))
|
|
1131
|
+
return false;
|
|
1132
|
+
}
|
|
1133
|
+
const pieces = leafExpressions(value);
|
|
1134
|
+
if (pieces.length === 0)
|
|
1135
|
+
return false;
|
|
1136
|
+
const factsThere = guardFactsAt(value, language);
|
|
1137
|
+
return pieces.every((piece) => {
|
|
1138
|
+
const fact = factsThere.get(normalizeExpression(piece));
|
|
1139
|
+
return fact !== undefined && usableFor(fact, kind);
|
|
1140
|
+
});
|
|
1141
|
+
}
|
|
1142
|
+
/**
|
|
1143
|
+
* The pieces of an expression that could carry a value. String literal TEXT
|
|
1144
|
+
* is skipped, but what is interpolated INTO a string is not - the first
|
|
1145
|
+
* version of this skipped every node with "string" in its type, so
|
|
1146
|
+
* `"... '$b'"` had no pieces at all and a check on a different value vouched
|
|
1147
|
+
* for it. A call is one opaque piece: what it returns is not something a
|
|
1148
|
+
* check on its arguments describes.
|
|
1149
|
+
*/
|
|
1150
|
+
function leafExpressions(node) {
|
|
1151
|
+
const out = [];
|
|
1152
|
+
const walk = (n) => {
|
|
1153
|
+
if (!n)
|
|
1154
|
+
return;
|
|
1155
|
+
if (n.type === 'integer' || n.type === 'float' || n.type === 'number' || n.type === 'boolean')
|
|
1156
|
+
return;
|
|
1157
|
+
if (n.type === 'string_content' || n.type === 'string_value' || n.type === 'string_fragment' || n.type === 'escape_sequence')
|
|
1158
|
+
return;
|
|
1159
|
+
if (CALL_TYPES.has(n.type)) {
|
|
1160
|
+
out.push(n.text ?? '');
|
|
1161
|
+
return;
|
|
1162
|
+
}
|
|
1163
|
+
if (n.type === 'variable_name' ||
|
|
1164
|
+
n.type === 'identifier' ||
|
|
1165
|
+
n.type === 'subscript_expression' ||
|
|
1166
|
+
n.type === 'member_access_expression' ||
|
|
1167
|
+
n.type === 'member_expression' ||
|
|
1168
|
+
n.type === 'subscript' ||
|
|
1169
|
+
n.type === 'attribute') {
|
|
1170
|
+
out.push((n.text ?? '').trim());
|
|
1171
|
+
return; // do not descend - `$octet[0]` is the unit a check names
|
|
1172
|
+
}
|
|
1173
|
+
for (const c of namedKids(n))
|
|
1174
|
+
walk(c);
|
|
1175
|
+
};
|
|
1176
|
+
walk(node);
|
|
1177
|
+
return out;
|
|
1178
|
+
}
|
|
1179
|
+
//# sourceMappingURL=guards.js.map
|