@sikkuumiii/defuse 0.0.0-stage → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (236) hide show
  1. package/LICENSE +235 -0
  2. package/README.md +561 -2
  3. package/dist/src/cli.d.ts +20 -0
  4. package/dist/src/cli.js +619 -0
  5. package/dist/src/cli.js.map +1 -0
  6. package/dist/src/core/analyze.d.ts +178 -0
  7. package/dist/src/core/analyze.js +834 -0
  8. package/dist/src/core/analyze.js.map +1 -0
  9. package/dist/src/core/coverage.d.ts +87 -0
  10. package/dist/src/core/coverage.js +181 -0
  11. package/dist/src/core/coverage.js.map +1 -0
  12. package/dist/src/core/finding.d.ts +196 -0
  13. package/dist/src/core/finding.js +178 -0
  14. package/dist/src/core/finding.js.map +1 -0
  15. package/dist/src/core/licence.d.ts +50 -0
  16. package/dist/src/core/licence.js +56 -0
  17. package/dist/src/core/licence.js.map +1 -0
  18. package/dist/src/core/paths.d.ts +21 -0
  19. package/dist/src/core/paths.js +58 -0
  20. package/dist/src/core/paths.js.map +1 -0
  21. package/dist/src/core/score.d.ts +137 -0
  22. package/dist/src/core/score.js +232 -0
  23. package/dist/src/core/score.js.map +1 -0
  24. package/dist/src/dast/targets.d.ts +30 -0
  25. package/dist/src/dast/targets.js +79 -0
  26. package/dist/src/dast/targets.js.map +1 -0
  27. package/dist/src/dast/zap.d.ts +42 -0
  28. package/dist/src/dast/zap.js +184 -0
  29. package/dist/src/dast/zap.js.map +1 -0
  30. package/dist/src/engine/scan.d.ts +80 -0
  31. package/dist/src/engine/scan.js +129 -0
  32. package/dist/src/engine/scan.js.map +1 -0
  33. package/dist/src/engine/shapes.d.ts +47 -0
  34. package/dist/src/engine/shapes.js +328 -0
  35. package/dist/src/engine/shapes.js.map +1 -0
  36. package/dist/src/engine/walk.d.ts +60 -0
  37. package/dist/src/engine/walk.js +156 -0
  38. package/dist/src/engine/walk.js.map +1 -0
  39. package/dist/src/parse/astDump.d.ts +31 -0
  40. package/dist/src/parse/astDump.js +66 -0
  41. package/dist/src/parse/astDump.js.map +1 -0
  42. package/dist/src/parse/languages.d.ts +70 -0
  43. package/dist/src/parse/languages.js +174 -0
  44. package/dist/src/parse/languages.js.map +1 -0
  45. package/dist/src/parse/location.d.ts +17 -0
  46. package/dist/src/parse/location.js +38 -0
  47. package/dist/src/parse/location.js.map +1 -0
  48. package/dist/src/parse/node-grammars.d.ts +12 -0
  49. package/dist/src/parse/node-grammars.js +35 -0
  50. package/dist/src/parse/node-grammars.js.map +1 -0
  51. package/dist/src/parse/parser.d.ts +126 -0
  52. package/dist/src/parse/parser.js +202 -0
  53. package/dist/src/parse/parser.js.map +1 -0
  54. package/dist/src/parse/tree-store.d.ts +139 -0
  55. package/dist/src/parse/tree-store.js +236 -0
  56. package/dist/src/parse/tree-store.js.map +1 -0
  57. package/dist/src/platform/dependency-rule.d.ts +27 -0
  58. package/dist/src/platform/dependency-rule.js +21 -0
  59. package/dist/src/platform/dependency-rule.js.map +1 -0
  60. package/dist/src/platform/deps.d.ts +86 -0
  61. package/dist/src/platform/deps.js +304 -0
  62. package/dist/src/platform/deps.js.map +1 -0
  63. package/dist/src/platform/php-facts.d.ts +49 -0
  64. package/dist/src/platform/php-facts.js +67 -0
  65. package/dist/src/platform/php-facts.js.map +1 -0
  66. package/dist/src/platform/runtime-version.d.ts +78 -0
  67. package/dist/src/platform/runtime-version.js +573 -0
  68. package/dist/src/platform/runtime-version.js.map +1 -0
  69. package/dist/src/report/colors.d.ts +50 -0
  70. package/dist/src/report/colors.js +97 -0
  71. package/dist/src/report/colors.js.map +1 -0
  72. package/dist/src/report/coverage-table.d.ts +90 -0
  73. package/dist/src/report/coverage-table.js +291 -0
  74. package/dist/src/report/coverage-table.js.map +1 -0
  75. package/dist/src/report/html-report.d.ts +66 -0
  76. package/dist/src/report/html-report.js +489 -0
  77. package/dist/src/report/html-report.js.map +1 -0
  78. package/dist/src/report/human.d.ts +44 -0
  79. package/dist/src/report/human.js +575 -0
  80. package/dist/src/report/human.js.map +1 -0
  81. package/dist/src/report/json.d.ts +24 -0
  82. package/dist/src/report/json.js +178 -0
  83. package/dist/src/report/json.js.map +1 -0
  84. package/dist/src/report/mark.d.ts +17 -0
  85. package/dist/src/report/mark.js +39 -0
  86. package/dist/src/report/mark.js.map +1 -0
  87. package/dist/src/report/pricing.d.ts +33 -0
  88. package/dist/src/report/pricing.js +343 -0
  89. package/dist/src/report/pricing.js.map +1 -0
  90. package/dist/src/report/sarif.d.ts +39 -0
  91. package/dist/src/report/sarif.js +219 -0
  92. package/dist/src/report/sarif.js.map +1 -0
  93. package/dist/src/report/site.d.ts +87 -0
  94. package/dist/src/report/site.js +742 -0
  95. package/dist/src/report/site.js.map +1 -0
  96. package/dist/src/rules/code-injection.d.ts +47 -0
  97. package/dist/src/rules/code-injection.js +326 -0
  98. package/dist/src/rules/code-injection.js.map +1 -0
  99. package/dist/src/rules/command-injection.d.ts +30 -0
  100. package/dist/src/rules/command-injection.js +286 -0
  101. package/dist/src/rules/command-injection.js.map +1 -0
  102. package/dist/src/rules/contract.d.ts +198 -0
  103. package/dist/src/rules/contract.js +39 -0
  104. package/dist/src/rules/contract.js.map +1 -0
  105. package/dist/src/rules/format-string.d.ts +46 -0
  106. package/dist/src/rules/format-string.js +146 -0
  107. package/dist/src/rules/format-string.js.map +1 -0
  108. package/dist/src/rules/hardcoded-secret.d.ts +38 -0
  109. package/dist/src/rules/hardcoded-secret.js +440 -0
  110. package/dist/src/rules/hardcoded-secret.js.map +1 -0
  111. package/dist/src/rules/lib/guards.d.ts +168 -0
  112. package/dist/src/rules/lib/guards.js +1179 -0
  113. package/dist/src/rules/lib/guards.js.map +1 -0
  114. package/dist/src/rules/lib/html-safety.d.ts +75 -0
  115. package/dist/src/rules/lib/html-safety.js +755 -0
  116. package/dist/src/rules/lib/html-safety.js.map +1 -0
  117. package/dist/src/rules/lib/local-build.d.ts +59 -0
  118. package/dist/src/rules/lib/local-build.js +320 -0
  119. package/dist/src/rules/lib/local-build.js.map +1 -0
  120. package/dist/src/rules/lib/strings.d.ts +208 -0
  121. package/dist/src/rules/lib/strings.js +1970 -0
  122. package/dist/src/rules/lib/strings.js.map +1 -0
  123. package/dist/src/rules/path-traversal.d.ts +33 -0
  124. package/dist/src/rules/path-traversal.js +96 -0
  125. package/dist/src/rules/path-traversal.js.map +1 -0
  126. package/dist/src/rules/registry.d.ts +19 -0
  127. package/dist/src/rules/registry.js +77 -0
  128. package/dist/src/rules/registry.js.map +1 -0
  129. package/dist/src/rules/sql-injection.d.ts +36 -0
  130. package/dist/src/rules/sql-injection.js +299 -0
  131. package/dist/src/rules/sql-injection.js.map +1 -0
  132. package/dist/src/rules/ssrf.d.ts +39 -0
  133. package/dist/src/rules/ssrf.js +111 -0
  134. package/dist/src/rules/ssrf.js.map +1 -0
  135. package/dist/src/rules/unbounded-copy.d.ts +37 -0
  136. package/dist/src/rules/unbounded-copy.js +147 -0
  137. package/dist/src/rules/unbounded-copy.js.map +1 -0
  138. package/dist/src/rules/unsafe-deserialization.d.ts +43 -0
  139. package/dist/src/rules/unsafe-deserialization.js +276 -0
  140. package/dist/src/rules/unsafe-deserialization.js.map +1 -0
  141. package/dist/src/rules/weak-hash.d.ts +37 -0
  142. package/dist/src/rules/weak-hash.js +197 -0
  143. package/dist/src/rules/weak-hash.js.map +1 -0
  144. package/dist/src/rules/xss.d.ts +38 -0
  145. package/dist/src/rules/xss.js +527 -0
  146. package/dist/src/rules/xss.js.map +1 -0
  147. package/dist/src/taint/content-type.d.ts +19 -0
  148. package/dist/src/taint/content-type.js +20 -0
  149. package/dist/src/taint/content-type.js.map +1 -0
  150. package/dist/src/taint/dictionaries.d.ts +23 -0
  151. package/dist/src/taint/dictionaries.js +1778 -0
  152. package/dist/src/taint/dictionaries.js.map +1 -0
  153. package/dist/src/taint/fold.d.ts +59 -0
  154. package/dist/src/taint/fold.js +1177 -0
  155. package/dist/src/taint/fold.js.map +1 -0
  156. package/dist/src/taint/java-scope.d.ts +61 -0
  157. package/dist/src/taint/java-scope.js +212 -0
  158. package/dist/src/taint/java-scope.js.map +1 -0
  159. package/dist/src/taint/local-map.d.ts +79 -0
  160. package/dist/src/taint/local-map.js +603 -0
  161. package/dist/src/taint/local-map.js.map +1 -0
  162. package/dist/src/taint/project.d.ts +172 -0
  163. package/dist/src/taint/project.js +617 -0
  164. package/dist/src/taint/project.js.map +1 -0
  165. package/dist/src/taint/tracer.d.ts +265 -0
  166. package/dist/src/taint/tracer.js +3817 -0
  167. package/dist/src/taint/tracer.js.map +1 -0
  168. package/dist/src/taint/types.d.ts +484 -0
  169. package/dist/src/taint/types.js +47 -0
  170. package/dist/src/taint/types.js.map +1 -0
  171. package/dist/src/taint/visibility.d.ts +25 -0
  172. package/dist/src/taint/visibility.js +96 -0
  173. package/dist/src/taint/visibility.js.map +1 -0
  174. package/docs/examples/defuse.json +20 -0
  175. package/docs/examples/dvwa-urls.json +14 -0
  176. package/docs/examples/github-code-scanning.yml +54 -0
  177. package/docs/examples/tiny.js +4 -0
  178. package/package.json +73 -4
  179. package/src/cli.ts +747 -0
  180. package/src/core/analyze.ts +1072 -0
  181. package/src/core/coverage.ts +269 -0
  182. package/src/core/finding.ts +311 -0
  183. package/src/core/licence.ts +61 -0
  184. package/src/core/paths.ts +59 -0
  185. package/src/core/score.ts +353 -0
  186. package/src/dast/targets.ts +121 -0
  187. package/src/dast/zap.ts +246 -0
  188. package/src/engine/scan.ts +200 -0
  189. package/src/engine/shapes.ts +348 -0
  190. package/src/engine/walk.ts +196 -0
  191. package/src/parse/astDump.ts +85 -0
  192. package/src/parse/languages.ts +232 -0
  193. package/src/parse/location.ts +44 -0
  194. package/src/parse/node-grammars.ts +41 -0
  195. package/src/parse/parser.ts +262 -0
  196. package/src/parse/tree-store.ts +301 -0
  197. package/src/platform/dependency-rule.ts +33 -0
  198. package/src/platform/deps.ts +374 -0
  199. package/src/platform/php-facts.ts +85 -0
  200. package/src/platform/runtime-version.ts +710 -0
  201. package/src/report/colors.ts +107 -0
  202. package/src/report/coverage-table.ts +359 -0
  203. package/src/report/html-report.ts +601 -0
  204. package/src/report/human.ts +811 -0
  205. package/src/report/json.ts +189 -0
  206. package/src/report/mark.ts +47 -0
  207. package/src/report/pricing.ts +374 -0
  208. package/src/report/sarif.ts +235 -0
  209. package/src/report/site.ts +760 -0
  210. package/src/rules/code-injection.ts +346 -0
  211. package/src/rules/command-injection.ts +296 -0
  212. package/src/rules/contract.ts +220 -0
  213. package/src/rules/format-string.ts +158 -0
  214. package/src/rules/hardcoded-secret.ts +485 -0
  215. package/src/rules/lib/guards.ts +1235 -0
  216. package/src/rules/lib/html-safety.ts +793 -0
  217. package/src/rules/lib/local-build.ts +329 -0
  218. package/src/rules/lib/strings.ts +2034 -0
  219. package/src/rules/path-traversal.ts +101 -0
  220. package/src/rules/registry.ts +90 -0
  221. package/src/rules/sql-injection.ts +311 -0
  222. package/src/rules/ssrf.ts +118 -0
  223. package/src/rules/unbounded-copy.ts +162 -0
  224. package/src/rules/unsafe-deserialization.ts +291 -0
  225. package/src/rules/weak-hash.ts +226 -0
  226. package/src/rules/xss.ts +572 -0
  227. package/src/taint/content-type.ts +20 -0
  228. package/src/taint/dictionaries.ts +1855 -0
  229. package/src/taint/fold.ts +1206 -0
  230. package/src/taint/java-scope.ts +200 -0
  231. package/src/taint/local-map.ts +594 -0
  232. package/src/taint/project.ts +774 -0
  233. package/src/taint/tracer.ts +4298 -0
  234. package/src/taint/types.ts +511 -0
  235. package/src/taint/visibility.ts +100 -0
  236. package/tsconfig.json +61 -0
@@ -0,0 +1,220 @@
1
+ /**
2
+ * THE RULE CONTRACT
3
+ * =================
4
+ *
5
+ * A "rule" is one thing Defuse knows how to look for. This file defines the shape
6
+ * every rule must have. Nothing else in the codebase knows what SQL injection
7
+ * is - the engine just runs whatever satisfies this interface.
8
+ *
9
+ * ARCHITECTURAL DECISION (this is the big one - it shapes Phases 3 and 5):
10
+ *
11
+ * Rules do NOT write tree-sitter queries and rules do NOT walk the AST.
12
+ * Instead, the engine lowers every language's syntax tree into a handful of
13
+ * language-neutral SHAPES - right now `CallSite` and `Assignment` - and a rule
14
+ * is a plain function that inspects a shape and says yes or no.
15
+ *
16
+ * Why:
17
+ * - A rule written once works for all five languages. `db.query(...)` in JS,
18
+ * `cursor.execute(...)` in Python and `stmt.executeQuery(...)` in Java all
19
+ * arrive at the rule as the same CallSite object with a different
20
+ * `calleeName`. The rule needs a name list per language, not a rewrite.
21
+ * - Adding language number six means teaching the SHAPE LAYER one new query.
22
+ * Every existing rule gains that language for free.
23
+ * - Phase 3's taint engine needs exactly these shapes anyway (a "source" is a
24
+ * CallSite, a "sink" is a CallSite, propagation happens through
25
+ * Assignments). Building them now means Phase 3 is an addition, not a
26
+ * rewrite.
27
+ *
28
+ * The escape hatch: a rule may declare `status: 'partial'` or
29
+ * `'not-implemented'` for a language the shape layer cannot express. That is a
30
+ * documented gap, printed in every report. It is never silence.
31
+ */
32
+
33
+ import type { Node } from 'web-tree-sitter';
34
+ import type { Severity } from '../core/finding.js';
35
+ import type { LanguageId } from '../parse/languages.js';
36
+ import type { ParsedFile } from '../parse/parser.js';
37
+
38
+ /**
39
+ * A function/method call, normalised across languages.
40
+ *
41
+ * JS db.query(sql) receiverText "db" calleeName "query"
42
+ * Python cursor.execute(sql) receiverText "cursor" calleeName "execute"
43
+ * Java stmt.executeQuery(sql) receiverText "stmt" calleeName "executeQuery"
44
+ * Go db.Query(sql) receiverText "db" calleeName "Query"
45
+ */
46
+ export interface CallSite {
47
+ readonly kind: 'call';
48
+ /** The whole call expression - what a finding points at. */
49
+ readonly node: Node;
50
+ /** The method or function name only, no dots. */
51
+ readonly calleeName: string;
52
+ /** Text before the dot, e.g. "db", "child_process", "Runtime.getRuntime()". */
53
+ readonly receiverText: string;
54
+ /** Full callee text, e.g. "crypto.createHash". */
55
+ readonly calleeText: string;
56
+ /** Argument expressions, already stripped of commas and parentheses. */
57
+ readonly args: readonly Node[];
58
+ }
59
+
60
+ /**
61
+ * A value being stored somewhere, normalised across languages. Covers
62
+ * variable declarations, plain assignments, object/dict literal entries and
63
+ * keyword arguments - anywhere a name gets bound to a value.
64
+ */
65
+ export interface Assignment {
66
+ readonly kind: 'assignment';
67
+ readonly node: Node;
68
+ /** Full left-hand side text, e.g. "el.innerHTML", "config.apiKey". */
69
+ readonly targetText: string;
70
+ /** Last segment of the target, e.g. "innerHTML", "apiKey". */
71
+ readonly targetName: string;
72
+ /** The value expression. */
73
+ readonly value: Node;
74
+ }
75
+
76
+ export type Shape = CallSite | Assignment;
77
+
78
+ /** Everything a rule is allowed to know while deciding. */
79
+ export interface RuleContext {
80
+ readonly file: ParsedFile;
81
+ readonly language: LanguageId;
82
+ /** Convenience: source text of a node, whitespace-collapsed. */
83
+ readonly text: (node: Node) => string;
84
+ /**
85
+ * Record that this rule examined a line and can PROVE it is not the bug the
86
+ * shape suggests - so the guess it would have made is withdrawn, not dropped.
87
+ *
88
+ * A rule that declines to report leaves no trace, and that was fine while
89
+ * declining meant "these are plain literals". Once a rule can decline because
90
+ * of REASONING - a branch that cannot run, a condition fixed at compile time -
91
+ * the reasoning has to be visible, because reasoning can be wrong. Receipts
92
+ * appear in --json as verifiedClean entries with the reason attached, are
93
+ * counted in the terminal summary, and show as "proved clean" in the UI.
94
+ *
95
+ * Optional so a caller that builds its own context is not broken by it.
96
+ */
97
+ readonly noteClean?: (ruleId: string, node: Node, reason: string) => void;
98
+ /**
99
+ * Java only: the static method a call like `Utils.m(...)` or
100
+ * `a.b.Utils.m(...)` runs, when that is not in doubt - see
101
+ * taint/project.ts, staticMethod(). Absent when cross-file resolution is
102
+ * off, and then nothing is read through another file.
103
+ */
104
+ readonly staticMethod?: StaticMethodLookup | undefined;
105
+ /** Java only: is a type of this simple name - top-level or nested - declared anywhere in the scan? */
106
+ readonly javaTypeInScan?: ((simpleName: string) => boolean) | undefined;
107
+ }
108
+
109
+ /** A static method found in the scan: the file a reader should be shown, and its declaration. */
110
+ export interface StaticMethodHit {
111
+ readonly path: string;
112
+ readonly method: Node;
113
+ }
114
+
115
+ /**
116
+ * `receiver` is the class as written at the call - `Utils` or `a.b.Utils`;
117
+ * `supertypes`, what the classes around the call inherit from.
118
+ */
119
+ export type StaticMethodLookup = (
120
+ receiver: string,
121
+ method: string,
122
+ supertypes?: readonly string[],
123
+ allowInstance?: boolean,
124
+ ) => StaticMethodHit | null;
125
+
126
+ /**
127
+ * What a rule returns when it decides something is worth reporting.
128
+ * Note there is NO confidence field: a rule cannot set its own confidence.
129
+ * The engine stamps a rule's result as signature-based; only the taint engine,
130
+ * which can produce a source-to-sink path, may emit flow-verified. A rule that wanted
131
+ * to claim more would have to change the engine, in a commit, in review.
132
+ */
133
+ export interface RuleHit {
134
+ /** Node to point the finding at. Usually the shape's node or a sub-node. */
135
+ readonly node: Node;
136
+ /** One line for the terminal. */
137
+ readonly message: string;
138
+ /** Why this matched, in plain language. Shown to the user. Required. */
139
+ readonly reasoning: string;
140
+ /** Optional per-hit override of the rule's general limitations text. */
141
+ readonly limitations?: string;
142
+ /** Optional severity override (e.g. a confirmed AWS key beats a generic one). */
143
+ readonly severity?: Severity;
144
+ }
145
+
146
+ export type SupportStatus = 'implemented' | 'partial' | 'not-implemented';
147
+
148
+ export interface LanguageSupport {
149
+ readonly status: SupportStatus;
150
+ /**
151
+ * REQUIRED for every language, including implemented ones. For 'partial' and
152
+ * 'not-implemented' this must say exactly what is missing. This text is
153
+ * printed in the coverage section of every scan, so a gap can never be silent.
154
+ */
155
+ readonly note: string;
156
+ }
157
+
158
+ export interface Rule {
159
+ /** Stable machine id, kebab-case. Used in --json, tests and suppressions. */
160
+ readonly id: string;
161
+ readonly name: string;
162
+ /** CWE identifier - the industry's shared catalogue of weakness types. */
163
+ readonly cwe: string;
164
+ /** OWASP Top 10 (2025) category. */
165
+ readonly owasp: string;
166
+ readonly severity: Severity;
167
+ /**
168
+ * 2-4 sentences explaining the VULNERABILITY (not the detection code) to
169
+ * someone who has never heard of it. Printed by `defuse rules --explain`.
170
+ */
171
+ readonly explanation: string;
172
+ /**
173
+ * ONE plain sentence a beginner can read under a finding: what goes wrong,
174
+ * no jargon. The terminal prints it on every finding; `explanation` is the
175
+ * long version behind --full and `rules --explain`.
176
+ */
177
+ readonly summary: string;
178
+ /** ONE sentence: what to change in the code. Printed as `fix` on every finding. */
179
+ readonly fix: string;
180
+ /**
181
+ * What this rule structurally cannot determine. Copied onto every finding it
182
+ * produces unless the hit overrides it. Required - a rule with no stated
183
+ * limitations is a rule pretending to be certain.
184
+ */
185
+ readonly limitations: string;
186
+ /**
187
+ * Per-language honesty declaration. REQUIRED FOR EVERY LANGUAGE.
188
+ *
189
+ * This was `Partial<Record<...>>` with "missing language = not-implemented"
190
+ * as the default, and that default caused the worst kind of bug this project
191
+ * can have: the tool contradicting itself about its own coverage.
192
+ *
193
+ * Adding C and C++ silently marked all eight existing rules not-implemented
194
+ * for them. rulesForLanguage() then filtered those rules out, so their
195
+ * SIGNATURE matchers never ran on a C file at all - while the taint engine,
196
+ * which reaches rules by id and never consults this table, went on producing
197
+ * flow-verified `command-injection` findings in C. The report printed six of
198
+ * them directly underneath a line saying command-injection/c was NOT
199
+ * IMPLEMENTED.
200
+ *
201
+ * A silent default is what made that possible. Requiring every language
202
+ * turns the next language addition into a compile error per rule, which is
203
+ * exactly what the taint dictionaries already do - nineteen of them, each a
204
+ * real decision someone had to make on purpose.
205
+ */
206
+ readonly support: Record<LanguageId, LanguageSupport>;
207
+ /** Which shapes this rule wants to see. Engine only calls matching ones. */
208
+ readonly shapes: readonly Shape['kind'][];
209
+ /** The decision. Return null for "not interesting". */
210
+ readonly check: (shape: Shape, ctx: RuleContext) => RuleHit | null;
211
+ }
212
+
213
+ /** Helper so a rule can narrow a Shape without repeating the same three lines. */
214
+ export function asCall(shape: Shape): CallSite | null {
215
+ return shape.kind === 'call' ? shape : null;
216
+ }
217
+
218
+ export function asAssignment(shape: Shape): Assignment | null {
219
+ return shape.kind === 'assignment' ? shape : null;
220
+ }
@@ -0,0 +1,158 @@
1
+ /**
2
+ * FORMAT STRING INJECTION (CWE-134)
3
+ *
4
+ * WHAT THE BUG IS.
5
+ *
6
+ * printf(name); // `name` came from the user
7
+ *
8
+ * looks like printing a string and is not. printf reads its first argument as a
9
+ * FORMAT, so whoever controls that string controls the conversions too:
10
+ *
11
+ * %x %x %x %x walks up the stack and prints whatever is sitting there
12
+ * %s treats a stack value as a pointer - crash, or a leak
13
+ * %n WRITES the number of bytes printed so far to a pointer
14
+ * argument, which turns a logging call into an arbitrary
15
+ * memory write
16
+ *
17
+ * That last conversion is why this is not a cosmetic bug. It is the standard
18
+ * route from "the program logs a username" to "the attacker runs code".
19
+ *
20
+ * WHY THIS RULE IS THE BEST ARGUMENT FOR POINTING A TAINT ENGINE AT C.
21
+ *
22
+ * Most famous C bugs are SIZE bugs - is this buffer big enough, is this integer
23
+ * about to wrap - and answering those needs an analysis of sizes, allocations
24
+ * and aliasing that this engine does not have. The unbounded-copy rule next
25
+ * door is careful to claim much less than its name suggests for exactly that
26
+ * reason.
27
+ *
28
+ * A format string bug needs none of it. The entire question is "is this ONE
29
+ * argument attacker-controlled?", which is the only question this engine
30
+ * answers, and it answers it with a printed path. So a flow-verified finding
31
+ * here means precisely what it says, with no hedge underneath it.
32
+ *
33
+ * THE TRAP THIS RULE MUST NOT FALL INTO.
34
+ *
35
+ * printf(name) dangerous
36
+ * printf("%s", name) correct
37
+ *
38
+ * Same function, one extra argument - the identical shape as a parameterised
39
+ * SQL query versus a concatenated one. A rule that fired on "printf with a
40
+ * tainted argument" would report the FIX as the bug, which this project treats
41
+ * as worse than a miss: it teaches people that hardening does not help. Only
42
+ * the format argument counts, and its index differs per function, so the table
43
+ * below is the whole rule.
44
+ */
45
+
46
+ import type { LanguageId } from '../parse/languages.js';
47
+ import { asCall, type Rule, type RuleHit } from './contract.js';
48
+ import { isStringLiteral } from './lib/strings.js';
49
+
50
+ /**
51
+ * Function name -> which argument is the FORMAT.
52
+ *
53
+ * Getting an index wrong here does not just miss a bug, it reports correct
54
+ * code, so each one is written out rather than inferred from the name.
55
+ */
56
+ const FORMAT_ARGUMENT: ReadonlyMap<string, number> = new Map([
57
+ ['printf', 0],
58
+ ['vprintf', 0],
59
+ ['fprintf', 1],
60
+ ['sprintf', 1],
61
+ ['vfprintf', 1],
62
+ ['vsprintf', 1],
63
+ ['dprintf', 1],
64
+ ['asprintf', 1],
65
+ ['syslog', 1],
66
+ ['snprintf', 2],
67
+ ['vsnprintf', 2],
68
+ ]);
69
+
70
+ const C_FAMILY: ReadonlySet<LanguageId> = new Set<LanguageId>(['c', 'cpp']);
71
+
72
+ const NOT_C = {
73
+ status: 'not-implemented' as const,
74
+ note:
75
+ 'Format string injection is a C-family bug. Other languages either take the format ' +
76
+ 'as a constant by construction or have no %n equivalent, so there is nothing here to ' +
77
+ 'match and this rule never fires. Stated rather than left as a blank column.',
78
+ };
79
+
80
+ export const formatStringRule: Rule = {
81
+ id: 'format-string',
82
+ name: 'Attacker-controlled format string',
83
+ cwe: 'CWE-134',
84
+ owasp: 'A05:2025 Injection',
85
+ severity: 'high',
86
+ summary: 'The format string is not fixed; if it comes from outside, it can read or even write memory.',
87
+ fix:
88
+ 'Make the format a fixed string and pass the value separately: printf("%s", name), not printf(name).',
89
+ explanation:
90
+ 'A format string is a small program: %x reads, %s dereferences, and %n WRITES. When ' +
91
+ 'the format itself comes from the user, they are writing that program. The fix is ' +
92
+ 'always the same shape and always cheap - make the format a constant and pass the ' +
93
+ 'value as data: printf("%s", name) instead of printf(name).',
94
+ limitations:
95
+ 'A signature-based finding here means only that the format argument is not a literal ' +
96
+ 'in this call - it may be a constant defined elsewhere, which we cannot see because ' +
97
+ 'the preprocessor is not run and constants are not folded. A flow-verified finding ' +
98
+ 'means the value was traced from an attacker-controlled source into the format ' +
99
+ 'position, and that claim carries its path.',
100
+ shapes: ['call'],
101
+ support: {
102
+ c: {
103
+ status: 'implemented',
104
+ note:
105
+ 'Covers the printf family (printf, fprintf, sprintf, snprintf, dprintf, asprintf, ' +
106
+ 'syslog and their v- variants), with the format argument index recorded per ' +
107
+ 'function so that the correct form - a constant format with the value passed as ' +
108
+ 'data - is never reported. A format built by a macro is missed: the preprocessor ' +
109
+ 'is not run.',
110
+ },
111
+ cpp: {
112
+ status: 'implemented',
113
+ note:
114
+ 'Identical to C. std::format and iostreams are type-safe and cannot carry this ' +
115
+ 'bug, so they are deliberately not matched; C++ code that calls printf still can, ' +
116
+ 'and still does.',
117
+ },
118
+ javascript: NOT_C,
119
+ typescript: NOT_C,
120
+ python: NOT_C,
121
+ java: NOT_C,
122
+ go: NOT_C,
123
+ php: NOT_C,
124
+ },
125
+
126
+ check(shape, ctx): RuleHit | null {
127
+ if (!C_FAMILY.has(ctx.language)) return null;
128
+ const call = asCall(shape);
129
+ if (!call) return null;
130
+
131
+ const index = FORMAT_ARGUMENT.get(call.calleeName);
132
+ if (index === undefined) return null;
133
+
134
+ const formatArgument = call.args[index];
135
+ // No argument in the format position at all - nothing to say about it.
136
+ if (!formatArgument) return null;
137
+
138
+ /*
139
+ * A literal format is the SAFE form and the whole point of the fix. It is
140
+ * checked first and returns null, so `printf("%s", name)` can never be
141
+ * reported however dirty `name` is.
142
+ */
143
+ if (isStringLiteral(formatArgument, ctx.language)) return null;
144
+
145
+ const argumentText = ctx.text(formatArgument);
146
+ return {
147
+ node: call.node,
148
+ message: `${call.calleeName}() is called with a non-constant format string (\`${argumentText}\`)`,
149
+ reasoning:
150
+ `The format argument of \`${call.calleeName}()\` is \`${argumentText}\`, which is not a ` +
151
+ `string literal. A format string is executable: \`%x\` reads values off the stack, ` +
152
+ `\`%s\` dereferences one as a pointer, and \`%n\` writes to memory. If that value ` +
153
+ `can be influenced by a user, this is a memory-write primitive rather than a print ` +
154
+ `statement. The fix is to make the format constant and pass the value as data: ` +
155
+ `\`${call.calleeName}(${index === 0 ? '' : '..., '}"%s", ${argumentText})\`.`,
156
+ };
157
+ },
158
+ };