@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,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* COPIES WITH NO LENGTH BOUND (CWE-120)
|
|
3
|
+
*
|
|
4
|
+
* READ THE NAME OF THIS RULE CAREFULLY. It is not `buffer-overflow`, and the
|
|
5
|
+
* difference is the entire honesty argument for shipping it.
|
|
6
|
+
*
|
|
7
|
+
* A buffer overflow is a SIZE bug. Whether `strcpy(dst, src)` overflows depends
|
|
8
|
+
* on how big `dst` is, how long `src` turns out to be at runtime, and whether
|
|
9
|
+
* any pointer between them aliases something else. This engine models none of
|
|
10
|
+
* that - it tracks VALUES, not sizes, allocation lifetimes or aliasing. It
|
|
11
|
+
* cannot tell you whether a copy overflows and does not claim to.
|
|
12
|
+
*
|
|
13
|
+
* What it CAN prove from data flow alone is narrower and still worth saying:
|
|
14
|
+
*
|
|
15
|
+
* attacker-controlled data reaches a function that writes until it
|
|
16
|
+
* meets a NUL byte, and the attacker chooses where that byte is.
|
|
17
|
+
*
|
|
18
|
+
* That is what a finding here asserts, and the wording says so. A `strcpy` into
|
|
19
|
+
* a destination that genuinely is large enough is a FALSE POSITIVE of this
|
|
20
|
+
* rule. That is declared here, in the finding text and in the coverage report,
|
|
21
|
+
* rather than left for somebody to discover and lose trust over.
|
|
22
|
+
*
|
|
23
|
+
* It is still worth reporting, because the unbounded functions have no safe
|
|
24
|
+
* upper bound by construction. Every one of them has a bounded sibling that
|
|
25
|
+
* takes a length - strncpy, strncat, snprintf, strlcpy - and those are
|
|
26
|
+
* registered as sanitisers for this sink kind, so switching to them retracts
|
|
27
|
+
* the finding. A rule that cannot be satisfied by fixing the code is a rule
|
|
28
|
+
* nobody will act on.
|
|
29
|
+
*
|
|
30
|
+
* gets() IS THE EXCEPTION, and it needs no data flow at all. It has no length
|
|
31
|
+
* parameter and no way to acquire one, which is why C11 removed it from the
|
|
32
|
+
* standard library outright. There is nothing to trace, so it is reported on
|
|
33
|
+
* sight as a SIGNATURE finding - honestly labelled as unverified flow, because
|
|
34
|
+
* there is no flow to verify, not because we failed to find one.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
import type { LanguageId } from '../parse/languages.js';
|
|
38
|
+
import { asCall, type Rule, type RuleHit } from './contract.js';
|
|
39
|
+
import { isStringLiteral } from './lib/strings.js';
|
|
40
|
+
|
|
41
|
+
/** Copies with no length parameter, and which argument carries the data. */
|
|
42
|
+
const UNBOUNDED_COPIES: ReadonlyMap<string, number> = new Map([
|
|
43
|
+
['strcpy', 1],
|
|
44
|
+
['stpcpy', 1],
|
|
45
|
+
['strcat', 1],
|
|
46
|
+
]);
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Cannot be called safely under any circumstances, so no source is needed.
|
|
50
|
+
* C11 removed gets() from the standard library rather than try to document it.
|
|
51
|
+
*/
|
|
52
|
+
const UNCONDITIONALLY_UNSAFE: ReadonlySet<string> = new Set(['gets']);
|
|
53
|
+
|
|
54
|
+
const C_FAMILY: ReadonlySet<LanguageId> = new Set<LanguageId>(['c', 'cpp']);
|
|
55
|
+
|
|
56
|
+
const NOT_C = {
|
|
57
|
+
status: 'not-implemented' as const,
|
|
58
|
+
note:
|
|
59
|
+
'Manual memory copies are a C-family concern. Managed languages bound their own ' +
|
|
60
|
+
'string operations, so this rule has nothing to match and never fires. Stated rather ' +
|
|
61
|
+
'than left as a blank column.',
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
export const unboundedCopyRule: Rule = {
|
|
65
|
+
id: 'unbounded-copy',
|
|
66
|
+
name: 'Attacker data reaches a copy with no length bound',
|
|
67
|
+
cwe: 'CWE-120',
|
|
68
|
+
owasp: 'A05:2025 Injection',
|
|
69
|
+
severity: 'high',
|
|
70
|
+
summary: 'Data is copied into a buffer with no size limit; if the data comes from outside, long input can overwrite memory.',
|
|
71
|
+
fix:
|
|
72
|
+
'Use the bounded version that takes the buffer size: snprintf, strncpy/strlcpy, strncat; replace gets with fgets.',
|
|
73
|
+
explanation:
|
|
74
|
+
'strcpy, strcat and sprintf write until they meet a NUL byte, and when the source is ' +
|
|
75
|
+
'attacker-controlled the attacker decides where that byte is. Each has a bounded ' +
|
|
76
|
+
'sibling that takes a length - strncpy, strncat, snprintf, strlcpy - and switching to ' +
|
|
77
|
+
'one retracts this finding. gets() has no bounded form at all and was removed from ' +
|
|
78
|
+
'the C standard in C11.',
|
|
79
|
+
limitations:
|
|
80
|
+
'THIS IS NOT A CLAIM THAT THE BUFFER OVERFLOWS. Deciding that needs the size of the ' +
|
|
81
|
+
'destination, the length of the source and the aliasing between them; this engine ' +
|
|
82
|
+
'tracks values, not sizes, so it cannot and does not decide it. What a flow-verified ' +
|
|
83
|
+
'finding here asserts is only that attacker-controlled data reached a copy with no ' +
|
|
84
|
+
'length bound. A copy into a destination that is genuinely large enough is a false ' +
|
|
85
|
+
'positive of this rule, and a declared one.',
|
|
86
|
+
shapes: ['call'],
|
|
87
|
+
support: {
|
|
88
|
+
c: {
|
|
89
|
+
status: 'partial',
|
|
90
|
+
note:
|
|
91
|
+
'PARTIAL BY CONSTRUCTION, and the limit is the point. Covers strcpy, stpcpy, ' +
|
|
92
|
+
'strcat and the sprintf family reached by attacker data, plus gets() on sight. ' +
|
|
93
|
+
'It does NOT model buffer sizes, so it cannot say whether any given copy ' +
|
|
94
|
+
'overflows - only that the copy has no bound. memcpy and friends with an ' +
|
|
95
|
+
'attacker-controlled LENGTH are a different bug (the size is the taint, not the ' +
|
|
96
|
+
'data) and are not covered. Use-after-free, double-free and integer overflow are ' +
|
|
97
|
+
'entirely out of scope: none of them is a data-flow question.',
|
|
98
|
+
},
|
|
99
|
+
cpp: {
|
|
100
|
+
status: 'partial',
|
|
101
|
+
note:
|
|
102
|
+
'Identical to C. std::string and std::vector manage their own storage and are ' +
|
|
103
|
+
'not matched; C++ code that calls the C string functions still is, and a great ' +
|
|
104
|
+
'deal of it does.',
|
|
105
|
+
},
|
|
106
|
+
javascript: NOT_C,
|
|
107
|
+
typescript: NOT_C,
|
|
108
|
+
python: NOT_C,
|
|
109
|
+
java: NOT_C,
|
|
110
|
+
go: NOT_C,
|
|
111
|
+
php: NOT_C,
|
|
112
|
+
},
|
|
113
|
+
|
|
114
|
+
check(shape, ctx): RuleHit | null {
|
|
115
|
+
if (!C_FAMILY.has(ctx.language)) return null;
|
|
116
|
+
const call = asCall(shape);
|
|
117
|
+
if (!call) return null;
|
|
118
|
+
|
|
119
|
+
if (UNCONDITIONALLY_UNSAFE.has(call.calleeName)) {
|
|
120
|
+
return {
|
|
121
|
+
node: call.node,
|
|
122
|
+
severity: 'high',
|
|
123
|
+
message: 'gets() cannot be called safely and was removed from the C standard',
|
|
124
|
+
reasoning:
|
|
125
|
+
'gets() reads a line from standard input into a buffer and takes no length ' +
|
|
126
|
+
'parameter, so there is no way to tell it how much room it has. The caller ' +
|
|
127
|
+
'cannot bound it, the function cannot bound itself, and the input comes from ' +
|
|
128
|
+
'outside the program. C11 removed it from the standard library rather than ' +
|
|
129
|
+
'attempt to document safe use. Replace it with fgets(buf, sizeof buf, stdin). ' +
|
|
130
|
+
'No data flow is traced for this finding because none is needed - the call is ' +
|
|
131
|
+
'unsafe with any input at all.',
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const dataArgument = UNBOUNDED_COPIES.get(call.calleeName);
|
|
136
|
+
if (dataArgument === undefined) return null;
|
|
137
|
+
|
|
138
|
+
const source = call.args[dataArgument];
|
|
139
|
+
if (!source) return null;
|
|
140
|
+
|
|
141
|
+
/*
|
|
142
|
+
* A literal source is bounded at compile time - the compiler knows exactly
|
|
143
|
+
* how long `strcpy(buf, "ok")` writes. Reporting it would be noise on code
|
|
144
|
+
* that cannot be attacked, so the literal case returns null and only an
|
|
145
|
+
* opaque value is worth a signature guess.
|
|
146
|
+
*/
|
|
147
|
+
if (isStringLiteral(source, ctx.language)) return null;
|
|
148
|
+
|
|
149
|
+
const sourceText = ctx.text(source);
|
|
150
|
+
return {
|
|
151
|
+
node: call.node,
|
|
152
|
+
message: `${call.calleeName}() copies \`${sourceText}\` with no length bound`,
|
|
153
|
+
reasoning:
|
|
154
|
+
`\`${call.calleeName}()\` writes until it meets a NUL byte in \`${sourceText}\`, and ` +
|
|
155
|
+
`nothing in the call says how much room the destination has. Whether this ` +
|
|
156
|
+
`actually overflows depends on the size of the destination, which is NOT analysed ` +
|
|
157
|
+
`here - so this is a guess about a risky shape, not a proof of a bug. Use ` +
|
|
158
|
+
`${call.calleeName === 'strcat' ? 'strncat' : 'strncpy'}() or strlcpy() with an ` +
|
|
159
|
+
`explicit length, and this finding goes away.`,
|
|
160
|
+
};
|
|
161
|
+
},
|
|
162
|
+
};
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* UNSAFE DESERIALIZATION (CWE-502, OWASP A08:2025 - Software or Data Integrity Failures)
|
|
3
|
+
*
|
|
4
|
+
* WHAT THE BUG IS, in plain language:
|
|
5
|
+
* Serialising turns an object in memory into bytes you can store or send.
|
|
6
|
+
* Deserialising turns those bytes back into an object. The danger is in the
|
|
7
|
+
* word OBJECT: the bytes do not just carry data, they carry the NAME OF THE
|
|
8
|
+
* CLASS to rebuild, and rebuilding a class runs that class's code.
|
|
9
|
+
*
|
|
10
|
+
* $session = unserialize($_COOKIE['session']);
|
|
11
|
+
*
|
|
12
|
+
* The attacker controls the cookie, so the attacker chooses which classes get
|
|
13
|
+
* constructed and with which fields. They then look through your code (and
|
|
14
|
+
* your libraries) for a class with a `__destruct`, `__wakeup` or `readObject`
|
|
15
|
+
* method that does something useful - deletes a file, writes a file, runs a
|
|
16
|
+
* command - and hand you an object designed to trigger it. Chaining a few of
|
|
17
|
+
* these together is called a POP CHAIN or a gadget chain, and the usual end
|
|
18
|
+
* result is remote code execution. No memory corruption, no exploit skill: the
|
|
19
|
+
* language does exactly what it was told.
|
|
20
|
+
*
|
|
21
|
+
* WHY THIS RULE IS DIFFERENT FROM THE OTHER INJECTION RULES:
|
|
22
|
+
* There is no "escaping" fix. You cannot sanitise attacker bytes into safe
|
|
23
|
+
* bytes, because the danger is the structure, not the characters. The real
|
|
24
|
+
* fixes are:
|
|
25
|
+
*
|
|
26
|
+
* - Use a DATA format instead of an OBJECT format. `json_decode`,
|
|
27
|
+
* `JSON.parse` and `json.loads` produce plain values and cannot construct
|
|
28
|
+
* your classes. This is the right answer almost every time.
|
|
29
|
+
* - If you must deserialise objects, restrict which classes are allowed
|
|
30
|
+
* (`unserialize($x, ['allowed_classes' => false])`, `yaml.safe_load`,
|
|
31
|
+
* a `MessageFormat` allowlist) - and this rule recognises those and stays
|
|
32
|
+
* quiet, because reporting the hardened form would punish the fix.
|
|
33
|
+
* - Sign the payload so you can tell it is yours before you open it.
|
|
34
|
+
*
|
|
35
|
+
* WHY IT TOOK A NEW SINK KIND:
|
|
36
|
+
* The engine's three existing kinds - sql, command, xss - all describe a
|
|
37
|
+
* string being read as instructions. This one is bytes being read as an
|
|
38
|
+
* object graph, so a SQL escaper or an HTML escaper means nothing here. Sink
|
|
39
|
+
* kinds are scoped for exactly this reason: `htmlspecialchars` on the way to
|
|
40
|
+
* `unserialize` clears nothing, and the engine has to know that.
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
import type { LanguageId } from '../parse/languages.js';
|
|
44
|
+
import { asCall, type Rule, type RuleHit } from './contract.js';
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Functions that turn bytes into OBJECTS.
|
|
48
|
+
*
|
|
49
|
+
* Deliberately excludes every JSON parser. `json_decode`, `JSON.parse` and
|
|
50
|
+
* `json.loads` return plain arrays, strings and numbers - they cannot name a
|
|
51
|
+
* class, so they cannot start a gadget chain. Listing them would bury the real
|
|
52
|
+
* findings under the single most common safe call in every codebase.
|
|
53
|
+
*/
|
|
54
|
+
const DESERIALIZERS: Record<LanguageId, readonly string[]> = {
|
|
55
|
+
// node-serialize and funcster revive functions from their payloads. The
|
|
56
|
+
// built-in JSON parser does not and is deliberately absent.
|
|
57
|
+
javascript: ['unserialize', 'deserialize'],
|
|
58
|
+
typescript: ['unserialize', 'deserialize'],
|
|
59
|
+
// pickle and marshal execute constructor code by design; yaml.load does too
|
|
60
|
+
// unless a safe Loader is named, which `unlessOption` checks for.
|
|
61
|
+
python: ['loads', 'load', 'Unpickler'],
|
|
62
|
+
// readObject is the classic Java gadget-chain entry point.
|
|
63
|
+
java: ['readObject', 'readUnshared', 'readResolve'],
|
|
64
|
+
php: ['unserialize'],
|
|
65
|
+
// Go's encoding/gob decodes into a type YOU name, so an attacker cannot
|
|
66
|
+
// choose the class to construct. That removes the gadget-chain shape this
|
|
67
|
+
// rule is about, which is why the list is empty rather than guessed at.
|
|
68
|
+
go: [],
|
|
69
|
+
// C has no object graph to revive, so the gadget-chain bug this rule is
|
|
70
|
+
// about cannot exist at all. Parsing untrusted binary in C IS dangerous, but
|
|
71
|
+
// for a completely different reason - it is where the memory-safety bugs
|
|
72
|
+
// live - and that is declared out of scope rather than half-covered here.
|
|
73
|
+
c: [],
|
|
74
|
+
cpp: [],
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Receivers that make an otherwise-ambiguous method name unambiguous.
|
|
79
|
+
*
|
|
80
|
+
* `load` and `loads` are far too common to report on the name alone - every
|
|
81
|
+
* config reader in Python has one. Requiring a known-dangerous module in front
|
|
82
|
+
* of them is what separates `pickle.loads(data)` from `settings.loads(data)`.
|
|
83
|
+
*/
|
|
84
|
+
const REQUIRED_RECEIVERS: Record<LanguageId, readonly string[]> = {
|
|
85
|
+
javascript: ['serialize', 'node-serialize', 'funcster'],
|
|
86
|
+
typescript: ['serialize', 'node-serialize', 'funcster'],
|
|
87
|
+
python: ['pickle', 'cPickle', 'dill', 'marshal', 'yaml', 'shelve', 'jsonpickle'],
|
|
88
|
+
java: ['ois', 'objectInputStream', 'in', 'input', 'stream', 'decoder'],
|
|
89
|
+
php: [],
|
|
90
|
+
go: [],
|
|
91
|
+
c: [],
|
|
92
|
+
cpp: [],
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Deserialisers whose payload arrives through the RECEIVER, not an argument.
|
|
97
|
+
* `ois.readObject()` reads from a stream that was loaded a line earlier, so
|
|
98
|
+
* there is no argument to inspect and the receiver check is the whole test.
|
|
99
|
+
*/
|
|
100
|
+
const ZERO_ARGUMENT_READERS: ReadonlySet<string> = new Set([
|
|
101
|
+
'readObject',
|
|
102
|
+
'readUnshared',
|
|
103
|
+
'readResolve',
|
|
104
|
+
]);
|
|
105
|
+
|
|
106
|
+
/** Calls that are safe BECAUSE of an argument, and must not be reported. */
|
|
107
|
+
const SAFE_FORMS: Record<LanguageId, RegExp | null> = {
|
|
108
|
+
javascript: null,
|
|
109
|
+
typescript: null,
|
|
110
|
+
// yaml.load(x, Loader=SafeLoader) and yaml.safe_load are the hardened forms.
|
|
111
|
+
python: /\b(SafeLoader|CSafeLoader|BaseLoader|safe_load)\b/,
|
|
112
|
+
java: null,
|
|
113
|
+
// unserialize($x, ['allowed_classes' => false]) cannot construct your classes.
|
|
114
|
+
php: /allowed_classes/,
|
|
115
|
+
// Nothing to make safe, because nothing above is listed as dangerous.
|
|
116
|
+
c: null,
|
|
117
|
+
cpp: null,
|
|
118
|
+
go: null,
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Does the receiver in front of this call identify a dangerous deserialiser?
|
|
123
|
+
* A language with no required receivers answers yes for everything, because
|
|
124
|
+
* its function names (`unserialize`, `readObject`) are already unambiguous.
|
|
125
|
+
*/
|
|
126
|
+
function receiverQualifies(language: LanguageId, receiverText: string): boolean {
|
|
127
|
+
const required = REQUIRED_RECEIVERS[language];
|
|
128
|
+
if (required.length === 0) return true;
|
|
129
|
+
const last = receiverText.split(/[.\s(]/).filter(Boolean).pop() ?? receiverText;
|
|
130
|
+
return required.some((name) => last === name || receiverText.includes(name));
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export const unsafeDeserializationRule: Rule = {
|
|
134
|
+
id: 'unsafe-deserialization',
|
|
135
|
+
name: 'Untrusted bytes rebuilt into an object',
|
|
136
|
+
cwe: 'CWE-502',
|
|
137
|
+
owasp: 'A08:2025 Software or Data Integrity Failures',
|
|
138
|
+
severity: 'critical',
|
|
139
|
+
summary: 'Bytes are turned back into objects; if they come from outside, they can make the program run code the attacker picks.',
|
|
140
|
+
fix:
|
|
141
|
+
'Use a plain data format such as JSON, or restrict which classes may be loaded, or sign the data.',
|
|
142
|
+
explanation:
|
|
143
|
+
'Deserialising does not just read data - it names a class and rebuilds it, ' +
|
|
144
|
+
'which runs that class\'s code. Whoever controls the bytes therefore chooses ' +
|
|
145
|
+
'which objects your program constructs, and can chain together classes whose ' +
|
|
146
|
+
'destructors or wake-up methods do something useful to them. The usual end of ' +
|
|
147
|
+
'that chain is remote code execution. There is no escaping fix, because the ' +
|
|
148
|
+
'danger is the structure rather than the characters: use a data format such as ' +
|
|
149
|
+
'JSON, restrict which classes may be constructed, or sign the payload.',
|
|
150
|
+
limitations:
|
|
151
|
+
'UNVERIFIED (signature-based): we identified a call that rebuilds objects from ' +
|
|
152
|
+
'bytes and confirmed its argument is not a fixed literal. We did NOT confirm the ' +
|
|
153
|
+
'bytes are attacker-controlled - the data-flow pass is what upgrades this to ' +
|
|
154
|
+
'flow-verified, and it is worth waiting for, because deserialising your own ' +
|
|
155
|
+
'trusted data is ordinary and safe. Hardened forms are recognised and NOT ' +
|
|
156
|
+
'reported: unserialize with allowed_classes, and yaml with a Safe/Base Loader. ' +
|
|
157
|
+
'DELIBERATE GAP: that check reads the visible text of the call, so a Loader or ' +
|
|
158
|
+
'an options array assembled in another variable is missed and the finding still ' +
|
|
159
|
+
'fires. JSON parsers are deliberately excluded entirely - json_decode, JSON.parse ' +
|
|
160
|
+
'and json.loads return plain values and cannot name a class to construct. ' +
|
|
161
|
+
'Go is NOT covered: encoding/gob decodes into a type the PROGRAM names, so an ' +
|
|
162
|
+
'attacker cannot choose the class, and the gadget-chain shape does not arise.',
|
|
163
|
+
shapes: ['call'],
|
|
164
|
+
support: {
|
|
165
|
+
javascript: {
|
|
166
|
+
status: 'partial',
|
|
167
|
+
note: 'PARTIAL: node-serialize and funcster only, and only when called on a recognisable receiver. JSON.parse is deliberately excluded - it cannot construct a class. Most JavaScript deserialization risk lives in libraries with their own APIs that this does not model.',
|
|
168
|
+
},
|
|
169
|
+
typescript: {
|
|
170
|
+
status: 'partial',
|
|
171
|
+
note: 'PARTIAL: identical to JavaScript.',
|
|
172
|
+
},
|
|
173
|
+
python: {
|
|
174
|
+
status: 'implemented',
|
|
175
|
+
note: 'Covers pickle/cPickle/dill/marshal loads and load, yaml.load, shelve and jsonpickle, matched by module receiver so an ordinary settings.load() is not reported. yaml.load with a Safe/Base Loader is recognised as the hardened form and stays quiet.',
|
|
176
|
+
},
|
|
177
|
+
java: {
|
|
178
|
+
status: 'partial',
|
|
179
|
+
note: 'PARTIAL: ObjectInputStream.readObject/readUnshared, matched on a stream-looking receiver name. A stream stored under an unusual variable name is missed. XMLDecoder and SnakeYAML are NOT covered.',
|
|
180
|
+
},
|
|
181
|
+
php: {
|
|
182
|
+
status: 'implemented',
|
|
183
|
+
note: 'Covers unserialize(), the language\'s classic POP-chain entry point. unserialize($x, [\'allowed_classes\' => false]) is recognised as the hardened form and is not reported. json_decode is deliberately excluded.',
|
|
184
|
+
},
|
|
185
|
+
go: {
|
|
186
|
+
status: 'not-implemented',
|
|
187
|
+
note: 'NOT IMPLEMENTED: encoding/gob and encoding/json both decode into a type the program names, so an attacker cannot choose which class gets constructed. The gadget-chain shape this rule detects does not exist in Go, and a rule that fired here would be noise rather than coverage.',
|
|
188
|
+
},
|
|
189
|
+
c: {
|
|
190
|
+
status: 'not-implemented',
|
|
191
|
+
note:
|
|
192
|
+
'C has no object graph to revive, so the gadget-chain weakness this rule detects cannot exist. Parsing untrusted binary in C IS dangerous, but for memory-safety reasons that are declared out of scope rather than half-covered here.',
|
|
193
|
+
},
|
|
194
|
+
cpp: {
|
|
195
|
+
status: 'not-implemented',
|
|
196
|
+
note:
|
|
197
|
+
'Not implemented. C++ serialisation libraries - Boost.Serialization, cereal, protobuf - do reconstruct typed object graphs, and none of them is modelled here, so a gadget chain through Boost.Serialization is missed.',
|
|
198
|
+
},
|
|
199
|
+
},
|
|
200
|
+
check(shape, ctx): RuleHit | null {
|
|
201
|
+
const language = ctx.language;
|
|
202
|
+
const call = asCall(shape);
|
|
203
|
+
if (!call) return null;
|
|
204
|
+
|
|
205
|
+
const names = DESERIALIZERS[language];
|
|
206
|
+
if (!names.includes(call.calleeName)) return null;
|
|
207
|
+
if (!receiverQualifies(language, call.receiverText)) return null;
|
|
208
|
+
|
|
209
|
+
// The hardened form is the same call plus a restriction. Seeing the
|
|
210
|
+
// restriction means this is the fix, and reporting a fix teaches people
|
|
211
|
+
// that fixing does not help.
|
|
212
|
+
const safeForm = SAFE_FORMS[language];
|
|
213
|
+
if (safeForm && safeForm.test(call.node.text ?? '')) return null;
|
|
214
|
+
|
|
215
|
+
/*
|
|
216
|
+
* SOME DESERIALISERS TAKE NO ARGUMENTS.
|
|
217
|
+
*
|
|
218
|
+
* Map<String,String> grant = (Map<String,String>) ois.readObject();
|
|
219
|
+
*
|
|
220
|
+
* The bytes were handed to the STREAM, one line earlier; `readObject()`
|
|
221
|
+
* just pulls the next object out of it. This rule required `args[0]` and
|
|
222
|
+
* therefore stayed silent on the single most famous gadget-chain entry
|
|
223
|
+
* point in Java - the exact call every CVE writeup about deserialization
|
|
224
|
+
* is about.
|
|
225
|
+
*
|
|
226
|
+
* Found by a Gemini-written test file. The taint engine already had this
|
|
227
|
+
* idea (`pb.command(list); pb.start()` - a sink whose payload was loaded
|
|
228
|
+
* earlier); the signature rule did not.
|
|
229
|
+
*/
|
|
230
|
+
const argument = call.args[0];
|
|
231
|
+
if (!argument) {
|
|
232
|
+
if (!ZERO_ARGUMENT_READERS.has(call.calleeName)) return null;
|
|
233
|
+
return {
|
|
234
|
+
node: call.node,
|
|
235
|
+
message: `Untrusted bytes deserialised by ${call.calleeName}()`,
|
|
236
|
+
reasoning:
|
|
237
|
+
`\`${call.calleeName}()\` takes no arguments because the bytes were handed to ` +
|
|
238
|
+
`\`${call.receiverText || 'the stream'}\` earlier - it pulls the next object out of ` +
|
|
239
|
+
`that stream, and the bytes get to name the class it constructs. If the stream was ` +
|
|
240
|
+
`built from anything a caller controls, this is the classic Java gadget-chain entry ` +
|
|
241
|
+
`point. Validate and sign the payload before it reaches a stream, or use a data ` +
|
|
242
|
+
`format that cannot name a class.`,
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/*
|
|
247
|
+
* A ROUND TRIP IS NOT AN INPUT.
|
|
248
|
+
*
|
|
249
|
+
* pickle.loads(pickle.dumps(base))
|
|
250
|
+
* json.loads(json.dumps(obj))
|
|
251
|
+
*
|
|
252
|
+
* Six of the sixteen deserialisation findings on a pandas scan were this,
|
|
253
|
+
* all in tests. Deserialising is dangerous because the BYTES come from
|
|
254
|
+
* somewhere else - a file, a socket, a cookie. Here they are produced two
|
|
255
|
+
* characters to the right, by this process, from a value this process
|
|
256
|
+
* already holds. There is no attacker position anywhere in the expression,
|
|
257
|
+
* and no amount of tracing will find one.
|
|
258
|
+
*
|
|
259
|
+
* The serialiser has to be visible as the DIRECT argument. `data = dumps(x)`
|
|
260
|
+
* on an earlier line then `loads(data)` still reports, because proving that
|
|
261
|
+
* one is the same round trip is a data-flow question, and this rule is not
|
|
262
|
+
* allowed to guess at data flow - that is what flow-verified means.
|
|
263
|
+
*/
|
|
264
|
+
const argumentText = argument.text ?? '';
|
|
265
|
+
if (/^\s*(?:[\w.]+\.)?(?:dumps?|serialize|pack|marshal|encode)\s*\(/.test(argumentText)) {
|
|
266
|
+
return null;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// A fixed literal is your own data, not an attacker's. `unserialize('a:0:{}')`
|
|
270
|
+
// is a constant and cannot be chosen by anyone.
|
|
271
|
+
if (argument.type === 'string' || argument.type === 'encapsed_string') {
|
|
272
|
+
const interpolates = argument.namedChildren.some(
|
|
273
|
+
(c) => c !== null && c.type !== 'string_content' && c.type !== 'escape_sequence',
|
|
274
|
+
);
|
|
275
|
+
if (!interpolates) return null;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
return {
|
|
279
|
+
node: call.node,
|
|
280
|
+
message: `Untrusted bytes deserialised by ${call.calleeName}()`,
|
|
281
|
+
reasoning:
|
|
282
|
+
`\`${call.calleeName}()\` rebuilds an object graph from bytes, which means the ` +
|
|
283
|
+
`bytes get to name the classes your program constructs. If any class reachable ` +
|
|
284
|
+
`from here has a destructor or wake-up method that touches the filesystem or ` +
|
|
285
|
+
`runs a command, an attacker who controls this input can chain them into code ` +
|
|
286
|
+
`execution. Escaping does not help - the danger is the structure, not the ` +
|
|
287
|
+
`characters. Parse it as data (JSON) instead, or restrict which classes may be ` +
|
|
288
|
+
`constructed.`,
|
|
289
|
+
};
|
|
290
|
+
},
|
|
291
|
+
};
|