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