@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.
Files changed (236) hide show
  1. package/LICENSE +235 -0
  2. package/README.md +561 -2
  3. package/dist/src/cli.d.ts +20 -0
  4. package/dist/src/cli.js +619 -0
  5. package/dist/src/cli.js.map +1 -0
  6. package/dist/src/core/analyze.d.ts +178 -0
  7. package/dist/src/core/analyze.js +834 -0
  8. package/dist/src/core/analyze.js.map +1 -0
  9. package/dist/src/core/coverage.d.ts +87 -0
  10. package/dist/src/core/coverage.js +181 -0
  11. package/dist/src/core/coverage.js.map +1 -0
  12. package/dist/src/core/finding.d.ts +196 -0
  13. package/dist/src/core/finding.js +178 -0
  14. package/dist/src/core/finding.js.map +1 -0
  15. package/dist/src/core/licence.d.ts +50 -0
  16. package/dist/src/core/licence.js +56 -0
  17. package/dist/src/core/licence.js.map +1 -0
  18. package/dist/src/core/paths.d.ts +21 -0
  19. package/dist/src/core/paths.js +58 -0
  20. package/dist/src/core/paths.js.map +1 -0
  21. package/dist/src/core/score.d.ts +137 -0
  22. package/dist/src/core/score.js +232 -0
  23. package/dist/src/core/score.js.map +1 -0
  24. package/dist/src/dast/targets.d.ts +30 -0
  25. package/dist/src/dast/targets.js +79 -0
  26. package/dist/src/dast/targets.js.map +1 -0
  27. package/dist/src/dast/zap.d.ts +42 -0
  28. package/dist/src/dast/zap.js +184 -0
  29. package/dist/src/dast/zap.js.map +1 -0
  30. package/dist/src/engine/scan.d.ts +80 -0
  31. package/dist/src/engine/scan.js +129 -0
  32. package/dist/src/engine/scan.js.map +1 -0
  33. package/dist/src/engine/shapes.d.ts +47 -0
  34. package/dist/src/engine/shapes.js +328 -0
  35. package/dist/src/engine/shapes.js.map +1 -0
  36. package/dist/src/engine/walk.d.ts +60 -0
  37. package/dist/src/engine/walk.js +156 -0
  38. package/dist/src/engine/walk.js.map +1 -0
  39. package/dist/src/parse/astDump.d.ts +31 -0
  40. package/dist/src/parse/astDump.js +66 -0
  41. package/dist/src/parse/astDump.js.map +1 -0
  42. package/dist/src/parse/languages.d.ts +70 -0
  43. package/dist/src/parse/languages.js +174 -0
  44. package/dist/src/parse/languages.js.map +1 -0
  45. package/dist/src/parse/location.d.ts +17 -0
  46. package/dist/src/parse/location.js +38 -0
  47. package/dist/src/parse/location.js.map +1 -0
  48. package/dist/src/parse/node-grammars.d.ts +12 -0
  49. package/dist/src/parse/node-grammars.js +35 -0
  50. package/dist/src/parse/node-grammars.js.map +1 -0
  51. package/dist/src/parse/parser.d.ts +126 -0
  52. package/dist/src/parse/parser.js +202 -0
  53. package/dist/src/parse/parser.js.map +1 -0
  54. package/dist/src/parse/tree-store.d.ts +139 -0
  55. package/dist/src/parse/tree-store.js +236 -0
  56. package/dist/src/parse/tree-store.js.map +1 -0
  57. package/dist/src/platform/dependency-rule.d.ts +27 -0
  58. package/dist/src/platform/dependency-rule.js +21 -0
  59. package/dist/src/platform/dependency-rule.js.map +1 -0
  60. package/dist/src/platform/deps.d.ts +86 -0
  61. package/dist/src/platform/deps.js +304 -0
  62. package/dist/src/platform/deps.js.map +1 -0
  63. package/dist/src/platform/php-facts.d.ts +49 -0
  64. package/dist/src/platform/php-facts.js +67 -0
  65. package/dist/src/platform/php-facts.js.map +1 -0
  66. package/dist/src/platform/runtime-version.d.ts +78 -0
  67. package/dist/src/platform/runtime-version.js +573 -0
  68. package/dist/src/platform/runtime-version.js.map +1 -0
  69. package/dist/src/report/colors.d.ts +50 -0
  70. package/dist/src/report/colors.js +97 -0
  71. package/dist/src/report/colors.js.map +1 -0
  72. package/dist/src/report/coverage-table.d.ts +90 -0
  73. package/dist/src/report/coverage-table.js +291 -0
  74. package/dist/src/report/coverage-table.js.map +1 -0
  75. package/dist/src/report/html-report.d.ts +66 -0
  76. package/dist/src/report/html-report.js +489 -0
  77. package/dist/src/report/html-report.js.map +1 -0
  78. package/dist/src/report/human.d.ts +44 -0
  79. package/dist/src/report/human.js +575 -0
  80. package/dist/src/report/human.js.map +1 -0
  81. package/dist/src/report/json.d.ts +24 -0
  82. package/dist/src/report/json.js +178 -0
  83. package/dist/src/report/json.js.map +1 -0
  84. package/dist/src/report/mark.d.ts +17 -0
  85. package/dist/src/report/mark.js +39 -0
  86. package/dist/src/report/mark.js.map +1 -0
  87. package/dist/src/report/pricing.d.ts +33 -0
  88. package/dist/src/report/pricing.js +343 -0
  89. package/dist/src/report/pricing.js.map +1 -0
  90. package/dist/src/report/sarif.d.ts +39 -0
  91. package/dist/src/report/sarif.js +219 -0
  92. package/dist/src/report/sarif.js.map +1 -0
  93. package/dist/src/report/site.d.ts +87 -0
  94. package/dist/src/report/site.js +742 -0
  95. package/dist/src/report/site.js.map +1 -0
  96. package/dist/src/rules/code-injection.d.ts +47 -0
  97. package/dist/src/rules/code-injection.js +326 -0
  98. package/dist/src/rules/code-injection.js.map +1 -0
  99. package/dist/src/rules/command-injection.d.ts +30 -0
  100. package/dist/src/rules/command-injection.js +286 -0
  101. package/dist/src/rules/command-injection.js.map +1 -0
  102. package/dist/src/rules/contract.d.ts +198 -0
  103. package/dist/src/rules/contract.js +39 -0
  104. package/dist/src/rules/contract.js.map +1 -0
  105. package/dist/src/rules/format-string.d.ts +46 -0
  106. package/dist/src/rules/format-string.js +146 -0
  107. package/dist/src/rules/format-string.js.map +1 -0
  108. package/dist/src/rules/hardcoded-secret.d.ts +38 -0
  109. package/dist/src/rules/hardcoded-secret.js +440 -0
  110. package/dist/src/rules/hardcoded-secret.js.map +1 -0
  111. package/dist/src/rules/lib/guards.d.ts +168 -0
  112. package/dist/src/rules/lib/guards.js +1179 -0
  113. package/dist/src/rules/lib/guards.js.map +1 -0
  114. package/dist/src/rules/lib/html-safety.d.ts +75 -0
  115. package/dist/src/rules/lib/html-safety.js +755 -0
  116. package/dist/src/rules/lib/html-safety.js.map +1 -0
  117. package/dist/src/rules/lib/local-build.d.ts +59 -0
  118. package/dist/src/rules/lib/local-build.js +320 -0
  119. package/dist/src/rules/lib/local-build.js.map +1 -0
  120. package/dist/src/rules/lib/strings.d.ts +208 -0
  121. package/dist/src/rules/lib/strings.js +1970 -0
  122. package/dist/src/rules/lib/strings.js.map +1 -0
  123. package/dist/src/rules/path-traversal.d.ts +33 -0
  124. package/dist/src/rules/path-traversal.js +96 -0
  125. package/dist/src/rules/path-traversal.js.map +1 -0
  126. package/dist/src/rules/registry.d.ts +19 -0
  127. package/dist/src/rules/registry.js +77 -0
  128. package/dist/src/rules/registry.js.map +1 -0
  129. package/dist/src/rules/sql-injection.d.ts +36 -0
  130. package/dist/src/rules/sql-injection.js +299 -0
  131. package/dist/src/rules/sql-injection.js.map +1 -0
  132. package/dist/src/rules/ssrf.d.ts +39 -0
  133. package/dist/src/rules/ssrf.js +111 -0
  134. package/dist/src/rules/ssrf.js.map +1 -0
  135. package/dist/src/rules/unbounded-copy.d.ts +37 -0
  136. package/dist/src/rules/unbounded-copy.js +147 -0
  137. package/dist/src/rules/unbounded-copy.js.map +1 -0
  138. package/dist/src/rules/unsafe-deserialization.d.ts +43 -0
  139. package/dist/src/rules/unsafe-deserialization.js +276 -0
  140. package/dist/src/rules/unsafe-deserialization.js.map +1 -0
  141. package/dist/src/rules/weak-hash.d.ts +37 -0
  142. package/dist/src/rules/weak-hash.js +197 -0
  143. package/dist/src/rules/weak-hash.js.map +1 -0
  144. package/dist/src/rules/xss.d.ts +38 -0
  145. package/dist/src/rules/xss.js +527 -0
  146. package/dist/src/rules/xss.js.map +1 -0
  147. package/dist/src/taint/content-type.d.ts +19 -0
  148. package/dist/src/taint/content-type.js +20 -0
  149. package/dist/src/taint/content-type.js.map +1 -0
  150. package/dist/src/taint/dictionaries.d.ts +23 -0
  151. package/dist/src/taint/dictionaries.js +1778 -0
  152. package/dist/src/taint/dictionaries.js.map +1 -0
  153. package/dist/src/taint/fold.d.ts +59 -0
  154. package/dist/src/taint/fold.js +1177 -0
  155. package/dist/src/taint/fold.js.map +1 -0
  156. package/dist/src/taint/java-scope.d.ts +61 -0
  157. package/dist/src/taint/java-scope.js +212 -0
  158. package/dist/src/taint/java-scope.js.map +1 -0
  159. package/dist/src/taint/local-map.d.ts +79 -0
  160. package/dist/src/taint/local-map.js +603 -0
  161. package/dist/src/taint/local-map.js.map +1 -0
  162. package/dist/src/taint/project.d.ts +172 -0
  163. package/dist/src/taint/project.js +617 -0
  164. package/dist/src/taint/project.js.map +1 -0
  165. package/dist/src/taint/tracer.d.ts +265 -0
  166. package/dist/src/taint/tracer.js +3817 -0
  167. package/dist/src/taint/tracer.js.map +1 -0
  168. package/dist/src/taint/types.d.ts +484 -0
  169. package/dist/src/taint/types.js +47 -0
  170. package/dist/src/taint/types.js.map +1 -0
  171. package/dist/src/taint/visibility.d.ts +25 -0
  172. package/dist/src/taint/visibility.js +96 -0
  173. package/dist/src/taint/visibility.js.map +1 -0
  174. package/docs/examples/defuse.json +20 -0
  175. package/docs/examples/dvwa-urls.json +14 -0
  176. package/docs/examples/github-code-scanning.yml +54 -0
  177. package/docs/examples/tiny.js +4 -0
  178. package/package.json +73 -4
  179. package/src/cli.ts +747 -0
  180. package/src/core/analyze.ts +1072 -0
  181. package/src/core/coverage.ts +269 -0
  182. package/src/core/finding.ts +311 -0
  183. package/src/core/licence.ts +61 -0
  184. package/src/core/paths.ts +59 -0
  185. package/src/core/score.ts +353 -0
  186. package/src/dast/targets.ts +121 -0
  187. package/src/dast/zap.ts +246 -0
  188. package/src/engine/scan.ts +200 -0
  189. package/src/engine/shapes.ts +348 -0
  190. package/src/engine/walk.ts +196 -0
  191. package/src/parse/astDump.ts +85 -0
  192. package/src/parse/languages.ts +232 -0
  193. package/src/parse/location.ts +44 -0
  194. package/src/parse/node-grammars.ts +41 -0
  195. package/src/parse/parser.ts +262 -0
  196. package/src/parse/tree-store.ts +301 -0
  197. package/src/platform/dependency-rule.ts +33 -0
  198. package/src/platform/deps.ts +374 -0
  199. package/src/platform/php-facts.ts +85 -0
  200. package/src/platform/runtime-version.ts +710 -0
  201. package/src/report/colors.ts +107 -0
  202. package/src/report/coverage-table.ts +359 -0
  203. package/src/report/html-report.ts +601 -0
  204. package/src/report/human.ts +811 -0
  205. package/src/report/json.ts +189 -0
  206. package/src/report/mark.ts +47 -0
  207. package/src/report/pricing.ts +374 -0
  208. package/src/report/sarif.ts +235 -0
  209. package/src/report/site.ts +760 -0
  210. package/src/rules/code-injection.ts +346 -0
  211. package/src/rules/command-injection.ts +296 -0
  212. package/src/rules/contract.ts +220 -0
  213. package/src/rules/format-string.ts +158 -0
  214. package/src/rules/hardcoded-secret.ts +485 -0
  215. package/src/rules/lib/guards.ts +1235 -0
  216. package/src/rules/lib/html-safety.ts +793 -0
  217. package/src/rules/lib/local-build.ts +329 -0
  218. package/src/rules/lib/strings.ts +2034 -0
  219. package/src/rules/path-traversal.ts +101 -0
  220. package/src/rules/registry.ts +90 -0
  221. package/src/rules/sql-injection.ts +311 -0
  222. package/src/rules/ssrf.ts +118 -0
  223. package/src/rules/unbounded-copy.ts +162 -0
  224. package/src/rules/unsafe-deserialization.ts +291 -0
  225. package/src/rules/weak-hash.ts +226 -0
  226. package/src/rules/xss.ts +572 -0
  227. package/src/taint/content-type.ts +20 -0
  228. package/src/taint/dictionaries.ts +1855 -0
  229. package/src/taint/fold.ts +1206 -0
  230. package/src/taint/java-scope.ts +200 -0
  231. package/src/taint/local-map.ts +594 -0
  232. package/src/taint/project.ts +774 -0
  233. package/src/taint/tracer.ts +4298 -0
  234. package/src/taint/types.ts +511 -0
  235. package/src/taint/visibility.ts +100 -0
  236. package/tsconfig.json +61 -0
@@ -0,0 +1,1778 @@
1
+ /**
2
+ * THE TAINT DICTIONARIES
3
+ *
4
+ * Pure data. The tracer in tracer.ts contains no knowledge of Express, Flask,
5
+ * psycopg2 or DOMPurify - it only knows how to follow a value. Everything
6
+ * language- and framework-specific lives here.
7
+ *
8
+ * That split is the reason Phase 3b (Java and Go) is a data task rather than an
9
+ * engine task: write two more entries in this file and the same tracer works.
10
+ *
11
+ * HONESTY NOTE ON SOURCES. We recognise a source by the SHAPE of the
12
+ * expression - `req.query.id` looks like an HTTP query parameter. We do not
13
+ * prove that `req` is really an Express request object; proving that needs
14
+ * type inference we do not have. In practice the convention is near-universal,
15
+ * but it means a variable someone happened to name `req` with a `.body`
16
+ * property would be treated as attacker-controlled. That approximation is
17
+ * listed in ENGINE_CAPABILITIES.notImplemented and printed in every report.
18
+ */
19
+ import { DECLARED_NOT_HTML } from './content-type.js';
20
+ /* ========================================================================== *
21
+ * JavaScript / TypeScript
22
+ * ========================================================================== */
23
+ const JS_DICTIONARY = {
24
+ /*
25
+ * NestJS parameter decorators - Spring's @RequestParam in TypeScript. The
26
+ * framework binds the value before the body runs, so the decorator on the
27
+ * declaration is the only evidence (nest-decorated-params.ts). Plain
28
+ * JavaScript has no parameter decorators, so sharing this costs it nothing.
29
+ * @Req() is absent: it binds the whole request, whose `.query` and `.body`
30
+ * reads the sources below already match.
31
+ */
32
+ parameterSources: [
33
+ { pattern: /^@Param\s*\(/, description: 'a NestJS @Param binding - part of the URL path' },
34
+ { pattern: /^@Query\s*\(/, description: 'a NestJS @Query binding - the query string' },
35
+ { pattern: /^@Body\s*\(/, description: 'a NestJS @Body binding - the request body' },
36
+ { pattern: /^@Headers\s*\(/, description: 'a NestJS @Headers binding - an HTTP request header' },
37
+ ],
38
+ mutators: ['push', 'unshift', 'splice', 'add', 'set', 'append', 'write', 'writeln'],
39
+ // Separable items only. `append` is left out: in JS it is DOM appendChild,
40
+ // which takes a node rather than filing one string among many.
41
+ elementMutators: ['push', 'unshift', 'splice', 'add', 'set'],
42
+ sources: [
43
+ {
44
+ pattern: /\b(req|request|ctx)\.(query|body|params|param)\b/,
45
+ description: 'HTTP request parameters (query string, body or route params)',
46
+ },
47
+ {
48
+ pattern: /\b(req|request|ctx)\.(headers?|cookies|get)\b/,
49
+ description: 'HTTP request headers or cookies',
50
+ },
51
+ {
52
+ pattern: /\b(req|request)\.(url|originalUrl|path|hostname|ip)\b/,
53
+ description: 'HTTP request URL or connection metadata',
54
+ },
55
+ {
56
+ pattern: /\bevent\.(body|queryStringParameters|pathParameters|headers)\b/,
57
+ description: 'serverless function event payload (AWS Lambda style)',
58
+ },
59
+ {
60
+ pattern: /\b(location|document\.location|window\.location)\.(search|hash|href|pathname)\b/,
61
+ description: 'the browser address bar - fully attacker-controllable via a link',
62
+ browserOnly: true,
63
+ },
64
+ { pattern: /\bdocument\.referrer\b/, description: 'the HTTP Referer header', browserOnly: true },
65
+ { pattern: /\bwindow\.name\b/, description: 'window.name, settable by any page that opened this one', browserOnly: true },
66
+ { pattern: /\bdocument\.cookie\b/, description: 'document.cookie', browserOnly: true },
67
+ {
68
+ pattern: /\bsearchParams\.get\s*\(/,
69
+ description: 'a URL query parameter read via URLSearchParams',
70
+ },
71
+ { pattern: /\bprocess\.argv\b/, description: 'command-line arguments', localOnly: true },
72
+ {
73
+ pattern: /\b(localStorage|sessionStorage)\.getItem\s*\(/,
74
+ description: 'browser storage, which any script on the page can write',
75
+ browserOnly: true,
76
+ },
77
+ {
78
+ // Angular's ActivatedRoute: the address bar, read through the router.
79
+ // `this.route.snapshot.queryParams.q` is Juice Shop's search box. The
80
+ // observable form - `route.queryParams.subscribe(p => p.q)` - hands the
81
+ // value to a callback parameter and is NOT recognised.
82
+ pattern: /\b(route|activatedRoute|activeRoute)\.(snapshot\.)?(queryParams|params|queryParamMap|paramMap|fragment)\b/,
83
+ description: "an Angular route's URL parameters - the address bar, set by whoever wrote the link",
84
+ browserOnly: true,
85
+ },
86
+ ],
87
+ callSinks: [
88
+ {
89
+ kind: 'sql',
90
+ // Deliberately NARROWER than the signature rule's list. `exec`, `run`,
91
+ // `all`, `get` and `each` are database methods in some libraries and
92
+ // completely unrelated methods in others. The signature rule can afford
93
+ // them because it also demands SQL-looking text; a taint sink that fires
94
+ // on an opaque variable cannot, so ambiguous names are left out.
95
+ methods: [
96
+ 'query', 'execute', 'raw', 'prepare', 'queryRaw',
97
+ '$queryRawUnsafe', '$executeRawUnsafe', 'unsafe',
98
+ ],
99
+ // `query` and `execute` belong to a database here and to a job runner,
100
+ // an HTTP client or a GraphQL layer three files away. The content check
101
+ // narrows the VALUE; this narrows the OBJECT, which is what every one of
102
+ // the six scoping bugs got wrong.
103
+ receiverPattern: /db|conn|client|pool|knex|sequelize|prisma|sql|database|mysql|maria|postgres|sqlite|mssql|oracle|\btrx\b|transaction|session|repo|cursor|orm|store|adapter/i,
104
+ // ONLY argument 0. See the note on argIndexes in types.ts - this is what
105
+ // stops us reporting the correct, parameterised form as a vulnerability.
106
+ argIndexes: [0],
107
+ contentCheck: 'sql',
108
+ description: 'a database query',
109
+ },
110
+ {
111
+ kind: 'command',
112
+ methods: ['exec', 'execSync'],
113
+ /*
114
+ * `exec` IS ALSO RegExp.prototype.exec, and that one is everywhere.
115
+ *
116
+ * Unscoped, this entry produced the worst finding this project has made:
117
+ * CRITICAL, FLOW-VERIFIED, in gitea's browser code, reading "attacker-
118
+ * controlled data from `window.location.pathname` reaches a shell
119
+ * command" over `/([^/]+)\/([^/]+)/.exec(pathname)`. No shell, no server,
120
+ * no command - a regular expression matching a URL path in a browser tab.
121
+ *
122
+ * BARE, NOT BANNED. The normal way to reach the dangerous one is the
123
+ * destructured import - `const {exec} = require('child_process')` - so a
124
+ * bare call still fires, and only a call with an unrecognised receiver in
125
+ * front of it is dropped. `require('child_process').exec(cmd)` in one
126
+ * expression is missed by this and is a documented gap rather than a
127
+ * silent one.
128
+ */
129
+ bareOnly: true,
130
+ allowedReceivers: ['child_process', 'childProcess', 'cp', 'shell', 'proc'],
131
+ argIndexes: [0],
132
+ description: 'a shell command',
133
+ },
134
+ {
135
+ kind: 'command',
136
+ methods: ['spawn', 'spawnSync', 'execFile', 'execFileSync'],
137
+ argIndexes: [0],
138
+ requiresShellOption: /shell\s*:\s*true/,
139
+ description: 'a shell command (shell: true is set on this call)',
140
+ },
141
+ {
142
+ kind: 'ssrf',
143
+ /*
144
+ * RECEIVER-SCOPED, and the first version was not.
145
+ *
146
+ * `get`, `post` and `request` are among the most common method names in
147
+ * any codebase. Scanning VS Code's server package produced a beautiful
148
+ * eighteen-hop flow-verified SSRF - `req.url` traced through five
149
+ * functions into `active(consumer)` - and the sink at the end of it was
150
+ *
151
+ * this._consumers.get(consumer) // a Map
152
+ *
153
+ * Eighteen correct hops to a wrong conclusion. The trace was right; the
154
+ * sink list was wrong. Every other rule here learned to ask WHICH object
155
+ * (pickle vs settings, mathjs vs re) and this one had not.
156
+ */
157
+ methods: ['get', 'request', 'post', 'put', 'patch', 'head', 'del'],
158
+ requiredReceivers: [
159
+ 'http', 'https', 'axios', 'got', 'request', 'superagent',
160
+ 'needle', 'undici', 'fetch', 'agent', 'httpClient',
161
+ ],
162
+ argIndexes: [0],
163
+ description: 'an outbound HTTP request, so the server fetches whatever host this names',
164
+ },
165
+ {
166
+ kind: 'path',
167
+ methods: [
168
+ 'readFile', 'readFileSync', 'createReadStream', 'writeFile', 'writeFileSync',
169
+ 'appendFile', 'appendFileSync', 'createWriteStream', 'unlink', 'unlinkSync',
170
+ 'rm', 'rmSync', 'readdir', 'readdirSync', 'open', 'openSync',
171
+ ],
172
+ requiredReceivers: ['fs', 'fsp', 'promises', 'fse', 'fsExtra', 'fsPromises'],
173
+ argIndexes: [0], // writeFile(path, data): the data is not a path
174
+ description: 'a file path the server opens, so `../` in it reaches files outside the folder the code meant',
175
+ },
176
+ {
177
+ kind: 'path',
178
+ methods: ['copyFile', 'copyFileSync', 'rename', 'renameSync', 'cp', 'cpSync'],
179
+ requiredReceivers: ['fs', 'fsp', 'promises', 'fse', 'fsExtra', 'fsPromises'],
180
+ argIndexes: [0, 1], // from, to
181
+ description: 'a file path the server opens, so `../` in it reaches files outside the folder the code meant',
182
+ },
183
+ {
184
+ kind: 'path',
185
+ // `const { readFileSync } = require('fs')`. Only the Sync and stream
186
+ // names: a bare `readFile` is as often somebody's own helper.
187
+ methods: ['readFileSync', 'writeFileSync', 'appendFileSync', 'createReadStream', 'createWriteStream', 'unlinkSync'],
188
+ bareOnly: true,
189
+ argIndexes: [0],
190
+ description: 'a file path the server opens, so `../` in it reaches files outside the folder the code meant',
191
+ },
192
+ {
193
+ kind: 'path',
194
+ // Express. With a `root` option, send() refuses any path that climbs
195
+ // out of it, which is the documented fix - so that form is not a sink.
196
+ methods: ['sendFile', 'download'],
197
+ requiredReceivers: ['res', 'response', 'reply'],
198
+ argIndexes: [0],
199
+ unlessOption: /\broot\s*:/,
200
+ description: 'a file sent back in the HTTP response, so `../` in this path serves any file the server can read',
201
+ },
202
+ {
203
+ kind: 'ssrf',
204
+ // The global `fetch` and `got` take a URL with nothing in front of them.
205
+ methods: ['fetch', 'got'],
206
+ bareOnly: true,
207
+ argIndexes: [0],
208
+ description: 'an outbound HTTP request, so the server fetches whatever host this names',
209
+ },
210
+ {
211
+ kind: 'code',
212
+ methods: ['eval', 'Function', 'execScript'],
213
+ // Bare, OR on an object that really is an evaluator. `mathjs.eval` has
214
+ // its own CVEs; `compiler.compile` is somebody's SQL builder.
215
+ bareOnly: true,
216
+ allowedReceivers: ['mathjs', 'math', 'vm', 'window', 'global', 'globalThis', 'safeEval', 'eval5'],
217
+ argIndexes: 'all',
218
+ description: 'the JavaScript interpreter, which runs the string as program code',
219
+ },
220
+ {
221
+ kind: 'code',
222
+ // vm.runInNewContext IS a method - it is supposed to have a receiver.
223
+ methods: ['runInNewContext', 'runInThisContext'],
224
+ argIndexes: 'all',
225
+ description: 'the JavaScript interpreter, which runs the string as program code',
226
+ },
227
+ {
228
+ kind: 'code',
229
+ // Only as a TAINT sink, never on shape - see the note in code-injection.ts.
230
+ // A request cannot deliver a function, so a tainted value here is a string,
231
+ // and a string argument to setTimeout is executed.
232
+ methods: ['setTimeout', 'setInterval'],
233
+ argIndexes: [0],
234
+ description: 'setTimeout/setInterval, which execute a STRING argument as code',
235
+ },
236
+ {
237
+ kind: 'xss',
238
+ methods: ['write', 'writeln'],
239
+ argIndexes: 'all',
240
+ /*
241
+ * `write` HAS TO BE SCOPED, because it is the single most common method
242
+ * name in JavaScript that is attached to something other than a page.
243
+ *
244
+ * Unscoped, this entry produced nine of the fourteen flow-verified
245
+ * findings in a scan of VS Code, every one of them a
246
+ * `process.stdout.write(...)` in a command-line script - and every one of
247
+ * them printed "reaches document.write, which parses its argument as
248
+ * HTML" over a file with no browser anywhere in it. Streams, sockets,
249
+ * file handles, hashes and every hand-rolled logger all have `.write()`.
250
+ *
251
+ * Two receivers earn the finding: a DOCUMENT (`document.write` parses
252
+ * HTML by definition) and an HTTP RESPONSE (`res.write('<p>' + dirty)`
253
+ * is the ordinary Express XSS, and is why this cannot simply require the
254
+ * word "document"). Anything else is a byte sink, and a byte sink is not
255
+ * a page.
256
+ *
257
+ * KNOWN CONSEQUENCE, stated rather than hidden: a response object stored
258
+ * under a name we do not recognise - `httpOut.write(dirty)` - is now
259
+ * missed. That is a false negative bought deliberately, because the
260
+ * alternative was calling every terminal in every CLI a browser.
261
+ */
262
+ receiverPattern: /(^|\.)(document|doc|contentDocument)$|\b(res|resp|response|reply)\b/i,
263
+ description: 'document.write or an HTTP response body, which is parsed as HTML',
264
+ },
265
+ {
266
+ kind: 'xss',
267
+ methods: ['insertAdjacentHTML'],
268
+ argIndexes: [1], // argument 0 is the position ("beforeend"), not content
269
+ description: 'insertAdjacentHTML, which parses its argument as HTML',
270
+ },
271
+ {
272
+ kind: 'xss',
273
+ // Angular escapes everything it renders unless told not to - and these
274
+ // tell it not to. The names are Angular's own and nothing else uses them.
275
+ methods: [
276
+ 'bypassSecurityTrustHtml', 'bypassSecurityTrustScript', 'bypassSecurityTrustUrl',
277
+ 'bypassSecurityTrustResourceUrl', 'bypassSecurityTrustStyle',
278
+ ],
279
+ argIndexes: [0],
280
+ description: "Angular's DomSanitizer bypass, which marks the value as trusted and turns Angular's escaping off",
281
+ },
282
+ {
283
+ kind: 'xss',
284
+ methods: ['html', 'append', 'prepend'],
285
+ // jQuery-style insertion, and ONLY that. `formData.append(k, v)`,
286
+ // `headers.append(...)` and `searchParams.append(...)` are the same word
287
+ // on objects that never touch a page.
288
+ receiverPattern: /\$|jquery|element|\bel\b|\bnode\b|\bdom\b|container|wrapper|\bdiv\b|\bbody\b|target|parent|selector|\bhtml\b/i,
289
+ argIndexes: [0],
290
+ contentCheck: 'html',
291
+ description: 'a jQuery-style HTML insertion',
292
+ },
293
+ ],
294
+ assignSinks: [
295
+ {
296
+ kind: 'xss',
297
+ properties: ['innerHTML', 'outerHTML', 'srcdoc', '__html'],
298
+ description: 'a property the browser parses as HTML',
299
+ },
300
+ ],
301
+ sanitizers: [
302
+ {
303
+ names: ['escapeHtml', 'escapeHTML', 'sanitize', 'sanitizeHtml', 'purify', 'encodeURIComponent'],
304
+ kinds: ['xss'],
305
+ description: 'HTML escaping / sanitising',
306
+ },
307
+ {
308
+ names: ['escapeId', 'escapeIdentifier', 'escapeLiteral'],
309
+ kinds: ['sql'],
310
+ description: 'SQL identifier escaping',
311
+ },
312
+ {
313
+ // parseInt/Number turn text into a number. A number cannot carry a quote,
314
+ // a semicolon or a tag, so it is safe for every text-injection sink at
315
+ // once. This is the strongest and most under-used sanitiser there is.
316
+ names: ['parseInt', 'parseFloat', 'Number', 'BigInt'],
317
+ kinds: ['sql', 'command', 'xss', 'path'],
318
+ description: 'conversion to a number',
319
+ },
320
+ {
321
+ names: ['quote', 'shellQuote', 'shellescape'],
322
+ kinds: ['command'],
323
+ description: 'shell quoting',
324
+ },
325
+ {
326
+ names: ['basename'],
327
+ kinds: ['path'],
328
+ description: 'path.basename - the last name in the path, with every folder and `..` gone',
329
+ },
330
+ {
331
+ // Node's hash and HMAC objects. Only the encodings that return a fixed
332
+ // alphabet: no argument returns a raw Buffer, which can hold anything.
333
+ names: ['digest'],
334
+ argumentPattern: /^['"`](hex|base64|base64url)['"`]$/,
335
+ fromReceiver: true,
336
+ kinds: ['sql', 'command', 'xss', 'code', 'ssrf', 'deserialization', 'path'],
337
+ description: 'a hash digest as hex or base64 - letters, digits and +/=, none of the text that went in',
338
+ },
339
+ ],
340
+ propagators: {
341
+ names: [
342
+ 'String', 'toString', 'valueOf', 'trim', 'trimStart', 'trimEnd',
343
+ 'toLowerCase', 'toUpperCase', 'slice', 'substring', 'substr', 'concat',
344
+ 'join', 'split', 'padStart', 'padEnd', 'repeat', 'normalize', 'at',
345
+ 'charAt', 'replace', 'replaceAll', 'decodeURIComponent', 'decodeURI',
346
+ 'JSON.stringify', 'stringify',
347
+ ],
348
+ },
349
+ /*
350
+ * ORM READS RETURN STORED ROWS. Juice Shop's userProfile.ts picks a user by
351
+ * the login cookie with findByPk() and evaluates the username saved on an
352
+ * earlier request: a real bug, and a false proof - "the cookie reaches eval".
353
+ * ORMs bind their arguments, so unlike a raw query string an argument here
354
+ * does not let the attacker choose the rows. `find` is NOT listed: it is also
355
+ * Array.prototype.find, which hands back an element of a dirty array.
356
+ */
357
+ freshResults: [
358
+ {
359
+ names: [
360
+ 'findByPk', 'findOne', 'findAll', 'findAndCountAll', 'findOrCreate', 'findById',
361
+ 'findMany', 'findFirst', 'findUnique', 'findOneBy', 'findBy',
362
+ 'getOne', 'getMany', 'getRawOne', 'getRawMany',
363
+ ],
364
+ description: 'an ORM or database read - it returns stored rows, not the arguments that picked them',
365
+ },
366
+ {
367
+ names: ['readFileSync', 'readFile'],
368
+ description: 'a file read - it returns what the file holds, not the path that named it',
369
+ },
370
+ ],
371
+ };
372
+ /* ========================================================================== *
373
+ * Python
374
+ * ========================================================================== */
375
+ const PYTHON_DICTIONARY = {
376
+ mutators: ['append', 'add', 'insert', 'extend', 'update', 'setdefault', 'write', 'writelines'],
377
+ // Python's `append` is a LIST element - the OPPOSITE classification to Java's
378
+ // StringBuilder append, and the reason this list cannot be shared.
379
+ elementMutators: ['append', 'add', 'insert', 'extend', 'update', 'setdefault'],
380
+ // Flask and Django views return the response body directly - there is no
381
+ // res.send() to match on. See htmlReturnIsSink in types.ts for why this is
382
+ // needed and why JavaScript deliberately does not set it.
383
+ htmlReturnIsSink: true,
384
+ sources: [
385
+ {
386
+ pattern: /\brequest\.(args|form|values|json|data|files|query_params|GET|POST|body)\b/,
387
+ description: 'HTTP request parameters (Flask/Django/DRF)',
388
+ },
389
+ {
390
+ pattern: /\brequest\.(headers|cookies|META|COOKIES)\b/,
391
+ description: 'HTTP request headers or cookies',
392
+ /*
393
+ * request.META also holds keys the SERVER writes. CSRF_COOKIE is set by
394
+ * Django's CSRF middleware only after the cookie passes its format check
395
+ * (letters and digits, fixed length), and CSRF_COOKIE_NEEDS_UPDATE is a
396
+ * flag it sets. Scanning Django reported the CSRF token reaching a page
397
+ * as a flow-verified XSS (tests/fixtures/safe/django-not-html.py).
398
+ * Only these exact reads are excepted, with no fallback value that could
399
+ * itself be dirty; every other key - HTTP_*, QUERY_STRING, an unknown
400
+ * one - stays a source (tests/fixtures/vulnerable/django-responses.py).
401
+ */
402
+ except: /^(?:self\.)?request\.META(?:\[\s*(["'])(?:CSRF_COOKIE|CSRF_COOKIE_NEEDS_UPDATE)\1\s*\]|\.get\(\s*(["'])(?:CSRF_COOKIE|CSRF_COOKIE_NEEDS_UPDATE)\2\s*(?:,\s*(?:(["'])[^"'\\]*\3|None|True|False))?\s*\))$/,
403
+ },
404
+ { pattern: /\bsys\.argv\b/, description: 'command-line arguments', localOnly: true },
405
+ { pattern: /(^|[^.\w])input\s*\(/, description: 'input() read from the terminal', localOnly: true },
406
+ {
407
+ pattern: /\bself\.(get_argument|request\.(arguments|body|headers))\b/,
408
+ description: 'Tornado request data',
409
+ },
410
+ ],
411
+ callSinks: [
412
+ {
413
+ kind: 'sql',
414
+ methods: ['execute', 'executemany', 'executescript', 'raw'],
415
+ // `execute` is a DB-API cursor here and a task, a plan or a pipeline
416
+ // elsewhere; `raw` is Django's queryset escape hatch and half of every
417
+ // other library's byte accessor.
418
+ receiverPattern: /cursor|\bcur\b|conn|\bdb\b|session|engine|objects|sqlalchemy|connection|pool|\bcnx\b|database/i,
419
+ argIndexes: [0], // cursor.execute(sql, params) - params are safe by design
420
+ contentCheck: 'sql',
421
+ description: 'a database query',
422
+ },
423
+ {
424
+ kind: 'sql',
425
+ // SQLAlchemy's `text()` is imported and called bare - it has no receiver
426
+ // to scope against, and `obj.text()` is somebody's accessor.
427
+ methods: ['text'],
428
+ bareOnly: true,
429
+ allowedReceivers: ['sqlalchemy', 'sa', 'db'],
430
+ argIndexes: [0],
431
+ contentCheck: 'sql',
432
+ description: 'a SQLAlchemy text() query',
433
+ },
434
+ {
435
+ kind: 'command',
436
+ methods: ['system', 'popen', 'getoutput', 'getstatusoutput'],
437
+ argIndexes: [0],
438
+ description: 'a shell command',
439
+ },
440
+ {
441
+ kind: 'command',
442
+ methods: ['run', 'call', 'check_call', 'check_output', 'Popen'],
443
+ // `run` and `call` are two of the most reusable verbs in Python. Bare is
444
+ // kept because `from subprocess import run` is the ordinary import.
445
+ bareOnly: true,
446
+ allowedReceivers: ['subprocess', 'sp', 'sub', 'commands'],
447
+ argIndexes: [0],
448
+ requiresShellOption: /shell\s*=\s*True/,
449
+ description: 'a shell command (shell=True is set on this call)',
450
+ },
451
+ {
452
+ kind: 'path',
453
+ // The builtin, and the two modules that re-export it. `Image.open` and
454
+ // `webbrowser.open` share only the name.
455
+ methods: ['open'],
456
+ bareOnly: true,
457
+ allowedReceivers: ['io', 'builtins', 'codecs'],
458
+ argIndexes: [0],
459
+ description: 'a file path the server opens, so `../` in it reaches files outside the folder the code meant',
460
+ },
461
+ {
462
+ kind: 'path',
463
+ methods: ['remove', 'unlink', 'rmdir', 'removedirs', 'listdir', 'scandir', 'makedirs', 'mkdir', 'rename', 'replace'],
464
+ requiredReceivers: ['os'],
465
+ argIndexes: [0, 1], // rename(src, dst); the others' second argument is a mode or a flag
466
+ description: 'a file path the server opens, so `../` in it reaches files outside the folder the code meant',
467
+ },
468
+ {
469
+ kind: 'path',
470
+ methods: ['copy', 'copyfile', 'copy2', 'copytree', 'move', 'rmtree'],
471
+ requiredReceivers: ['shutil'],
472
+ argIndexes: [0, 1],
473
+ description: 'a file path the server opens, so `../` in it reaches files outside the folder the code meant',
474
+ },
475
+ {
476
+ kind: 'path',
477
+ // Flask's send_file takes any path; send_from_directory refuses one that
478
+ // climbs out of its folder, which is the documented fix.
479
+ methods: ['send_file'],
480
+ bareOnly: true,
481
+ allowedReceivers: ['flask'],
482
+ argIndexes: [0],
483
+ description: 'a file sent back in the HTTP response, so `../` in this path serves any file the server can read',
484
+ },
485
+ {
486
+ kind: 'ssrf',
487
+ methods: ['get', 'post', 'put', 'delete', 'head', 'patch', 'request', 'urlopen', 'Request'],
488
+ requiredReceivers: ['requests', 'httpx', 'urllib', 'request', 'session', 'client'],
489
+ argIndexes: [0],
490
+ description: 'an outbound HTTP request, so the server fetches whatever host this names',
491
+ },
492
+ {
493
+ kind: 'code',
494
+ methods: ['eval', 'exec', 'compile'],
495
+ // Builtins only. `re.compile(dirty)` is a regex, not an interpreter.
496
+ bareOnly: true,
497
+ allowedReceivers: ['builtins', '__builtins__'],
498
+ argIndexes: [0],
499
+ description: 'the Python interpreter, which runs the string as program code',
500
+ },
501
+ {
502
+ kind: 'deserialization',
503
+ methods: ['loads', 'load', 'Unpickler'],
504
+ // Without this, `json.loads(request.data)` - the SAFE parser everyone is
505
+ // told to use instead - reports as a deserialization sink.
506
+ requiredReceivers: ['pickle', 'cPickle', 'dill', 'marshal', 'yaml', 'shelve', 'jsonpickle'],
507
+ argIndexes: [0],
508
+ unlessOption: /\b(SafeLoader|CSafeLoader|BaseLoader|safe_load)\b/,
509
+ description: 'pickle/yaml deserialisation, which runs constructor code from the bytes',
510
+ },
511
+ {
512
+ kind: 'xss',
513
+ methods: ['mark_safe', 'Markup', 'render_template_string', 'format_html'],
514
+ argIndexes: [0],
515
+ description: 'a template escape hatch that marks the value as trusted HTML',
516
+ },
517
+ {
518
+ kind: 'xss',
519
+ methods: ['HttpResponse'],
520
+ argIndexes: [0],
521
+ // A response the call declares text/plain, CSV or JSON is not a page
522
+ // - see taint/content-type.ts.
523
+ unlessOption: DECLARED_NOT_HTML,
524
+ description: 'an HTTP response body written without escaping',
525
+ },
526
+ ],
527
+ assignSinks: [],
528
+ sanitizers: [
529
+ {
530
+ // hashlib's and hmac's text form: hex digits, whatever went in. The
531
+ // bytes form, .digest(), is not listed - raw bytes can hold anything.
532
+ names: ['hexdigest'],
533
+ fromReceiver: true,
534
+ kinds: ['sql', 'command', 'xss', 'code', 'ssrf', 'deserialization', 'path'],
535
+ description: 'a hex digest - none of the text that went in',
536
+ },
537
+ {
538
+ names: ['escape', 'clean', 'conditional_escape', 'escapejs'],
539
+ kinds: ['xss'],
540
+ description: 'HTML escaping (html.escape / markupsafe / bleach)',
541
+ },
542
+ {
543
+ names: ['quote'], // shlex.quote / pipes.quote
544
+ kinds: ['command'],
545
+ description: 'shell quoting (shlex.quote)',
546
+ },
547
+ {
548
+ names: ['basename', 'secure_filename'],
549
+ kinds: ['path'],
550
+ description: 'os.path.basename / werkzeug secure_filename - a single file name, no folders and no `..`',
551
+ },
552
+ {
553
+ // ord() and len() always return an int. Without ord, BenchmarkPython's
554
+ // char-by-char escaper `f'&#x{hex(ord(c))[2:]};'` read as passing c through.
555
+ names: ['int', 'float', 'ord', 'len'],
556
+ kinds: ['sql', 'command', 'xss', 'path'],
557
+ description: 'conversion to a number',
558
+ },
559
+ {
560
+ names: ['adapt', 'bindparam', 'literal_column'],
561
+ kinds: ['sql'],
562
+ description: 'database adapter escaping',
563
+ },
564
+ ],
565
+ propagators: {
566
+ names: [
567
+ 'str', 'strip', 'lstrip', 'rstrip', 'lower', 'upper', 'title',
568
+ 'replace', 'join', 'split', 'rsplit', 'format', 'encode', 'decode',
569
+ 'ljust', 'rjust', 'center', 'removeprefix', 'removesuffix',
570
+ ],
571
+ },
572
+ };
573
+ /* ========================================================================== *
574
+ * Java (Servlet API + JDBC)
575
+ * ========================================================================== */
576
+ const JAVA_DICTIONARY = {
577
+ /*
578
+ * SPRING REQUEST BINDINGS - the sources that were missing while the benchmark
579
+ * said recall was 76.4%.
580
+ *
581
+ * Scanning OWASP WebGoat, an application made ENTIRELY of labelled
582
+ * vulnerabilities, produced 126 findings and zero flow-verified. Elasticsearch
583
+ * (3,999 Java files): also zero. The reason turned out to be countable:
584
+ * WebGoat contains 161 `@RequestParam` and 4 `getParameter()`, while
585
+ * BenchmarkJava - the thing producing the 76.4% - is raw servlet
586
+ * getParameter/getHeader from end to end.
587
+ *
588
+ * So the recall figure was honest about a dialect almost nobody writes any
589
+ * more, and every modern Spring application received no data-flow analysis at
590
+ * all. A benchmark is only evidence about the code the benchmark contains.
591
+ *
592
+ * @ModelAttribute is deliberately absent: it binds a whole command object,
593
+ * and its FIELDS are what carry the request data, which needs object-state
594
+ * tracking this tracer does not have. Listing it would imply coverage of a
595
+ * shape that would go nowhere.
596
+ */
597
+ parameterSources: [
598
+ {
599
+ pattern: /@RequestParam\b/,
600
+ description: 'a Spring @RequestParam binding - the query string or form field',
601
+ },
602
+ { pattern: /@PathVariable\b/, description: 'a Spring @PathVariable binding - part of the URL path' },
603
+ { pattern: /@RequestHeader\b/, description: 'a Spring @RequestHeader binding - an HTTP request header' },
604
+ { pattern: /@CookieValue\b/, description: 'a Spring @CookieValue binding - a cookie the client sent' },
605
+ { pattern: /@RequestBody\b/, description: 'a Spring @RequestBody binding - the whole request body' },
606
+ { pattern: /@RequestPart\b/, description: 'a Spring @RequestPart binding - one part of a multipart upload' },
607
+ /*
608
+ * JAX-RS - the Jakarta standard, and therefore Jersey, RESTEasy, Dropwizard
609
+ * and Quarkus all at once. Same mechanism as Spring: the framework binds the
610
+ * request value to the parameter before the body runs, so the annotation on
611
+ * the declaration is the only evidence in the file.
612
+ *
613
+ * Six lines, four frameworks. Worth doing immediately, because the moment
614
+ * the capability text listed JAX-RS as recognised, not having it would have
615
+ * made the report a false statement about its own engine.
616
+ */
617
+ { pattern: /@QueryParam\b/, description: 'a JAX-RS @QueryParam binding - the query string' },
618
+ { pattern: /@PathParam\b/, description: 'a JAX-RS @PathParam binding - part of the URL path' },
619
+ { pattern: /@HeaderParam\b/, description: 'a JAX-RS @HeaderParam binding - an HTTP request header' },
620
+ { pattern: /@FormParam\b/, description: 'a JAX-RS @FormParam binding - a submitted form field' },
621
+ { pattern: /@CookieParam\b/, description: 'a JAX-RS @CookieParam binding - a cookie the client sent' },
622
+ { pattern: /@MatrixParam\b/, description: 'a JAX-RS @MatrixParam binding - a URL matrix parameter' },
623
+ ],
624
+ // StringBuilder/StringBuffer, the Collections API, and servlet response
625
+ // writers - the three ways Java code carries a value without assigning it.
626
+ mutators: ['append', 'insert', 'add', 'addAll', 'put', 'putAll', 'push', 'offer', 'write', 'print', 'println', 'setAttribute', 'addHeader', 'setHeader', 'command', 'directory', 'environment'],
627
+ // `append` and `insert` are StringBuilder concatenation - the builder IS its
628
+ // contents, so they keep their proof however many times they run. `command`
629
+ // sets the whole list at once rather than filing one item among many.
630
+ elementMutators: ['add', 'addAll', 'put', 'putAll', 'push', 'offer'],
631
+ // A servlet request is not its attribute map, and a response is not its
632
+ // header map. See safe/keyed-container.java - Jenkins was reported for
633
+ // `req.getContextPath()` because a setAttribute() elsewhere in the method had
634
+ // dirtied the whole request object.
635
+ keyedMutators: ['setAttribute', 'addHeader', 'setHeader'],
636
+ keyedReaders: ['getAttribute', 'getHeader', 'getAttributeNames', 'getHeaders', 'getHeaderNames'],
637
+ receiverSinks: [
638
+ {
639
+ methods: ['start'],
640
+ kind: 'command',
641
+ description: 'a process launched from a command list built earlier',
642
+ },
643
+ ],
644
+ sources: [
645
+ {
646
+ /*
647
+ * THE PARAMETER NAME IS ALSO THE ATTACKER'S, and the list read only the
648
+ * ways of getting what they SENT, never the way of getting what they
649
+ * CALLED IT.
650
+ *
651
+ * GET /page?<script>alert(1)</script>=anything
652
+ *
653
+ * Forty-six BenchmarkJava cases iterate getParameterNames(), keep one
654
+ * name, and print it - and every one was missed. getHeaderNames is the
655
+ * same shape for the same reason: the client picks its own header names.
656
+ *
657
+ * WHERE THIS STOPS. Attribute names are chosen by the application's own
658
+ * setAttribute calls; the context path, servlet path, method and protocol
659
+ * are chosen by the container. A name ending in "Names" is not the test -
660
+ * WHO CHOOSES IT is the test, and vulnerable/server-side-names.java holds
661
+ * the ones that must never be traced as attacker data.
662
+ */
663
+ pattern: /\b(request|req|httpRequest|servletRequest)\.get(ParameterNames|HeaderNames|Parameter|ParameterValues|ParameterMap|Header|Headers|QueryString|Cookies|InputStream|Reader|RequestURI|RequestURL|PathInfo|RemoteUser)\s*\(/,
664
+ description: 'HTTP servlet request data',
665
+ },
666
+ {
667
+ pattern: /\bgetParameterNames\s*\(|\bgetHeaderNames\s*\(|\bgetParameter\s*\(|\bgetParameterValues\s*\(|\bgetParameterMap\s*\(|\bgetHeaders?\s*\(|\bgetQueryString\s*\(|\bgetCookies\s*\(|\bgetRequestURI\s*\(|\bgetPathInfo\s*\(/,
668
+ description: 'an HTTP request parameter or header',
669
+ },
670
+ {
671
+ pattern: /\bnew\s+Scanner\s*\(\s*System\.in\s*\)|\bSystem\.in\b/,
672
+ description: 'console input',
673
+ },
674
+ ],
675
+ callSinks: [
676
+ {
677
+ kind: 'sql',
678
+ // Names that mean a database and nothing else. Safe bare.
679
+ methods: [
680
+ 'executeQuery', 'executeUpdate', 'prepareStatement', 'prepareCall',
681
+ 'createNativeQuery', 'createSQLQuery', 'addBatch',
682
+ 'queryForObject', 'queryForList', 'queryForRowSet',
683
+ ],
684
+ argIndexes: [0],
685
+ contentCheck: 'sql',
686
+ description: 'a JDBC/JPA database query',
687
+ },
688
+ {
689
+ kind: 'sql',
690
+ /*
691
+ * `execute` AND `createQuery` NEED A RECEIVER, and this is the sixth time
692
+ * this project has learned that lesson about a different method name.
693
+ *
694
+ * Teaching Spring bindings to the tracer immediately produced three new
695
+ * flow-verified SQL injections in WebGoat's PATH TRAVERSAL lesson -
696
+ * including in `ProfileUploadFix` and `ProfileUploadRemoveUserInput`,
697
+ * which are the lesson's CORRECTED variants. The sink was
698
+ *
699
+ * super.execute(file, fullName, username)
700
+ *
701
+ * `execute` is one of the most common method names in Java and belongs to
702
+ * Runnable, to every command object, and to half the abstract base classes
703
+ * ever written. The new sources did not create this bug; they revealed one
704
+ * that had been unreachable because nothing tainted ever got that far.
705
+ *
706
+ * `super` and `this` are excluded outright: a call to your own base class
707
+ * is not a database, whatever it is called.
708
+ */
709
+ methods: ['execute', 'createQuery'],
710
+ receiverPattern: /statement|stmt|conn|connection|jdbc|template|session|entityManager|em\b|db\b|database|query|cursor|dao|repo/i,
711
+ argIndexes: [0],
712
+ contentCheck: 'sql',
713
+ description: 'a JDBC/JPA database query',
714
+ },
715
+ {
716
+ kind: 'command',
717
+ methods: ['exec'],
718
+ /*
719
+ * `Runtime.getRuntime().exec(cmd)` is the shell. `parser.exec()`,
720
+ * `matcher.exec()` and every command-object `exec()` in Java are not, and
721
+ * `exec` is short enough that plenty of APIs reach for it.
722
+ *
723
+ * A receiver naming the runtime is the whole signal. BenchmarkJava's
724
+ * cmdi cases all spell it `Runtime.getRuntime().exec`, so this is checked
725
+ * against 1,210 scored cases rather than argued about.
726
+ */
727
+ receiverPattern: /runtime/i, // Runtime.getRuntime().exec(...)
728
+ /*
729
+ * POSITION 0 IS THE COMMAND. POSITION 1 IS THE ENVIRONMENT, AND IT IS
730
+ * ALSO A SINK.
731
+ *
732
+ * Runtime.exec(String[] cmdarray, String[] envp, File dir)
733
+ * ^0 ^1 ^2
734
+ *
735
+ * Roughly fifty BenchmarkJava cases put the attacker's value in `envp`
736
+ * and a constant in `cmdarray`. The trace followed the value correctly
737
+ * and then stopped, because this entry read argument 0 only.
738
+ *
739
+ * An attacker who controls the environment of a spawned process controls
740
+ * LD_PRELOAD, PATH and IFS - command execution by a slightly longer
741
+ * route - so OWASP scores those cases as real and this now agrees.
742
+ *
743
+ * Position 2, the working directory, is deliberately excluded. Control
744
+ * there changes WHERE a program runs rather than WHAT runs; including it
745
+ * would be widening on a hunch instead of on what the parameter means.
746
+ * Nothing about the SQL sinks changes: their argIndexes: [0] is what
747
+ * stops `stmt.execute(query, params)` - correctly parameterised code -
748
+ * being reported as injection, which is the worse failure of the two.
749
+ */
750
+ argIndexes: [0, 1],
751
+ description: 'a shell command via Runtime.exec (argument 0 is the command, 1 the environment it runs in)',
752
+ },
753
+ {
754
+ kind: 'path',
755
+ // Constructors, read as `new X(...)`. The tracer follows variables, not
756
+ // what an object holds, so the File is the sink rather than the read
757
+ // that happens on a later line.
758
+ methods: ['File', 'FileInputStream', 'FileOutputStream', 'FileReader', 'FileWriter', 'RandomAccessFile', 'FileSystemResource'],
759
+ argIndexes: [0, 1], // new File(dir, name): either half can climb
760
+ description: 'a file path the server opens, so `../` in it reaches files outside the folder the code meant',
761
+ },
762
+ {
763
+ kind: 'path',
764
+ // Paths.get / Path.of only build a Path - `Paths.get(x).getFileName()` is
765
+ // the fix - so the sink is the Files call that opens it.
766
+ methods: [
767
+ 'readAllBytes', 'readAllLines', 'readString', 'lines', 'newInputStream', 'newOutputStream',
768
+ 'newBufferedReader', 'newBufferedWriter', 'write', 'writeString', 'delete', 'deleteIfExists',
769
+ 'list', 'walk', 'createFile', 'createDirectory', 'createDirectories',
770
+ ],
771
+ receiverPattern: /^(java\.nio\.file\.)?Files$/,
772
+ argIndexes: [0], // write(path, bytes): the bytes are not a path
773
+ description: 'a file path the server opens, so `../` in it reaches files outside the folder the code meant',
774
+ },
775
+ {
776
+ kind: 'path',
777
+ methods: ['copy', 'move'],
778
+ receiverPattern: /^(java\.nio\.file\.)?Files$/,
779
+ argIndexes: [0, 1], // from, to
780
+ description: 'a file path the server opens, so `../` in it reaches files outside the folder the code meant',
781
+ },
782
+ {
783
+ kind: 'command',
784
+ methods: ['ProcessBuilder'], // new ProcessBuilder(...)
785
+ argIndexes: 'all',
786
+ description: 'a process launched via ProcessBuilder',
787
+ },
788
+ {
789
+ kind: 'ssrf',
790
+ // `new URL(x)`, read as the sink like `new File(x)`: the tracer follows
791
+ // variables, not what an object holds, so the openStream() on a later
792
+ // line is out of reach. bareOnly because a constructor has no receiver,
793
+ // and it keeps `foo.URL(x)` - some unrelated method - out.
794
+ methods: ['URL'],
795
+ bareOnly: true,
796
+ // new URL(spec) | new URL(context, spec) | new URL(protocol, host, ...):
797
+ // an absolute spec replaces the context's host, so 0 and 1 both steer it.
798
+ argIndexes: [0, 1],
799
+ description: 'a java.net.URL the server opens, so it fetches whatever host this names',
800
+ },
801
+ {
802
+ kind: 'ssrf',
803
+ methods: ['create'],
804
+ receiverPattern: /^(java\.net\.)?URI$/,
805
+ argIndexes: [0],
806
+ description: 'a java.net.URI the server requests, so it fetches whatever host this names',
807
+ },
808
+ {
809
+ kind: 'ssrf',
810
+ methods: ['newBuilder'],
811
+ receiverPattern: /^(java\.net\.http\.)?HttpRequest$/,
812
+ argIndexes: [0],
813
+ description: 'an outbound java.net.http request, so the server fetches whatever host this names',
814
+ },
815
+ {
816
+ kind: 'ssrf',
817
+ // Spring's RestTemplate. `exchange` is also Exchanger's, hence the receiver.
818
+ methods: ['getForObject', 'getForEntity', 'postForObject', 'exchange'],
819
+ receiverPattern: /resttemplate|\brest\b/i,
820
+ argIndexes: [0],
821
+ description: 'an outbound HTTP request via RestTemplate, so the server fetches whatever host this names',
822
+ },
823
+ {
824
+ kind: 'xss',
825
+ // `format` and `printf` are new here, and the receiver test is what makes
826
+ // them safe to add: `String.format(fmt, dirty)` builds a string and is
827
+ // not a sink, while `response.getWriter().format(fmt, dirty)` writes it
828
+ // to the page and is.
829
+ methods: ['print', 'println', 'write', 'printf', 'format', 'append'],
830
+ // Every argument. `write(param, 0, length)` puts the payload first, but
831
+ // `printf(locale, fmt, dirty)` puts it third, and watching only position
832
+ // 0 misses the value that actually carries the attack.
833
+ argIndexes: 'all',
834
+ // What replaced contentCheck: 'html'. See receiverPattern in types.ts -
835
+ // this one change is the difference between 204 missed real bugs and one
836
+ // avoided false one.
837
+ /*
838
+ * `System.out` IS NOT A RESPONSE BODY, and `\bout\b` said it was.
839
+ *
840
+ * Scanning Keycloak - 4,000 Java files - produced six flow-verified XSS
841
+ * findings and four were `System.out.println(...)` reported as "reaches
842
+ * an HTTP response body written without escaping". A console, described
843
+ * as a web page, at high severity, with a traced path claiming proof.
844
+ *
845
+ * Third time in this exact shape: process.stdout.write read as
846
+ * document.write, fmt.Fprintf(buf) read as page output, now this. The
847
+ * scoping sweep did not catch it because the sink IS scoped - the
848
+ * receiver pattern simply had the wrong contents. A test that asks
849
+ * "is there a constraint?" cannot see a constraint that is wrong, so
850
+ * there is now a second test that asks whether each pattern matches a
851
+ * console, and it is what found this line.
852
+ */
853
+ receiverPattern: /getWriter|getOutputStream|\b(pw|writer|resp|response)\b|(?<!System\.)\bout\b/i,
854
+ description: 'an HTTP response body written without escaping',
855
+ },
856
+ ],
857
+ assignSinks: [],
858
+ sanitizers: [
859
+ {
860
+ /*
861
+ * FRAMEWORK ESCAPERS, which is a list that can never be finished.
862
+ *
863
+ * Scanning Jenkins produced two FLOW-VERIFIED XSS findings on lines that
864
+ * read `Functions.htmlAttributeEscape(redirectUrl)`. Jenkins escapes with
865
+ * its own helper; this list knew `escapeHtml` and `htmlEscape` and not
866
+ * that one, so a proof was published over code doing the right thing.
867
+ *
868
+ * Escapers are per-framework in exactly the way SOURCES turned out to be.
869
+ * The signature rule now also matches them by shape - a name that says
870
+ * escape/encode/sanitize AND names a markup context - and the named
871
+ * entries here are the precise, intentional core it falls back from.
872
+ * Jenkins, Struts and ESAPI spellings are added because they were met.
873
+ */
874
+ names: [
875
+ 'escapeHtml', 'escapeHtml3', 'escapeHtml4', 'htmlEscape', 'encodeForHTML', 'escapeXml',
876
+ 'htmlAttributeEscape', 'htmlAttributeEscapeString', 'escapeEcmaScript', 'escapeJavaScript',
877
+ 'encodeForHTMLAttribute', 'encodeForJavaScript', 'escapeXml10', 'escapeXml11',
878
+ ],
879
+ kinds: ['xss'],
880
+ description: 'HTML escaping (Commons Text / Spring / ESAPI / Jenkins)',
881
+ },
882
+ {
883
+ names: ['getFileName'],
884
+ kinds: ['path'],
885
+ description: 'Path.getFileName - the last name in the path, with every folder and `..` gone',
886
+ },
887
+ {
888
+ // Commons Lang's escapeSql only doubles single quotes: a defence inside
889
+ // '...' and none outside. See onlyInsideQuotes in types.ts.
890
+ names: ['escapeSql'],
891
+ kinds: ['sql'],
892
+ onlyInsideQuotes: ['sql'],
893
+ description: 'SQL escaping - it protects a value inside quotes in the query, and nothing outside them',
894
+ },
895
+ {
896
+ names: ['parseInt', 'parseLong', 'parseDouble', 'parseFloat', 'parseShort'],
897
+ kinds: ['sql', 'command', 'xss', 'path'],
898
+ description: 'conversion to a number',
899
+ },
900
+ {
901
+ /*
902
+ * A NUMBER OR A TRUE/FALSE ANSWER ABOUT THE VALUE IS NOT THE VALUE.
903
+ *
904
+ * BenchmarkTest00036 writes `write(param.toCharArray(), 0, length)` with
905
+ * `length = param.length()`, and the engine reported a second path to it
906
+ * through length(). Right line, wrong reason: an int cannot carry a tag.
907
+ */
908
+ names: [
909
+ 'length', 'size', 'indexOf', 'lastIndexOf', 'hashCode', 'compareTo', 'compareToIgnoreCase',
910
+ 'equals', 'equalsIgnoreCase', 'isEmpty', 'isBlank', 'contains', 'containsKey',
911
+ 'startsWith', 'endsWith', 'matches',
912
+ ],
913
+ kinds: ['sql', 'command', 'xss', 'code', 'ssrf', 'deserialization', 'path'],
914
+ description: 'returns a number or true/false - none of the text that went in',
915
+ },
916
+ {
917
+ // Apache Commons Codec's DigestUtils: hex digits, whatever went in.
918
+ names: ['md5Hex', 'sha1Hex', 'sha256Hex', 'sha384Hex', 'sha512Hex', 'sha3_256Hex', 'sha3_512Hex'],
919
+ kinds: ['sql', 'command', 'xss', 'code', 'ssrf', 'deserialization', 'path'],
920
+ description: 'a hex digest - none of the text that went in',
921
+ },
922
+ ],
923
+ propagators: {
924
+ names: [
925
+ 'toString', 'trim', 'strip', 'concat', 'substring', 'replace', 'replaceAll',
926
+ 'format', 'append', 'toLowerCase', 'toUpperCase', 'join', 'split',
927
+ 'intern', 'valueOf', 'getBytes', 'repeat',
928
+ // URL decoding does not clean anything - it makes an encoded payload
929
+ // ACTIVE again, so it is a propagator, never a sanitiser.
930
+ 'decode', 'encode',
931
+ // Reading a value out of an enumeration/iterator/collection of dirty data.
932
+ 'nextElement', 'nextToken', 'next', 'readLine', 'get', 'getValue',
933
+ // Constructors and conversions that keep every character. Each was a
934
+ // guessed step on BenchmarkJava proofs, right every time it was made.
935
+ // Base64 DECODING is here and encoding is not: decoded bytes are whatever
936
+ // the attacker encoded, while encoded output is letters, digits and +/=,
937
+ // so calling encodeBase64() a carrier would be a claim, not a guess.
938
+ 'String', 'StringBuilder', 'StringBuffer', 'toCharArray', 'copyValueOf', 'decodeBase64',
939
+ 'ProcessBuilder',
940
+ // Paths.get is 'get' above; Path.of and toPath() keep the whole path too.
941
+ 'of', 'toPath', 'resolve',
942
+ ],
943
+ },
944
+ };
945
+ /* ========================================================================== *
946
+ * Go (net/http + database/sql)
947
+ * ========================================================================== */
948
+ const GO_DICTIONARY = {
949
+ mutators: ['WriteString', 'Write', 'WriteByte', 'WriteRune'],
950
+ sources: [
951
+ {
952
+ pattern: /\b(r|req|request)\.(URL|Form|PostForm|MultipartForm|Header|Body|Host|RemoteAddr)\b/,
953
+ description: 'an incoming *http.Request',
954
+ },
955
+ {
956
+ pattern: /\b(r|req|request)\.(FormValue|PostFormValue|Referer|UserAgent|Cookie)\s*\(/,
957
+ description: 'an HTTP request value read from the request',
958
+ },
959
+ { pattern: /\bmux\.Vars\s*\(/, description: 'gorilla/mux route variables' },
960
+ {
961
+ // Gin, Echo, Fiber and Chi all hand the handler a context object and all
962
+ // spell the getters slightly differently: Gin has Param/Query/PostForm,
963
+ // Echo has QueryParam/FormValue, Fiber has Params/Query/Body. Matching the
964
+ // union costs nothing and the capability text now names Echo, so it has to
965
+ // actually reach it.
966
+ pattern: /\b(c|ctx)\.(Param|Params|Query|QueryParam|PostForm|FormValue|DefaultQuery|GetHeader|Body)\s*\(/,
967
+ description: 'Gin/Echo request parameters',
968
+ },
969
+ { pattern: /\bos\.Args\b/, description: 'command-line arguments' },
970
+ ],
971
+ callSinks: [
972
+ {
973
+ kind: 'sql',
974
+ methods: [
975
+ 'Query', 'QueryRow', 'QueryContext', 'QueryRowContext',
976
+ 'Exec', 'ExecContext', 'Prepare', 'PrepareContext', 'NamedExec', 'MustExec',
977
+ ],
978
+ // Matched against the receiver's NAME or its declared type, so both
979
+ // `db.Query(...)` and `var store *sql.DB; store.Query(...)` are seen.
980
+ receiverPattern: /\bdb\b|conn|\btx\b|stmt|sqlx|database|pool|sql\.DB|sql\.Tx/i,
981
+ argIndexes: [0], // db.Query("... $1", id) - the rest are bound parameters
982
+ contentCheck: 'sql',
983
+ description: 'a database/sql query',
984
+ },
985
+ {
986
+ kind: 'path',
987
+ methods: ['Open', 'OpenFile', 'ReadFile', 'WriteFile', 'Create', 'Remove', 'RemoveAll', 'ReadDir', 'Mkdir', 'MkdirAll', 'Rename'],
988
+ requiredReceivers: ['os', 'ioutil'],
989
+ argIndexes: [0],
990
+ description: 'a file path the server opens, so `../` in it reaches files outside the folder the code meant',
991
+ },
992
+ {
993
+ kind: 'path',
994
+ // http.ServeFile(w, r, name): ServeFile checks r.URL.Path for `..`, not name.
995
+ methods: ['ServeFile'],
996
+ requiredReceivers: ['http'],
997
+ argIndexes: [2],
998
+ description: 'a file sent back in the HTTP response, so `../` in this path serves any file the server can read',
999
+ },
1000
+ {
1001
+ kind: 'ssrf',
1002
+ // Package-level net/http only: `client.Get(u)` is not seen.
1003
+ methods: ['Get', 'Post', 'Head', 'PostForm'],
1004
+ requiredReceivers: ['http'],
1005
+ argIndexes: [0],
1006
+ description: 'an outbound HTTP request, so the server fetches whatever host this names',
1007
+ },
1008
+ {
1009
+ kind: 'ssrf',
1010
+ methods: ['NewRequest'], // NewRequest(method, url, body)
1011
+ requiredReceivers: ['http'],
1012
+ argIndexes: [1],
1013
+ description: 'an outbound HTTP request, so the server fetches whatever host this names',
1014
+ },
1015
+ {
1016
+ kind: 'ssrf',
1017
+ methods: ['NewRequestWithContext'], // NewRequestWithContext(ctx, method, url, body)
1018
+ requiredReceivers: ['http'],
1019
+ argIndexes: [2],
1020
+ description: 'an outbound HTTP request, so the server fetches whatever host this names',
1021
+ },
1022
+ {
1023
+ kind: 'command',
1024
+ methods: ['Command', 'CommandContext'],
1025
+ argIndexes: 'all',
1026
+ // exec.Command("ping", host) is genuinely safe - no shell parses it. Only
1027
+ // a shell PROGRAM makes this dangerous.
1028
+ requiresShellOption: /["'`](sh|bash|zsh|cmd\.exe|powershell)["'`]/,
1029
+ description: 'a shell command via os/exec',
1030
+ },
1031
+ {
1032
+ kind: 'xss',
1033
+ methods: ['HTML', 'HTMLAttr', 'JS', 'JSStr', 'URL'],
1034
+ /*
1035
+ * These are conversions into html/template's trusted string types, and
1036
+ * they only mean that on the template package. Bare, `URL(...)` and
1037
+ * `JS(...)` are two of the most reusable identifiers in Go - a URL
1038
+ * builder, a JS minifier, somebody's helper.
1039
+ */
1040
+ receiverPattern: /template/i,
1041
+ argIndexes: [0],
1042
+ description: 'a html/template conversion that switches auto-escaping OFF',
1043
+ },
1044
+ {
1045
+ kind: 'xss',
1046
+ // The writer IS the receiver here: `w.Write(...)`, `w.WriteString(...)`.
1047
+ // `Write` belongs to every buffer, file, hash, socket and gzip stream in
1048
+ // Go, so the declared type does the work - `http.ResponseWriter` is a
1049
+ // page, `bytes.Buffer` is not.
1050
+ methods: ['Write', 'WriteString'],
1051
+ receiverPattern: /^w$|writer|\brw\b|resp|response|ResponseWriter/i,
1052
+ argIndexes: 'all',
1053
+ contentCheck: 'html',
1054
+ description: 'the HTTP response body',
1055
+ },
1056
+ {
1057
+ kind: 'xss',
1058
+ /*
1059
+ * THE LAST INSTANCE OF THE SCOPING CLASS, and the only one a receiver
1060
+ * rule could not reach.
1061
+ *
1062
+ * `fmt.Fprintf(w, format, args...)` writes to `w`. The receiver is `fmt`.
1063
+ * Widening the receiver pattern to keep these firing is what kept
1064
+ * `fmt.Fprintf(out, "%s: %s\\n", name, value)` in a gitea TEST MOCK
1065
+ * reported as cross-site scripting through an entire scoping sweep.
1066
+ *
1067
+ * So the destination is checked where it actually is - argument zero -
1068
+ * and argument zero is excluded from the payload check, because the
1069
+ * writer is where the output goes, not what is written.
1070
+ */
1071
+ methods: ['Fprintf', 'Fprint', 'Fprintln'],
1072
+ writerArgPattern: /^w$|writer|\brw\b|resp|response|ResponseWriter/i,
1073
+ argIndexes: 'all',
1074
+ contentCheck: 'html',
1075
+ description: 'the HTTP response body',
1076
+ },
1077
+ ],
1078
+ assignSinks: [],
1079
+ sanitizers: [
1080
+ {
1081
+ names: ['Atoi', 'ParseInt', 'ParseUint', 'ParseFloat', 'ParseBool'],
1082
+ kinds: ['sql', 'command', 'xss', 'path'],
1083
+ description: 'strconv conversion to a number',
1084
+ },
1085
+ {
1086
+ names: ['HTMLEscapeString', 'HTMLEscape', 'EscapeString', 'JSEscapeString'],
1087
+ kinds: ['xss'],
1088
+ description: 'HTML escaping',
1089
+ },
1090
+ { names: ['QuoteIdentifier'], kinds: ['sql'], description: 'SQL identifier quoting' },
1091
+ // filepath.Base / path.Base. Clean is NOT here: Clean("../../x") keeps the climb.
1092
+ { names: ['Base'], kinds: ['path'], description: 'filepath.Base - the last name in the path, with every folder and `..` gone' },
1093
+ ],
1094
+ propagators: {
1095
+ names: [
1096
+ 'Sprintf', 'Sprint', 'Sprintln', 'Join', 'TrimSpace', 'Trim', 'TrimPrefix',
1097
+ 'TrimSuffix', 'ToLower', 'ToUpper', 'Replace', 'ReplaceAll', 'Split',
1098
+ 'Fields', 'Repeat', 'String', 'Title', 'Get',
1099
+ // filepath.Clean("../../x") is "../../x": tidier, still climbing.
1100
+ 'Clean', 'FromSlash', 'ToSlash',
1101
+ ],
1102
+ },
1103
+ freshResults: [
1104
+ {
1105
+ names: ['ReadFile'],
1106
+ description: 'a file read - it returns what the file holds, not the path that named it',
1107
+ },
1108
+ ],
1109
+ };
1110
+ /* ========================================================================== *
1111
+ * Registry
1112
+ * ========================================================================== */
1113
+ /**
1114
+ * PHP.
1115
+ *
1116
+ * PHP is the language this whole tool class was invented for: the superglobals
1117
+ * are attacker input by definition, and the classic sinks take a string.
1118
+ *
1119
+ * What makes PHP different from the other four here is that the SOURCE is
1120
+ * unambiguous. `req.query.id` in JavaScript is a shape we guess at; `$_GET` is
1121
+ * attacker-controlled by specification, with no framework convention to read
1122
+ * and no chance of a false source. That is why PHP flow-verification is
1123
+ * unusually reliable - the uncertainty in the other languages lives at the
1124
+ * source, and here there is none.
1125
+ */
1126
+ const PHP_DICTIONARY = {
1127
+ mutators: ['push', 'append', 'add', 'write', 'bindValue', 'bindParam'],
1128
+ // bindValue/bindParam stay out: a bound parameter is not read back out of the
1129
+ // statement as one of several items.
1130
+ elementMutators: ['push', 'append', 'add'],
1131
+ sources: [
1132
+ {
1133
+ // Listed before the superglobals so it wins for this one key.
1134
+ pattern: /^\$_FILES\s*\[[^\]]+\]\s*\[\s*['"]name['"]\s*\]$/,
1135
+ description: 'the name of an uploaded file, as the uploader wrote it',
1136
+ fileNameOnly: true,
1137
+ },
1138
+ {
1139
+ pattern: /\$_(GET|POST|REQUEST|COOKIE|FILES)\b/,
1140
+ description: 'an HTTP request superglobal, which is attacker-controlled by definition',
1141
+ },
1142
+ {
1143
+ pattern: /\$_SERVER\s*\[\s*['"](QUERY_STRING|HTTP_[A-Z_]+|REQUEST_URI|PATH_INFO|argv)['"]/,
1144
+ description: 'a request-derived $_SERVER entry (headers, URI, query string)',
1145
+ },
1146
+ {
1147
+ pattern: /\bfile_get_contents\s*\(\s*['"]php:\/\/input['"]/,
1148
+ description: 'the raw HTTP request body',
1149
+ },
1150
+ {
1151
+ pattern: /\$request->(get|input|query|post|all|json|cookie|header)\b/,
1152
+ description: 'a Symfony/Laravel request accessor',
1153
+ },
1154
+ ],
1155
+ callSinks: [
1156
+ {
1157
+ kind: 'sql',
1158
+ // The `mysqli_*` / `pg_*` / `db_query` names below are unambiguous
1159
+ // functions. `query`, `exec`, `prepare` and `statement` are method names
1160
+ // anything can have, so the receiver has to look like a database handle -
1161
+ // or be absent, which is how the procedural functions are called.
1162
+ bareOnly: true,
1163
+ allowedReceivers: [
1164
+ 'pdo', 'db', 'conn', 'connection', 'mysqli', 'link', 'dbh',
1165
+ 'database', 'wpdb', 'capsule', 'stmt', 'statement', 'client', 'driver', 'this',
1166
+ ],
1167
+ methods: [
1168
+ 'query', 'exec', 'prepare', 'unprepared', 'statement',
1169
+ 'mysqli_query', 'mysqli_real_query', 'mysqli_multi_query', 'mysqli_prepare',
1170
+ 'pg_query', 'mysql_query', 'sqlsrv_query', 'db_query',
1171
+ ],
1172
+ // mysqli_query($conn, $sql) puts the query SECOND; $pdo->query($sql) puts
1173
+ // it first. Watching both positions covers the procedural and the
1174
+ // object-oriented API without a separate entry for each.
1175
+ argIndexes: [0, 1],
1176
+ contentCheck: 'sql',
1177
+ description: 'a database query',
1178
+ },
1179
+ {
1180
+ kind: 'command',
1181
+ methods: ['system', 'exec', 'shell_exec', 'passthru', 'popen', 'proc_open', 'pcntl_exec'],
1182
+ /*
1183
+ * These are PHP FUNCTIONS, so they are only themselves when called bare.
1184
+ * `$deployer->exec($cmd)` is somebody's method that happens to share the
1185
+ * name with the language's shell primitive - the same collision that made
1186
+ * a regular expression's `.exec()` a critical shell-command finding in
1187
+ * gitea's browser code.
1188
+ */
1189
+ bareOnly: true,
1190
+ argIndexes: [0],
1191
+ description: 'a shell command',
1192
+ },
1193
+ {
1194
+ kind: 'ssrf',
1195
+ // file_get_contents doubles as a file reader. A hit is either SSRF or
1196
+ // path traversal, and both deserve the same second look.
1197
+ methods: ['file_get_contents', 'fopen', 'curl_init', 'curl_setopt', 'readfile'],
1198
+ argIndexes: 'all',
1199
+ description: 'an outbound fetch, so the server retrieves whatever this names',
1200
+ },
1201
+ {
1202
+ kind: 'path',
1203
+ // file_get_contents, fopen and readfile are SSRF sinks too: a URL there
1204
+ // is a request, a path a file read, and either is reported as itself.
1205
+ methods: [
1206
+ 'file_get_contents', 'fopen', 'readfile', 'file', 'file_put_contents', 'unlink',
1207
+ 'opendir', 'scandir', 'mkdir', 'rmdir',
1208
+ ],
1209
+ bareOnly: true,
1210
+ argIndexes: [0], // file_put_contents($path, $data): the data is not a path
1211
+ description: 'a file path the server opens, so `../` in it reaches files outside the folder the code meant',
1212
+ },
1213
+ {
1214
+ kind: 'path',
1215
+ methods: ['copy', 'rename', 'symlink', 'link'],
1216
+ bareOnly: true,
1217
+ argIndexes: [0, 1], // from, to
1218
+ description: 'a file path the server opens, so `../` in it reaches files outside the folder the code meant',
1219
+ },
1220
+ {
1221
+ kind: 'path',
1222
+ // The destination only: the source is PHP's own temp file, which PHP
1223
+ // checks is the upload before it moves anything.
1224
+ methods: ['move_uploaded_file'],
1225
+ bareOnly: true,
1226
+ argIndexes: [1],
1227
+ description: 'the place an uploaded file is stored, so `../` in it writes outside the upload folder',
1228
+ },
1229
+ {
1230
+ kind: 'code',
1231
+ methods: ['eval', 'assert', 'create_function'],
1232
+ bareOnly: true,
1233
+ argIndexes: [0],
1234
+ description: 'the PHP interpreter, which runs the string as program code',
1235
+ },
1236
+ {
1237
+ kind: 'deserialization',
1238
+ methods: ['unserialize'],
1239
+ argIndexes: [0],
1240
+ // The hardened form is the same call with a second argument. Reporting it
1241
+ // would punish the fix.
1242
+ unlessOption: /allowed_classes/,
1243
+ description: 'unserialize(), which rebuilds objects the bytes name',
1244
+ },
1245
+ {
1246
+ kind: 'xss',
1247
+ methods: ['printf', 'vprintf', 'print_r', 'var_dump'],
1248
+ // ALL arguments, not just the first. printf's argument 0 is the format
1249
+ // string and every argument after it is the data being printed into the
1250
+ // page - watching only position 0 misses the value that actually carries
1251
+ // the payload, which is the normal shape: printf("<p>%s</p>", $bio).
1252
+ argIndexes: 'all',
1253
+ description: 'output written straight to the response body',
1254
+ },
1255
+ ],
1256
+ // `echo $x;` is captured by the shape layer as an assignment whose target is
1257
+ // the `echo` keyword, so the taint engine sees it the same way it sees
1258
+ // `el.innerHTML = x` in JavaScript. See ASSIGNMENT_QUERIES.php.
1259
+ assignSinks: [
1260
+ {
1261
+ kind: 'xss',
1262
+ properties: ['echo', 'print'],
1263
+ description: 'output written straight to the response body by echo/print',
1264
+ },
1265
+ {
1266
+ kind: 'xss',
1267
+ /*
1268
+ * THE PAGE BUFFER - and the reason PHP XSS recall was zero.
1269
+ *
1270
+ * The first recall measurement outside Java scored 9 of DVWA's 9 XSS
1271
+ * cases as MISSED, every one of them this shape:
1272
+ *
1273
+ * $html .= '<pre>Hello ' . $_GET['name'] . '</pre>';
1274
+ *
1275
+ * DVWA never echoes at the point of the bug. It accumulates the page into
1276
+ * `$html` and prints it from a template in a DIFFERENT FILE - and PHP has
1277
+ * no cross-file tracing, so following it there is not an option either.
1278
+ * This is not a DVWA quirk; building output into a buffer and emitting it
1279
+ * once is ordinary PHP.
1280
+ *
1281
+ * WHAT THIS IS EVIDENCE OF, honestly. A variable named like page output,
1282
+ * accumulating a string whose literal parts contain HTML tags. That is
1283
+ * weaker than seeing `echo` - the name is a convention, not a proof - and
1284
+ * it is the same class of assumption the engine already makes when it
1285
+ * treats a `req.query`-shaped expression as a real request. The
1286
+ * looksLikeHtml gate is what keeps it from firing on every accumulator:
1287
+ * `$sql .= $where` builds a query, not a page.
1288
+ */
1289
+ properties: ['html', 'output', 'page', 'content', 'body', 'buffer', 'markup', 'out'],
1290
+ contentCheck: 'html',
1291
+ description: 'a page buffer that is printed later, often from another file',
1292
+ },
1293
+ ],
1294
+ sanitizers: [
1295
+ {
1296
+ names: ['htmlspecialchars', 'htmlentities', 'strip_tags', 'html_entity_decode_safe'],
1297
+ kinds: ['xss'],
1298
+ description: 'HTML escaping (htmlspecialchars / htmlentities)',
1299
+ },
1300
+ {
1301
+ /*
1302
+ * WORDPRESS'S ESCAPING API, WHICH IS MOST OF THE PHP ON THE INTERNET.
1303
+ *
1304
+ * WordPress does not call htmlspecialchars directly; it wraps it. A scan
1305
+ * of 2,119 WordPress files produced 2,971 XSS findings and 2,547 of them
1306
+ * were `echo esc_html( $x )` - the correct, documented way to output
1307
+ * anything in WordPress, reported as the bug it prevents.
1308
+ *
1309
+ * 223 of them were FLOW-VERIFIED, which is the part that mattered. The
1310
+ * tracer found a real source, followed it correctly, and stopped inside
1311
+ * `esc_html( $result->get_error_message() )` calling the flow proven. A
1312
+ * guess that lands on escaped code is noise; a proof that lands on it is
1313
+ * a false statement, and the sanitiser was in the argument list being
1314
+ * read at the time.
1315
+ *
1316
+ * KNOWN IMPRECISION, stated rather than implied: these are CONTEXT
1317
+ * escapers. esc_html is correct in element text and wrong inside a
1318
+ * <script>; esc_js is the reverse. We accept any of them for any XSS
1319
+ * sink, so using the wrong one for the context is not detected. That is a
1320
+ * narrower bug than the one this fixes, and pretending otherwise by
1321
+ * leaving all 2,547 in place would not have found it either.
1322
+ */
1323
+ names: [
1324
+ 'esc_html', 'esc_attr', 'esc_url', 'esc_url_raw', 'esc_js', 'esc_textarea', 'esc_xml',
1325
+ 'esc_html__', 'esc_html_e', 'esc_html_x', 'esc_attr__', 'esc_attr_e', 'esc_attr_x',
1326
+ 'wp_kses', 'wp_kses_post', 'wp_kses_data', 'tag_escape', 'sanitize_html_class',
1327
+ 'sanitize_text_field', 'sanitize_textarea_field', 'sanitize_title', 'sanitize_email',
1328
+ 'sanitize_key', 'sanitize_user', 'wp_json_encode', 'absint',
1329
+ ],
1330
+ kinds: ['xss'],
1331
+ description: "WordPress escaping (esc_html / esc_attr / esc_url / wp_kses)",
1332
+ },
1333
+ {
1334
+ // Native PHP encoders that cannot emit a bare `<`, plus the one-letter
1335
+ // helper Laravel and Twig both spell `e()`.
1336
+ names: ['urlencode', 'rawurlencode', 'json_encode', 'e', 'number_format'],
1337
+ kinds: ['xss'],
1338
+ description: 'URL/JSON encoding, which cannot produce a raw tag',
1339
+ },
1340
+ {
1341
+ names: ['escapeshellarg', 'escapeshellcmd'],
1342
+ kinds: ['command'],
1343
+ description: 'shell argument quoting (escapeshellarg)',
1344
+ },
1345
+ {
1346
+ // Also a propagator below, for every other kind: it keeps the file name's text.
1347
+ names: ['basename'],
1348
+ kinds: ['path'],
1349
+ description: 'basename - the last name in the path, with every folder and `..` gone',
1350
+ },
1351
+ {
1352
+ names: ['intval', 'floatval', 'intdiv'],
1353
+ kinds: ['sql', 'command', 'xss', 'path'],
1354
+ description: 'conversion to a number',
1355
+ },
1356
+ {
1357
+ names: ['real_escape_string', 'mysqli_real_escape_string'],
1358
+ kinds: ['sql'],
1359
+ // Deliberately called escaping, not parameterisation. It IS weaker than a
1360
+ // prepared statement and gets the wrong answer under some charsets, but a
1361
+ // traced value that passes through it is not the same finding as one that
1362
+ // does not - so we stop the trace and say which one we saw.
1363
+ //
1364
+ // And it only works INSIDE QUOTES. DVWA's sqli/source/medium.php escapes
1365
+ // the id and then writes `WHERE user_id = $id` with no quotes, which this
1366
+ // entry used to list as checked and cleared. See onlyInsideQuotes.
1367
+ onlyInsideQuotes: ['sql'],
1368
+ description: 'database string escaping - it protects a value inside quotes in the query, and nothing outside them',
1369
+ },
1370
+ {
1371
+ // These two add the quotes themselves, so their result is safe anywhere.
1372
+ names: ['pg_escape_literal', 'quote'],
1373
+ kinds: ['sql'],
1374
+ description: 'database quoting - the function escapes the value and adds the quotes itself',
1375
+ },
1376
+ /*
1377
+ * OUTPUT THAT CANNOT CARRY THE INPUT'S TEXT.
1378
+ *
1379
+ * Not escapers: these return a number, true/false, or a fixed-alphabet
1380
+ * digest, so nothing the attacker typed survives into the result. On DVWA
1381
+ * the engine assumed md5() and isset() handed their argument back and
1382
+ * published proofs through both - brute/source/impossible.php and
1383
+ * bac/source/low.php - and both were wrong.
1384
+ */
1385
+ {
1386
+ names: [
1387
+ 'isset', 'empty', 'is_numeric', 'is_int', 'is_integer', 'is_float', 'is_string', 'is_array',
1388
+ 'is_bool', 'is_null', 'ctype_digit', 'ctype_alpha', 'ctype_alnum', 'ctype_xdigit',
1389
+ 'strlen', 'mb_strlen', 'count', 'sizeof', 'strpos', 'stripos', 'strrpos', 'strcmp',
1390
+ 'strcasecmp', 'strncmp', 'strncasecmp', 'in_array', 'array_key_exists', 'preg_match',
1391
+ 'preg_match_all', 'password_verify', 'hash_equals', 'crc32', 'bin2hex',
1392
+ ],
1393
+ kinds: ['sql', 'command', 'xss', 'code', 'ssrf', 'deserialization', 'path'],
1394
+ description: 'returns a number, true/false or hex digits - none of the text that went in',
1395
+ },
1396
+ {
1397
+ // Hex digests - but only in the forms that return hex. Each has a
1398
+ // trailing flag that switches to raw binary, which CAN contain a quote.
1399
+ names: ['md5', 'sha1'],
1400
+ maxArgs: 1,
1401
+ kinds: ['sql', 'command', 'xss', 'code', 'ssrf', 'deserialization', 'path'],
1402
+ description: 'returns a hex digest - none of the text that went in',
1403
+ },
1404
+ {
1405
+ names: ['hash'],
1406
+ maxArgs: 2,
1407
+ kinds: ['sql', 'command', 'xss', 'code', 'ssrf', 'deserialization', 'path'],
1408
+ description: 'returns a hex digest - none of the text that went in',
1409
+ },
1410
+ {
1411
+ names: ['hash_hmac'],
1412
+ maxArgs: 3,
1413
+ kinds: ['sql', 'command', 'xss', 'code', 'ssrf', 'deserialization', 'path'],
1414
+ description: 'returns a hex digest - none of the text that went in',
1415
+ },
1416
+ ],
1417
+ propagators: {
1418
+ names: [
1419
+ 'strval', 'trim', 'ltrim', 'rtrim', 'strtolower', 'strtoupper', 'ucfirst',
1420
+ 'str_replace', 'substr', 'sprintf', 'implode', 'explode', 'json_encode',
1421
+ 'urlencode', 'rawurlencode', 'str_pad', 'nl2br',
1422
+ 'base64_decode', 'base64_encode', 'gzuncompress', 'gzinflate',
1423
+ // Measured on DVWA, where each was a guessed step on a proof an AI
1424
+ // review confirmed: they change the text and leave the attacker's part in.
1425
+ 'stripslashes', 'stripcslashes', 'basename', 'dirname', 'preg_replace', 'str_ireplace',
1426
+ 'ucwords', 'lcfirst', 'wordwrap', 'strrev', 'urldecode', 'rawurldecode',
1427
+ 'html_entity_decode', 'htmlspecialchars_decode', 'strstr', 'stristr', 'strrchr',
1428
+ 'mb_substr', 'mb_strtolower', 'mb_strtoupper',
1429
+ ],
1430
+ },
1431
+ freshResults: [
1432
+ {
1433
+ // NOT the generic `query`: on a request object it is a source.
1434
+ names: [
1435
+ 'mysqli_query', 'mysql_query', 'pg_query', 'pg_query_params', 'pg_execute',
1436
+ 'mysqli_fetch_assoc', 'mysqli_fetch_array', 'mysqli_fetch_row', 'mysqli_fetch_object',
1437
+ 'mysqli_fetch_all', 'mysql_fetch_assoc', 'mysql_fetch_array', 'mysql_fetch_row',
1438
+ 'mysql_fetch_object', 'pg_fetch_assoc', 'pg_fetch_array', 'pg_fetch_row', 'pg_fetch_object',
1439
+ 'pg_fetch_all', 'pg_fetch_result', 'fetch', 'fetchAll', 'fetchColumn', 'fetchObject',
1440
+ 'fetch_assoc', 'fetch_array', 'fetch_row', 'fetch_object', 'fetch_all',
1441
+ ],
1442
+ description: 'a database read - it returns rows stored in the database, not the query text that went in',
1443
+ rowsFollowInjection: true,
1444
+ },
1445
+ {
1446
+ names: ['file_get_contents', 'fread', 'fgets', 'fgetcsv', 'stream_get_contents'],
1447
+ description: 'a file or URL read - it returns the contents of what the argument names, not the argument. ' +
1448
+ 'Whoever controls a whole URL can control what it returns, so a tainted URL here is a ' +
1449
+ 'separate question, asked by the SSRF check',
1450
+ },
1451
+ ],
1452
+ };
1453
+ /* ========================================================================== *
1454
+ * C
1455
+ *
1456
+ * THE SHORTEST SOURCE LIST IN THIS FILE, AND THE MOST RELIABLE ONE.
1457
+ *
1458
+ * Every other language here is a list of FRAMEWORK accessors, and the honesty
1459
+ * note on each one says the same thing: we recognise `req.query` by its shape
1460
+ * and cannot prove `req` is really a request. That approximation is why the
1461
+ * Java entry has to name Spring and JAX-RS separately, and why elasticsearch's
1462
+ * house-built RestRequest is invisible to all of them.
1463
+ *
1464
+ * C has no frameworks. Its sources are the operating system, and they have not
1465
+ * changed since the 1970s: the command line, the environment, standard input,
1466
+ * a file descriptor, a socket. There is no Express-versus-Koa problem to have.
1467
+ * `argv` is argv in every C program ever written.
1468
+ *
1469
+ * The sinks are equally old and equally unambiguous. `system()` is a shell,
1470
+ * always - it has no options object, no parameterised form, and nothing safe
1471
+ * to be confused with. The scoping problems that dominate the JS and Java sink
1472
+ * tables (is this `exec` a shell or a regular expression? is this `query` a
1473
+ * database or a Map?) simply do not arise.
1474
+ *
1475
+ * WHAT IS NOT HERE, and it is the important half: memory safety. Buffer
1476
+ * overflows, use-after-free, double-free and integer overflow are what C is
1477
+ * actually famous for, and this engine models none of them, because deciding
1478
+ * any of them needs sizes, allocation lifetimes and pointer aliasing rather
1479
+ * than values. The `overflow` sink below is narrower than its name suggests -
1480
+ * see the comment on it.
1481
+ * ========================================================================== */
1482
+ /** Shared by C and C++: the libc surface is identical in both. */
1483
+ const C_SOURCES = [
1484
+ {
1485
+ pattern: /\bargv\s*\[|\*\s*argv\b|\bargv\b/,
1486
+ description: 'a command-line argument, which the caller of this program chooses',
1487
+ localOnly: true,
1488
+ },
1489
+ {
1490
+ pattern: /\bgetenv\s*\(/,
1491
+ description: 'an environment variable - attacker-controlled whenever the attacker controls the ' +
1492
+ 'parent process, which is the normal case for CGI programs and setuid binaries',
1493
+ localOnly: true,
1494
+ },
1495
+ {
1496
+ pattern: /\b(fgets|gets|scanf|fscanf|sscanf|getline|getchar|fgetc)\s*\(/,
1497
+ description: 'standard input',
1498
+ localOnly: true,
1499
+ },
1500
+ {
1501
+ pattern: /\b(read|recv|recvfrom|recvmsg|fread)\s*\(/,
1502
+ description: 'bytes read from a file descriptor or a socket',
1503
+ },
1504
+ ];
1505
+ /**
1506
+ * The printf family, and WHICH argument is the format in each.
1507
+ *
1508
+ * This table is the whole format-string rule. Getting an index wrong here does
1509
+ * not merely miss a bug, it reports the FIX as the bug: `printf("%s", name)`
1510
+ * is the correct form, and a rule that scanned every argument would flag it.
1511
+ * The dangerous call and the safe call are the same function with an argument
1512
+ * added - exactly the prepared-statement shape from the SQL rules.
1513
+ */
1514
+ const C_FORMAT_SINKS = [
1515
+ {
1516
+ kind: 'format',
1517
+ methods: ['printf', 'vprintf'],
1518
+ argIndexes: [0],
1519
+ description: 'a format string, where %n writes to memory the attacker chooses',
1520
+ },
1521
+ {
1522
+ kind: 'format',
1523
+ methods: ['fprintf', 'sprintf', 'vfprintf', 'vsprintf', 'dprintf', 'asprintf', 'syslog'],
1524
+ argIndexes: [1],
1525
+ description: 'a format string, where %n writes to memory the attacker chooses',
1526
+ },
1527
+ {
1528
+ kind: 'format',
1529
+ methods: ['snprintf', 'vsnprintf'],
1530
+ argIndexes: [2],
1531
+ description: 'a format string, where %n writes to memory the attacker chooses',
1532
+ },
1533
+ ];
1534
+ /**
1535
+ * Copies with no length parameter.
1536
+ *
1537
+ * READ THE KIND NAME AS WRITTEN: `overflow` here means "reaches a copy with no
1538
+ * length bound", not "overflows". The destination's size is not modelled, so a
1539
+ * strcpy into a genuinely large enough buffer is a FALSE POSITIVE of this
1540
+ * rule - declared, not discovered. It is still worth reporting because these
1541
+ * functions have no upper bound by construction: they write until they meet a
1542
+ * NUL byte and the attacker picks where that is.
1543
+ *
1544
+ * The bounded siblings - strncpy, snprintf, strlcpy - are deliberately absent,
1545
+ * and c-format-constant.c asserts they stay quiet.
1546
+ */
1547
+ const C_OVERFLOW_SINKS = [
1548
+ {
1549
+ kind: 'overflow',
1550
+ methods: ['strcpy', 'strcat', 'stpcpy'],
1551
+ argIndexes: [1],
1552
+ description: 'a copy with no length bound, which writes until it meets a NUL byte',
1553
+ },
1554
+ {
1555
+ // Argument 0 is the destination and argument 1 is the format; anything
1556
+ // after that is a value being written out through a %s with no width.
1557
+ kind: 'overflow',
1558
+ methods: ['sprintf', 'vsprintf'],
1559
+ argIndexes: [2, 3, 4, 5, 6],
1560
+ description: 'a copy with no length bound, which writes until it meets a NUL byte',
1561
+ },
1562
+ ];
1563
+ const C_DICTIONARY = {
1564
+ sources: C_SOURCES,
1565
+ callSinks: [
1566
+ {
1567
+ kind: 'command',
1568
+ methods: ['system', 'popen'],
1569
+ argIndexes: [0],
1570
+ description: 'a shell, which runs the string as a command line',
1571
+ },
1572
+ {
1573
+ // exec* takes an argument VECTOR, so a semicolon in an argument is just a
1574
+ // semicolon - UNLESS the program being run is itself a shell, which turns
1575
+ // the next argument back into a command line.
1576
+ kind: 'command',
1577
+ methods: ['execl', 'execlp', 'execv', 'execvp', 'execle', 'execve'],
1578
+ argIndexes: 'all',
1579
+ requiresShellOption: /["'](\/bin\/)?(sh|bash|zsh|ksh|dash)["']/,
1580
+ description: 'a shell invoked through exec',
1581
+ },
1582
+ {
1583
+ kind: 'path',
1584
+ methods: ['fopen', 'freopen', 'open', 'creat', 'remove', 'unlink', 'opendir'],
1585
+ // The libc functions are always called bare. This keeps out C++'s
1586
+ // ifstream.open(), which is not followed, and std::remove, an algorithm.
1587
+ bareOnly: true,
1588
+ argIndexes: [0], // fopen(path, mode): the mode is not a path
1589
+ description: 'a file path the server opens, so `../` in it reaches files outside the folder the code meant',
1590
+ },
1591
+ {
1592
+ kind: 'path',
1593
+ methods: ['rename', 'openat', 'symlink', 'link'],
1594
+ bareOnly: true,
1595
+ argIndexes: [0, 1], // rename(from, to); openat(dirfd, path): the dirfd is a number
1596
+ description: 'a file path the server opens, so `../` in it reaches files outside the folder the code meant',
1597
+ },
1598
+ ...C_FORMAT_SINKS,
1599
+ ...C_OVERFLOW_SINKS,
1600
+ {
1601
+ kind: 'sql',
1602
+ methods: [
1603
+ 'mysql_query', 'mysql_real_query', 'PQexec', 'sqlite3_exec',
1604
+ 'sqlite3_get_table', 'SQLExecDirect',
1605
+ ],
1606
+ // PQexec(conn, sql) and sqlite3_exec(db, sql, ...) both put the handle
1607
+ // first, so the statement is argument one. mysql_query is the same shape.
1608
+ argIndexes: [1],
1609
+ description: 'a database query',
1610
+ },
1611
+ ],
1612
+ assignSinks: [],
1613
+ /*
1614
+ * C's sanitiser list is SHORT AND HONEST, and the shortness is the point.
1615
+ *
1616
+ * The bounded copy functions genuinely stop an unbounded write, so they clear
1617
+ * `overflow`. They do nothing whatsoever about a shell: snprintf into a
1618
+ * command buffer produces a perfectly well-sized command line that still runs
1619
+ * whatever the attacker put in it. Kind-scoping is what keeps those separate,
1620
+ * and it matters more here than anywhere else in this file, because the C
1621
+ * habit of "fixing" a system() call by switching sprintf to snprintf is
1622
+ * extremely common and fixes nothing.
1623
+ *
1624
+ * There is no standard C function that makes a string safe for a shell.
1625
+ * None. That is why the command sink has no sanitiser at all - the fix is to
1626
+ * stop using a shell, not to clean the string.
1627
+ */
1628
+ sanitizers: [
1629
+ {
1630
+ names: ['strncpy', 'strncat', 'strlcpy', 'strlcat', 'snprintf', 'vsnprintf'],
1631
+ kinds: ['overflow'],
1632
+ description: 'a bounded copy - the write cannot run past the length given',
1633
+ },
1634
+ {
1635
+ names: ['sqlite3_bind_text', 'sqlite3_bind_int', 'mysql_stmt_bind_param', 'PQexecParams'],
1636
+ kinds: ['sql'],
1637
+ description: 'a bound parameter - the value is data, never part of the statement',
1638
+ },
1639
+ ],
1640
+ propagators: {
1641
+ names: [
1642
+ 'strdup', 'strndup', 'basename', 'dirname', 'realpath', 'strtok', 'strchr',
1643
+ 'strrchr', 'strstr', 'memcpy', 'memmove', 'atoi', 'atol', 'strtol',
1644
+ ],
1645
+ },
1646
+ /*
1647
+ * THE LINE THAT MAKES C WORK AT ALL. See checkOutParamMutation in tracer.ts.
1648
+ *
1649
+ * sprintf(cmd, "ping %s", argv[1]); -> cmd is tainted
1650
+ * system(cmd); -> reported
1651
+ *
1652
+ * Without this the two lines are unrelated and every command injection in
1653
+ * the language reads as clean. Note snprintf and strncpy appear both here
1654
+ * and as sanitisers: they BOUND the write (so `overflow` is cleared) while
1655
+ * still carrying the attacker's bytes into the destination (so `command` is
1656
+ * not). The kind-scoped sanitiser model is what lets one function do both.
1657
+ */
1658
+ outParamMutators: [
1659
+ 'sprintf', 'snprintf', 'vsprintf', 'vsnprintf', 'strcpy', 'strncpy',
1660
+ 'strcat', 'strncat', 'strlcpy', 'strlcat', 'memcpy', 'memmove', 'sscanf',
1661
+ ],
1662
+ /*
1663
+ * The destination index is written out per function because it genuinely
1664
+ * differs, and guessing zero would be wrong more often than right:
1665
+ *
1666
+ * fgets(buf, n, stream) buf is 0
1667
+ * scanf("%s", buf) buf is 1 - the format comes first
1668
+ * read(fd, buf, n) buf is 1 - the descriptor comes first
1669
+ * fscanf(f, "%s", buf) buf is 2
1670
+ */
1671
+ outParamSources: [
1672
+ {
1673
+ names: ['fgets', 'gets', 'getline', 'getdelim'],
1674
+ destination: 0,
1675
+ description: 'standard input or an open stream',
1676
+ },
1677
+ { names: ['scanf'], destination: 1, description: 'standard input' },
1678
+ { names: ['fscanf', 'sscanf'], destination: 2, description: 'a stream or a string' },
1679
+ {
1680
+ names: ['read', 'recv', 'fread'],
1681
+ destination: 1,
1682
+ description: 'a file descriptor or a socket',
1683
+ },
1684
+ ],
1685
+ };
1686
+ /* ========================================================================== *
1687
+ * C++
1688
+ *
1689
+ * Every C entry applies - C++ inherits the whole libc surface and people still
1690
+ * use it. What is added is the three things that are genuinely different:
1691
+ * namespace-qualified calls (`std::system`), method calls on objects
1692
+ * (`db.query(sql)`), and std::string, which brings `+` concatenation back and
1693
+ * so makes the string-building analysis relevant in a way it is not in C.
1694
+ * ========================================================================== */
1695
+ const CPP_DICTIONARY = {
1696
+ ...C_DICTIONARY,
1697
+ /*
1698
+ * C HAS NO METHODS, SO IT DECLARES NO MUTATORS. C++ INHERITED THAT EMPTY
1699
+ * LIST AND SHOULD NOT HAVE.
1700
+ *
1701
+ * Every other language here declares mutators - the methods that dirty the
1702
+ * object they are called ON, rather than returning a dirty value. C++ had
1703
+ * none, so this was silent:
1704
+ *
1705
+ * std::string cmd = "gzip ";
1706
+ * cmd.append(argv[1]); // nothing recorded cmd as dirty
1707
+ * std::system(cmd.c_str());
1708
+ *
1709
+ * `append` was already a PROPAGATOR, which is a different direction: it
1710
+ * carries taint from the receiver out to the result, so `dirty.append(" -v")`
1711
+ * traced and `clean.append(dirty)` did not. Being listed in one direction
1712
+ * looked from the outside like being handled.
1713
+ *
1714
+ * STRING METHODS ONLY, AND DELIBERATELY SO. push_back, emplace_back and the
1715
+ * container form of insert are missing on purpose: they write ELEMENTS, and
1716
+ * an element write needs a matching `elementMutators` entry or the
1717
+ * containerGuess rule cannot do its job - a container written many times and
1718
+ * read back once would come out claiming a proof it has not got. Modelling
1719
+ * C++ containers means modelling both lists together, and that is its own
1720
+ * piece of work rather than a line added here. Until then, taint into a
1721
+ * std::vector is a documented miss and not a quiet one.
1722
+ */
1723
+ mutators: ['append', 'assign', 'replace'],
1724
+ sources: [
1725
+ ...C_SOURCES,
1726
+ {
1727
+ pattern: /\bstd::cin\b|\bgetline\s*\(\s*std::cin/,
1728
+ description: 'standard input, read through the C++ stream API',
1729
+ localOnly: true,
1730
+ },
1731
+ ],
1732
+ callSinks: [
1733
+ ...C_DICTIONARY.callSinks,
1734
+ {
1735
+ // Receiver-scoped, for the same reason the JS `query` sink is: `query` on
1736
+ // a database handle is SQL, and `query` on anything else is somebody's
1737
+ // search helper. Without the receiver check this is the VS Code
1738
+ // RegExp.exec mistake waiting to happen again.
1739
+ kind: 'sql',
1740
+ methods: ['query', 'execute', 'exec', 'prepare'],
1741
+ receiverPattern: /db|conn|connection|session|stmt|sql|database|mysql|postgres|sqlite|pqxx|soci/i,
1742
+ argIndexes: [0],
1743
+ contentCheck: 'sql',
1744
+ description: 'a database query',
1745
+ },
1746
+ ],
1747
+ propagators: {
1748
+ names: [
1749
+ ...C_DICTIONARY.propagators.names,
1750
+ // std::string's accessors hand the bytes straight back out. c_str() in
1751
+ // particular is how a tainted std::string reaches system(), so without it
1752
+ // every C++ command injection in cmdi.cpp would stop one hop short.
1753
+ 'c_str', 'data', 'substr', 'to_string', 'string', 'append', 'assign',
1754
+ ],
1755
+ },
1756
+ };
1757
+ export const TAINT_DICTIONARIES = {
1758
+ javascript: JS_DICTIONARY,
1759
+ typescript: JS_DICTIONARY,
1760
+ python: PYTHON_DICTIONARY,
1761
+ java: JAVA_DICTIONARY,
1762
+ go: GO_DICTIONARY,
1763
+ php: PHP_DICTIONARY,
1764
+ c: C_DICTIONARY,
1765
+ cpp: CPP_DICTIONARY,
1766
+ };
1767
+ /** Per-language honesty notes, printed in the coverage section of every scan. */
1768
+ export const TAINT_COVERAGE_NOTES = {
1769
+ c: 'IMPLEMENTED: the operating system IS the framework here, so the sources are argv, getenv, stdin (fgets/scanf/getline) and socket reads - a shorter and far more reliable list than any framework language, because argv is argv in every C program ever written. Sinks: shells (system, popen, and exec* only when the program being run is itself a shell), format strings (the printf family, where a tainted FORMAT argument is CWE-134), copies with no length bound (strcpy, strcat, sprintf), and the C database APIs (mysql_query, PQexec, sqlite3_exec). The output-parameter idiom is modelled - sprintf(cmd, "...", dirty) taints cmd - which is the shape every real C command injection is written in. NOT COVERED, and this is the important half: MEMORY SAFETY. Buffer overflows, use-after-free, double-free and integer overflow are what C is famous for and this engine models none of them, because deciding any of them needs sizes, allocation lifetimes and pointer aliasing rather than values. An unbounded-copy finding claims only that attacker data reached a function that writes until it meets a NUL byte; it does NOT claim the destination is too small, so a strcpy into a genuinely large buffer is a declared false positive. THE PREPROCESSOR IS NOT RUN: #include is not followed, every #ifdef branch is parsed as though taken, and a macro hiding a sink (#define RUN(x) system(x)) is invisible. Cross-file tracing is OFF for the C family - #include brings in declarations rather than definitions, and same-directory matching would be unsafe because `static` gives a C function file scope and every large project has a dozen different static init() definitions in one folder.',
1770
+ cpp: 'IMPLEMENTED: everything listed for C, plus namespace-qualified calls (std::system), receiver-scoped database methods (db.query(sql)), and std::string - whose c_str(), substr() and operator+ carry taint, which is how a tainted string actually reaches system() in C++ code. NOT COVERED: every C limitation applies unchanged, and additionally templates are parsed but never instantiated, so a sink reached only through a specialisation is missed, and operator overloading is not resolved - a custom operator<< that runs a command reads as an ordinary stream write. The .h extension is analysed as C++ rather than C: the grammars are not symmetric (measured, the C++ grammar parses C with zero errors while the C grammar produces eleven on C++), so the superset is used, which costs a pure-C project a cosmetic mislabel in the per-language counts instead of costing a C++ header every std:: sink in it.',
1771
+ javascript: 'IMPLEMENTED: Express/Koa/Lambda request objects, browser location and storage. Follows values into other functions, and into other files where a relative import resolves. A value checked by an allow-list - Number.isInteger, the .test() or .match() of an anchored regex literal, ["a", "b"].includes(x), x === "literal" - is treated as safe where the check holds (see rules/lib/guards.ts). NOT covered: re-exports, dynamic imports and build-tool path aliases; a regex or a list held in a variable is not read as a check, so a value it checked still reports.',
1772
+ typescript: 'IMPLEMENTED: the JavaScript dictionary, plus the type syntax that sits between a source and a sink - annotated bindings, `as`, `<T>` angle-bracket assertions, `!`, `satisfies`, generic call boundaries, enum-keyed reads, abstract and decorated methods, and .tsx with React. This used to read "identical to JavaScript" and that was an assertion, not a measurement: TypeScript has its own grammar, every construct above is a node the JavaScript lowerer never sees, and and until ts-only-syntax.ts was written every TypeScript finding in the fixture suite was signature-based, so not one of them had ever been traced. When it was finally tested, one had been broken since the day TypeScript was added - `<string>x` put its type where `as` puts its value, so the tracer read the word `string` and called the value clean. NOT COVERED: object interiors, which is not a TypeScript limitation but is where TypeScript code meets it most often - a value that enters an object literal and leaves through a destructured prop or an interface-typed field loses its proof and is reported signature-based rather than flow-verified. A React prop is exactly that shape, so component boundaries downgrade. Type-only constructs carry nothing and are never treated as data. NestJS @Param, @Query, @Body and @Headers parameters are sources (nest-decorated-params.ts); other decorators are read as syntax only.',
1773
+ python: 'IMPLEMENTED: Flask/Django/DRF request objects, sys.argv, input(), and URL path parameters a Flask or FastAPI route decorator names - <name>, <int:name>, {name} (route-path-params.py). Follows values across functions, and across modules where a relative import resolves. A view that RETURNS an HTML string is treated as a sink, since in Flask/Django the return value is the response body - we do not verify the function is a route handler. A value checked by an allow-list - isdigit()/isalnum() and siblings, re.fullmatch, re.match with an end anchor, x in ("a", "b"), x == "literal" - is treated as safe where the check holds (see rules/lib/guards.ts); abort() and other calls that never return are not read as leaving, so a check followed by abort(400) still reports. Constant conditions are evaluated, so a value that only ever reaches a branch decided at compile time is not claimed as proven - and until constant-branch.py was written, NONE of that worked in Python. The three shapes it needs - `A if C else B`, `a > b` and `not x` - name none of their parts in the Python grammar, unlike every other grammar here, so every Python comparison read as undecidable and `not True` read as true. NOT covered: Django URLconf kwargs (bound in a separate urls.py), FastAPI query parameters the path does not name, and Pydantic body models; and a CHAINED comparison (1 < x < 5) is not folded, because a chain is a list of operands rather than a pair - it reads as undecidable, the branch is assumed live, and the value is carried, which costs a false positive rather than a miss.',
1774
+ java: 'IMPLEMENTED: Servlet request getters (getParameter, getParameterValues, getHeader, getQueryString, getCookies and friends), JDBC/JPA query sinks, Runtime.exec and ProcessBuilder, and response writers - print/println/write/printf/format/append are sinks when the RECEIVER is a servlet writer, which is what separates response.getWriter().format(fmt, dirty) from String.format(fmt, dirty). Array initialisers carry taint, so Object[] a = {"x", dirty} stays dirty. Spring (@RequestParam, @PathVariable, @RequestBody and friends) and JAX-RS (@QueryParam, @PathParam and friends) annotated parameters are sources. A keyed write - req.setAttribute(name, dirty), resp.setHeader(name, dirty) - dirties that named store and NOT the rest of the object, so req.getContextPath() stays clean; reads out of the store are key-INSENSITIVE, so getAttribute() under any name comes back tainted. A switch on a character whose value is fixed in the source - `String guess = "ABC"; switch (guess.charAt(1))` - is decided, so only the case that can run is walked; 299 BenchmarkJava cases are built on that shape (SwitchOnConstant.java). A local HashMap or ArrayList that nothing else can write to is read by key or position: `map.get("keyA")` returns what was put under "keyA" and `list.get(1)` what sits at index 1 after replaying its adds and removes, not "anything the collection ever held" (taint/local-map.ts, KeyedMapRead.java, ListIndexRead.java). NOT covered: validation checks - `x.matches("\\\\d+")`, list.contains(x) - are not read at all yet, so a value they checked still reports; a writer stored under an unrecognised variable name is missed because the receiver is matched by its text, and the keyed-store rule is Java-only - a JS Map.set() or a Python dict update still dirties the whole container.',
1775
+ php: 'IMPLEMENTED: $_GET/$_POST/$_REQUEST/$_COOKIE/$_FILES superglobals, request-derived $_SERVER entries, PDO and mysqli query sinks, shell functions, and echo. Follows values across functions WITHIN one file. NOT covered: cross-file tracing is off for PHP entirely - require/include are statements and Composer autoloading resolves classes with no import line to read, so a call into another file ends the trace. Blade and Twig templates are not parsed. A value checked by an ALLOW-LIST - is_numeric, ctype_digit, ctype_alnum and siblings, preg_match with a whole-value pattern of digits, letters and underscores (/^\\d+$/, /^(asc|desc)$/i), in_array against a literal list, or === against a literal - is treated as safe wherever the check holds: its true branch, the else of a negated check, after an `if (!check) { exit; }`, and the matching arm of a ternary - and only while the value is not written again. A value built by joining strings from the checked one BEFORE the check is covered too (as in DVWA upload/impossible.php). Checks that prove nothing about the characters (strlen, isset, empty, an unanchored or multi-line preg_match, `preg_match(...) !== 0`, which counts an error as a match) are not counted, and a bare word is not counted as safe in SQL outside quotes. See rules/lib/guards.ts. Constant conditions are evaluated, and PHP needed three grammar-specific repairs before that was true here: the middle operand of `a ? b : c` is unnamed, the elvis form `a ?: b` has no middle operand at all, an `if` calls its then-block `body` rather than `consequence`, and a `$variable` is a wrapper node rather than a bare identifier. The `if` one is worth naming because it broke in ONE DIRECTION - a condition that folded true correctly dropped the dead else, a condition that folded false kept the dead then - which is the kind of half-working that no test notices.',
1776
+ go: 'IMPLEMENTED: net/http request values, gorilla/mux and Gin params, database/sql sinks, template.HTML escape hatches. A switch counts as a branch: until switch-constant.go, a clean write in ANY case of a Go switch erased the taint, because Go spells the node expression_switch_statement and the list of "might not run" constructs did not have it. A switch on a fixed number or rune is now decided, with Go\'s own rule that a case does not fall into the next unless it says `fallthrough`. NOT covered: multi-value assignments (a, b := f()) bind EVERY name on the left to the same taint, so the engine over-claims which of the returned names is dirty rather than losing the value. Aliased imports were previously listed here as a general miss and that was too broad: the taint sinks match on the method name, so `sh "os/exec"` and `runner "os/exec"` are both traced. The alias limitation is real only where a rule matches on the receiver PACKAGE text - weak-hash - and that rule states it itself.',
1777
+ };
1778
+ //# sourceMappingURL=dictionaries.js.map