@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,2034 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* STRING BUILDING ANALYSIS
|
|
3
|
+
* ========================
|
|
4
|
+
*
|
|
5
|
+
* Almost every injection bug in this tool's catalogue has the same skeleton:
|
|
6
|
+
* a string was ASSEMBLED at runtime and then handed to something that treats
|
|
7
|
+
* strings as instructions (a database, a shell, a browser).
|
|
8
|
+
*
|
|
9
|
+
* So the one question we ask over and over is: "was this expression a fixed
|
|
10
|
+
* piece of text, or was it stitched together from parts?" This file answers it
|
|
11
|
+
* for all five languages, and it is the piece of shared machinery that lets one
|
|
12
|
+
* rule cover JavaScript, Python, Java and Go at once.
|
|
13
|
+
*
|
|
14
|
+
* VOCABULARY YOU'LL NEED:
|
|
15
|
+
*
|
|
16
|
+
* Sanitisation - cleaning or neutralising untrusted input before use. For SQL
|
|
17
|
+
* the real fix is not cleaning at all but PARAMETERISATION: you hand the
|
|
18
|
+
* database the query and the values separately, so the values can never be
|
|
19
|
+
* read as commands. For HTML it means escaping < > & " so text is displayed
|
|
20
|
+
* rather than executed.
|
|
21
|
+
*
|
|
22
|
+
* Interpolation - languages' built-in "put this value inside this string"
|
|
23
|
+
* syntax: `${x}` in JS, f"{x}" in Python, fmt.Sprintf("%s", x) in Go.
|
|
24
|
+
* To a database or a shell it is identical to concatenation. Feeling
|
|
25
|
+
* modern does not make it safe.
|
|
26
|
+
*
|
|
27
|
+
* PHASE 1 HONESTY NOTE: knowing a string was built dynamically does NOT mean
|
|
28
|
+
* the parts came from an attacker. `"SELECT * FROM " + TABLE_NAME` with a
|
|
29
|
+
* hard-coded constant is fine. We cannot tell the difference without data-flow
|
|
30
|
+
* analysis, which is Phase 3. That is exactly why these findings are labelled
|
|
31
|
+
* signature-based, and why every one of them says so in its `limitations`.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import type { Node } from 'web-tree-sitter';
|
|
35
|
+
import type { LanguageId } from '../../parse/languages.js';
|
|
36
|
+
import { SWITCH_NODES, createFolder, type Folder } from '../../taint/fold.js';
|
|
37
|
+
import type { StaticMethodLookup } from '../contract.js';
|
|
38
|
+
import { declaredImmutable, enclosingMethod, resolveContainerRead } from '../../taint/local-map.js';
|
|
39
|
+
import {
|
|
40
|
+
boundElsewhere,
|
|
41
|
+
enclosingClassLikes,
|
|
42
|
+
hasModifier,
|
|
43
|
+
isClassLike,
|
|
44
|
+
supertypesOf,
|
|
45
|
+
usedOnlyAsQualifier,
|
|
46
|
+
} from '../../taint/java-scope.js';
|
|
47
|
+
import { declarationVisibility, hidesCodeFromParser } from '../../taint/visibility.js';
|
|
48
|
+
|
|
49
|
+
/** Node types that ARE a string literal, per language. */
|
|
50
|
+
const STRING_LITERAL_TYPES: Record<LanguageId, readonly string[]> = {
|
|
51
|
+
javascript: ['string', 'template_string'],
|
|
52
|
+
typescript: ['string', 'template_string'],
|
|
53
|
+
python: ['string', 'concatenated_string'],
|
|
54
|
+
java: ['string_literal', 'text_block'],
|
|
55
|
+
go: ['interpreted_string_literal', 'raw_string_literal'],
|
|
56
|
+
// C has one string node type and no interpolation at all, which is why every
|
|
57
|
+
// dangerous C string is BUILT - by sprintf, strcat or std::string's operator+
|
|
58
|
+
// - rather than interpolated in place. `concatenated_string` is adjacent
|
|
59
|
+
// literals ("a" "b"), which C joins at compile time.
|
|
60
|
+
c: ['string_literal', 'concatenated_string', 'char_literal'],
|
|
61
|
+
cpp: ['string_literal', 'concatenated_string', 'char_literal', 'raw_string_literal'],
|
|
62
|
+
// PHP's `string` is single-quoted (never interpolates). `encapsed_string` is
|
|
63
|
+
// double-quoted and CAN interpolate, which is why it appears in both this
|
|
64
|
+
// list and INTERPOLATION_TYPES - isStringLiteral checks for a spliced-in
|
|
65
|
+
// value before calling it a literal.
|
|
66
|
+
php: ['string', 'encapsed_string', 'heredoc'],
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
/** Node types that mean "a value was spliced into this string". */
|
|
70
|
+
const INTERPOLATION_TYPES = new Set([
|
|
71
|
+
'template_substitution', // JS/TS `... ${x} ...`
|
|
72
|
+
'interpolation', // Python f"...{x}..."
|
|
73
|
+
// PHP splices a variable straight into a double-quoted string with no wrapper
|
|
74
|
+
// node: "id = $id" holds a `variable_name` child directly. The name exists
|
|
75
|
+
// only in the PHP grammar, so listing it here cannot affect another language.
|
|
76
|
+
'variable_name', // PHP "... $x ..." and "... {$x} ..."
|
|
77
|
+
]);
|
|
78
|
+
|
|
79
|
+
/** Node types holding the plain-text chunks inside a string literal. */
|
|
80
|
+
const STRING_CONTENT_TYPES = new Set([
|
|
81
|
+
'string_fragment',
|
|
82
|
+
'string_content',
|
|
83
|
+
'interpreted_string_literal_content',
|
|
84
|
+
'raw_string_literal_content',
|
|
85
|
+
]);
|
|
86
|
+
|
|
87
|
+
/** Function names that build a string from a template plus values. */
|
|
88
|
+
const FORMAT_FUNCTIONS = new Set([
|
|
89
|
+
'format', // Python "...".format(), Java String.format
|
|
90
|
+
'sprintf',
|
|
91
|
+
'Sprintf', // Go fmt.Sprintf
|
|
92
|
+
'Sprintln',
|
|
93
|
+
'Fprintf',
|
|
94
|
+
'printf',
|
|
95
|
+
'Printf',
|
|
96
|
+
'join', // "".join([...]) and String.join
|
|
97
|
+
'concat',
|
|
98
|
+
'append', // Java StringBuilder.append chains
|
|
99
|
+
'valueOf',
|
|
100
|
+
]);
|
|
101
|
+
|
|
102
|
+
export type BuildMechanism =
|
|
103
|
+
| 'literal' // a fixed string, nothing spliced in
|
|
104
|
+
| 'concatenation' // built with + (or % in Python)
|
|
105
|
+
| 'interpolation' // built with `${}` / f-strings
|
|
106
|
+
| 'format-call' // built with Sprintf / .format() / String.format
|
|
107
|
+
| 'opaque'; // a variable or call - we cannot see the text at all
|
|
108
|
+
|
|
109
|
+
export interface StringExpression {
|
|
110
|
+
/** True when the text is not fully known at write time. */
|
|
111
|
+
readonly isDynamic: boolean;
|
|
112
|
+
readonly mechanism: BuildMechanism;
|
|
113
|
+
/** The fixed parts we could read, joined. Empty when the whole thing is opaque. */
|
|
114
|
+
readonly literalText: string;
|
|
115
|
+
/** The expressions spliced in. Reported so a human can judge them. */
|
|
116
|
+
readonly dynamicParts: readonly string[];
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Node types that can be the TOP of a string-building expression.
|
|
121
|
+
*
|
|
122
|
+
* This gate exists because of a false positive found by pointing the scanner at
|
|
123
|
+
* its own source code. `analyzeStringExpression` walks a whole subtree and
|
|
124
|
+
* concatenates every string literal it finds. Handed an object literal like
|
|
125
|
+
*
|
|
126
|
+
* export const sqlInjectionRule: Rule = {
|
|
127
|
+
* name: 'SQL query assembled from a built string',
|
|
128
|
+
* explanation: '... the query and the values ... update ... table ...',
|
|
129
|
+
* ...
|
|
130
|
+
* };
|
|
131
|
+
*
|
|
132
|
+
* it happily merged prose from a dozen unrelated fields into one blob, and
|
|
133
|
+
* `looksLikeSql()` found "select", "from" and "where" scattered through it.
|
|
134
|
+
* The scanner reported its own rule definitions as SQL injection.
|
|
135
|
+
*
|
|
136
|
+
* The lesson generalises: an analysis is only as good as the SCOPE it runs on.
|
|
137
|
+
* Before asking "does this text look like SQL?", make sure the thing you are
|
|
138
|
+
* looking at is a single string expression - not a container full of unrelated
|
|
139
|
+
* strings that happen to sit next to each other.
|
|
140
|
+
*/
|
|
141
|
+
const STRING_EXPRESSION_TYPES: Record<LanguageId, readonly string[]> = {
|
|
142
|
+
javascript: ['string', 'template_string', 'binary_expression', 'call_expression'],
|
|
143
|
+
typescript: ['string', 'template_string', 'binary_expression', 'call_expression'],
|
|
144
|
+
python: ['string', 'concatenated_string', 'binary_operator', 'call'],
|
|
145
|
+
java: ['string_literal', 'text_block', 'binary_expression', 'method_invocation'],
|
|
146
|
+
go: [
|
|
147
|
+
'interpreted_string_literal',
|
|
148
|
+
'raw_string_literal',
|
|
149
|
+
'binary_expression',
|
|
150
|
+
'call_expression',
|
|
151
|
+
],
|
|
152
|
+
php: [
|
|
153
|
+
'string',
|
|
154
|
+
'encapsed_string',
|
|
155
|
+
'heredoc',
|
|
156
|
+
'binary_expression', // PHP concatenates with `.`
|
|
157
|
+
'function_call_expression',
|
|
158
|
+
'member_call_expression',
|
|
159
|
+
],
|
|
160
|
+
// In C a "string expression" is nearly always a CALL - sprintf or strcat -
|
|
161
|
+
// rather than an operator, because char arrays do not concatenate with `+`.
|
|
162
|
+
c: ['string_literal', 'concatenated_string', 'binary_expression', 'call_expression'],
|
|
163
|
+
// C++ gets the operator back via std::string, so binary_expression matters
|
|
164
|
+
// here in a way it does not in C.
|
|
165
|
+
cpp: [
|
|
166
|
+
'string_literal',
|
|
167
|
+
'concatenated_string',
|
|
168
|
+
'raw_string_literal',
|
|
169
|
+
'binary_expression',
|
|
170
|
+
'call_expression',
|
|
171
|
+
],
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* True when this node could plausibly evaluate to a string that was assembled.
|
|
176
|
+
* Object literals, arrays, functions and class bodies return false - they are
|
|
177
|
+
* containers, not strings, even though they contain strings.
|
|
178
|
+
*/
|
|
179
|
+
export function isStringBuildingExpression(node: Node, language: LanguageId): boolean {
|
|
180
|
+
return STRING_EXPRESSION_TYPES[language].includes(node.type);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export function isStringLiteral(node: Node, language: LanguageId): boolean {
|
|
184
|
+
const types = STRING_LITERAL_TYPES[language];
|
|
185
|
+
if (!types.includes(node.type)) return false;
|
|
186
|
+
// An f-string or template literal WITH a substitution is not a literal.
|
|
187
|
+
return !containsType(node, INTERPOLATION_TYPES, 6);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function containsType(node: Node, types: Set<string>, maxDepth: number): boolean {
|
|
191
|
+
if (maxDepth <= 0) return false;
|
|
192
|
+
for (const child of node.namedChildren) {
|
|
193
|
+
if (!child) continue;
|
|
194
|
+
if (types.has(child.type)) return true;
|
|
195
|
+
if (containsType(child, types, maxDepth - 1)) return true;
|
|
196
|
+
}
|
|
197
|
+
return false;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Remove surrounding quotes, backticks and prefixes (f"", b'', @"", `` ). */
|
|
201
|
+
export function unquote(raw: string): string {
|
|
202
|
+
return raw
|
|
203
|
+
.replace(/^[a-zA-Z]{0,2}(["'`])((?:.|\n)*)\1$/, '$2')
|
|
204
|
+
.replace(/^"""|"""$/g, '')
|
|
205
|
+
.replace(/^'''|'''$/g, '');
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Read an expression and describe how its text was assembled.
|
|
210
|
+
*
|
|
211
|
+
* The walk is deliberately shallow (depth-limited) and deliberately simple: it
|
|
212
|
+
* collects three things - fixed text chunks, evidence of splicing, and the
|
|
213
|
+
* spliced expressions - and infers the mechanism from what it found. It does
|
|
214
|
+
* NOT try to evaluate the expression. A scanner that guesses at runtime values
|
|
215
|
+
* is a scanner that lies.
|
|
216
|
+
*/
|
|
217
|
+
export function analyzeStringExpression(
|
|
218
|
+
node: Node,
|
|
219
|
+
language: LanguageId,
|
|
220
|
+
maxDepth = 10,
|
|
221
|
+
): StringExpression {
|
|
222
|
+
const literalChunks: string[] = [];
|
|
223
|
+
const dynamicParts: string[] = [];
|
|
224
|
+
let sawInterpolation = false;
|
|
225
|
+
let sawConcatenation = false;
|
|
226
|
+
let sawFormatCall = false;
|
|
227
|
+
let sawOpaqueRef = false;
|
|
228
|
+
|
|
229
|
+
const visit = (current: Node, depth: number): void => {
|
|
230
|
+
if (depth > maxDepth) return;
|
|
231
|
+
|
|
232
|
+
// A string literal: harvest its fixed text and stop (unless it interpolates).
|
|
233
|
+
if (STRING_LITERAL_TYPES[language].includes(current.type)) {
|
|
234
|
+
const contents = current.namedChildren.filter(
|
|
235
|
+
(c): c is Node => c !== null && STRING_CONTENT_TYPES.has(c.type),
|
|
236
|
+
);
|
|
237
|
+
if (contents.length > 0) {
|
|
238
|
+
for (const chunk of contents) literalChunks.push(chunk.text ?? '');
|
|
239
|
+
} else if (!containsType(current, INTERPOLATION_TYPES, 4)) {
|
|
240
|
+
literalChunks.push(unquote(current.text ?? ''));
|
|
241
|
+
}
|
|
242
|
+
// Still descend, because a template string can hold substitutions.
|
|
243
|
+
for (const child of current.namedChildren) {
|
|
244
|
+
if (child && INTERPOLATION_TYPES.has(child.type)) visit(child, depth + 1);
|
|
245
|
+
}
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
if (INTERPOLATION_TYPES.has(current.type)) {
|
|
250
|
+
sawInterpolation = true;
|
|
251
|
+
const inner = (current.text ?? '').replace(/^\$?\{|\}$/g, '').trim();
|
|
252
|
+
if (inner) dynamicParts.push(inner);
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Concatenation. JS/Java/Go call it binary_expression, Python binary_operator.
|
|
257
|
+
if (current.type === 'binary_expression' || current.type === 'binary_operator') {
|
|
258
|
+
const operator = current.childForFieldName('operator')?.text ?? '';
|
|
259
|
+
if (operator === '+' || operator === '%') {
|
|
260
|
+
sawConcatenation = true;
|
|
261
|
+
for (const child of current.namedChildren) if (child) visit(child, depth + 1);
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// A call: is it a formatter, or just an opaque value?
|
|
267
|
+
if (
|
|
268
|
+
current.type === 'call' ||
|
|
269
|
+
current.type === 'call_expression' ||
|
|
270
|
+
current.type === 'method_invocation' ||
|
|
271
|
+
/*
|
|
272
|
+
* PHP'S CALL NODES WERE MISSING, AND THAT COST 2,547 FINDINGS.
|
|
273
|
+
*
|
|
274
|
+
* Without these three types the walker fell through to "recurse into
|
|
275
|
+
* every child" and descended INSIDE the call, so
|
|
276
|
+
*
|
|
277
|
+
* echo esc_html( $user->user_login );
|
|
278
|
+
*
|
|
279
|
+
* was described as "interpolation splicing in `$user`". The escaper was
|
|
280
|
+
* walked straight past and its argument reported as if it had been
|
|
281
|
+
* concatenated in raw - which is both a wrong description and the reason
|
|
282
|
+
* every WordPress escaping call looked like an unescaped one.
|
|
283
|
+
*
|
|
284
|
+
* A call is opaque: its RESULT is what gets spliced, and the arguments
|
|
285
|
+
* are not that result. Naming the node types makes PHP behave like the
|
|
286
|
+
* other five languages, which had them all along.
|
|
287
|
+
*/
|
|
288
|
+
current.type === 'function_call_expression' ||
|
|
289
|
+
current.type === 'member_call_expression' ||
|
|
290
|
+
current.type === 'scoped_call_expression'
|
|
291
|
+
) {
|
|
292
|
+
const nameNode =
|
|
293
|
+
current.childForFieldName('name') ??
|
|
294
|
+
current.childForFieldName('function') ??
|
|
295
|
+
current.childForFieldName('constructor');
|
|
296
|
+
const nameText = nameNode?.text ?? '';
|
|
297
|
+
const lastSegment = nameText.split(/[.:]/).pop() ?? nameText;
|
|
298
|
+
if (FORMAT_FUNCTIONS.has(lastSegment)) {
|
|
299
|
+
sawFormatCall = true;
|
|
300
|
+
/*
|
|
301
|
+
* SKIP THE FUNCTION'S OWN NAME.
|
|
302
|
+
*
|
|
303
|
+
* `fmt.Sprintf` is a selector_expression, so recursing into every child
|
|
304
|
+
* walked into the callee and recorded it as a spliced-in value. Every
|
|
305
|
+
* format-call finding therefore opened with "It splices in
|
|
306
|
+
* `fmt.Sprintf`, ..." - a sentence about the mechanism presented as a
|
|
307
|
+
* sentence about the data, and simply untrue.
|
|
308
|
+
*
|
|
309
|
+
* It also broke a real proof: a Go escape hatch whose arguments were
|
|
310
|
+
* ALL escaped still had `fmt.Sprintf` sitting in its parts list, so
|
|
311
|
+
* "is every part escaped?" answered no about the function's own name.
|
|
312
|
+
*/
|
|
313
|
+
// Compared by POSITION, not identity: web-tree-sitter hands back a new
|
|
314
|
+
// wrapper object on every access, so `child !== nameNode` is always true.
|
|
315
|
+
for (const child of current.namedChildren) {
|
|
316
|
+
if (!child) continue;
|
|
317
|
+
if (nameNode && child.startIndex === nameNode.startIndex && child.endIndex === nameNode.endIndex) {
|
|
318
|
+
continue;
|
|
319
|
+
}
|
|
320
|
+
visit(child, depth + 1);
|
|
321
|
+
}
|
|
322
|
+
/*
|
|
323
|
+
* BUT NOT THE THING IT IS CALLED ON. In JavaScript and Python the
|
|
324
|
+
* callee is `dir.concat` or `template.format` as one node, so skipping
|
|
325
|
+
* "the function's name" skipped `dir` and `template` with it - and they
|
|
326
|
+
* are the very strings being built on (tests/probes/s09b, p01). Their
|
|
327
|
+
* receiver is visited like any other part. Go's receiver here is a
|
|
328
|
+
* package (`fmt`), and Java's call keeps its receiver in a field of its
|
|
329
|
+
* own, which the loop above already visited.
|
|
330
|
+
*/
|
|
331
|
+
if (nameNode && (nameNode.type === 'member_expression' || nameNode.type === 'attribute')) {
|
|
332
|
+
const receiver = nameNode.childForFieldName('object');
|
|
333
|
+
if (receiver) visit(receiver, depth + 1);
|
|
334
|
+
}
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
sawOpaqueRef = true;
|
|
338
|
+
dynamicParts.push((current.text ?? '').replace(/\s+/g, ' ').trim().slice(0, 60));
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/*
|
|
343
|
+
* VALUES THAT ARE NOT WHAT THEY SPELL. `(yield prompt)` is whatever the
|
|
344
|
+
* caller sends back in, not `prompt` (tests/probes/s08). And `"ls " + obj`
|
|
345
|
+
* in Java or JavaScript calls obj's toString(), which can return anything:
|
|
346
|
+
* `new Arg(name)` is not `name` (j08), and `this` is not nothing (j14).
|
|
347
|
+
* Each is one opaque part - never descended into.
|
|
348
|
+
*/
|
|
349
|
+
if (
|
|
350
|
+
current.type === 'yield_expression' ||
|
|
351
|
+
current.type === 'yield' ||
|
|
352
|
+
((language === 'java' || language === 'javascript' || language === 'typescript') && current.type === 'this') ||
|
|
353
|
+
(language === 'java' && current.type === 'object_creation_expression') ||
|
|
354
|
+
((language === 'javascript' || language === 'typescript') && current.type === 'new_expression')
|
|
355
|
+
) {
|
|
356
|
+
sawOpaqueRef = true;
|
|
357
|
+
dynamicParts.push((current.text ?? '').replace(/\s+/g, ' ').trim().slice(0, 60));
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
if (
|
|
362
|
+
current.type === 'identifier' ||
|
|
363
|
+
current.type === 'member_expression' ||
|
|
364
|
+
current.type === 'attribute' ||
|
|
365
|
+
current.type === 'selector_expression' ||
|
|
366
|
+
current.type === 'field_access' ||
|
|
367
|
+
current.type === 'subscript' ||
|
|
368
|
+
current.type === 'subscript_expression' ||
|
|
369
|
+
current.type === 'index_expression'
|
|
370
|
+
) {
|
|
371
|
+
sawOpaqueRef = true;
|
|
372
|
+
dynamicParts.push((current.text ?? '').replace(/\s+/g, ' ').trim().slice(0, 60));
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
for (const child of current.namedChildren) if (child) visit(child, depth + 1);
|
|
377
|
+
};
|
|
378
|
+
|
|
379
|
+
visit(node, 0);
|
|
380
|
+
|
|
381
|
+
const literalText = literalChunks.join('');
|
|
382
|
+
let mechanism: BuildMechanism = 'literal';
|
|
383
|
+
if (sawInterpolation) mechanism = 'interpolation';
|
|
384
|
+
else if (sawConcatenation && dynamicParts.length > 0) mechanism = 'concatenation';
|
|
385
|
+
else if (sawFormatCall) mechanism = 'format-call';
|
|
386
|
+
else if (sawOpaqueRef && literalChunks.length === 0) mechanism = 'opaque';
|
|
387
|
+
else if (sawOpaqueRef) mechanism = 'concatenation';
|
|
388
|
+
|
|
389
|
+
return {
|
|
390
|
+
isDynamic: mechanism !== 'literal',
|
|
391
|
+
mechanism,
|
|
392
|
+
literalText,
|
|
393
|
+
dynamicParts,
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/* ---------------------------------------------------------------------------
|
|
398
|
+
* "Does this text look like X?" helpers.
|
|
399
|
+
*
|
|
400
|
+
* These are intentionally conservative. Their job is to stop the scanner
|
|
401
|
+
* shouting about `"Hello " + name`, which is not a SQL query and never was.
|
|
402
|
+
* ------------------------------------------------------------------------ */
|
|
403
|
+
|
|
404
|
+
/* ---------------------------------------------------------------------------
|
|
405
|
+
* DOES THIS TEXT LOOK LIKE SQL?
|
|
406
|
+
*
|
|
407
|
+
* The first version of this asked three independent questions of the text:
|
|
408
|
+
* is there a SQL verb ANYWHERE, a clause keyword ANYWHERE, and a general SQL
|
|
409
|
+
* keyword ANYWHERE. Three yesses meant "this is a query".
|
|
410
|
+
*
|
|
411
|
+
* Pointing the scanner at its own source found the flaw. This paragraph -
|
|
412
|
+
* the honesty disclaimer printed under every flow-verified finding - is built
|
|
413
|
+
* from eight string chunks joined with `+`. `analyzeStringExpression` merges
|
|
414
|
+
* them into one 638-character blob, and the blob contains:
|
|
415
|
+
*
|
|
416
|
+
* verb "call" from ... a CALL into a third-party package ...
|
|
417
|
+
* clause "into" from ... a call INTO a third-party package ...
|
|
418
|
+
* clause "where" from ... - WHERE imports resolve - ...
|
|
419
|
+
*
|
|
420
|
+
* Three matches, drawn from two unrelated sentences, and the scanner reported
|
|
421
|
+
* its own disclaimer as a SQL injection. Proximity alone would not have saved
|
|
422
|
+
* it either: `call ... into` are adjacent words.
|
|
423
|
+
*
|
|
424
|
+
* The fix is to stop asking "are these words present?" and start asking
|
|
425
|
+
* "is this the SHAPE of a statement?". Real SQL is not a bag of keywords, it
|
|
426
|
+
* is a grammar: SELECT is followed by FROM, INSERT is followed by INTO,
|
|
427
|
+
* UPDATE is followed by SET. English prose containing the same words almost
|
|
428
|
+
* never puts them in that order, in that span, without a full stop between.
|
|
429
|
+
*
|
|
430
|
+
* This is the same lesson as the STRING_EXPRESSION_TYPES gate above, one level
|
|
431
|
+
* down: there the scope was wrong, here the test was too weak for the scope.
|
|
432
|
+
* ------------------------------------------------------------------------ */
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* The canonical statement shapes. Each one pairs a verb with the clause the
|
|
436
|
+
* SQL grammar REQUIRES to follow it, within a bounded window - so the two
|
|
437
|
+
* halves have to belong to the same statement, not the same paragraph.
|
|
438
|
+
*/
|
|
439
|
+
const SQL_STATEMENT_SHAPES: readonly RegExp[] = [
|
|
440
|
+
/\bselect\b[\s\S]{0,300}?\bfrom\b/i,
|
|
441
|
+
/\binsert\s+(?:ignore\s+|or\s+\w+\s+)?into\b/i,
|
|
442
|
+
/\bupdate\b[\s\S]{0,200}?\bset\b/i,
|
|
443
|
+
/\bdelete\s+from\b/i,
|
|
444
|
+
/\bmerge\s+into\b/i,
|
|
445
|
+
/\breplace\s+into\b/i,
|
|
446
|
+
/\bunion(?:\s+all)?\s+select\b/i,
|
|
447
|
+
/\bwith\b[\s\S]{0,200}?\bas\s*\(/i, // common table expression
|
|
448
|
+
/\bcall\s+[\w.]+\s*\(/i, // stored procedure
|
|
449
|
+
/\bexec(?:ute)?\s+(?:sp_|dbo\.|[\w.]+\s*[('@])/i,
|
|
450
|
+
/\b(?:drop|alter|create|truncate)\s+(?:table|index|view|database|schema)\b/i,
|
|
451
|
+
// Query FRAGMENTS: a lot of real injection is ` WHERE id = ` + x, appended
|
|
452
|
+
// to a query built elsewhere. A clause keyword sitting at the very start of
|
|
453
|
+
// the fragment is the tell, and prose does not open that way.
|
|
454
|
+
/^\s*(?:where|from|order\s+by|group\s+by|having|limit|offset)\b/i,
|
|
455
|
+
/*
|
|
456
|
+
* VALUES AND AND ARE ALSO ENGLISH, so these two need the rest of the clause.
|
|
457
|
+
*
|
|
458
|
+
* An f-string's literal parts ARE fragments in the sense above - the text
|
|
459
|
+
* between two interpolations is exactly a string appended to something else -
|
|
460
|
+
* which is why the fragment rule reads them. Scanning pandas turned up four
|
|
461
|
+
* assertion messages that way:
|
|
462
|
+
*
|
|
463
|
+
* f"{obj} values are different ({pct} %)" -> " values are different ("
|
|
464
|
+
* f"{a} and {b} were approximately equal" -> " and "
|
|
465
|
+
*
|
|
466
|
+
* Both open with a clause keyword and neither continues into anything a
|
|
467
|
+
* database could parse. SQL's VALUES is always followed by a parenthesis, and
|
|
468
|
+
* a WHERE clause continued by AND or OR always reaches a comparison. Asking
|
|
469
|
+
* for the second half of the clause keeps ` AND id = ` and ` VALUES (` - the
|
|
470
|
+
* shapes the rule was written for - and drops the English.
|
|
471
|
+
*/
|
|
472
|
+
/^\s*values\s*\(/i,
|
|
473
|
+
/^\s*(?:and|or)\b[\s\S]{0,80}?(?:[=<>]|\b(?:like|ilike|in|is|between)\b)/i,
|
|
474
|
+
];
|
|
475
|
+
|
|
476
|
+
/**
|
|
477
|
+
* A full stop followed by a capital letter, or a comma followed by a
|
|
478
|
+
* conjunction, means the window crossed a sentence boundary - so the verb and
|
|
479
|
+
* the clause came from different thoughts, not from one statement.
|
|
480
|
+
* SQL has no sentences; prose has little else.
|
|
481
|
+
*/
|
|
482
|
+
const SENTENCE_BREAK = /[.!?]\s+[A-Z(]|[,;:]\s+(?:and|or|but|so|which|because|then)\b/;
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* SQL needs the SHAPE of a statement, not a scattering of keywords.
|
|
486
|
+
* Conservative on purpose: a fragment we cannot recognise is reported as
|
|
487
|
+
* "not SQL", which loses a finding rather than inventing one.
|
|
488
|
+
*/
|
|
489
|
+
export function looksLikeSql(text: string): boolean {
|
|
490
|
+
if (text.length < 8) return false;
|
|
491
|
+
for (const shape of SQL_STATEMENT_SHAPES) {
|
|
492
|
+
const match = shape.exec(text);
|
|
493
|
+
if (!match) continue;
|
|
494
|
+
if (SENTENCE_BREAK.test(match[0])) continue;
|
|
495
|
+
return true;
|
|
496
|
+
}
|
|
497
|
+
return false;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
export function looksLikeHtml(text: string): boolean {
|
|
501
|
+
return /<\s*[a-zA-Z][a-zA-Z0-9-]*(\s|>|\/)/.test(text) || /&(lt|gt|amp|quot);/.test(text);
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/**
|
|
505
|
+
* Shannon entropy: a number for "how random does this look?".
|
|
506
|
+
* Roughly - English prose scores ~2.5-3.5 bits/char, a base64 API key ~4.5-6.
|
|
507
|
+
* It is a hint, never a verdict, and we say so in the finding.
|
|
508
|
+
*/
|
|
509
|
+
export function shannonEntropy(text: string): number {
|
|
510
|
+
if (text.length === 0) return 0;
|
|
511
|
+
const counts = new Map<string, number>();
|
|
512
|
+
for (const char of text) counts.set(char, (counts.get(char) ?? 0) + 1);
|
|
513
|
+
let entropy = 0;
|
|
514
|
+
for (const count of counts.values()) {
|
|
515
|
+
const p = count / text.length;
|
|
516
|
+
entropy -= p * Math.log2(p);
|
|
517
|
+
}
|
|
518
|
+
return entropy;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/** Strings that are obviously not real credentials. Keeps the noise down. */
|
|
522
|
+
const PLACEHOLDER_PATTERNS: readonly RegExp[] = [
|
|
523
|
+
/^$/,
|
|
524
|
+
/^(x{3,}|\*{3,}|\.{3,}|-{3,}|_{3,})$/i,
|
|
525
|
+
/^(none|null|nil|undefined|true|false|test|todo|fixme|example|sample|dummy|placeholder|changeme|change_me|your[-_ ]?\w*|my[-_ ]?\w*|insert[-_ ]?\w*|redacted|secret|password|passwd|pwd|key|token|value|string|foo|bar|baz|abc123?|123456?)$/i,
|
|
526
|
+
/^\$\{?[\w.]+\}?$/, // ${ENV_VAR} or $VAR - a reference, not a value
|
|
527
|
+
/^%[\ws]$/, // printf placeholder
|
|
528
|
+
/^\{\{?[\w.]+\}?\}$/, // {{ template }}
|
|
529
|
+
/^<[^>]+>$/, // <your-key-here>
|
|
530
|
+
/^(process\.env|os\.environ|System\.getenv|os\.Getenv)/,
|
|
531
|
+
// Values that announce themselves as not-real anywhere inside the string.
|
|
532
|
+
// Added after scanning real repositories, where `'faketoken'`, `'Bearer ...'`
|
|
533
|
+
// and `'s00pers3cret'`-style fixtures dominated the results.
|
|
534
|
+
/(fake|dummy|sample|example|placeholder|redacted|changeme|notreal|xxxx|\.\.\.)/i,
|
|
535
|
+
/*
|
|
536
|
+
* THE VALUE NAMES THE KIND OF CREDENTIAL IT IS STANDING IN FOR.
|
|
537
|
+
*
|
|
538
|
+
* accessToken: 'exact-token' 'wrong-token' 'notion-token'
|
|
539
|
+
* clientSecret: 'notion-client-secret'
|
|
540
|
+
* apiKey: 'new-api-key' connectionToken: 'test-token'
|
|
541
|
+
*
|
|
542
|
+
* Ninety-six of the 191 hardcoded-secret findings on a VS Code scan were this
|
|
543
|
+
* shape, all from test files. A real credential does not contain the word
|
|
544
|
+
* "secret" - the whole point of one is that it carries no information about
|
|
545
|
+
* itself. A lowercase hyphenated phrase with `token`, `secret` or `key` as one
|
|
546
|
+
* of its words is a label a person wrote so a failing assertion would be
|
|
547
|
+
* readable.
|
|
548
|
+
*
|
|
549
|
+
* DELIBERATELY NARROW. An earlier attempt rejected short lowercase words
|
|
550
|
+
* outright and had to be reverted: it took `password = 'configpass'` and
|
|
551
|
+
* `SECRET_KEY = 'config'` with it, and those are weak credentials but they
|
|
552
|
+
* are credentials. Neither has a separator or names itself, so neither
|
|
553
|
+
* matches this. A passphrase like `correct-horse-battery-staple` does not
|
|
554
|
+
* match either - it has the separators but names nothing.
|
|
555
|
+
*/
|
|
556
|
+
/^[a-z][a-z0-9]*(?:[-_][a-z0-9]+)*[-_](token|secret|key|password|passwd|pwd|auth|credential|creds?|apikey|pat|jwt|salt|nonce|otp)$/i,
|
|
557
|
+
/^(token|secret|key|password|passwd|pwd|auth|credential|creds?|apikey|pat|jwt|salt|nonce|otp)[-_][a-z0-9]+(?:[-_][a-z0-9]+)*$/i,
|
|
558
|
+
];
|
|
559
|
+
|
|
560
|
+
/*
|
|
561
|
+
* Values that are SETTINGS, not secrets.
|
|
562
|
+
*
|
|
563
|
+
* `credentials` matches the credential naming pattern, so this line
|
|
564
|
+
*
|
|
565
|
+
* fetch(url, { credentials: "same-origin" })
|
|
566
|
+
*
|
|
567
|
+
* read as a hardcoded credential - twice, inside the vendored tree-sitter
|
|
568
|
+
* runtime, on a self-scan. It is a fixed enum from the fetch specification and
|
|
569
|
+
* there is no possible value of it that is a secret.
|
|
570
|
+
*
|
|
571
|
+
* The whole set is listed rather than just the three `credentials` values,
|
|
572
|
+
* because the neighbouring RequestInit fields (`mode`, `cache`, `redirect`)
|
|
573
|
+
* are one rename away from the same problem. These are exact, whole-string
|
|
574
|
+
* matches against a closed vocabulary defined by the web platform - not a
|
|
575
|
+
* guess about what looks secret-ish.
|
|
576
|
+
*/
|
|
577
|
+
const WEB_PLATFORM_ENUMS: ReadonlySet<string> = new Set([
|
|
578
|
+
// RequestInit.credentials
|
|
579
|
+
'same-origin',
|
|
580
|
+
'include',
|
|
581
|
+
'omit',
|
|
582
|
+
// RequestInit.mode
|
|
583
|
+
'cors',
|
|
584
|
+
'no-cors',
|
|
585
|
+
'navigate',
|
|
586
|
+
// RequestInit.cache
|
|
587
|
+
'default',
|
|
588
|
+
'no-store',
|
|
589
|
+
'reload',
|
|
590
|
+
'no-cache',
|
|
591
|
+
'force-cache',
|
|
592
|
+
'only-if-cached',
|
|
593
|
+
// RequestInit.redirect
|
|
594
|
+
'follow',
|
|
595
|
+
'manual',
|
|
596
|
+
'error',
|
|
597
|
+
]);
|
|
598
|
+
|
|
599
|
+
/**
|
|
600
|
+
* Is this value STRUCTURALLY incapable of being a credential, whatever the
|
|
601
|
+
* variable holding it is called?
|
|
602
|
+
*
|
|
603
|
+
* The name-based signal ("the variable is called `token`") never asked this,
|
|
604
|
+
* and VS Code's editor package showed why: 151 of its 163 high-severity secret
|
|
605
|
+
* findings were Monaco's syntax-highlighting tokens.
|
|
606
|
+
*
|
|
607
|
+
* token: 'delimiter.curly'
|
|
608
|
+
* token: 'variable.predefined'
|
|
609
|
+
* token: 'comment'
|
|
610
|
+
*
|
|
611
|
+
* The variable really is called `token`. The value is a theme scope name. No
|
|
612
|
+
* amount of suspicion about the NAME should survive looking at the VALUE.
|
|
613
|
+
*
|
|
614
|
+
* Two shapes, both decidable:
|
|
615
|
+
* - a dotted namespace (`delimiter.curly`, `com.example.thing`) - no key
|
|
616
|
+
* format contains dots, except a JWT, which starts `eyJ`;
|
|
617
|
+
* - a single lowercase dictionary-shaped word (`comment`, `invalid`) - real
|
|
618
|
+
* credentials mix character classes, because they are generated.
|
|
619
|
+
*/
|
|
620
|
+
export function isStructuralIdentifier(value: string): boolean {
|
|
621
|
+
const trimmed = value.trim();
|
|
622
|
+
if (trimmed.length === 0) return false;
|
|
623
|
+
// The trailing dot is not a typo. A dotted name used as a STORAGE PREFIX is
|
|
624
|
+
// written with the separator still attached, because the caller appends to it:
|
|
625
|
+
// SECRET_KEY_PREFIX = 'chat.lm.secret.'
|
|
626
|
+
// STORAGE_PREFIX = 'chat.modelFeedbackSurvey.'
|
|
627
|
+
// Both were reported as credentials on a VS Code scan, the first at HIGH
|
|
628
|
+
// because its name contains SECRET and KEY. It is a namespace, and the value
|
|
629
|
+
// in it is whatever gets appended - which is not in the file.
|
|
630
|
+
if (/^[\w$-]+(?:\.[\w$-]+)+\.?$/.test(trimmed) && !/^eyJ/.test(trimmed)) return true;
|
|
631
|
+
/*
|
|
632
|
+
* A value carrying a URI SCHEME is an address or a standard identifier, not
|
|
633
|
+
* a key. VS Code's OAuth constants are the clean example:
|
|
634
|
+
*
|
|
635
|
+
* TOKEN_TYPE_ACCESS_TOKEN = 'urn:ietf:params:oauth:token-type:access_token'
|
|
636
|
+
* token_endpoint = 'http://localhost:8080/token'
|
|
637
|
+
*
|
|
638
|
+
* Every one of those variable names screams credential, and every value is
|
|
639
|
+
* published in an RFC. The name cannot be trusted over the shape.
|
|
640
|
+
*/
|
|
641
|
+
if (/^(urn|https?|ftp|file|ws{1,2}|mailto|data|vscode[\w-]*):/i.test(trimmed)) return true;
|
|
642
|
+
/*
|
|
643
|
+
* A COLON-NAMESPACED PERMISSION IS A SCOPE, NOT A CREDENTIAL.
|
|
644
|
+
*
|
|
645
|
+
* AccessTokenScopeReadAdmin AccessTokenScope = "read:admin"
|
|
646
|
+
* AccessTokenScopeWriteRepoHook = "write:repo_hook"
|
|
647
|
+
*
|
|
648
|
+
* 106 of gitea's 359 findings were high-severity "hardcoded secret" and this
|
|
649
|
+
* block is most of them. The constant NAME contains "Token", which is exactly
|
|
650
|
+
* what the name signal is for - but the value is an OAuth scope printed in
|
|
651
|
+
* gitea's own public API documentation.
|
|
652
|
+
*
|
|
653
|
+
* LETTERS ONLY, deliberately. `admin:password123` keeps its digits and still
|
|
654
|
+
* reports, and so does anything with mixed case; generated credentials
|
|
655
|
+
* essentially always carry one or the other. What is left - a lowercase
|
|
656
|
+
* colon-namespaced phrase - is how every permission system in the world
|
|
657
|
+
* spells a scope. A bare `user:pass` is lost to this, which is a placeholder
|
|
658
|
+
* anyway, and a real one inside a URL is caught earlier by the
|
|
659
|
+
* connection-string format.
|
|
660
|
+
*/
|
|
661
|
+
if (/^[a-z]+(?:[_-][a-z]+)*(?::[a-z]+(?:[_-][a-z]+)*)+$/.test(trimmed)) return true;
|
|
662
|
+
/*
|
|
663
|
+
* A Subresource Integrity hash is published in the HTML that loads the
|
|
664
|
+
* script. It is the opposite of a secret - its whole job is to be public so
|
|
665
|
+
* a browser can check the file was not tampered with.
|
|
666
|
+
*
|
|
667
|
+
* webWorkerExtensionHostIframeScriptSHA = 'sha256-daEgfo2VIXpx2Np71Kq...'
|
|
668
|
+
*/
|
|
669
|
+
if (/^sha(256|384|512)-/.test(trimmed)) return true;
|
|
670
|
+
/*
|
|
671
|
+
* A SCREAMING_SNAKE value is a constant or an environment VARIABLE NAME, not
|
|
672
|
+
* its value. VS Code names the variable after what it holds and gets caught
|
|
673
|
+
* by both halves:
|
|
674
|
+
*
|
|
675
|
+
* agentHostBridgeConnectionTokenEnvironmentVariable =
|
|
676
|
+
* 'VSCODE_AGENT_HOST_BRIDGE_CONNECTION_TOKEN'
|
|
677
|
+
*/
|
|
678
|
+
if (/^[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)+$/.test(trimmed)) return true;
|
|
679
|
+
/*
|
|
680
|
+
* A single lowercase word is NOT on this list, and the first version of it
|
|
681
|
+
* was. `token: 'comment'` and `token: 'invalid'` in Monaco are exactly that
|
|
682
|
+
* shape - but so are
|
|
683
|
+
*
|
|
684
|
+
* password: 'configpass'
|
|
685
|
+
* password: 'exfil'
|
|
686
|
+
* SECRET_KEY = 'config'
|
|
687
|
+
*
|
|
688
|
+
* and rejecting the shape took the library corpus from 48 findings to 32.
|
|
689
|
+
* Weak human-chosen passwords ARE lowercase words; that is what makes them
|
|
690
|
+
* weak. Trading sixteen real hardcoded credentials for a dozen theme scope
|
|
691
|
+
* names is the wrong direction, so the dotted check stands alone and the
|
|
692
|
+
* remaining Monaco tokens are accepted noise.
|
|
693
|
+
*/
|
|
694
|
+
return false;
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
/**
|
|
698
|
+
* Does this spliced-in expression produce nothing but SQL PLACEHOLDERS?
|
|
699
|
+
*
|
|
700
|
+
* VS Code builds a bulk insert the correct way:
|
|
701
|
+
*
|
|
702
|
+
* `INSERT INTO ItemTable VALUES ${new Array(n).fill('(?,?)').join(',')} ...`
|
|
703
|
+
* stmt.run(keysValuesChunk)
|
|
704
|
+
*
|
|
705
|
+
* The interpolation expands to `(?,?),(?,?),(?,?)`. Not one byte of data is in
|
|
706
|
+
* that string - the values go to the driver separately, which is exactly what
|
|
707
|
+
* parameterisation means. And the rule reported it as SQL injection.
|
|
708
|
+
*
|
|
709
|
+
* That is the worst mistake an injection rule can make. Flagging the CORRECT
|
|
710
|
+
* form teaches people that doing it properly does not help, and this is the
|
|
711
|
+
* third time today the same failure has turned up in a different rule -
|
|
712
|
+
* unserialize with allowed_classes, yaml with a SafeLoader, and now this.
|
|
713
|
+
*
|
|
714
|
+
* The test is narrow on purpose. Every string literal inside the expression
|
|
715
|
+
* must be placeholder punctuation, AND at least one must contain an actual
|
|
716
|
+
* placeholder marker (`?`, `$1`, `%s`). That second half is what stops
|
|
717
|
+
* `rows.map(r => `'${r}'`).join(',')` - which builds real quoted DATA and is a
|
|
718
|
+
* genuine injection - from slipping through on its commas.
|
|
719
|
+
*/
|
|
720
|
+
export function expandsOnlyPlaceholders(expressionText: string): boolean {
|
|
721
|
+
const literals = [...expressionText.matchAll(/(['"`])((?:\\.|(?!\1)[^\\])*)\1/g)].map(
|
|
722
|
+
(m) => m[2] ?? '',
|
|
723
|
+
);
|
|
724
|
+
if (literals.length === 0) return false;
|
|
725
|
+
const hasMarker = literals.some((lit) => /\?|\$\d|%s/.test(lit));
|
|
726
|
+
if (!hasMarker) return false;
|
|
727
|
+
return literals.every((lit) => /^[\s?,()$%sd\d]*$/.test(lit));
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
export function isLikelyPlaceholder(text: string): boolean {
|
|
731
|
+
const trimmed = text.trim();
|
|
732
|
+
if (WEB_PLATFORM_ENUMS.has(trimmed.toLowerCase())) return true;
|
|
733
|
+
return PLACEHOLDER_PATTERNS.some((pattern) => pattern.test(trimmed));
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
/**
|
|
737
|
+
* "Could this string plausibly BE a credential?" - a structural gate we apply
|
|
738
|
+
* before the entropy heuristic.
|
|
739
|
+
*
|
|
740
|
+
* This function exists because of measurement, not theory. Running the entropy
|
|
741
|
+
* check alone against five real open-source repositories produced 80 findings,
|
|
742
|
+
* and the top offenders were things like:
|
|
743
|
+
*
|
|
744
|
+
* 'application/x-www-form-urlencoded' a MIME type
|
|
745
|
+
* 'abcdefghijklmnopqrstuvwxyz' a literal alphabet
|
|
746
|
+
* 'https://images.opencollective.com/…' a URL
|
|
747
|
+
*
|
|
748
|
+
* All three score high on randomness and none of them is a secret. Real tokens
|
|
749
|
+
* share a shape: no spaces, no slashes, and a MIX of character classes. Testing
|
|
750
|
+
* for that shape first removes the whole category of noise above without
|
|
751
|
+
* weakening detection of anything that actually looks like a key.
|
|
752
|
+
*/
|
|
753
|
+
export function looksLikeToken(value: string): boolean {
|
|
754
|
+
if (/[\s/\\@]/.test(value)) return false; // URLs, paths, MIME types, sentences
|
|
755
|
+
/*
|
|
756
|
+
* STRUCTURAL PUNCTUATION IS NOT IN ANY CREDENTIAL ALPHABET.
|
|
757
|
+
*
|
|
758
|
+
* Every real key format draws from a narrow set of characters: base64 uses
|
|
759
|
+
* A-Za-z0-9+/=, hex uses 0-9a-f, and vendor keys (AKIA..., ghp_..., sk_live_)
|
|
760
|
+
* use A-Za-z0-9_-. None of them contains a brace, a bracket, a quote, a
|
|
761
|
+
* colon or a semicolon.
|
|
762
|
+
*
|
|
763
|
+
* Auditing this tool's findings across five real libraries found NINE
|
|
764
|
+
* entropy-only false positives and every one was killed by this single line:
|
|
765
|
+
*
|
|
766
|
+
* json = "{field1:'abc',field2:'def'}" a JSON test fixture
|
|
767
|
+
* systemProperty = "gson.allowCapturing..." a property name
|
|
768
|
+
* dataURI = "data:;charset=UTF-8,hello" a data URI
|
|
769
|
+
*
|
|
770
|
+
* Entropy alone cannot tell a key from a small structured document - both
|
|
771
|
+
* score above 4 bits per character. Shape can, and shape is not a guess.
|
|
772
|
+
*/
|
|
773
|
+
if (/[{}[\]<>(),;:'"`|!?*&^%$#~]/.test(value)) return false;
|
|
774
|
+
if (/^[a-z.\-_]+$/.test(value)) return false; // lowercase words / kebab / dotted names
|
|
775
|
+
if (/^[A-Z.\-_]+$/.test(value)) return false; // SCREAMING_CONSTANT names
|
|
776
|
+
if (/^\d+$/.test(value)) return false; // pure numbers, timestamps, ids
|
|
777
|
+
/*
|
|
778
|
+
* NAMESPACED IDENTIFIERS ARE NOT KEYS.
|
|
779
|
+
*
|
|
780
|
+
* workbench.action.focusCommentsPanel
|
|
781
|
+
* workbench.contrib.viewsExtensionHandler
|
|
782
|
+
* editor.action.formatDocument
|
|
783
|
+
*
|
|
784
|
+
* Scanning VS Code produced 1,369 hardcoded-secret findings and 1,246 were
|
|
785
|
+
* entropy-only hits on strings exactly like these - command ids, context
|
|
786
|
+
* keys, extension ids. They score above 4 bits per character because they mix
|
|
787
|
+
* case and use many distinct letters, which is precisely what the entropy
|
|
788
|
+
* heuristic was built to notice, and precisely why entropy alone is not
|
|
789
|
+
* evidence of anything.
|
|
790
|
+
*
|
|
791
|
+
* A dotted name is the single most common long string in a large application,
|
|
792
|
+
* and no credential format is shaped like one. The exception is a JWT, which
|
|
793
|
+
* is also dot-separated - so a value that opens with the base64 of `{"` is
|
|
794
|
+
* kept. That is the standard JWT header prefix and nothing else starts that
|
|
795
|
+
* way by accident.
|
|
796
|
+
*/
|
|
797
|
+
if (/^[\w$-]+(?:\.[\w$-]+)+$/.test(value) && !/^eyJ/.test(value)) return false;
|
|
798
|
+
// The alphabet itself, and any run of consecutive letters. `letterBytes =
|
|
799
|
+
// "abcdefghijklmnopqrstuvwxyzABCDEF..."` in gin scores 4.7 bits/char and is
|
|
800
|
+
// the least random string it is possible to write.
|
|
801
|
+
if (/abcdefghij/i.test(value)) return false;
|
|
802
|
+
/*
|
|
803
|
+
* A CAMELCASE RUN OF PURE LETTERS IS AN IDENTIFIER, NOT A KEY.
|
|
804
|
+
*
|
|
805
|
+
* enablePreviewFromQuickOpen a settings key
|
|
806
|
+
* ChatToolsEligibleForAutoApproval an experiment name
|
|
807
|
+
* didNotEnableEditSessionsWhenPrompted a telemetry outcome
|
|
808
|
+
* historyItemChangeViewModel a view-model type tag
|
|
809
|
+
*
|
|
810
|
+
* The dotted-name rule above catches `workbench.action.focus...`; these are
|
|
811
|
+
* the same thing with the dots removed, and they were the next 35 findings on
|
|
812
|
+
* a VS Code scan once the dotted ones were gone. They score above 4 bits per
|
|
813
|
+
* character for the same reason - many distinct letters, mixed case - which
|
|
814
|
+
* is again a fact about English identifiers rather than about randomness.
|
|
815
|
+
*
|
|
816
|
+
* The discriminator is what is ABSENT. Every generated credential draws on
|
|
817
|
+
* digits or punctuation somewhere: base64 has digits and +/=, hex is digits
|
|
818
|
+
* and a-f, vendor keys carry a prefix and an underscore. Twenty-nine letters
|
|
819
|
+
* and nothing else is a name a person typed.
|
|
820
|
+
*
|
|
821
|
+
* This gate is on the ENTROPY path only, which matters: `password =
|
|
822
|
+
* 'MySecretPassword'` has this exact shape and is a genuine bad credential,
|
|
823
|
+
* but it is caught by the variable NAME (Signal 2), which never consults this
|
|
824
|
+
* function. Weak alphabetic passwords therefore still report.
|
|
825
|
+
*/
|
|
826
|
+
if (value.length >= 12 && /^[A-Za-z]+$/.test(value)) {
|
|
827
|
+
const humps = value.match(/[a-z][A-Z]/g)?.length ?? 0;
|
|
828
|
+
if (humps >= 2) return false;
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
const classes =
|
|
832
|
+
(/[a-z]/.test(value) ? 1 : 0) + (/[A-Z]/.test(value) ? 1 : 0) + (/[0-9]/.test(value) ? 1 : 0);
|
|
833
|
+
return classes >= 2;
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
/**
|
|
837
|
+
* Paths that mean "this is a fixture, not production configuration".
|
|
838
|
+
* A credential in a test file is still a credential in git history, so we do
|
|
839
|
+
* not drop these findings - we lower their severity and say why.
|
|
840
|
+
*/
|
|
841
|
+
export function isTestPath(filePath: string): boolean {
|
|
842
|
+
return TEST_DIRECTORY.test(filePath) || TEST_FILENAME.test(filePath);
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
/*
|
|
846
|
+
* TEST-PATH CONVENTIONS ARE PER-ECOSYSTEM, and this list was built from the
|
|
847
|
+
* repositories I had happened to be shown.
|
|
848
|
+
*
|
|
849
|
+
* Scanning cal.com - 3,941 TypeScript files - produced 196 findings, 138 of
|
|
850
|
+
* them hardcoded-secret. The ones still ranked HIGH were led by
|
|
851
|
+
*
|
|
852
|
+
* apps/web/playwright/payment-apps.e2e.ts access_token: "sk_test_randomString"
|
|
853
|
+
* packages/testing/src/lib/bookingScenario/bookingScenario.ts
|
|
854
|
+
*
|
|
855
|
+
* Both are unambiguously test code, and this function said neither was. The
|
|
856
|
+
* reasons were boring and specific: `e2e` was recognised only as a whole
|
|
857
|
+
* DIRECTORY segment, never as the `.e2e.ts` filename suffix that Playwright
|
|
858
|
+
* actually uses; and `tests?` matches `test` and `tests` but not `testing`.
|
|
859
|
+
*
|
|
860
|
+
* That is the third time this exact class has cost real precision. Scanning
|
|
861
|
+
* elasticsearch proved SOURCES are per-framework rather than per-language;
|
|
862
|
+
* scanning Jenkins proved ESCAPERS are too. The lesson generalises past both:
|
|
863
|
+
* ANY list of conventions in this engine is a list of the conventions I have
|
|
864
|
+
* been shown, and it looks complete right up until a codebase with different
|
|
865
|
+
* habits arrives.
|
|
866
|
+
*
|
|
867
|
+
* WHY THIS IS TWO NARROW PATTERNS AND NOT ONE WIDE ONE. Downranking is a
|
|
868
|
+
* silencing mechanism, so the failure mode is a real credential in application
|
|
869
|
+
* source ranked as test noise. `latest.ts`, `contest/`, `testimonials.tsx`,
|
|
870
|
+
* `protester.js` and `attestation/` all contain the letters "test". Every one
|
|
871
|
+
* of them is asserted as NOT a test path in the suite, which is what stops a
|
|
872
|
+
* future widening from quietly eating them.
|
|
873
|
+
*/
|
|
874
|
+
|
|
875
|
+
/** A whole path SEGMENT that names a test area. Anchored both sides. */
|
|
876
|
+
const TEST_DIRECTORY =
|
|
877
|
+
/(^|[/\\])(tests?|testing|spec|specs|__tests__|__mocks__|fixtures?|examples?|e2e|benchmarks?|mocks?|playwright|cypress|testdata)([/\\]|$)/i;
|
|
878
|
+
|
|
879
|
+
/**
|
|
880
|
+
* A FILENAME that names itself a test. Kept separate from the directory rule
|
|
881
|
+
* because these are suffixes, not segments - `login.e2e.ts` has no `e2e`
|
|
882
|
+
* segment anywhere in it, which is exactly what cal.com exposed.
|
|
883
|
+
*/
|
|
884
|
+
const TEST_FILENAME =
|
|
885
|
+
/\.(test|spec|e2e|cy|e2e-spec|int-spec)\.[a-z]+$|[._-]test\.[a-z]+$|Tests?\.(java|cs|kt|scala)$/i;
|
|
886
|
+
|
|
887
|
+
|
|
888
|
+
/**
|
|
889
|
+
* Is this value just a restatement of the NAME it is stored under?
|
|
890
|
+
*
|
|
891
|
+
* PRIVATE_KEY_KEY = "privateKey" Keycloak, 4,000 Java files
|
|
892
|
+
* KEYSTORE_PASSWORD_KEY = "keystorePassword"
|
|
893
|
+
* MANAGE_OAUTH = "manage_oauth" Mattermost, TypeScript
|
|
894
|
+
* TOKEN_CREATING = "creating"
|
|
895
|
+
* SECTION_TOKENS = "tokens"
|
|
896
|
+
*
|
|
897
|
+
* These are the strings a program uses to LOOK UP a secret in a config map, and
|
|
898
|
+
* they are dense in exactly the codebases that handle credentials for a living
|
|
899
|
+
* - which is why an identity server produced 152 findings and most were this.
|
|
900
|
+
*
|
|
901
|
+
* The test is a subset check on words, which is what makes it safe: a real
|
|
902
|
+
* credential shares no vocabulary with its own variable name. `password =
|
|
903
|
+
* "Tr0ub4dor3xK"`, `SECRET_KEY = "config"` and `password = "configpass"` all
|
|
904
|
+
* survive it, and all three are asserted elsewhere in the suite.
|
|
905
|
+
*
|
|
906
|
+
* Requires at least one word on each side, so an empty or punctuation-only
|
|
907
|
+
* value cannot vacuously qualify.
|
|
908
|
+
*/
|
|
909
|
+
export function echoesItsOwnName(name: string, value: string): boolean {
|
|
910
|
+
const words = (text: string): string[] =>
|
|
911
|
+
text
|
|
912
|
+
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
|
913
|
+
.split(/[^A-Za-z0-9]+/)
|
|
914
|
+
.map((w) => w.toLowerCase())
|
|
915
|
+
.filter((w) => w.length > 1);
|
|
916
|
+
const nameWords = new Set(words(name));
|
|
917
|
+
const valueWords = words(value);
|
|
918
|
+
if (nameWords.size === 0 || valueWords.length === 0) return false;
|
|
919
|
+
return valueWords.every((w) => nameWords.has(w));
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
/**
|
|
923
|
+
* Does this value read as an English SENTENCE rather than as a credential?
|
|
924
|
+
*
|
|
925
|
+
* encryption_key_missing: "CALENDSO_ENCRYPTION_KEY is not set"
|
|
926
|
+
*
|
|
927
|
+
* From cal.com's OAuthService. The NAME matches the credential pattern - it
|
|
928
|
+
* contains `key` - and a name-based guess is allowed to be wrong. What it is
|
|
929
|
+
* not allowed to do is ignore a value that could not possibly be a credential.
|
|
930
|
+
*
|
|
931
|
+
* THE TRAP THIS IS SHAPED AROUND: passphrases contain spaces and are real
|
|
932
|
+
* secrets. `password = "correct horse battery staple"` must still report.
|
|
933
|
+
*
|
|
934
|
+
* THE FIRST VERSION OF THIS FUNCTION WAS NOT SAFE, and the corpus caught it -
|
|
935
|
+
* for the third time in this project's life, after the lowercase-word rejection
|
|
936
|
+
* that dropped the corpus 48 to 32 and the mathjs.eval regression that showed
|
|
937
|
+
* up only as dvna's flow count falling 3 to 2.
|
|
938
|
+
*
|
|
939
|
+
* Requiring "several words plus an English function word" looked airtight
|
|
940
|
+
* against a DICEWARE passphrase, whose wordlist is concrete nouns and verbs.
|
|
941
|
+
* It is not airtight against a HUMAN-CHOSEN one. Express ships
|
|
942
|
+
*
|
|
943
|
+
* secret: 'manny is cool' examples/cookie-sessions/index.js
|
|
944
|
+
*
|
|
945
|
+
* a genuine session secret, silenced by the word `is`. One good suppression
|
|
946
|
+
* (dvna's `'A2: Broken Authentication'` label) and one real miss.
|
|
947
|
+
*
|
|
948
|
+
* So the value's shape is no longer the whole test. The NAME has to say the
|
|
949
|
+
* slot holds a message - `encryption_key_missing`, `api_key_invalid` - which is
|
|
950
|
+
* what cal.com's error table actually looks like and what `secret` never does.
|
|
951
|
+
* Both halves are asserted: the express string is now a fixture that must fire.
|
|
952
|
+
*/
|
|
953
|
+
const PROSE_WORDS = new Set([
|
|
954
|
+
'is', 'are', 'was', 'were', 'be', 'been', 'not', 'no', 'the', 'a', 'an',
|
|
955
|
+
'this', 'that', 'these', 'those', 'your', 'you', 'must', 'should',
|
|
956
|
+
'cannot', 'could', 'would', 'please', 'try', 'again', 'invalid', 'missing',
|
|
957
|
+
'expired', 'required', 'failed', 'error', 'unable', 'least', 'provided',
|
|
958
|
+
'for', 'to', 'of', 'in', 'at', 'with', 'and', 'or', 'but', 'if', 'set',
|
|
959
|
+
]);
|
|
960
|
+
|
|
961
|
+
export function looksLikeProse(name: string, value: string): boolean {
|
|
962
|
+
// The NAME has to say this slot holds a message. Prose-ness alone is not
|
|
963
|
+
// enough - see the express regression in the note above.
|
|
964
|
+
if (!MESSAGE_NAME.test(name)) return false;
|
|
965
|
+
const words = value.trim().split(/\s+/);
|
|
966
|
+
if (words.length < 3) return false;
|
|
967
|
+
const lowered = words.map((w) => w.replace(/[^A-Za-z]/g, '').toLowerCase());
|
|
968
|
+
return lowered.some((w) => PROSE_WORDS.has(w));
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
/**
|
|
972
|
+
* Names that describe a STATE or a MESSAGE rather than a stored credential.
|
|
973
|
+
* `encryption_key_missing` is not a key; it is what you print when the key is
|
|
974
|
+
* absent. The word that matters is the suffix.
|
|
975
|
+
*/
|
|
976
|
+
const MESSAGE_NAME =
|
|
977
|
+
/(_|\b)(missing|invalid|expired|required|failed|error|errors|message|messages|msg|description|hint|warning|notice|reason|too_short|too_long|not_found|unauthorized|denied)$/i;
|
|
978
|
+
|
|
979
|
+
/** Well-known credential formats. A hit here is far stronger than entropy alone. */
|
|
980
|
+
export interface KnownSecretFormat {
|
|
981
|
+
readonly label: string;
|
|
982
|
+
readonly pattern: RegExp;
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
export const KNOWN_SECRET_FORMATS: readonly KnownSecretFormat[] = [
|
|
986
|
+
{ label: 'AWS access key id', pattern: /\b(AKIA|ASIA|ABIA|ACCA)[0-9A-Z]{16}\b/ },
|
|
987
|
+
{ label: 'GitHub token', pattern: /\bgh[pousr]_[A-Za-z0-9]{16,}\b/ },
|
|
988
|
+
{ label: 'Slack token', pattern: /\bxox[abprs]-[A-Za-z0-9-]{10,}\b/ },
|
|
989
|
+
{ label: 'Stripe secret key', pattern: /\b(sk|rk)_(live|test)_[A-Za-z0-9]{16,}\b/ },
|
|
990
|
+
{ label: 'Google API key', pattern: /\bAIza[0-9A-Za-z_-]{35}\b/ },
|
|
991
|
+
{ label: 'OpenAI API key', pattern: /\bsk-[A-Za-z0-9]{20,}\b/ },
|
|
992
|
+
/*
|
|
993
|
+
* OpenAI's CURRENT key format, which the line above cannot match.
|
|
994
|
+
*
|
|
995
|
+
* The legacy shape was `sk-` followed by one unbroken run of alphanumerics.
|
|
996
|
+
* Project and service-account keys put named segments in between -
|
|
997
|
+
* `sk-proj-...`, `sk-svcacct-...` - and the hyphen ends the `[A-Za-z0-9]{20,}`
|
|
998
|
+
* run, so a live project key scored only "high-entropy literal, possible
|
|
999
|
+
* secret" at medium severity instead of being identified outright.
|
|
1000
|
+
*
|
|
1001
|
+
* Found by checking that a placeholder this rule had just started ignoring
|
|
1002
|
+
* (`apiKey: 'sk-secret-internal-key'`) had not taken a real format with it.
|
|
1003
|
+
* It had not - it had exposed one that was already missing. The named segment
|
|
1004
|
+
* is required precisely so that the placeholder stays ignored: "secret" is
|
|
1005
|
+
* not "proj".
|
|
1006
|
+
*/
|
|
1007
|
+
{ label: 'OpenAI project key', pattern: /\bsk-(proj|svcacct|admin)-[A-Za-z0-9_-]{20,}\b/ },
|
|
1008
|
+
{ label: 'JSON Web Token', pattern: /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/ },
|
|
1009
|
+
{
|
|
1010
|
+
label: 'private key block',
|
|
1011
|
+
/*
|
|
1012
|
+
* The delimiter has to be followed by KEY MATERIAL. Keycloak defines
|
|
1013
|
+
* `BEGIN_PRIVATE_KEY = "-----BEGIN PRIVATE KEY-----"` as a constant so it
|
|
1014
|
+
* can assemble and parse PEM files; the string IS the delimiter and there
|
|
1015
|
+
* is no key in it. Requiring base64 after the header keeps every real
|
|
1016
|
+
* embedded key and drops the label on its own.
|
|
1017
|
+
*/
|
|
1018
|
+
pattern: /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?[A-Za-z0-9+/]{40}/,
|
|
1019
|
+
},
|
|
1020
|
+
{ label: 'Twilio account SID', pattern: /\bAC[0-9a-fA-F]{32}\b/ },
|
|
1021
|
+
{ label: 'SendGrid API key', pattern: /\bSG\.[A-Za-z0-9_-]{16,}\.[A-Za-z0-9_-]{16,}\b/ },
|
|
1022
|
+
{ label: 'connection string with password', pattern: /:\/\/[^\s:@/]+:[^\s:@/]{3,}@/ },
|
|
1023
|
+
];
|
|
1024
|
+
|
|
1025
|
+
/* ---------------------------------------------------------------------------
|
|
1026
|
+
* VENDOR DOCUMENTATION EXAMPLES
|
|
1027
|
+
*
|
|
1028
|
+
* `AKIAIOSFODNN7EXAMPLE` is not an AWS access key. It is the string AWS
|
|
1029
|
+
* itself prints in its own documentation, chosen so that it matches the key
|
|
1030
|
+
* FORMAT while being guaranteed never to authenticate anything. It appears in
|
|
1031
|
+
* README files, tutorials and UI demos everywhere, and the scanner found one
|
|
1032
|
+
* in this project's own demo panel and called it a hardcoded credential.
|
|
1033
|
+
*
|
|
1034
|
+
* That finding was not merely noisy, it was FALSE: nothing was hardcoded and
|
|
1035
|
+
* nothing needs rotating. So this is not a severity downgrade like the
|
|
1036
|
+
* test-path rule - the claim itself does not hold, and the finding is dropped.
|
|
1037
|
+
*
|
|
1038
|
+
* The allowlist is deliberately narrow. Vendors mark their examples by ending
|
|
1039
|
+
* them in EXAMPLE (AWS's convention, used consistently across its docs) or by
|
|
1040
|
+
* filling the random portion with an obvious run of digits. A real key landing
|
|
1041
|
+
* on one of these by chance is a 1-in-10^12 event; a real key that a developer
|
|
1042
|
+
* has deliberately named EXAMPLE is not a case this tool can help with.
|
|
1043
|
+
*
|
|
1044
|
+
* Anything dropped here is listed in the rule's `limitations` text, so the gap
|
|
1045
|
+
* is documented rather than silent.
|
|
1046
|
+
* ------------------------------------------------------------------------ */
|
|
1047
|
+
const VENDOR_DOC_EXAMPLES: readonly RegExp[] = [
|
|
1048
|
+
/EXAMPLE(KEY|SECRET)?$/, // AWS: AKIAIOSFODNN7EXAMPLE, ...bPxRfiCYEXAMPLEKEY
|
|
1049
|
+
/\b(AKIA|ASIA|ABIA|ACCA)(?:0{6,}|1234567890|X{6,})/i, // filler-digit stand-ins
|
|
1050
|
+
/^(?:sk|rk|pk)_(?:live|test)_(?:example|1234567890|abcdef|0000)/i,
|
|
1051
|
+
/\bAIza(?:SyExample|0{10,})/i,
|
|
1052
|
+
/\bxox[abprs]-(?:example|0000|1234567890)/i,
|
|
1053
|
+
/-----BEGIN [A-Z ]*PRIVATE KEY-----\s*(?:\.\.\.|EXAMPLE|YOUR)/i,
|
|
1054
|
+
];
|
|
1055
|
+
|
|
1056
|
+
/**
|
|
1057
|
+
* True when a value matches a vendor key format but is a published,
|
|
1058
|
+
* deliberately non-functional documentation example rather than a credential.
|
|
1059
|
+
*
|
|
1060
|
+
* Pass the MATCHED KEY, not the string it was found in. The first version of
|
|
1061
|
+
* this tested the whole enclosing string, so a demo code sample containing
|
|
1062
|
+
* `AKIAIOSFODNN7EXAMPLE` in the middle of two hundred other characters never
|
|
1063
|
+
* matched the `...EXAMPLE$` anchor and was still reported as a live key. The
|
|
1064
|
+
* question is about the credential, so it has to be asked of the credential.
|
|
1065
|
+
*/
|
|
1066
|
+
export function isVendorDocExample(matchedKey: string): boolean {
|
|
1067
|
+
return VENDOR_DOC_EXAMPLES.some((pattern) => pattern.test(matchedKey));
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
export function matchKnownSecret(text: string): KnownSecretFormat | null {
|
|
1071
|
+
for (const format of KNOWN_SECRET_FORMATS) {
|
|
1072
|
+
const match = format.pattern.exec(text);
|
|
1073
|
+
if (!match) continue;
|
|
1074
|
+
if (isVendorDocExample(match[0])) continue;
|
|
1075
|
+
return format;
|
|
1076
|
+
}
|
|
1077
|
+
return null;
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
/* -------------------------------------------------------------------------- *
|
|
1081
|
+
* PROVABLY-CONSTANT INTERPOLATION
|
|
1082
|
+
*
|
|
1083
|
+
* A tester handed the scanner this, expecting silence:
|
|
1084
|
+
*
|
|
1085
|
+
* def get_admin_table():
|
|
1086
|
+
* table_name = "admin_users" # hardcoded, no external input
|
|
1087
|
+
* query = f"SELECT * FROM {table_name}"
|
|
1088
|
+
* return conn.execute(query)
|
|
1089
|
+
*
|
|
1090
|
+
* It flagged the f-string. The label was correct - `signature-based`, data flow
|
|
1091
|
+
* NOT traced - but the finding was still noise: the only spliced-in value is a
|
|
1092
|
+
* string literal written on the line above. A scanner that reports that teaches
|
|
1093
|
+
* people to skim past it, and this project's own test suite treats a false
|
|
1094
|
+
* positive as a failure for exactly that reason.
|
|
1095
|
+
*
|
|
1096
|
+
* So: if EVERY spliced-in part is a plain name bound only to literal constants
|
|
1097
|
+
* in the enclosing scope, the string is not dynamic in any sense a reader cares
|
|
1098
|
+
* about, and the rule stays quiet.
|
|
1099
|
+
*
|
|
1100
|
+
* Deliberately conservative in three ways, because losing a real finding is a
|
|
1101
|
+
* worse trade than printing a weak one:
|
|
1102
|
+
* - a part that is not a bare identifier (a call, an index, an attribute)
|
|
1103
|
+
* is never treated as constant;
|
|
1104
|
+
* - a name with NO visible binding - a parameter, an import, a global from
|
|
1105
|
+
* another file - is never treated as constant;
|
|
1106
|
+
* - a name bound more than once is constant only if EVERY binding is a
|
|
1107
|
+
* literal, so `t = "x"` followed later by `t = request.args.get(...)`
|
|
1108
|
+
* still flags.
|
|
1109
|
+
* -------------------------------------------------------------------------- */
|
|
1110
|
+
|
|
1111
|
+
/** Nodes that introduce a scope worth searching for bindings. */
|
|
1112
|
+
const SCOPE_NODES = new Set([
|
|
1113
|
+
'function_declaration', 'function_expression', 'arrow_function', 'method_definition',
|
|
1114
|
+
'generator_function_declaration', 'generator_function', 'class_static_block',
|
|
1115
|
+
'function_definition', 'lambda',
|
|
1116
|
+
'method_declaration', 'constructor_declaration',
|
|
1117
|
+
'func_literal', 'program', 'module', 'source_file', 'compilation_unit',
|
|
1118
|
+
]);
|
|
1119
|
+
|
|
1120
|
+
/** Nodes that bind a name to a value, and the fields that hold each side. */
|
|
1121
|
+
const BINDING_NODES: Record<string, { name: string; value: string }> = {
|
|
1122
|
+
assignment: { name: 'left', value: 'right' }, // Python
|
|
1123
|
+
assignment_expression: { name: 'left', value: 'right' }, // JS/TS/Java
|
|
1124
|
+
variable_declarator: { name: 'name', value: 'value' }, // JS/TS/Java
|
|
1125
|
+
short_var_declaration: { name: 'left', value: 'right' }, // Go
|
|
1126
|
+
assignment_statement: { name: 'left', value: 'right' }, // Go
|
|
1127
|
+
};
|
|
1128
|
+
|
|
1129
|
+
/** A literal with no spliced-in parts: a genuinely fixed value. */
|
|
1130
|
+
function isPlainLiteral(node: Node | null, language: LanguageId): boolean {
|
|
1131
|
+
if (!node) return false;
|
|
1132
|
+
if (node.type === 'number' || node.type === 'integer' || node.type === 'float') return true;
|
|
1133
|
+
// Java's `null` is a fixed value too - `String command = null;` before the
|
|
1134
|
+
// branches that set it is how BenchmarkJava's command helper begins.
|
|
1135
|
+
if (language === 'java' && node.type === 'null_literal') return true;
|
|
1136
|
+
if (!STRING_LITERAL_TYPES[language].includes(node.type)) return false;
|
|
1137
|
+
return !containsType(node, INTERPOLATION_TYPES, 4);
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
function enclosingScopes(node: Node): Node[] {
|
|
1141
|
+
const scopes: Node[] = [];
|
|
1142
|
+
let current: Node | null = node.parent;
|
|
1143
|
+
while (current) {
|
|
1144
|
+
if (SCOPE_NODES.has(current.type)) scopes.push(current);
|
|
1145
|
+
current = current.parent;
|
|
1146
|
+
}
|
|
1147
|
+
return scopes;
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
/** Names bound by the parameter list of a function. Never constant. */
|
|
1151
|
+
function parameterNames(scope: Node): Set<string> {
|
|
1152
|
+
const names = new Set<string>();
|
|
1153
|
+
const walk = (n: Node | null): void => {
|
|
1154
|
+
if (!n) return;
|
|
1155
|
+
if (n.type === 'identifier') names.add(n.text ?? '');
|
|
1156
|
+
for (const child of n.namedChildren) walk(child ?? null);
|
|
1157
|
+
};
|
|
1158
|
+
/*
|
|
1159
|
+
* Not only `parameters`: a JavaScript arrow with one bare parameter keeps it
|
|
1160
|
+
* in `parameter` (tests/probes/s07), and a Go method's receiver and named
|
|
1161
|
+
* results are input too (g02).
|
|
1162
|
+
*/
|
|
1163
|
+
for (const field of ['parameters', 'parameter', 'receiver', 'result']) walk(scope.childForFieldName(field));
|
|
1164
|
+
return names;
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
/** Why a string built from pieces is still provably fixed - or null when it is not. */
|
|
1168
|
+
export interface ConstantProof {
|
|
1169
|
+
/**
|
|
1170
|
+
* True when the proof needed control-flow reasoning - a branch that cannot
|
|
1171
|
+
* run, a condition decided at compile time, a ternary whose every arm is a
|
|
1172
|
+
* literal - rather than "every binding is a plain literal". Only reasoned
|
|
1173
|
+
* proofs leave a receipt, because only reasoning can be wrong.
|
|
1174
|
+
*/
|
|
1175
|
+
readonly reasoned: boolean;
|
|
1176
|
+
/** One sentence per name, written so a reader can check it against the code. */
|
|
1177
|
+
readonly reason: string;
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
const TERNARY_NODES = new Set(['ternary_expression', 'conditional_expression']);
|
|
1181
|
+
/*
|
|
1182
|
+
* Go binds a NEW variable in two places the write survey does not collect:
|
|
1183
|
+
* `switch t := v.(type)` and `case t := <-ch`. Inside them, `t` is not the
|
|
1184
|
+
* one declared outside (tests/probes/g03, g06).
|
|
1185
|
+
*/
|
|
1186
|
+
function goBindsOtherwise(root: Node, name: string): boolean {
|
|
1187
|
+
const word = new RegExp(`(^|[^\\w])${name}(?![\\w])`);
|
|
1188
|
+
for (const n of root.descendantsOfType(['type_switch_statement', 'receive_statement'])) {
|
|
1189
|
+
if (!n) continue;
|
|
1190
|
+
const bound = n.type === 'type_switch_statement' ? n.childForFieldName('alias') : n.childForFieldName('left');
|
|
1191
|
+
if (bound && word.test(bound.text ?? '')) return true;
|
|
1192
|
+
}
|
|
1193
|
+
return false;
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
function rootOf(n: Node): Node {
|
|
1197
|
+
let root = n;
|
|
1198
|
+
while (root.parent) root = root.parent;
|
|
1199
|
+
return root;
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
/** Nodes that declare a variable: Java and JavaScript declarators, Go's `var` and `:=`. */
|
|
1203
|
+
const DECLARATION_NODES = new Set(['variable_declarator', 'var_spec', 'short_var_declaration']);
|
|
1204
|
+
|
|
1205
|
+
/** Languages whose `+` joins two strings, and the node that holds it. */
|
|
1206
|
+
const CONCATENATING: ReadonlySet<LanguageId> = new Set(['java', 'javascript', 'typescript', 'python', 'go']);
|
|
1207
|
+
const CONCAT_NODES = new Set(['binary_expression', 'binary_operator']);
|
|
1208
|
+
|
|
1209
|
+
|
|
1210
|
+
/*
|
|
1211
|
+
* PROVABLY CONSTANT, NOW WITH THE FOLDER.
|
|
1212
|
+
*
|
|
1213
|
+
* This used to accept a name only if EVERY binding of it was a plain literal.
|
|
1214
|
+
* That is sound, and it missed the single most common false positive left in
|
|
1215
|
+
* BenchmarkJava - 65 of them:
|
|
1216
|
+
*
|
|
1217
|
+
* String bar;
|
|
1218
|
+
* int num = 86;
|
|
1219
|
+
* if ((7 * 42) - num > 200) bar = "This_should_always_happen";
|
|
1220
|
+
* else bar = param;
|
|
1221
|
+
* String sql = "SELECT ... PASSWORD='" + bar + "'";
|
|
1222
|
+
*
|
|
1223
|
+
* Three bindings. `String bar;` binds no value at all. `bar = param` sits in an
|
|
1224
|
+
* arm that cannot run, because 294 - 86 is 208. The only write that can reach
|
|
1225
|
+
* the string is a literal - and the rule could not see any of that, because the
|
|
1226
|
+
* only code that knew how to decide a condition was sealed inside the tracer.
|
|
1227
|
+
* src/taint/fold.ts is that same code, moved out and shared.
|
|
1228
|
+
*
|
|
1229
|
+
* What changed, exactly:
|
|
1230
|
+
* - a declaration that binds no value is not a write, and is skipped;
|
|
1231
|
+
* - a write inside an arm the folder proves cannot run is set aside;
|
|
1232
|
+
* - a ternary contributes the side that runs when its condition is decided,
|
|
1233
|
+
* and BOTH sides when it is not - so `flag ? "asc" : "desc"` is constant;
|
|
1234
|
+
* - there must still be at least one live write, and every live value must
|
|
1235
|
+
* be a plain literal. A parameter is still disqualifying, full stop.
|
|
1236
|
+
*
|
|
1237
|
+
* Every doubt resolves toward reporting. An undecidable condition keeps both
|
|
1238
|
+
* arms live; a value the folder cannot see into is not a literal; a name with
|
|
1239
|
+
* no live write is not provable. ConstantProofFence.java is the list of shapes
|
|
1240
|
+
* that look withdrawable and must not be withdrawn.
|
|
1241
|
+
*/
|
|
1242
|
+
export interface ConstantProofOptions {
|
|
1243
|
+
/** RuleContext.staticMethod. Absent: no helper in another file is read. */
|
|
1244
|
+
readonly staticMethod?: StaticMethodLookup | undefined;
|
|
1245
|
+
/** RuleContext.javaTypeInScan. Absent: every supertype is assumed to be in the scan. */
|
|
1246
|
+
readonly javaTypeInScan?: ((simpleName: string) => boolean) | undefined;
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1249
|
+
export function constantProof(
|
|
1250
|
+
node: Node,
|
|
1251
|
+
dynamicParts: readonly string[],
|
|
1252
|
+
language: LanguageId,
|
|
1253
|
+
options: ConstantProofOptions = {},
|
|
1254
|
+
): ConstantProof | null {
|
|
1255
|
+
if (dynamicParts.length === 0) return null;
|
|
1256
|
+
// A comment the compiler ends where the parser does not - see
|
|
1257
|
+
// taint/visibility.ts. Such a file is one this proof cannot read.
|
|
1258
|
+
if (hidesCodeFromParser(node, language)) return null;
|
|
1259
|
+
|
|
1260
|
+
/*
|
|
1261
|
+
* A fresh folder per question AND PER FILE: a folder's caches are keyed by
|
|
1262
|
+
* source span, and a span is only meaningful inside one tree. Once a helper
|
|
1263
|
+
* in another file can be read, one folder for both would answer a question
|
|
1264
|
+
* about bytes 400-980 of Utils.java with what it learned about bytes
|
|
1265
|
+
* 400-980 of the caller.
|
|
1266
|
+
*/
|
|
1267
|
+
const folders = new Map<unknown, Folder>();
|
|
1268
|
+
const folderOf = (n: Node): Folder => {
|
|
1269
|
+
let folder = folders.get(n.tree);
|
|
1270
|
+
if (!folder) {
|
|
1271
|
+
folder = createFolder(language);
|
|
1272
|
+
folders.set(n.tree, folder);
|
|
1273
|
+
}
|
|
1274
|
+
return folder;
|
|
1275
|
+
};
|
|
1276
|
+
const treeIds = new Map<unknown, number>();
|
|
1277
|
+
/** A key for a node that is unique across files, not just within one. */
|
|
1278
|
+
const spanKey = (n: Node): string => {
|
|
1279
|
+
let id = treeIds.get(n.tree);
|
|
1280
|
+
if (id === undefined) {
|
|
1281
|
+
id = treeIds.size;
|
|
1282
|
+
treeIds.set(n.tree, id);
|
|
1283
|
+
}
|
|
1284
|
+
return `${id}:${n.startIndex}:${n.endIndex}`;
|
|
1285
|
+
};
|
|
1286
|
+
|
|
1287
|
+
const lineOf = (n: Node): number => n.startPosition.row + 1;
|
|
1288
|
+
const contains = (outer: Node, inner: Node): boolean =>
|
|
1289
|
+
inner.startIndex >= outer.startIndex && inner.endIndex <= outer.endIndex;
|
|
1290
|
+
const sameSpan = (a: Node, b: Node): boolean =>
|
|
1291
|
+
a.startIndex === b.startIndex && a.endIndex === b.endIndex;
|
|
1292
|
+
|
|
1293
|
+
/*
|
|
1294
|
+
* The arm of an enclosing `if`, or the case of an enclosing switch, that
|
|
1295
|
+
* cannot run - if `n` sits in one. Returned with the sentence the receipt
|
|
1296
|
+
* uses, so the reader is told which decision made the write irrelevant.
|
|
1297
|
+
*/
|
|
1298
|
+
const deadArmHolding = (n: Node, scope: Node, body: Node): { at: Node; why: string } | null => {
|
|
1299
|
+
let current: Node | null = n.parent;
|
|
1300
|
+
while (current && contains(scope, current)) {
|
|
1301
|
+
if (current.type === 'if_statement') {
|
|
1302
|
+
const dead = folderOf(current).deadBranchOf(current, body);
|
|
1303
|
+
if (dead && contains(dead, n)) {
|
|
1304
|
+
return { at: current, why: `is in a branch that cannot run (line ${lineOf(current)})` };
|
|
1305
|
+
}
|
|
1306
|
+
}
|
|
1307
|
+
if (SWITCH_NODES.has(current.type)) {
|
|
1308
|
+
const decision = folderOf(current).switchDecision(current, body);
|
|
1309
|
+
if (decision?.dead.some((group) => contains(group, n))) {
|
|
1310
|
+
return { at: current, why: `is in a case that cannot run: ${decision.note}` };
|
|
1311
|
+
}
|
|
1312
|
+
}
|
|
1313
|
+
if (sameSpan(current, scope)) break;
|
|
1314
|
+
current = current.parent;
|
|
1315
|
+
}
|
|
1316
|
+
return null;
|
|
1317
|
+
};
|
|
1318
|
+
|
|
1319
|
+
/** Every value an expression can take, as nodes - or null if that cannot be bounded. */
|
|
1320
|
+
const liveValues = (value: Node, body: Node, depth: number): { nodes: Node[]; note: string | null } | null => {
|
|
1321
|
+
let v: Node = value;
|
|
1322
|
+
/*
|
|
1323
|
+
* Go wraps EVERY assigned value in an expression_list, even a single one,
|
|
1324
|
+
* because `a, b := 1, 2` is legal. So `q := "x"` never looked like a literal
|
|
1325
|
+
* to this check - not after the folder arrived, and not before it either:
|
|
1326
|
+
* Go had no constant proof at all until constant-branch.go asked for one.
|
|
1327
|
+
* A one-element list is unwrapped like parentheses; a real multi-value
|
|
1328
|
+
* assignment is left alone, and reads as "not a literal", which is safe.
|
|
1329
|
+
*/
|
|
1330
|
+
while (
|
|
1331
|
+
(v.type === 'parenthesized_expression' || v.type === 'expression_list') &&
|
|
1332
|
+
v.namedChildren.length === 1
|
|
1333
|
+
) {
|
|
1334
|
+
v = v.namedChildren[0] ?? v;
|
|
1335
|
+
}
|
|
1336
|
+
if (!TERNARY_NODES.has(v.type) || depth > 4) return { nodes: [v], note: null };
|
|
1337
|
+
const parts = folderOf(v).ternaryParts(v);
|
|
1338
|
+
const truth = folderOf(v).conditionTruth(parts.condition, body);
|
|
1339
|
+
if (truth !== undefined) {
|
|
1340
|
+
const live = truth ? parts.consequence : parts.alternative;
|
|
1341
|
+
if (!live) return null;
|
|
1342
|
+
const inner = liveValues(live, body, depth + 1);
|
|
1343
|
+
return inner && {
|
|
1344
|
+
nodes: inner.nodes,
|
|
1345
|
+
note: `the condition on line ${lineOf(v)} is fixed at compile time, and the side that runs is a literal`,
|
|
1346
|
+
};
|
|
1347
|
+
}
|
|
1348
|
+
if (!parts.consequence || !parts.alternative) return null;
|
|
1349
|
+
const a = liveValues(parts.consequence, body, depth + 1);
|
|
1350
|
+
const b = liveValues(parts.alternative, body, depth + 1);
|
|
1351
|
+
if (!a || !b) return null;
|
|
1352
|
+
return { nodes: [...a.nodes, ...b.nodes], note: `both sides of the condition on line ${lineOf(v)} are literals` };
|
|
1353
|
+
};
|
|
1354
|
+
|
|
1355
|
+
/*
|
|
1356
|
+
* ONE LEVEL INTO A HELPER. See HelperConstantProof.java and
|
|
1357
|
+
* StaticHelperCommand.java for the fences.
|
|
1358
|
+
*
|
|
1359
|
+
* 46 of BenchmarkJava's surviving SQL false positives had their decoy inside
|
|
1360
|
+
* a helper: `String bar = new Test().doSomething(request, param)`. The tracer
|
|
1361
|
+
* follows that call; this proof stopped at it. It follows three shapes, each
|
|
1362
|
+
* chosen because the method that runs is not in doubt:
|
|
1363
|
+
*
|
|
1364
|
+
* `new X(...).m(...)`, X declared in this file, X declaring ONE method m;
|
|
1365
|
+
* `m(...)` in this class, when m is private, static or final;
|
|
1366
|
+
* `Utils.m(...)` or `a.b.Utils.m(...)` - a static method, which may live
|
|
1367
|
+
* in ANOTHER file; taint/project.ts, staticMethod(), says when that file
|
|
1368
|
+
* and that method are the only candidates.
|
|
1369
|
+
*
|
|
1370
|
+
* Every `return` in m must be provable by the same rules as everything else
|
|
1371
|
+
* here, with one addition: a parameter of m is as fixed as the argument this
|
|
1372
|
+
* call passes for it - when that argument is provably fixed itself, the
|
|
1373
|
+
* counts line up with no varargs, and m never writes to the parameter. That
|
|
1374
|
+
* is what makes `getOSCommandString("echo")` fixed: it wraps literals around
|
|
1375
|
+
* `append`, and `append` is "echo".
|
|
1376
|
+
*
|
|
1377
|
+
* Java only. A receiver that is a variable could be any subclass; an
|
|
1378
|
+
* overload depends on argument types this proof does not read; a helper
|
|
1379
|
+
* returning another helper is a second level, and there is no second level.
|
|
1380
|
+
* Other languages' helpers are not followed yet, and the SQL rule's
|
|
1381
|
+
* limitations say so rather than letting "constant" imply they are.
|
|
1382
|
+
*/
|
|
1383
|
+
const bindings = new Map<string, Map<string, () => string[] | null>>();
|
|
1384
|
+
|
|
1385
|
+
/*
|
|
1386
|
+
* THE `new` A LOCAL CERTAINLY HOLDS, if it holds one.
|
|
1387
|
+
*
|
|
1388
|
+
* SeparateClassRequest scr = new SeparateClassRequest(request);
|
|
1389
|
+
* String param = scr.getTheValue("BenchmarkTest00930");
|
|
1390
|
+
*
|
|
1391
|
+
* A variable could hold any subclass - unless it is a local declared once,
|
|
1392
|
+
* in view of the call, with `= new X(...)`, and never written again anywhere
|
|
1393
|
+
* in the method (closures included). Then it holds that X and nothing else.
|
|
1394
|
+
* Not a parameter, not a field, not a name a lambda or a pattern also binds,
|
|
1395
|
+
* and not read from inside a class that could inherit a field of that name
|
|
1396
|
+
* (LocalObjectHelper.java has each).
|
|
1397
|
+
*
|
|
1398
|
+
* And the object must never leave the method's hands: every use of the name
|
|
1399
|
+
* is as the receiver of a call, `h.m(...)`. Passed as an argument, it can be
|
|
1400
|
+
* changed by whatever receives it - reflection rewrites a private field
|
|
1401
|
+
* (tests/probes/r3-b07) - and `h.f = ...` writes a field directly.
|
|
1402
|
+
*/
|
|
1403
|
+
const heldCreation = (name: string, call: Node): Node | null => {
|
|
1404
|
+
if (language !== 'java' || !name) return null;
|
|
1405
|
+
const fn = enclosingScopes(call)[0];
|
|
1406
|
+
if (!fn || parameterNames(fn).has(name) || boundElsewhere(rootOf(call), name)) return null;
|
|
1407
|
+
const declarators = fn
|
|
1408
|
+
.descendantsOfType('variable_declarator')
|
|
1409
|
+
.filter((d): d is Node => !!d && d.childForFieldName('name')?.text === name);
|
|
1410
|
+
const declarator = declarators.length === 1 ? declarators[0] : undefined;
|
|
1411
|
+
const value = declarator?.childForFieldName('value');
|
|
1412
|
+
if (!declarator || value?.type !== 'object_creation_expression') return null;
|
|
1413
|
+
if (declarator.parent?.type !== 'local_variable_declaration') return null;
|
|
1414
|
+
const visible = declarationVisibility(declarator, language);
|
|
1415
|
+
if (!visible || !contains(visible.region, call) || call.startIndex < visible.from) return null;
|
|
1416
|
+
const f = folderOf(fn);
|
|
1417
|
+
if (f.writtenOtherwise(name, fn) || f.plainWriteCount(name, fn) !== 1) return null;
|
|
1418
|
+
if (enclosingClassLikes(call).some((c) => contains(visible.region, c) && supertypesOf(c).length > 0)) return null;
|
|
1419
|
+
const nameNode = declarator.childForFieldName('name');
|
|
1420
|
+
const onlyReceiver = fn.descendantsOfType('identifier').every((id) => {
|
|
1421
|
+
if (!id || id.text !== name || (nameNode && sameSpan(id, nameNode))) return true;
|
|
1422
|
+
const p = id.parent;
|
|
1423
|
+
return p?.type === 'method_invocation' && !!p.childForFieldName('object') && sameSpan(p.childForFieldName('object')!, id);
|
|
1424
|
+
});
|
|
1425
|
+
if (!onlyReceiver) return null;
|
|
1426
|
+
return value;
|
|
1427
|
+
};
|
|
1428
|
+
|
|
1429
|
+
const helperReturnsConstant = (call: Node, depth: number): string[] | null => {
|
|
1430
|
+
if (depth > 0 || language !== 'java' || call.type !== 'method_invocation') return null;
|
|
1431
|
+
const receiver = call.childForFieldName('object');
|
|
1432
|
+
const methodName = call.childForFieldName('name')?.text ?? '';
|
|
1433
|
+
if (!methodName) return null;
|
|
1434
|
+
|
|
1435
|
+
const methodsNamed = (klass: Node): Node[] =>
|
|
1436
|
+
(klass.childForFieldName('body')?.namedChildren ?? []).filter(
|
|
1437
|
+
(m): m is Node => m !== null && m.type === 'method_declaration' && m.childForFieldName('name')?.text === methodName,
|
|
1438
|
+
);
|
|
1439
|
+
let root: Node = call;
|
|
1440
|
+
while (root.parent) root = root.parent;
|
|
1441
|
+
|
|
1442
|
+
let method: Node | null = null;
|
|
1443
|
+
let typeName = '';
|
|
1444
|
+
let where = '';
|
|
1445
|
+
/** Could a supertype declared in the scan supply a member we did not look at? */
|
|
1446
|
+
const inherits = (classLike: Node): boolean =>
|
|
1447
|
+
supertypesOf(classLike).some((t) =>
|
|
1448
|
+
t.split('.').some((part) => (options.javaTypeInScan ? options.javaTypeInScan(part) : true)),
|
|
1449
|
+
);
|
|
1450
|
+
/*
|
|
1451
|
+
* `h.m(...)` where `h` is a local that certainly holds a `new X(...)` - see
|
|
1452
|
+
* heldCreation below. The object is exactly an X, so it is read like
|
|
1453
|
+
* `new X(...).m(...)`.
|
|
1454
|
+
*/
|
|
1455
|
+
const created =
|
|
1456
|
+
receiver?.type === 'object_creation_expression'
|
|
1457
|
+
? receiver
|
|
1458
|
+
: receiver?.type === 'identifier'
|
|
1459
|
+
? heldCreation(receiver.text ?? '', call)
|
|
1460
|
+
: null;
|
|
1461
|
+
if (created) {
|
|
1462
|
+
/*
|
|
1463
|
+
* `new X(...).m(...)`: the type is exact, so X's own m is the one that
|
|
1464
|
+
* runs - when X is the class in scope here. Not with a body attached
|
|
1465
|
+
* (`new X() { ... }` is an anonymous subclass that can override m, p25),
|
|
1466
|
+
* not when X is a class nested somewhere the call cannot see (p26), and
|
|
1467
|
+
* not when X inherits from anything - a superclass or an interface's
|
|
1468
|
+
* default method - since an inherited overload can be the one chosen
|
|
1469
|
+
* (p34, j03). X may live in another file, found the way a static
|
|
1470
|
+
* helper's class is (staticMethod), with an instance method allowed.
|
|
1471
|
+
*/
|
|
1472
|
+
if (isClassLike(created)) return null;
|
|
1473
|
+
// `o.new X()` creates o's member class X - not the X this name finds
|
|
1474
|
+
// from here (tests/probes/r3-a06, r3-a08, r3-d01).
|
|
1475
|
+
if (created.child(0)?.type !== 'new') return null;
|
|
1476
|
+
typeName = (created.childForFieldName('type')?.text ?? '').replace(/\s+/g, '');
|
|
1477
|
+
if (!/^[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*$/.test(typeName)) return null; // generic: not read
|
|
1478
|
+
// A class around the call can inherit a member class named X, and then
|
|
1479
|
+
// that is the X created here (tests/probes/j04).
|
|
1480
|
+
if (enclosingClassLikes(call).some(inherits)) return null;
|
|
1481
|
+
const declared: Node[] = [];
|
|
1482
|
+
const findTypes = (n: Node): void => {
|
|
1483
|
+
if (n.type !== 'object_creation_expression' && isClassLike(n) && n.childForFieldName('name')?.text === typeName) {
|
|
1484
|
+
declared.push(n);
|
|
1485
|
+
}
|
|
1486
|
+
for (const child of n.namedChildren) if (child) findTypes(child);
|
|
1487
|
+
};
|
|
1488
|
+
if (!typeName.includes('.')) findTypes(root);
|
|
1489
|
+
if (declared.length > 0) {
|
|
1490
|
+
const klass = declared.length === 1 ? declared[0] : undefined;
|
|
1491
|
+
if (!klass || klass.type !== 'class_declaration' || supertypesOf(klass).length > 0) return null;
|
|
1492
|
+
const owner = klass.parent?.parent ?? null;
|
|
1493
|
+
const inScope =
|
|
1494
|
+
klass.parent?.type === 'program' ||
|
|
1495
|
+
(klass.parent?.type === 'class_body' && !!owner && contains(owner, call) && isClassLike(owner));
|
|
1496
|
+
if (!inScope) return null;
|
|
1497
|
+
const candidates = methodsNamed(klass);
|
|
1498
|
+
if (candidates.length !== 1) return null;
|
|
1499
|
+
method = candidates[0]!;
|
|
1500
|
+
} else {
|
|
1501
|
+
const first = typeName.split('.')[0] ?? '';
|
|
1502
|
+
if (!options.staticMethod || !first || !usedOnlyAsQualifier(root, first)) return null;
|
|
1503
|
+
const supertypes = enclosingClassLikes(call).flatMap((c) => supertypesOf(c));
|
|
1504
|
+
const found = options.staticMethod(typeName, methodName, supertypes, true);
|
|
1505
|
+
if (!found || hidesCodeFromParser(found.method, 'java')) return null;
|
|
1506
|
+
method = found.method;
|
|
1507
|
+
where = ` in ${found.path}`;
|
|
1508
|
+
}
|
|
1509
|
+
typeName = typeName.split('.').pop() ?? typeName;
|
|
1510
|
+
} else if (!receiver) {
|
|
1511
|
+
/*
|
|
1512
|
+
* A bare call, `doSomething(request, param)`. It binds to this class's
|
|
1513
|
+
* method only if no subclass can replace it - so only when that method
|
|
1514
|
+
* is private, static or final, read from the modifier tokens (p29).
|
|
1515
|
+
* BenchmarkJava declares its helpers `private static`, and 27 of the
|
|
1516
|
+
* survivors were exactly this shape. The
|
|
1517
|
+
* class is the INNERMOST class-like around the call: inside an anonymous
|
|
1518
|
+
* class or an enum, `m()` means theirs (p27, p28), and those are not
|
|
1519
|
+
* read. A superclass declared in the scan can supply a more specific
|
|
1520
|
+
* overload (p30); one from outside it - BenchmarkJava's HttpServlet - is
|
|
1521
|
+
* not read, and the SQL rule's limitations say so.
|
|
1522
|
+
*/
|
|
1523
|
+
const klass = enclosingClassLikes(call)[0];
|
|
1524
|
+
if (!klass || klass.type !== 'class_declaration' || inherits(klass)) return null;
|
|
1525
|
+
typeName = klass.childForFieldName('name')?.text ?? '';
|
|
1526
|
+
const candidates = methodsNamed(klass);
|
|
1527
|
+
if (candidates.length !== 1) return null;
|
|
1528
|
+
const candidate = candidates[0]!;
|
|
1529
|
+
if (!['private', 'static', 'final'].some((k) => hasModifier(candidate, k))) return null;
|
|
1530
|
+
method = candidate;
|
|
1531
|
+
} else if ((receiver.type === 'identifier' || receiver.type === 'field_access') && options.staticMethod) {
|
|
1532
|
+
/*
|
|
1533
|
+
* `Utils.m(...)`. In Java a variable named `Utils` - or `a` for
|
|
1534
|
+
* `a.b.Utils` - hides the class or package of that name, and so does a
|
|
1535
|
+
* type, a pattern variable, an enum constant, a lambda parameter... So
|
|
1536
|
+
* EVERY appearance of that name in this file must be as a qualifier,
|
|
1537
|
+
* like the one here (p10, p11, p12). Names that arrive without appearing
|
|
1538
|
+
* - static imports, inherited members - are checked by the lookup.
|
|
1539
|
+
*/
|
|
1540
|
+
const dotted = (receiver.text ?? '').replace(/\s+/g, '');
|
|
1541
|
+
const first = dotted.split('.')[0] ?? '';
|
|
1542
|
+
if (!first || !usedOnlyAsQualifier(root, first)) return null;
|
|
1543
|
+
const supertypes = enclosingClassLikes(call).flatMap((c) => supertypesOf(c));
|
|
1544
|
+
const found = options.staticMethod(dotted, methodName, supertypes);
|
|
1545
|
+
if (!found || hidesCodeFromParser(found.method, 'java')) return null;
|
|
1546
|
+
method = found.method;
|
|
1547
|
+
typeName = dotted.split('.').pop() ?? dotted;
|
|
1548
|
+
where = ` in ${found.path}`;
|
|
1549
|
+
}
|
|
1550
|
+
if (!method) return null; // a receiver that is a variable: it could be any subclass
|
|
1551
|
+
/*
|
|
1552
|
+
* A method calling itself. Its parameters are bound to what this call
|
|
1553
|
+
* passes - and the arguments are the CALLER's parameters, the same names in
|
|
1554
|
+
* the same method, which would then read their own binding: `m(b, "x")`
|
|
1555
|
+
* inside m makes the caller's `b` look like "x" (tests/probes/r3-a07,
|
|
1556
|
+
* r3-z-scope-recursive-static). Not read.
|
|
1557
|
+
*/
|
|
1558
|
+
if (spanKey(rootOf(method)) === spanKey(rootOf(call)) && contains(method, call)) return null;
|
|
1559
|
+
|
|
1560
|
+
/* ---- the parameters, bound to what this call passes ---- */
|
|
1561
|
+
const formal = (method.childForFieldName('parameters')?.namedChildren ?? []).filter(
|
|
1562
|
+
(p): p is Node => p !== null && (p.type === 'formal_parameter' || p.type === 'spread_parameter'),
|
|
1563
|
+
);
|
|
1564
|
+
const args = (call.childForFieldName('arguments')?.namedChildren ?? []).filter(
|
|
1565
|
+
(a): a is Node => a !== null && a.type !== 'line_comment' && a.type !== 'block_comment',
|
|
1566
|
+
);
|
|
1567
|
+
// The count must match with no varargs, whatever is read: a call that
|
|
1568
|
+
// passes more or fewer arguments than this method takes is calling
|
|
1569
|
+
// another one (p02).
|
|
1570
|
+
if (formal.length !== args.length || formal.some((p) => p.type !== 'formal_parameter')) return null;
|
|
1571
|
+
const callerBody = enclosingScopes(call)[0];
|
|
1572
|
+
const bound = new Map<string, () => string[] | null>();
|
|
1573
|
+
const used: string[] = [];
|
|
1574
|
+
if (callerBody) {
|
|
1575
|
+
formal.forEach((p, i) => {
|
|
1576
|
+
const name = p.childForFieldName('name')?.text ?? '';
|
|
1577
|
+
const arg = args[i];
|
|
1578
|
+
if (!name || !arg) return;
|
|
1579
|
+
let memo: string[] | null | undefined;
|
|
1580
|
+
bound.set(name, () => {
|
|
1581
|
+
if (memo === undefined) {
|
|
1582
|
+
const why = valueIsConstant(arg, callerBody, depth);
|
|
1583
|
+
memo = why && [
|
|
1584
|
+
...why,
|
|
1585
|
+
`\`${name}\` is \`${arg.text.replace(/\s+/g, ' ')}\`, passed on line ${lineOf(arg)}`,
|
|
1586
|
+
];
|
|
1587
|
+
if (memo) used.push(name);
|
|
1588
|
+
}
|
|
1589
|
+
return memo;
|
|
1590
|
+
});
|
|
1591
|
+
});
|
|
1592
|
+
}
|
|
1593
|
+
|
|
1594
|
+
const returns: Node[] = [];
|
|
1595
|
+
const findReturns = (n: Node): void => {
|
|
1596
|
+
if (n.type === 'return_statement') returns.push(n);
|
|
1597
|
+
// A return inside a nested class or lambda belongs to that, not to m.
|
|
1598
|
+
if (n.type === 'class_body' || n.type === 'lambda_expression') return;
|
|
1599
|
+
for (const child of n.namedChildren) if (child) findReturns(child);
|
|
1600
|
+
};
|
|
1601
|
+
const methodBody = method.childForFieldName('body');
|
|
1602
|
+
if (!methodBody) return null;
|
|
1603
|
+
findReturns(methodBody);
|
|
1604
|
+
if (returns.length === 0) return null;
|
|
1605
|
+
|
|
1606
|
+
const key = spanKey(method);
|
|
1607
|
+
bindings.set(key, bound);
|
|
1608
|
+
const notes: string[] = [];
|
|
1609
|
+
try {
|
|
1610
|
+
for (const ret of returns) {
|
|
1611
|
+
const expr = ret.namedChildren.find((c): c is Node => c !== null) ?? null;
|
|
1612
|
+
if (!expr) return null;
|
|
1613
|
+
const why = valueIsConstant(expr, method, depth + 1);
|
|
1614
|
+
if (!why) return null;
|
|
1615
|
+
notes.push(...why);
|
|
1616
|
+
}
|
|
1617
|
+
} finally {
|
|
1618
|
+
bindings.delete(key);
|
|
1619
|
+
}
|
|
1620
|
+
if (used.length === 0) {
|
|
1621
|
+
return [`the helper ${typeName}.${methodName}()${where} on line ${lineOf(method)} returns a fixed literal on every path`];
|
|
1622
|
+
}
|
|
1623
|
+
return [
|
|
1624
|
+
`the helper ${typeName}.${methodName}()${where} on line ${lineOf(method)} returns only literals and ` +
|
|
1625
|
+
`its parameter${used.length === 1 ? '' : 's'} ${used.map((u) => `\`${u}\``).join(', ')}, never written inside it`,
|
|
1626
|
+
...notes,
|
|
1627
|
+
];
|
|
1628
|
+
};
|
|
1629
|
+
|
|
1630
|
+
/** Is this one live value fixed? Returns the notes that justify it, or null. */
|
|
1631
|
+
const valueIsConstant = (v: Node, body: Node, depth: number): string[] | null => {
|
|
1632
|
+
const values = liveValues(v, body, 0);
|
|
1633
|
+
if (!values) return null;
|
|
1634
|
+
const notes: string[] = values.note ? [values.note] : [];
|
|
1635
|
+
for (const raw of values.nodes) {
|
|
1636
|
+
if (isPlainLiteral(raw, language)) continue;
|
|
1637
|
+
/*
|
|
1638
|
+
* `(String) map.get("keyA")` or `list.get(1)` - a read from a local map or
|
|
1639
|
+
* list whose every write is on the page (taint/local-map.ts) is the value
|
|
1640
|
+
* stored there. The cast is looked through first; it changes the type,
|
|
1641
|
+
* not the value.
|
|
1642
|
+
*/
|
|
1643
|
+
let each = raw;
|
|
1644
|
+
while (each.type === 'cast_expression' || each.type === 'parenthesized_expression') {
|
|
1645
|
+
each = each.childForFieldName('value') ?? each.namedChildren[each.namedChildren.length - 1] ?? each;
|
|
1646
|
+
if (each === raw) break;
|
|
1647
|
+
}
|
|
1648
|
+
const read = resolveContainerRead(each, language);
|
|
1649
|
+
if (read) {
|
|
1650
|
+
const where =
|
|
1651
|
+
`\`${each.text.replace(/\s+/g, ' ')}\` on line ${lineOf(each)} returns what was stored at ` +
|
|
1652
|
+
`${read.selector} on line ${lineOf(read.write)} - the collection is local, handed to nothing, ` +
|
|
1653
|
+
`and written only by straight-line statements`;
|
|
1654
|
+
if (isPlainLiteral(read.value, language)) {
|
|
1655
|
+
notes.push(where.replace('returns what was stored', 'returns the literal stored'));
|
|
1656
|
+
continue;
|
|
1657
|
+
}
|
|
1658
|
+
/*
|
|
1659
|
+
* A NAME was stored - `map.put("keyB", param)`. Its value is judged
|
|
1660
|
+
* where it was stored, not where it is read: the map holds what the
|
|
1661
|
+
* name held then (ContainerHeldValue.java). Only for a type that cannot
|
|
1662
|
+
* change in place, or an alias could change the stored object later.
|
|
1663
|
+
*/
|
|
1664
|
+
const fn = enclosingMethod(each);
|
|
1665
|
+
const stored = read.value.text ?? '';
|
|
1666
|
+
if (read.value.type !== 'identifier' || !fn || !declaredImmutable(fn, stored)) return null;
|
|
1667
|
+
const inner = nameIsConstant(stored, read.value, depth);
|
|
1668
|
+
if (!inner) return null;
|
|
1669
|
+
notes.push(...inner, `${where}, and \`${stored}\` was fixed when stored`);
|
|
1670
|
+
continue;
|
|
1671
|
+
}
|
|
1672
|
+
/*
|
|
1673
|
+
* `"cmd.exe /c " + append + " "` - pieces joined with `+` are as fixed as
|
|
1674
|
+
* the pieces. Only `+`, only where it joins strings (C's is pointer
|
|
1675
|
+
* arithmetic, PHP's is `.`), and every piece judged by these same rules.
|
|
1676
|
+
*/
|
|
1677
|
+
if (CONCATENATING.has(language) && CONCAT_NODES.has(each.type) && each.childForFieldName('operator')?.text === '+') {
|
|
1678
|
+
const left = each.childForFieldName('left');
|
|
1679
|
+
const right = each.childForFieldName('right');
|
|
1680
|
+
const l = left ? valueIsConstant(left, body, depth) : null;
|
|
1681
|
+
const r = l && right ? valueIsConstant(right, body, depth) : null;
|
|
1682
|
+
if (!l || !r) return null;
|
|
1683
|
+
notes.push(...l, ...r);
|
|
1684
|
+
continue;
|
|
1685
|
+
}
|
|
1686
|
+
if (/^[A-Za-z_$][\w$]*$/.test((each.text ?? '').trim()) && each.namedChildren.length === 0) {
|
|
1687
|
+
const inner = nameIsConstant((each.text ?? '').trim(), each, depth);
|
|
1688
|
+
if (!inner) return null;
|
|
1689
|
+
notes.push(...inner);
|
|
1690
|
+
continue;
|
|
1691
|
+
}
|
|
1692
|
+
const helper = helperReturnsConstant(each, depth);
|
|
1693
|
+
if (!helper) return null;
|
|
1694
|
+
notes.push(...helper);
|
|
1695
|
+
}
|
|
1696
|
+
return notes;
|
|
1697
|
+
};
|
|
1698
|
+
|
|
1699
|
+
/*
|
|
1700
|
+
* THE CYCLE GUARD, BECAUSE THIS PROJECT HAS ALREADY PAID FOR NOT HAVING ONE.
|
|
1701
|
+
*
|
|
1702
|
+
* A value can be another name - `bar = baz` - so resolving a name can land on
|
|
1703
|
+
* a name, and `a = b; b = a` is a loop. The tracer's folder had exactly this
|
|
1704
|
+
* shape with no guard, and DVWA's packed sha256 turned it into an
|
|
1705
|
+
* out-of-memory on a 10KB file. A name already being resolved answers "not
|
|
1706
|
+
* provable", which is the direction that keeps the finding.
|
|
1707
|
+
*/
|
|
1708
|
+
const resolving = new Set<string>();
|
|
1709
|
+
|
|
1710
|
+
/** Is every live write to `part`, as seen from `anchor`, a fixed value? */
|
|
1711
|
+
const nameIsConstant = (part: string, anchor: Node, depth: number): string[] | null => {
|
|
1712
|
+
let scopes = enclosingScopes(anchor);
|
|
1713
|
+
const body = scopes[0];
|
|
1714
|
+
if (!body) return null;
|
|
1715
|
+
/*
|
|
1716
|
+
* A Python name assigned in a function, with no global/nonlocal for it, is
|
|
1717
|
+
* that function's local: no module-level write - and no module-level exec()
|
|
1718
|
+
* - can reach it. Walking the module anyway let an eval() in ANY function
|
|
1719
|
+
* of the file sink every proof (BenchmarkPython's 22 constant-branch evals).
|
|
1720
|
+
*/
|
|
1721
|
+
if (
|
|
1722
|
+
language === 'python' && scopes.length > 1 && body.type === 'function_definition' &&
|
|
1723
|
+
folderOf(body).plainWriteCount(part, body) > 0 && !folderOf(body).writtenOtherwise(part, body)
|
|
1724
|
+
) {
|
|
1725
|
+
scopes = [body];
|
|
1726
|
+
}
|
|
1727
|
+
const key = `${spanKey(body)}#${part}`;
|
|
1728
|
+
if (resolving.has(key) || resolving.size > 24) return null;
|
|
1729
|
+
resolving.add(key);
|
|
1730
|
+
try {
|
|
1731
|
+
return resolveName(part, scopes, body, depth, anchor);
|
|
1732
|
+
} finally {
|
|
1733
|
+
resolving.delete(key);
|
|
1734
|
+
}
|
|
1735
|
+
};
|
|
1736
|
+
|
|
1737
|
+
/** Node types that hold a plain sequence of statements, each run once, in order. */
|
|
1738
|
+
const STATEMENT_LISTS = new Set([
|
|
1739
|
+
'block', 'statement_block', 'compound_statement', 'constructor_body', 'statement_list',
|
|
1740
|
+
'program', 'module', 'source_file',
|
|
1741
|
+
]);
|
|
1742
|
+
/** Wrappers between a write and the statement that holds it. */
|
|
1743
|
+
const STATEMENT_WRAPPERS = new Set([
|
|
1744
|
+
'expression_statement', 'local_variable_declaration', 'lexical_declaration', 'variable_declaration',
|
|
1745
|
+
]);
|
|
1746
|
+
|
|
1747
|
+
/** The write that certainly decides the value at `anchor`, if one does. See resolveName. */
|
|
1748
|
+
const certainLastWrite = (
|
|
1749
|
+
writes: ReadonlyArray<{ node: Node; value: Node; scope: Node }>,
|
|
1750
|
+
anchor: Node,
|
|
1751
|
+
): { node: Node; value: Node } | null => {
|
|
1752
|
+
if (language === 'c' || language === 'cpp' || writes.length === 0) return null;
|
|
1753
|
+
const innermost = enclosingScopes(anchor)[0];
|
|
1754
|
+
if (!innermost || writes.some((w) => !sameSpan(w.scope, innermost))) return null;
|
|
1755
|
+
const last = [...writes].sort((a, b) => a.node.startIndex - b.node.startIndex)[writes.length - 1];
|
|
1756
|
+
if (!last || last.node.endIndex > anchor.startIndex) return null;
|
|
1757
|
+
let statement: Node = last.node;
|
|
1758
|
+
while (statement.parent && STATEMENT_WRAPPERS.has(statement.parent.type)) statement = statement.parent;
|
|
1759
|
+
const holder = statement.parent;
|
|
1760
|
+
if (!holder || !STATEMENT_LISTS.has(holder.type) || !contains(holder, anchor)) return null;
|
|
1761
|
+
return last;
|
|
1762
|
+
};
|
|
1763
|
+
|
|
1764
|
+
const resolveName = (
|
|
1765
|
+
part: string,
|
|
1766
|
+
scopes: Node[],
|
|
1767
|
+
body: Node,
|
|
1768
|
+
depth: number,
|
|
1769
|
+
anchor: Node,
|
|
1770
|
+
): string[] | null => {
|
|
1771
|
+
|
|
1772
|
+
let live = 0;
|
|
1773
|
+
let fixed = true;
|
|
1774
|
+
const notes: string[] = [];
|
|
1775
|
+
|
|
1776
|
+
/* Every plain write, in every scope, before any value is judged. */
|
|
1777
|
+
const writes: Array<{ node: Node; value: Node; scope: Node }> = [];
|
|
1778
|
+
/* Every declaration of the name the same walk passes - with a value or without. */
|
|
1779
|
+
const declarations: Array<{ node: Node; scope: Node }> = [];
|
|
1780
|
+
for (const scope of scopes) {
|
|
1781
|
+
if (parameterNames(scope).has(part)) {
|
|
1782
|
+
/*
|
|
1783
|
+
* A parameter is input - unless this is a helper being read for one
|
|
1784
|
+
* particular call (helperReturnsConstant), and then it is whatever that
|
|
1785
|
+
* call passes. Only if the helper never writes to it: `a = b;` would
|
|
1786
|
+
* make the literal passed for `a` irrelevant.
|
|
1787
|
+
*/
|
|
1788
|
+
const bound = bindings.get(spanKey(scope))?.get(part);
|
|
1789
|
+
if (!bound) return null;
|
|
1790
|
+
const f = folderOf(scope);
|
|
1791
|
+
if (f.writtenOtherwise(part, scope) || f.plainWriteCount(part, scope) > 0) return null;
|
|
1792
|
+
return bound();
|
|
1793
|
+
}
|
|
1794
|
+
/*
|
|
1795
|
+
* A write this walk does not recognise - `bar, err := f()`, `[bar] = x`,
|
|
1796
|
+
* `bar += param` in Python, `for bar in ...` - would leave only the literal
|
|
1797
|
+
* writes visible, and the proof would call a tainted value constant. The
|
|
1798
|
+
* folder's survey sees every form of write; any irregular one ends this.
|
|
1799
|
+
*/
|
|
1800
|
+
if (folderOf(scope).writtenOtherwise(part, scope)) return null;
|
|
1801
|
+
const collect = (n: Node | null): void => {
|
|
1802
|
+
if (!n) return;
|
|
1803
|
+
if (n !== scope && SCOPE_NODES.has(n.type)) return;
|
|
1804
|
+
const fields = BINDING_NODES[n.type];
|
|
1805
|
+
if (fields && (n.childForFieldName(fields.name)?.text ?? '').trim() === part) {
|
|
1806
|
+
const value = n.childForFieldName(fields.value);
|
|
1807
|
+
if (value) writes.push({ node: n, value, scope });
|
|
1808
|
+
}
|
|
1809
|
+
if (DECLARATION_NODES.has(n.type) && (n.childForFieldName(n.type === 'short_var_declaration' ? 'left' : 'name')?.text ?? '').trim() === part) {
|
|
1810
|
+
declarations.push({ node: n, scope });
|
|
1811
|
+
}
|
|
1812
|
+
for (const child of n.namedChildren) collect(child ?? null);
|
|
1813
|
+
};
|
|
1814
|
+
collect(scope);
|
|
1815
|
+
}
|
|
1816
|
+
/*
|
|
1817
|
+
* A WRITE INSIDE A CLOSURE is a write this walk does not see - it stops at
|
|
1818
|
+
* nested functions on purpose - and `let bar = "x"; const set = () => {
|
|
1819
|
+
* bar = req.query.q; }; set();` would read as constant. The folder's survey
|
|
1820
|
+
* walks nested functions too; if it counts more writes here than this walk
|
|
1821
|
+
* did, one of them is hidden in a closure, and nothing is proved.
|
|
1822
|
+
*/
|
|
1823
|
+
const innermost = scopes[0];
|
|
1824
|
+
if (innermost && folderOf(innermost).plainWriteCount(part, innermost) > writes.filter((w) => w.scope === innermost).length) {
|
|
1825
|
+
return null;
|
|
1826
|
+
}
|
|
1827
|
+
|
|
1828
|
+
let outerBound = writes.some((w) => innermost && !sameSpan(w.scope, innermost));
|
|
1829
|
+
|
|
1830
|
+
/*
|
|
1831
|
+
* JAVA SCOPING. The walk above collects every declaration of the name in
|
|
1832
|
+
* the scopes around the read - and Java's scopes are smaller than that.
|
|
1833
|
+
* Each probe in tests/probes/p15-p24 was a constant declaration the read
|
|
1834
|
+
* could not see, standing in for one it could:
|
|
1835
|
+
*
|
|
1836
|
+
* - a local is visible from its declaration to the end of its block, so
|
|
1837
|
+
* one in another block (p17), one declared after the read (p18), one
|
|
1838
|
+
* inside a lambda (p22) and a field of a nested or local class (p19,
|
|
1839
|
+
* p23, p24) are somebody else's - and any of those ends the proof;
|
|
1840
|
+
* - a lambda parameter or a pattern variable of the same name can shadow
|
|
1841
|
+
* the declaration found, and the walk does not collect them (p15, p16);
|
|
1842
|
+
* - a class between the declaration and the read that inherits from
|
|
1843
|
+
* anything can inherit a field of the same name, which then wins (p20,
|
|
1844
|
+
* p21);
|
|
1845
|
+
* - a field counts only if it is private or final, read from the
|
|
1846
|
+
* modifier TOKENS - the word in `@SuppressWarnings("private")`, or a
|
|
1847
|
+
* `final` inside a comment, is not a modifier (p13, p14) - since any
|
|
1848
|
+
* other class can assign one that is neither;
|
|
1849
|
+
* - no declaration in view at all - an inherited field, a static import -
|
|
1850
|
+
* is nothing this proof can read.
|
|
1851
|
+
*/
|
|
1852
|
+
if (language === 'java') {
|
|
1853
|
+
if (boundElsewhere(rootOf(anchor), part)) return null;
|
|
1854
|
+
if (declarations.length === 0) return null;
|
|
1855
|
+
let local = false;
|
|
1856
|
+
for (const { node: declarator } of declarations) {
|
|
1857
|
+
const visible = declarationVisibility(declarator, language);
|
|
1858
|
+
if (!visible || !contains(visible.region, anchor) || anchor.startIndex < visible.from) return null;
|
|
1859
|
+
if (enclosingClassLikes(anchor).some((c) => contains(visible.region, c) && supertypesOf(c).length > 0)) {
|
|
1860
|
+
return null;
|
|
1861
|
+
}
|
|
1862
|
+
const holder = declarator.parent;
|
|
1863
|
+
if (holder?.type === 'local_variable_declaration') local = true;
|
|
1864
|
+
else if (holder?.type === 'field_declaration' && !hasModifier(holder, 'private') && !hasModifier(holder, 'final')) {
|
|
1865
|
+
return null;
|
|
1866
|
+
}
|
|
1867
|
+
}
|
|
1868
|
+
/*
|
|
1869
|
+
* A write inside a class nested in this code - a local or anonymous
|
|
1870
|
+
* class, a record's constructor, an initializer block - runs as THAT
|
|
1871
|
+
* class's code, where every name around it may mean something else:
|
|
1872
|
+
* `if (dbg) cmd = ...` inside a record reads the record's own `dbg`
|
|
1873
|
+
* (tests/probes/j09, j10). Its branches cannot be decided from here, so
|
|
1874
|
+
* such a write ends the proof.
|
|
1875
|
+
*/
|
|
1876
|
+
if (writes.some((w) => enclosingClassLikes(w.node).some((c) => !contains(c, anchor)))) return null;
|
|
1877
|
+
if (!local) outerBound = true;
|
|
1878
|
+
} else if (language === 'javascript' || language === 'typescript' || language === 'go') {
|
|
1879
|
+
/*
|
|
1880
|
+
* The same visibility rule as Java's, by these languages' own scoping:
|
|
1881
|
+
* every declaration found must be one the read can see (a block's
|
|
1882
|
+
* `let`, a `for` header's, a Go block's `:=` - s02, s03, s05, s06, g05),
|
|
1883
|
+
* and there must be one: a name declared nowhere in view is an import,
|
|
1884
|
+
* another script's global or another file's package variable (s04, g04).
|
|
1885
|
+
*/
|
|
1886
|
+
if (language === 'go' && goBindsOtherwise(rootOf(anchor), part)) return null;
|
|
1887
|
+
if (declarations.length === 0) return null;
|
|
1888
|
+
for (const { node: declarator } of declarations) {
|
|
1889
|
+
const visible = declarationVisibility(declarator, language);
|
|
1890
|
+
if (!visible || !contains(visible.region, anchor) || anchor.startIndex < visible.from) return null;
|
|
1891
|
+
}
|
|
1892
|
+
// Not declared in the reading function: it belongs to a scope outside,
|
|
1893
|
+
// where any other function can assign it - with or without a write of
|
|
1894
|
+
// its own in view.
|
|
1895
|
+
if (!declarations.some((d) => innermost && sameSpan(d.scope, innermost))) outerBound = true;
|
|
1896
|
+
}
|
|
1897
|
+
|
|
1898
|
+
/*
|
|
1899
|
+
* A NAME BOUND OUTSIDE THIS FUNCTION CAN BE WRITTEN BY ANY OTHER ONE.
|
|
1900
|
+
*
|
|
1901
|
+
* static String table = "users";
|
|
1902
|
+
* void setTable(HttpServletRequest r) { table = r.getParameter("t"); }
|
|
1903
|
+
* void list(Statement s) { s.execute("SELECT * FROM " + table); }
|
|
1904
|
+
*
|
|
1905
|
+
* The walk above does not enter other functions - on purpose, because a
|
|
1906
|
+
* sibling's OWN local of the same name is irrelevant (const-interp.py). But
|
|
1907
|
+
* `table` here is not anybody's local: it is a field, and setTable writes
|
|
1908
|
+
* it. The walk saw only "users" and called the query constant. So for a
|
|
1909
|
+
* name bound outside the reading function, every function inside the scope
|
|
1910
|
+
* that holds it is searched too, and any write not already judged ends the
|
|
1911
|
+
* proof - a sibling's same-named local included, which costs a little
|
|
1912
|
+
* precision and never a finding. OuterWriteFence.* has each shape.
|
|
1913
|
+
*
|
|
1914
|
+
* Python and PHP are left out on purpose: a plain assignment inside a
|
|
1915
|
+
* function makes a NEW local there, and reaching the outer name takes a
|
|
1916
|
+
* `global` (or `nonlocal`) statement - which the survey above already
|
|
1917
|
+
* treats as an irregular write.
|
|
1918
|
+
*/
|
|
1919
|
+
if (outerBound && language !== 'python' && language !== 'php') {
|
|
1920
|
+
const holding = [...declarations.map((d) => d.scope), ...writes.map((w) => w.scope)];
|
|
1921
|
+
const declaring =
|
|
1922
|
+
(holding.length > 0 && innermost && holding.every((h) => sameSpan(h, innermost))
|
|
1923
|
+
? scopes[scopes.length - 1]
|
|
1924
|
+
: [...scopes].reverse().find((s) => holding.some((h) => sameSpan(h, s)))) ?? scopes[scopes.length - 1];
|
|
1925
|
+
if (!declaring) return null;
|
|
1926
|
+
const folder = folderOf(declaring);
|
|
1927
|
+
if (folder.writtenOtherwise(part, declaring) || folder.plainWriteCount(part, declaring) > writes.length) return null;
|
|
1928
|
+
}
|
|
1929
|
+
|
|
1930
|
+
/*
|
|
1931
|
+
* THE LAST CERTAIN WRITE.
|
|
1932
|
+
*
|
|
1933
|
+
* bar = (String) map.get("keyB"); // the request parameter
|
|
1934
|
+
* bar = (String) map.get("keyA"); // a literal
|
|
1935
|
+
* sql = "... " + bar;
|
|
1936
|
+
*
|
|
1937
|
+
* Every write to `bar` used to have to be a literal, so the dirty one above
|
|
1938
|
+
* - overwritten one line later - sank the proof. When one write is certain
|
|
1939
|
+
* to run after all the others and before the read, it is the only one that
|
|
1940
|
+
* matters. Certain means: it is a statement directly in a block that holds
|
|
1941
|
+
* the read, it comes before the read, and NO write to the name comes after
|
|
1942
|
+
* it anywhere in the function - so no loop can bring an earlier or later
|
|
1943
|
+
* value round again. Not attempted in C or C++, where a declaration in an
|
|
1944
|
+
* inner block can shadow the name without this walk seeing a write.
|
|
1945
|
+
*/
|
|
1946
|
+
// Only for a name local to the reading function: a call between the last
|
|
1947
|
+
// write and the read can change a field, or a variable another function
|
|
1948
|
+
// shares.
|
|
1949
|
+
// A `goto` can jump over the last write (tests/probes/g01).
|
|
1950
|
+
const jumps = !!innermost && innermost.descendantsOfType('goto_statement').length > 0;
|
|
1951
|
+
const last = outerBound || jumps ? null : certainLastWrite(writes, anchor);
|
|
1952
|
+
if (last) {
|
|
1953
|
+
const why = valueIsConstant(last.value, body, depth);
|
|
1954
|
+
if (!why) return null;
|
|
1955
|
+
return [
|
|
1956
|
+
...why,
|
|
1957
|
+
`the value at line ${lineOf(anchor)} is the one written on line ${lineOf(last.node)}, which ` +
|
|
1958
|
+
`overwrites every earlier write and is never overwritten itself`,
|
|
1959
|
+
];
|
|
1960
|
+
}
|
|
1961
|
+
|
|
1962
|
+
/*
|
|
1963
|
+
* A FIELD OUTLIVES THE CALL. Inside a helper read for one call, a parameter
|
|
1964
|
+
* means what THAT call passes - but a field written from a parameter, `f =
|
|
1965
|
+
* v`, holds what whichever call ran LAST passed: `swap(tainted);
|
|
1966
|
+
* swap("x")` returns the tainted value, and a lazy cache keeps the first
|
|
1967
|
+
* call's (tests/probes/r3-a04, r3-a05, r3-a09, r3-z-scope-static-swap). So
|
|
1968
|
+
* a name bound outside the reading function is judged with no parameter
|
|
1969
|
+
* bound, and a parameter written into it is input.
|
|
1970
|
+
*/
|
|
1971
|
+
const suspended = outerBound && bindings.size > 0 ? new Map(bindings) : null;
|
|
1972
|
+
if (suspended) bindings.clear();
|
|
1973
|
+
try {
|
|
1974
|
+
for (const scope of scopes) {
|
|
1975
|
+
const walk = (n: Node | null): void => {
|
|
1976
|
+
if (!n || !fixed) return;
|
|
1977
|
+
// Do NOT descend into a nested scope. Searching the module body walked
|
|
1978
|
+
// straight into sibling functions, so an unrelated `t = request.args...`
|
|
1979
|
+
// three functions away made a genuinely constant `t` here look tainted.
|
|
1980
|
+
if (n !== scope && SCOPE_NODES.has(n.type)) return;
|
|
1981
|
+
const fields = BINDING_NODES[n.type];
|
|
1982
|
+
if (fields) {
|
|
1983
|
+
const target = n.childForFieldName(fields.name);
|
|
1984
|
+
if ((target?.text ?? '').trim() === part) {
|
|
1985
|
+
const value = n.childForFieldName(fields.value);
|
|
1986
|
+
if (value) {
|
|
1987
|
+
const deadIf = deadArmHolding(n, scope, body);
|
|
1988
|
+
if (deadIf) {
|
|
1989
|
+
notes.push(`the write on line ${lineOf(n)} ${deadIf.why}`);
|
|
1990
|
+
} else {
|
|
1991
|
+
live++;
|
|
1992
|
+
const why = valueIsConstant(value, body, depth);
|
|
1993
|
+
if (!why) fixed = false;
|
|
1994
|
+
else notes.push(...why);
|
|
1995
|
+
}
|
|
1996
|
+
}
|
|
1997
|
+
}
|
|
1998
|
+
}
|
|
1999
|
+
for (const child of n.namedChildren) walk(child ?? null);
|
|
2000
|
+
};
|
|
2001
|
+
walk(scope);
|
|
2002
|
+
}
|
|
2003
|
+
} finally {
|
|
2004
|
+
if (suspended) for (const [k, v] of suspended) bindings.set(k, v);
|
|
2005
|
+
}
|
|
2006
|
+
|
|
2007
|
+
// No live write means we cannot see where it comes from - so we do not get
|
|
2008
|
+
// to call it constant.
|
|
2009
|
+
if (live === 0 || !fixed) return null;
|
|
2010
|
+
return notes;
|
|
2011
|
+
};
|
|
2012
|
+
|
|
2013
|
+
let reasoned = false;
|
|
2014
|
+
const reasons: string[] = [];
|
|
2015
|
+
for (const raw of dynamicParts) {
|
|
2016
|
+
const part = raw.trim();
|
|
2017
|
+
// Only a bare name can be resolved this cheaply. Anything with a call, an
|
|
2018
|
+
// index or an attribute access is left alone.
|
|
2019
|
+
if (!/^[A-Za-z_$][\w$]*$/.test(part)) return null;
|
|
2020
|
+
const notes = nameIsConstant(part, node, 0);
|
|
2021
|
+
if (!notes) return null;
|
|
2022
|
+
if (notes.length > 0) {
|
|
2023
|
+
reasoned = true;
|
|
2024
|
+
reasons.push(`\`${part}\`: ${[...new Set(notes)].join('; ')}`);
|
|
2025
|
+
}
|
|
2026
|
+
}
|
|
2027
|
+
|
|
2028
|
+
return {
|
|
2029
|
+
reasoned,
|
|
2030
|
+
reason: reasoned
|
|
2031
|
+
? `Every value that can be spliced in is a fixed literal on every path that runs. ${reasons.join('. ')}.`
|
|
2032
|
+
: 'Every value spliced in is bound only to plain literals.',
|
|
2033
|
+
};
|
|
2034
|
+
}
|