@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,834 @@
1
+ /**
2
+ * THE ENGINE, WITH NO PLATFORM ATTACHED
3
+ * =====================================
4
+ *
5
+ * The browser UI was promised, from the first day of this project, to run THIS
6
+ * engine rather than a second implementation of it. Two scanners that drift
7
+ * apart is the failure mode: the web one quietly becoming more optimistic than
8
+ * the CLI, or the reverse, and nobody noticing until a finding disagrees with
9
+ * itself.
10
+ *
11
+ * So the analysis lives here and touches nothing platform-specific - no
12
+ * `node:fs`, no `process`, no file walking. It takes text that is already in
13
+ * memory and returns findings.
14
+ *
15
+ * Two thin shells wrap it:
16
+ * src/engine/scan.ts walks a directory and reads files (the CLI)
17
+ * ui/worker.js receives dropped files in a Web Worker (the browser)
18
+ *
19
+ * If either shell ever needs to change how a FINDING is produced, that is the
20
+ * signal that the change belongs in here instead.
21
+ */
22
+ import { buildCoverageReport } from './coverage.js';
23
+ import { flowVerifiedFinding, signatureFinding, SEVERITY_ORDER, } from './finding.js';
24
+ import { extractShapes } from '../engine/shapes.js';
25
+ import { nodeLocation } from '../parse/location.js';
26
+ import { initEngine, parseSourceFile } from '../parse/parser.js';
27
+ import { createTreeStore, DEFAULT_TREE_BUDGET_BYTES, } from '../parse/tree-store.js';
28
+ import { isTestPath } from '../rules/lib/strings.js';
29
+ import { getRule, rulesForLanguage, validateRegistry } from '../rules/registry.js';
30
+ import { buildProjectIndex, importSpecifiers, indexFunctions, javaTypeFacts, } from '../taint/project.js';
31
+ import { CLEAN_PART, traceFile } from '../taint/tracer.js';
32
+ /**
33
+ * A line ending in `defuse:ignore <rule-id> - <reason>` suppresses that rule
34
+ * on that line. Suppressions are COUNTED AND LISTED in the report: silencing a
35
+ * finding is a decision someone made, and the report should show it, not erase it.
36
+ */
37
+ const SUPPRESSION = /defuse:ignore\s+([a-z0-9-]+)(?:\s*[-:]\s*(.*))?/i;
38
+ function suppressionOnLine(sourceLines, line) {
39
+ // Check the finding's own line and the line above it - both are common.
40
+ for (const candidate of [line - 1, line - 2]) {
41
+ const text = sourceLines[candidate];
42
+ if (!text)
43
+ continue;
44
+ const match = SUPPRESSION.exec(text);
45
+ if (match?.[1])
46
+ return { ruleId: match[1], comment: (match[2] ?? '').trim() };
47
+ }
48
+ return null;
49
+ }
50
+ /*
51
+ * `if '../' in bar: return` before the open. BenchmarkPython marks seven such
52
+ * cases safe and the first run proved all seven. A substring test is read
53
+ * from the text of the function above the sink, in every spelling this
54
+ * project scans: `'..' in x`, `.includes('..')`, `.contains("..")`,
55
+ * `strings.Contains(x, "..")`, `strpos($x, '..')`.
56
+ */
57
+ const DOT_DOT_CHECK = /(["'])\.\.[\\/]*\1\s+(?:not\s+)?in\b|\b(?:includes|indexOf|contains|Contains|strpos|stripos|str_contains)\s*\([^()]*?(["'])\.\.[\\/]*\2|\.(?:startsWith|startswith)\s*\(|\b(?:HasPrefix|str_starts_with)\s*\(/;
58
+ // The last two alternatives are the containment check - resolve the path, then
59
+ // `startsWith(base)` - which is the right fix, done right or not (Juice Shop's
60
+ // /infrastructure route does it right). What it compares is not read.
61
+ /*
62
+ * `if (snippet == null) { res.status(404)... return }` before the open. Juice
63
+ * Shop's code-challenge routes look the key up first and answer 404 for one
64
+ * that is not listed, so only listed names reach the read - and both were
65
+ * proven, wrongly. An `if` that answers "not found" on its own line or the next.
66
+ */
67
+ const NOT_FOUND_EXIT = /\bif\b[^\n]*(?:\n[^\n]*)?(?:\b(?:status|sendStatus|abort|http_response_code)\s*\(\s*404\b|\bHttp404\b|\bhttp\.(?:NotFound|StatusNotFound)\b)/;
68
+ /** The `..` check or not-found exit in the function before this sink, as written, or undefined. */
69
+ function dotDotCheckBefore(sink) {
70
+ // ponytail: any such test above the sink in the function counts, whatever
71
+ // variable it reads and whatever it does on failure. It only ever turns a
72
+ // proof into "to review", never clears a line, so a loose match costs a proof.
73
+ let fn = sink.parent;
74
+ while (fn && !/function|method|lambda|arrow/.test(fn.type))
75
+ fn = fn.parent;
76
+ if (!fn)
77
+ return undefined;
78
+ const before = (fn.text ?? '').slice(0, sink.startIndex - fn.startIndex);
79
+ return (DOT_DOT_CHECK.exec(before) ?? NOT_FOUND_EXIT.exec(before))?.[0].replace(/\s+/g, ' ');
80
+ }
81
+ /** Findings at the same rule+file+line are the same finding reported twice. */
82
+ function dedupeKey(finding) {
83
+ return `${finding.ruleId}:${finding.location.file}:${finding.location.startLine}:${finding.location.startColumn}`;
84
+ }
85
+ export async function analyze(inputs, options = {}) {
86
+ const started = Date.now();
87
+ // Refuse to run at all if a rule breaks the honesty contract.
88
+ validateRegistry();
89
+ await initEngine();
90
+ const findings = [];
91
+ const seenFindings = new Set();
92
+ // Verified flows are collected separately so that, at the end, we can let a
93
+ // confirmed finding SUPERSEDE the unverified guess at the same place rather
94
+ // than reporting the same bug twice at two different confidence levels.
95
+ const flowFindings = [];
96
+ const seenFlows = new Set();
97
+ const flowRanges = [];
98
+ const cleanRanges = [];
99
+ const verifiedClean = [];
100
+ const unreachableCode = [];
101
+ const collectionReadsResolved = [];
102
+ const tracesStoppedAtNewData = [];
103
+ /** One receipt per line per rule, however many shapes asked the same question. */
104
+ const provedConstantKeys = new Set();
105
+ /** Summed across every file, so the report can say where the trace ran out. */
106
+ const traceLimits = {
107
+ astTruncations: 0, depthTruncations: 0, recursionStops: 0, unmodelledHops: 0,
108
+ sourcesFound: 0,
109
+ };
110
+ const suppressions = [];
111
+ const parseProblems = [];
112
+ const byLanguage = {};
113
+ let filesParsed = 0;
114
+ let filesSkipped = 0;
115
+ let shapesExamined = 0;
116
+ const only = options.only && options.only.length > 0 ? new Set(options.only) : null;
117
+ /* ---------------------------------------------------------------------- *
118
+ * PASS 1 - parse everything, index it, and hand the tree to the store.
119
+ *
120
+ * Analysis cannot begin until the import graph exists, and the import graph
121
+ * cannot exist until every file has been read, so this remains two passes.
122
+ * What CHANGED is what survives in between.
123
+ *
124
+ * It used to be every syntax tree in the project, all at once. A tree lives
125
+ * in WebAssembly memory that the garbage collector cannot reclaim, and
126
+ * nothing ever called delete(), so a scan cost about sixty times the size of
127
+ * the source it was scanning - a hard ceiling around 44 MB of source on an
128
+ * 8 GB machine, past which the operating system killed the process.
129
+ *
130
+ * The comment that used to sit here blamed cross-file tracing and pointed at
131
+ * --no-cross-file as the escape hatch. Measured, that was simply untrue:
132
+ * cross-file OFF used 535.8 MB against 528.8 MB with it ON. The trees were
133
+ * never freed in either mode.
134
+ *
135
+ * Now what survives PASS 1 is a RECORD per file - its text, its parse
136
+ * problems, its functions as byte offsets, its import specifiers as strings -
137
+ * and the tree goes to the store, which keeps as many as the budget allows
138
+ * and re-parses the rest on demand. See src/parse/tree-store.ts.
139
+ * ---------------------------------------------------------------------- */
140
+ const store = createTreeStore(options.treeBudgetBytes ?? DEFAULT_TREE_BUDGET_BYTES);
141
+ const fileRecords = [];
142
+ for (let index = 0; index < inputs.length; index++) {
143
+ const input = inputs[index];
144
+ if (!input)
145
+ continue;
146
+ options.onProgress?.(index + 1, inputs.length * 2, input.path);
147
+ const outcome = await parseSourceFile(input.path, input.source);
148
+ if (outcome.status === 'skipped') {
149
+ filesSkipped++;
150
+ continue;
151
+ }
152
+ const { tree, root, ...rest } = outcome.file;
153
+ const languageId = outcome.file.language.id;
154
+ // Everything the index needs is read out HERE, while the tree is in hand.
155
+ // After this line nothing but the store may hold it.
156
+ fileRecords.push({
157
+ path: outcome.file.path,
158
+ language: languageId,
159
+ functions: indexFunctions(root, languageId),
160
+ importSpecifiers: importSpecifiers(root, languageId),
161
+ javaTypes: languageId === 'java' ? javaTypeFacts(root) : undefined,
162
+ parsed: rest,
163
+ });
164
+ store.admit(outcome.file.path, outcome.file.source, outcome.file.grammar, tree);
165
+ filesParsed++;
166
+ byLanguage[languageId] = (byLanguage[languageId] ?? 0) + 1;
167
+ if (outcome.file.parseIssues.length > 0) {
168
+ parseProblems.push({ file: input.path, issues: outcome.file.parseIssues });
169
+ }
170
+ }
171
+ // How an absolute path should be shown to a human. The CLI shortens paths
172
+ // against the working directory; a browser has no working directory, so it
173
+ // passes them straight through. Everything downstream uses whatever this says.
174
+ const toDisplay = options.toDisplay ?? ((absolutePath) => absolutePath);
175
+ /* ---------------------------------------------------------------------- *
176
+ * PASS 2 - build the import graph, then analyse.
177
+ * ---------------------------------------------------------------------- */
178
+ /**
179
+ * The cross-file ceiling, which is now about the INDEX rather than the trees.
180
+ *
181
+ * It used to be justified by "cross-file analysis needs every syntax tree in
182
+ * memory at once, and a tree costs roughly ten times its source" - two claims,
183
+ * both wrong. The trees were held whether or not cross-file was on, and the
184
+ * cost is nearer sixty times than ten. What the index genuinely holds is a
185
+ * name, a parameter list and two integers per function, which is a few
186
+ * megabytes on a project of this size.
187
+ *
188
+ * The limit stays because an index over an enormous project is still real
189
+ * memory and a resolution over it is still real time, and because a stated
190
+ * limit that fires is better than an unstated one that kills the process. It
191
+ * is now what it always should have been: a threshold with a reason, not a
192
+ * consequence of a leak.
193
+ */
194
+ const MAX_CROSS_FILE_SOURCE_BYTES = 64 * 1024 * 1024;
195
+ const totalSourceBytes = fileRecords.reduce((sum, f) => sum + f.parsed.source.length, 0);
196
+ const tooBig = totalSourceBytes > MAX_CROSS_FILE_SOURCE_BYTES;
197
+ const crossFileEnabled = !options.noCrossFile && !tooBig;
198
+ const crossFileOffReason = options.noCrossFile
199
+ ? 'cross-file resolution was switched off'
200
+ : `project is ${(totalSourceBytes / 1048576).toFixed(0)}MB of source, over the ` +
201
+ `${MAX_CROSS_FILE_SOURCE_BYTES / 1048576}MB ceiling for indexing every function in the project`;
202
+ const projectIndex = crossFileEnabled ? buildProjectIndex(fileRecords, store) : undefined;
203
+ // A finding can now land in a file other than the one being scanned, so the
204
+ // suppression check needs any file's lines on demand. Splitting every source
205
+ // eagerly doubled peak memory on a large tree for data most scans never read,
206
+ // so it is computed lazily and cached.
207
+ const sourceByDisplayPath = new Map();
208
+ for (const record of fileRecords) {
209
+ sourceByDisplayPath.set(toDisplay(record.path), record.parsed.source);
210
+ }
211
+ const lineCache = new Map();
212
+ const linesOf = (displayPath) => {
213
+ const cached = lineCache.get(displayPath);
214
+ if (cached)
215
+ return cached;
216
+ const lines = (sourceByDisplayPath.get(displayPath) ?? '').split('\n');
217
+ lineCache.set(displayPath, lines);
218
+ return lines;
219
+ };
220
+ for (let index = 0; index < fileRecords.length; index++) {
221
+ const record = fileRecords[index];
222
+ if (!record)
223
+ continue;
224
+ const filePath = record.path;
225
+ options.onProgress?.(inputs.length + index + 1, inputs.length * 2, filePath);
226
+ /* ---- the tree comes back here, and is pinned for this iteration ----
227
+ *
228
+ * Usually it was never gone: the store holds everything that fits in the
229
+ * budget, so a normal project parses each file exactly once and this is a
230
+ * lookup. On a project too big for the budget it is a re-parse, which costs
231
+ * time the old code did not spend - and the old code, on a project that
232
+ * size, did not finish at all.
233
+ *
234
+ * The pin is what makes it safe. Every Node produced below points into this
235
+ * tree's memory; freeing it while a rule or the tracer still holds one would
236
+ * not raise an error, it would read whatever moved in afterwards. */
237
+ store.pin(filePath);
238
+ const tree = store.get(filePath);
239
+ const file = { ...record.parsed, tree, root: tree.rootNode };
240
+ const displayPath = toDisplay(filePath);
241
+ const sourceLines = linesOf(displayPath);
242
+ const context = {
243
+ file,
244
+ language: file.language.id,
245
+ text: (node) => (node.text ?? '').replace(/\s+/g, ' ').trim(),
246
+ noteClean: (ruleId, node, reason) => {
247
+ const line = node.startPosition.row + 1;
248
+ const key = `${displayPath}:${line}:${ruleId}`;
249
+ if (provedConstantKeys.has(key))
250
+ return;
251
+ provedConstantKeys.add(key);
252
+ verifiedClean.push({ file: displayPath, line, ruleId, kind: 'constant', reason });
253
+ },
254
+ staticMethod: projectIndex
255
+ ? (receiver, method, supertypes, allowInstance) => {
256
+ const hit = projectIndex.staticMethod(filePath, receiver, method, supertypes, allowInstance);
257
+ return hit && { path: toDisplay(hit.path), method: hit.method };
258
+ }
259
+ : undefined,
260
+ javaTypeInScan: projectIndex ? (name) => projectIndex.javaTypeInScan(name) : undefined,
261
+ };
262
+ const shapes = await extractShapes(file, file.language.id);
263
+ shapesExamined += shapes.length;
264
+ const rules = rulesForLanguage(file.language.id).filter((r) => !only || only.has(r.id));
265
+ for (const shape of shapes) {
266
+ for (const rule of rules) {
267
+ if (!rule.shapes.includes(shape.kind))
268
+ continue;
269
+ let hit;
270
+ try {
271
+ hit = rule.check(shape, context);
272
+ }
273
+ catch (error) {
274
+ // A crashing rule must not take the scan down, and must not be silent.
275
+ parseProblems.push({
276
+ file: filePath,
277
+ issues: [
278
+ {
279
+ kind: 'ERROR',
280
+ line: shape.node.startPosition.row + 1,
281
+ column: shape.node.startPosition.column + 1,
282
+ text: `rule '${rule.id}' threw: ${error.message}`.slice(0, 120),
283
+ },
284
+ ],
285
+ });
286
+ continue;
287
+ }
288
+ if (!hit)
289
+ continue;
290
+ const location = nodeLocation(hit.node, displayPath);
291
+ const suppressed = suppressionOnLine(sourceLines, location.startLine);
292
+ if (suppressed && (suppressed.ruleId === rule.id || suppressed.ruleId === 'all')) {
293
+ suppressions.push({
294
+ file: displayPath,
295
+ line: location.startLine,
296
+ ruleId: rule.id,
297
+ comment: suppressed.comment || '(no reason given)',
298
+ });
299
+ continue;
300
+ }
301
+ /*
302
+ * TEST-FILE SEVERITY, DECIDED IN ONE PLACE FOR EVERY RULE.
303
+ *
304
+ * This policy used to live inside hardcoded-secret alone, which meant a
305
+ * fake password in a test dropped to low while an `eval()` in the file
306
+ * next to it stayed critical. Scanning pandas made the cost visible: 41
307
+ * critical findings, 33 of them inside the test suite, burying the eight
308
+ * in shipped code that were the actual answer.
309
+ *
310
+ * The reason to discount a test file is not that the finding is wrong -
311
+ * `eval()` in a test IS Python's eval - it is that the attacker position
312
+ * does not exist. Nobody reaches your test suite over the network. So
313
+ * the finding is kept, always, and only its rank changes.
314
+ *
315
+ * Doing it here rather than in each rule means a rule added next year
316
+ * inherits it without anyone remembering to.
317
+ *
318
+ * NOT APPLIED TO FLOW-VERIFIED FINDINGS, deliberately. isTestPath is a
319
+ * guess about a path, and it is wrong sometimes in the expensive
320
+ * direction: pandas SHIPS `pandas/_testing/`, and plenty of projects
321
+ * ship an `examples/` directory that real users copy from. A traced
322
+ * source-to-sink path is the strongest evidence this tool produces, and
323
+ * a filename pattern is not good enough grounds to quiet it.
324
+ */
325
+ const inTestFile = isTestPath(file.path);
326
+ const testRank = (s) => !inTestFile ? s : s === 'critical' ? 'medium' : s === 'high' ? 'low' : s;
327
+ const finding = signatureFinding({
328
+ ruleId: rule.id,
329
+ ruleName: rule.name,
330
+ cwe: rule.cwe,
331
+ owasp: rule.owasp,
332
+ severity: testRank(hit.severity ?? rule.severity),
333
+ message: hit.message,
334
+ location,
335
+ reasoning: hit.reasoning +
336
+ (inTestFile
337
+ ? ' NOTE: this file looks like a test, fixture or example. The finding is ' +
338
+ 'accurate about the code, but a test suite is not reachable by an attacker, ' +
339
+ 'so severity is lowered rather than the finding dropped - test code does get ' +
340
+ 'copied into production, and this is still in git history either way.'
341
+ : ''),
342
+ limitations: hit.limitations ?? rule.limitations,
343
+ });
344
+ const key = dedupeKey(finding);
345
+ if (seenFindings.has(key))
346
+ continue;
347
+ seenFindings.add(key);
348
+ findings.push(finding);
349
+ }
350
+ }
351
+ /* ------------------------------------------------------------------ *
352
+ * PHASE 3a: data-flow verification.
353
+ *
354
+ * The signature pass above asked "does this code have the SHAPE of a bug?".
355
+ * This pass asks the harder question: "did attacker-controlled data
356
+ * actually get here?" Only this pass may produce flow-verified findings,
357
+ * and only because it can hand over the path that proves it.
358
+ * ------------------------------------------------------------------ */
359
+ const trace = traceFile(file, file.language.id, projectIndex, toDisplay);
360
+ traceLimits.astTruncations += trace.limits.astTruncations;
361
+ traceLimits.depthTruncations += trace.limits.depthTruncations;
362
+ traceLimits.recursionStops += trace.limits.recursionStops;
363
+ traceLimits.unmodelledHops += trace.limits.unmodelledHops;
364
+ traceLimits.sourcesFound += trace.limits.sourcesFound;
365
+ for (const read of trace.resolvedReads) {
366
+ collectionReadsResolved.push({
367
+ file: read.filePath || displayPath,
368
+ line: read.node.startPosition.row + 1,
369
+ why: read.why,
370
+ });
371
+ }
372
+ for (const stop of trace.freshStops) {
373
+ tracesStoppedAtNewData.push({
374
+ file: stop.filePath || displayPath,
375
+ line: stop.node.startPosition.row + 1,
376
+ why: stop.why,
377
+ });
378
+ }
379
+ for (const region of trace.unreachable) {
380
+ unreachableCode.push({
381
+ file: region.filePath || displayPath,
382
+ line: region.node.startPosition.row + 1,
383
+ why: region.why,
384
+ });
385
+ }
386
+ // Lines where a tainted value reached a sink but was properly sanitised.
387
+ // The signature pass cannot see a sanitiser, so it guessed; the tracer can,
388
+ // so the guess is withdrawn rather than left to nag.
389
+ for (const clean of trace.sanitized) {
390
+ verifiedClean.push({
391
+ file: clean.filePath || displayPath,
392
+ line: clean.sinkNode.startPosition.row + 1,
393
+ ruleId: clean.ruleId,
394
+ kind: clean.kind,
395
+ reason: clean.reason,
396
+ ...(clean.assumes ? { assumes: clean.assumes } : {}),
397
+ });
398
+ cleanRanges.push({
399
+ ruleId: clean.ruleId,
400
+ file: clean.filePath || displayPath,
401
+ start: clean.sinkNode.startPosition.row + 1,
402
+ end: clean.sinkNode.endPosition.row + 1,
403
+ });
404
+ }
405
+ /** Supersede the signature pass everywhere this flow touched. */
406
+ const recordFlowRanges = (f, file) => {
407
+ flowRanges.push({
408
+ ruleId: f.ruleId,
409
+ file,
410
+ start: f.sinkNode.startPosition.row + 1,
411
+ end: f.sinkNode.endPosition.row + 1,
412
+ });
413
+ for (const hop of f.path) {
414
+ flowRanges.push({
415
+ ruleId: f.ruleId,
416
+ file: hop.location.file,
417
+ start: hop.location.startLine,
418
+ end: hop.location.endLine,
419
+ });
420
+ }
421
+ };
422
+ for (const flow of trace.flows) {
423
+ if (only && !only.has(flow.ruleId))
424
+ continue;
425
+ const rule = getRule(flow.ruleId);
426
+ if (!rule)
427
+ continue;
428
+ // The sink may be in a different file from the one we are scanning.
429
+ const flowFile = flow.filePath || displayPath;
430
+ const location = nodeLocation(flow.node, flowFile);
431
+ const suppressed = suppressionOnLine(linesOf(flowFile), location.startLine);
432
+ if (suppressed && (suppressed.ruleId === flow.ruleId || suppressed.ruleId === 'all')) {
433
+ suppressions.push({
434
+ file: flowFile,
435
+ line: location.startLine,
436
+ ruleId: flow.ruleId,
437
+ comment: suppressed.comment || '(no reason given)',
438
+ });
439
+ continue;
440
+ }
441
+ const hops = flow.path.length;
442
+ const unmodelled = [...new Set(flow.unknownHops)];
443
+ const unmodelledNote = unmodelled.length > 0
444
+ ? ` THIS PATH PASSES THROUGH ${unmodelled.length} FUNCTION(S) WE DO NOT MODEL ` +
445
+ `(${unmodelled.map((n) => `\`${n}()\``).join(', ')}). We assumed each one preserves ` +
446
+ `the value. If any of them sanitises it, this finding is wrong - check those hops first.`
447
+ : '';
448
+ /*
449
+ * A BRANCH WAS ASSUMED, SO THIS IS A GUESS - AND IT IS STILL REPORTED.
450
+ *
451
+ * The tracer keeps taint through a clean write it cannot prove happens,
452
+ * which is deliberately cautious and right: it errs toward reporting a
453
+ * value that might be clean rather than staying silent about one that
454
+ * might be dirty. What it may not do is hand that assumption to
455
+ * flowVerifiedFinding(), which prints "this is not a pattern guess - the
456
+ * data flow was followed". At that point nothing was followed.
457
+ *
458
+ * Django found it. In contrib/admin/options.py a tainted `msg` is set in
459
+ * an `except` block and a clean literal `msg` is set in an `elif` forty
460
+ * lines later; the sink is reachable only through the second. Seven
461
+ * correct-looking hops, across two files, proving a flow that cannot run.
462
+ *
463
+ * DROPPING IT WAS THE WRONG FIX, and the benchmark said so within a
464
+ * minute: recall fell 89.3% to 75.5%, because 89 real vulnerabilities in
465
+ * BenchmarkJava sit behind exactly this shape and the signature rules did
466
+ * not cover those lines. Silence about a real bug is the failure this
467
+ * scanner cannot detect in itself.
468
+ *
469
+ * So the finding survives with the label it has earned. The path is still
470
+ * printed - a reader can check the branch themselves - and the reasoning
471
+ * says which assumption it rests on.
472
+ */
473
+ /*
474
+ * THE SECOND REASON A TRACE CANNOT CALL ITSELF PROVEN.
475
+ *
476
+ * branchAssumed is "we did not evaluate the branch". containerGuess is
477
+ * "we did not track which element". Both are the same failure in the same
478
+ * place - the engine wrote its own imprecision into the path and then
479
+ * labelled the result proven anyway - so both get the same treatment: the
480
+ * finding survives, the path is still printed, and the label tells the
481
+ * truth about what was and was not followed.
482
+ *
483
+ * Measured on OWASP BenchmarkJava: flow-verified paths that collect into a
484
+ * container scored 50.5% precision against 71.5% for paths that do not.
485
+ * Half the bucket was a coin flip wearing a certificate.
486
+ */
487
+ /*
488
+ * THE THIRD: ESCAPING THAT DEPENDS ON QUOTES WE COULD NOT SEE.
489
+ *
490
+ * The value went through an escape-only function (mysqli_real_escape_string,
491
+ * escapeSql) and was put into the query by something this engine does not
492
+ * read - sprintf, a builder. Inside quotes that escaping is a defence;
493
+ * outside, none. Clearing the line would claim the first, proving it would
494
+ * claim the second, and neither was seen. So it is reported as a guess.
495
+ */
496
+ /*
497
+ * THE FOURTH: A REQUEST THAT CANNOT GO WHERE THE ATTACKER CHOOSES.
498
+ *
499
+ * Server-side request forgery is the attacker picking the server a
500
+ * request goes to. When the value sits after a fixed start of the
501
+ * address, or the code runs in the user's own browser, that choice is
502
+ * not theirs. An AI review of DVWA's proven SSRF called all eight wrong
503
+ * for the first reason, and Juice Shop's Angular services were four more
504
+ * for the second. What is left - part of a path, a request from a
505
+ * browser - can still matter, so it is reported, as what it is.
506
+ */
507
+ if (flow.fixedDestination && !flow.branchAssumed && !flow.containerGuess && !flow.quoteUnknown) {
508
+ const shown = flow.fixedDestination.before.split(CLEAN_PART).join('...');
509
+ const browser = flow.fixedDestination.kind === 'browser';
510
+ const downgraded = signatureFinding({
511
+ ruleId: rule.id,
512
+ ruleName: rule.name,
513
+ cwe: rule.cwe,
514
+ owasp: rule.owasp,
515
+ severity: browser ? 'low' : 'medium',
516
+ message: browser
517
+ ? `Data from \`${flow.origin}\` steers a request the user's own browser makes - not server-side request forgery`
518
+ : `Data from \`${flow.origin}\` sets part of the path of a request whose address is fixed (\`${shown}\`)`,
519
+ location,
520
+ reasoning: browser
521
+ ? `A value from \`${flow.origin}\` reaches ${flow.sinkDescription}, but that source only exists ` +
522
+ `in a web browser, so this code runs in the user's own browser and the request is sent by the ` +
523
+ `user's browser, not by a server. Server-side request forgery needs a server to make the ` +
524
+ `request. Someone who can set this value - through a crafted link, or a script already on the ` +
525
+ `page - can still point the user's own requests somewhere else; check whether that matters here.`
526
+ : `A value from \`${flow.origin}\` reaches ${flow.sinkDescription}, but only AFTER a fixed start ` +
527
+ `of the address: \`${shown}\` (\`...\` is a value from the code, not from the request). The ` +
528
+ `request still goes where the code says, so this is not a request to a server the attacker picks. ` +
529
+ `What the attacker does control is the rest of the path or query: with \`../\` or an encoded ` +
530
+ `slash it may reach another endpoint on the same server, or another file for a file read. ` +
531
+ `Worth a look, not a proof of server-side request forgery.`,
532
+ limitations: rule.limitations,
533
+ });
534
+ const key = dedupeKey(downgraded);
535
+ if (!seenFlows.has(key)) {
536
+ seenFlows.add(key);
537
+ flowFindings.push(downgraded);
538
+ recordFlowRanges(flow, flowFile);
539
+ }
540
+ continue;
541
+ }
542
+ /*
543
+ * A FILE THE PERSON RUNNING THE PROGRAM NAMED.
544
+ *
545
+ * `open(sys.argv[1])` is what a command-line tool is for. Whoever typed
546
+ * the path can already read that file, so proving "path traversal" there
547
+ * would be a confident wrong answer about every CLI and game mod written.
548
+ * It matters only when the program runs with more rights than the person
549
+ * starting it, which this engine cannot see - so it is a question to
550
+ * review, at low severity, not a proof.
551
+ */
552
+ const dotDotCheck = flow.kind === 'path' ? dotDotCheckBefore(flow.sinkNode) : undefined;
553
+ if ((flow.localSource || dotDotCheck) && !flow.branchAssumed && !flow.containerGuess && !flow.quoteUnknown) {
554
+ const downgraded = signatureFinding({
555
+ ruleId: rule.id,
556
+ ruleName: rule.name,
557
+ cwe: rule.cwe,
558
+ owasp: rule.owasp,
559
+ severity: flow.localSource ? 'low' : rule.severity,
560
+ message: flow.localSource
561
+ ? `A file path from \`${flow.origin}\` - chosen by whoever runs this program - is opened`
562
+ : `A file path from \`${flow.origin}\` is opened after a check on it`,
563
+ location,
564
+ reasoning: flow.localSource
565
+ ? `A value from \`${flow.origin}\` reaches ${flow.sinkDescription}, but that source is the ` +
566
+ `program's own command line, environment or terminal. The person who supplies it can usually ` +
567
+ `read that file already, so this is not path traversal by itself. It is one when the program ` +
568
+ `runs with more rights than that person: a setuid binary, a service, a CGI program reading its ` +
569
+ `environment, or a script run on other people's input. Check how this program is started.`
570
+ : /starts?with|HasPrefix|starts_with/i.test(dotDotCheck ?? '')
571
+ ? `A value from \`${flow.origin}\` reaches ${flow.sinkDescription}, and a prefix check ` +
572
+ `(\`${dotDotCheck}\`) runs first. Resolving the path and then checking it still starts with ` +
573
+ `the intended folder is the right fix, so this is not proven - but what the check compares is ` +
574
+ `not read here. Confirm it tests the RESOLVED path against the folder, with a separator after ` +
575
+ `it (\`/srv/files\` also prefixes \`/srv/files-old\`).`
576
+ : NOT_FOUND_EXIT.test(dotDotCheck ?? '')
577
+ ? `A value from \`${flow.origin}\` reaches ${flow.sinkDescription}, but a "not found" answer ` +
578
+ `(\`${dotDotCheck}\`) can end the request first. If that comes from looking the name up in a ` +
579
+ `fixed list, only listed names reach the open and \`../\` never does - but what the lookup ` +
580
+ `accepts is not read here. Confirm it matches whole names exactly, or keep only the file name.`
581
+ : `A value from \`${flow.origin}\` reaches ${flow.sinkDescription}, and \`${dotDotCheck}\` ` +
582
+ `runs first. A check for \`../\` stops the usual climb, so this is not proven. It is not a fix ` +
583
+ `either: it misses \`..\\\` on Windows, a path with no folder in front of it, and a bare ` +
584
+ `trailing \`..\`. Confirm what the check rejects, or keep only the file name instead.`,
585
+ limitations: rule.limitations,
586
+ });
587
+ const key = dedupeKey(downgraded);
588
+ if (!seenFlows.has(key)) {
589
+ seenFlows.add(key);
590
+ flowFindings.push(downgraded);
591
+ recordFlowRanges(flow, flowFile);
592
+ }
593
+ continue;
594
+ }
595
+ /*
596
+ * THE FIFTH: A HOP WHOSE OWN NAME SAYS IT CLEANS THE VALUE.
597
+ *
598
+ * An unmodelled function is assumed to pass the value through - right for
599
+ * decode(), and a coin flip for escape_for_html(). BenchmarkPython runs
600
+ * every printed value through its own HTML escaper (helpers/utils.py),
601
+ * and 60 Flask routes were "proven" XSS through it. A name is no proof
602
+ * that it cleans either, so the finding stays - as a possibility.
603
+ */
604
+ // b64decode(b64encode(x)) is x again: an encode undone later cleaned nothing.
605
+ // The decode may be a function we DO model (Java's decodeBase64), so look
606
+ // for it in the path itself, after the step that encoded.
607
+ const undone = (n) => {
608
+ const inverse = n.replace(/encode/i, 'decode').replace(/escape/i, 'unescape');
609
+ const at = flow.path.findIndex((s) => s.location.snippet.includes(`${n}(`));
610
+ return inverse !== n && at >= 0 && flow.path.slice(at + 1).some((s) => s.location.snippet.includes(`${inverse}(`));
611
+ };
612
+ // An HTML escaper is no defence for pickle or a shell, so it only counts for XSS.
613
+ const cleanByName = unmodelled.find((n) => /(^|[^n])(escape|encode)|saniti[sz]|clean|purif|strip_?tags/i.test(n) && !undone(n) && (rule.id === 'xss' || !/html/i.test(n)));
614
+ if (flow.branchAssumed || flow.containerGuess || flow.quoteUnknown || cleanByName) {
615
+ const downgraded = signatureFinding({
616
+ ruleId: rule.id,
617
+ ruleName: rule.name,
618
+ cwe: rule.cwe,
619
+ owasp: rule.owasp,
620
+ severity: rule.severity,
621
+ message: `Possibly attacker-controlled data from \`${flow.origin}\` reaches ${flow.sinkDescription}`,
622
+ location,
623
+ reasoning: flow.quoteUnknown && !flow.branchAssumed && !flow.containerGuess
624
+ ? `A value from \`${flow.origin}\` reaches ${flow.sinkDescription} after an escaping ` +
625
+ `function that only protects a value placed INSIDE QUOTES in the query. The query ` +
626
+ `is put together somewhere this engine does not read, so whether the value sits ` +
627
+ `inside quotes is not known. If it does, this line is safe; if it does not, it is ` +
628
+ `SQL injection. Check the quotes around it.`
629
+ : cleanByName && !flow.branchAssumed && !flow.containerGuess
630
+ ? `A value from \`${flow.origin}\` reaches ${flow.sinkDescription}, but on the way it passes ` +
631
+ `through \`${cleanByName}()\`, a function this engine does not model and whose name says it ` +
632
+ `escapes or cleans its input. We could not check that it does, so this is not proven; if it ` +
633
+ `does the right escaping for this sink, this line is safe. Read \`${cleanByName}()\` first.`
634
+ : flow.branchAssumed
635
+ ? `A value from \`${flow.origin}\` can reach ${flow.sinkDescription}, but the ` +
636
+ `path depends on a BRANCH THIS ENGINE DOES NOT EVALUATE: somewhere along it a ` +
637
+ `clean assignment sits inside an if/else, and we keep the taint rather than ` +
638
+ `assume the clean write always happens. That is the cautious reading, not a ` +
639
+ `proven one - if the two writes are in mutually exclusive branches, this path ` +
640
+ `cannot actually run. Check the branch before acting on it.`
641
+ : `A value from \`${flow.origin}\` can reach ${flow.sinkDescription}, but the ` +
642
+ `path runs through a COLLECTION THAT TOOK MORE THAN ONE ELEMENT. We taint a ` +
643
+ `container as a whole and do not model which index or key comes back out, so ` +
644
+ `the value printed here may be one of the clean ones that went in. The dirty ` +
645
+ `value really is in that container - this is worth checking - but the trace ` +
646
+ `does not prove it is the one that arrives.`,
647
+ limitations: rule.limitations,
648
+ });
649
+ const key = dedupeKey(downgraded);
650
+ if (!seenFlows.has(key)) {
651
+ seenFlows.add(key);
652
+ flowFindings.push(downgraded);
653
+ /*
654
+ * RECORD THE RANGES EVEN THOUGH NOTHING WAS PROVEN.
655
+ *
656
+ * A downgraded flow and the signature rule that fired on the same
657
+ * line are ONE bug described twice, exactly as in the verified case,
658
+ * and before this the downgraded branch skipped straight past the
659
+ * bookkeeping - so branch-assumed.py has been reporting line 46
660
+ * twice, once from the rule and once from the tracer, for as long as
661
+ * branchAssumed has existed. It was invisible because so few lines hit
662
+ * both paths; containerGuess routes 183 more BenchmarkJava cases
663
+ * through here and would have made it loud.
664
+ *
665
+ * The tracer's version wins because it carries the path: same
666
+ * confidence, strictly more for a reader to check.
667
+ */
668
+ recordFlowRanges(flow, flowFile);
669
+ }
670
+ continue;
671
+ }
672
+ const finding = flowVerifiedFinding({
673
+ unmodelledHops: flow.unknownHops,
674
+ ruleId: rule.id,
675
+ ruleName: rule.name,
676
+ cwe: rule.cwe,
677
+ owasp: rule.owasp,
678
+ severity: rule.severity,
679
+ message: `Attacker-controlled data from \`${flow.origin}\` reaches ${flow.sinkDescription}`,
680
+ location,
681
+ reasoning: `Traced ${hops} step${hops === 1 ? '' : 's'}${new Set(flow.path.map((step) => step.location.file)).size > 1
682
+ ? ' across more than one file'
683
+ : ''}: the value starts at ` +
684
+ `\`${flow.origin}\`, which is attacker-controlled, and arrives at ${flow.sinkDescription} ` +
685
+ `with no sanitiser for a ${flow.kind} sink applied on the way. Every hop is listed below. ` +
686
+ `This is not a pattern guess - the data flow was followed.`,
687
+ limitations: 'FLOW-VERIFIED: the value was followed hop by hop, across function boundaries and - ' +
688
+ 'where imports resolve - across files. Only files included in this scan are resolved: ' +
689
+ 'a call into a third-party package, a dynamic import, or a name several modules define ' +
690
+ 'all end the trace. The tracer identifies sources by expression shape (a `req.query`-' +
691
+ 'looking expression is assumed to be a real request), reads statements in order without ' +
692
+ 'evaluating branch conditions, and does not track the interior of objects. It confirms ' +
693
+ 'the data flow, not exploitability in production - authentication, a WAF or ' +
694
+ 'framework-level escaping may still stand in the way.' + unmodelledNote,
695
+ flowPath: flow.path,
696
+ });
697
+ const key = dedupeKey(finding);
698
+ if (seenFlows.has(key))
699
+ continue;
700
+ seenFlows.add(key);
701
+ flowFindings.push(finding);
702
+ // Record EVERY line the verified flow passes through, not just the sink.
703
+ // `const sql = "..." + userId;` on one line and `db.query(sql)` on the
704
+ // next are one bug, and the signature pass reports the first line while
705
+ // the tracer reports the second. Superseding along the whole path stops
706
+ // the same bug appearing twice at two confidence levels.
707
+ flowRanges.push({
708
+ ruleId: flow.ruleId,
709
+ file: flowFile,
710
+ start: flow.sinkNode.startPosition.row + 1,
711
+ end: flow.sinkNode.endPosition.row + 1,
712
+ });
713
+ for (const hop of flow.path) {
714
+ flowRanges.push({
715
+ ruleId: flow.ruleId,
716
+ file: hop.location.file,
717
+ start: hop.location.startLine,
718
+ end: hop.location.endLine,
719
+ });
720
+ }
721
+ }
722
+ /* ---- the only safe place to let trees go ----
723
+ *
724
+ * Everything above has finished with this file: the rules have run, the
725
+ * tracer has returned, and every finding has been copied into plain data
726
+ * (paths, line numbers, text) rather than nodes. No Node from this file or
727
+ * from any file the tracer descended into is reachable any more, so the
728
+ * pins can be dropped and the store can reclaim whatever it needs to.
729
+ *
730
+ * Releasing anywhere INSIDE the loop body would be a use-after-free waiting
731
+ * for a big enough project to expose it. */
732
+ store.releaseAll();
733
+ }
734
+ // Findings are plain data by now; nothing below reads a syntax node.
735
+ const treeStoreStats = store.stats();
736
+ store.disposeAll();
737
+ /* -------------------------------------------------------------------- *
738
+ * MERGE. A signature finding at a place we have now verified is not a
739
+ * second bug - it is the same bug, guessed at. The verified one wins and
740
+ * the guess is dropped. Everything the tracer could NOT confirm stays
741
+ * exactly as it was, still labelled signature-based. That is the whole
742
+ * point: precision where we earned it, recall everywhere else, and a
743
+ * report that shows you the split.
744
+ * -------------------------------------------------------------------- */
745
+ const supersededBy = (finding) => flowRanges.some((range) => range.ruleId === finding.ruleId &&
746
+ range.file === finding.location.file &&
747
+ finding.location.startLine >= range.start &&
748
+ finding.location.startLine <= range.end);
749
+ const provenClean = (finding) => cleanRanges.some((range) => range.ruleId === finding.ruleId &&
750
+ range.file === finding.location.file &&
751
+ finding.location.startLine >= range.start &&
752
+ finding.location.startLine <= range.end);
753
+ const afterUpgrade = findings.filter((f) => !supersededBy(f));
754
+ const signaturesUpgraded = findings.length - afterUpgrade.length;
755
+ const survivingSignatures = afterUpgrade.filter((f) => !provenClean(f));
756
+ const signaturesRetracted = afterUpgrade.length - survivingSignatures.length;
757
+ const allFindings = [...flowFindings, ...survivingSignatures];
758
+ allFindings.sort((a, b) => {
759
+ const bySeverity = SEVERITY_ORDER[b.severity] - SEVERITY_ORDER[a.severity];
760
+ if (bySeverity !== 0)
761
+ return bySeverity;
762
+ const byFile = a.location.file.localeCompare(b.location.file);
763
+ if (byFile !== 0)
764
+ return byFile;
765
+ return a.location.startLine - b.location.startLine;
766
+ });
767
+ // Participation is derived here, once, from the finished finding list - so
768
+ // the terminal and the browser cannot disagree about which files a flow
769
+ // touches, and neither one has to work it out for itself.
770
+ const roles = new Map();
771
+ const roleFor = (file) => {
772
+ let entry = roles.get(file);
773
+ if (!entry) {
774
+ entry = { reported: 0, via: 0, clean: 0, lines: new Set() };
775
+ roles.set(file, entry);
776
+ }
777
+ return entry;
778
+ };
779
+ for (const finding of allFindings) {
780
+ roleFor(finding.location.file).reported++;
781
+ for (const file of new Set((finding.flowPath ?? []).map((s) => s.location.file))) {
782
+ // Counted only when the finding is reported SOMEWHERE ELSE - otherwise
783
+ // every flow-verified finding would also mark its own file as "on path",
784
+ // which says nothing.
785
+ if (file !== finding.location.file)
786
+ roleFor(file).via++;
787
+ }
788
+ for (const step of finding.flowPath ?? []) {
789
+ roleFor(step.location.file).lines.add(step.location.startLine);
790
+ }
791
+ }
792
+ for (const clean of verifiedClean)
793
+ roleFor(clean.file).clean++;
794
+ const fileRoles = [...roles.entries()]
795
+ .map(([file, entry]) => ({
796
+ file,
797
+ findingsReported: entry.reported,
798
+ onPathOf: entry.via,
799
+ verifiedCleanLines: entry.clean,
800
+ pathLines: [...entry.lines].sort((a, b) => a - b),
801
+ }))
802
+ .sort((a, b) => a.file.localeCompare(b.file));
803
+ return {
804
+ findings: allFindings,
805
+ verifiedClean,
806
+ unreachableCode,
807
+ collectionReadsResolved,
808
+ tracesStoppedAtNewData,
809
+ fileRoles,
810
+ traceLimits,
811
+ stats: {
812
+ filesParsed,
813
+ filesSkipped,
814
+ shapesExamined,
815
+ flowsVerified: flowFindings.length,
816
+ signaturesUpgraded,
817
+ signaturesRetracted,
818
+ signaturesProvedConstant: provedConstantKeys.size,
819
+ regionsProvedUnreachable: unreachableCode.length,
820
+ collectionReadsResolved: collectionReadsResolved.length,
821
+ tracesStoppedAtNewData: tracesStoppedAtNewData.length,
822
+ durationMs: Date.now() - started,
823
+ },
824
+ byLanguage,
825
+ parseProblems,
826
+ suppressions,
827
+ crossFile: projectIndex
828
+ ? { enabled: true, ...projectIndex.stats() }
829
+ : { enabled: false, reason: crossFileOffReason },
830
+ treeMemory: treeStoreStats,
831
+ coverage: buildCoverageReport(),
832
+ };
833
+ }
834
+ //# sourceMappingURL=analyze.js.map