@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,348 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE SHAPE LAYER
|
|
3
|
+
* ===============
|
|
4
|
+
*
|
|
5
|
+
* This is the translator between "five different languages" and "one set of
|
|
6
|
+
* rules". It uses tree-sitter's QUERY LANGUAGE, which is worth understanding
|
|
7
|
+
* because it is the only unfamiliar syntax in this project.
|
|
8
|
+
*
|
|
9
|
+
* A tree-sitter query is a pattern written as nested parentheses that describes
|
|
10
|
+
* a shape of tree, with @names marking the parts you want back:
|
|
11
|
+
*
|
|
12
|
+
* (call_expression
|
|
13
|
+
* function: (member_expression
|
|
14
|
+
* object: (_) @receiver
|
|
15
|
+
* property: (property_identifier) @method)
|
|
16
|
+
* arguments: (arguments) @args) @call
|
|
17
|
+
*
|
|
18
|
+
* Read it as: "find a call_expression; its `function` child must be a
|
|
19
|
+
* member_expression (that's the `a.b` shape); give me whatever is on the left
|
|
20
|
+
* of the dot as @receiver, the name after the dot as @method, the argument list
|
|
21
|
+
* as @args, and the whole call as @call."
|
|
22
|
+
*
|
|
23
|
+
* `(_)` means "any node". Field names before the colon (`function:`,
|
|
24
|
+
* `object:`) are the ROLE labels you saw in the AST dump. Matching on roles
|
|
25
|
+
* instead of child positions is what makes these patterns stable.
|
|
26
|
+
*
|
|
27
|
+
* Every language below gets its own query because the grammars use different
|
|
28
|
+
* node names for the same idea - JavaScript says `call_expression`, Python says
|
|
29
|
+
* `call`, Java says `method_invocation`. The OUTPUT is identical for all five,
|
|
30
|
+
* and that output is what rules see.
|
|
31
|
+
*
|
|
32
|
+
* ADDING A LANGUAGE LATER means adding two query strings to this file. No rule
|
|
33
|
+
* changes.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
import { Query, type Node, type QueryMatch } from 'web-tree-sitter';
|
|
37
|
+
import type { Assignment, CallSite, Shape } from '../rules/contract.js';
|
|
38
|
+
import type { LanguageId } from '../parse/languages.js';
|
|
39
|
+
import { getLanguage, type ParsedFile } from '../parse/parser.js';
|
|
40
|
+
|
|
41
|
+
/* ---------------------------------------------------------------------------
|
|
42
|
+
* CALL SITES: "something is being called with arguments"
|
|
43
|
+
* ------------------------------------------------------------------------ */
|
|
44
|
+
|
|
45
|
+
/** Shared by C and C++, because C++ is a superset of these two shapes. */
|
|
46
|
+
const C_CALL_QUERY = `
|
|
47
|
+
(call_expression
|
|
48
|
+
function: (identifier) @method
|
|
49
|
+
arguments: (argument_list) @args) @call
|
|
50
|
+
(call_expression
|
|
51
|
+
function: (field_expression argument: (_) @receiver field: (field_identifier) @method)
|
|
52
|
+
arguments: (argument_list) @args) @call
|
|
53
|
+
`;
|
|
54
|
+
|
|
55
|
+
const CALL_QUERIES: Record<LanguageId, string> = {
|
|
56
|
+
javascript: `
|
|
57
|
+
(call_expression
|
|
58
|
+
function: (member_expression object: (_) @receiver property: (property_identifier) @method)
|
|
59
|
+
arguments: (arguments) @args) @call
|
|
60
|
+
(call_expression
|
|
61
|
+
function: (identifier) @method
|
|
62
|
+
arguments: (arguments) @args) @call
|
|
63
|
+
(new_expression
|
|
64
|
+
constructor: (_) @method
|
|
65
|
+
arguments: (arguments) @args) @call
|
|
66
|
+
`,
|
|
67
|
+
// TypeScript's grammar is JavaScript's grammar plus types, so the call shapes
|
|
68
|
+
// are byte-for-byte identical. Kept as a separate entry rather than an alias
|
|
69
|
+
// so that a future TS-only pattern has an obvious home.
|
|
70
|
+
typescript: `
|
|
71
|
+
(call_expression
|
|
72
|
+
function: (member_expression object: (_) @receiver property: (property_identifier) @method)
|
|
73
|
+
arguments: (arguments) @args) @call
|
|
74
|
+
(call_expression
|
|
75
|
+
function: (identifier) @method
|
|
76
|
+
arguments: (arguments) @args) @call
|
|
77
|
+
(new_expression
|
|
78
|
+
constructor: (_) @method
|
|
79
|
+
arguments: (arguments) @args) @call
|
|
80
|
+
`,
|
|
81
|
+
python: `
|
|
82
|
+
(call
|
|
83
|
+
function: (attribute object: (_) @receiver attribute: (identifier) @method)
|
|
84
|
+
arguments: (argument_list) @args) @call
|
|
85
|
+
(call
|
|
86
|
+
function: (identifier) @method
|
|
87
|
+
arguments: (argument_list) @args) @call
|
|
88
|
+
`,
|
|
89
|
+
java: `
|
|
90
|
+
(method_invocation
|
|
91
|
+
object: (_) @receiver
|
|
92
|
+
name: (identifier) @method
|
|
93
|
+
arguments: (argument_list) @args) @call
|
|
94
|
+
(method_invocation
|
|
95
|
+
name: (identifier) @method
|
|
96
|
+
arguments: (argument_list) @args) @call
|
|
97
|
+
(object_creation_expression
|
|
98
|
+
type: (_) @method
|
|
99
|
+
arguments: (argument_list) @args) @call
|
|
100
|
+
`,
|
|
101
|
+
go: `
|
|
102
|
+
(call_expression
|
|
103
|
+
function: (selector_expression operand: (_) @receiver field: (field_identifier) @method)
|
|
104
|
+
arguments: (argument_list) @args) @call
|
|
105
|
+
(call_expression
|
|
106
|
+
function: (identifier) @method
|
|
107
|
+
arguments: (argument_list) @args) @call
|
|
108
|
+
`,
|
|
109
|
+
// PHP has three call shapes: a bare function, a method on an object, and a
|
|
110
|
+
// static method on a class. `echo` is deliberately NOT here - it is a
|
|
111
|
+
// statement, not a call, and the XSS rule handles it separately.
|
|
112
|
+
php: `
|
|
113
|
+
(function_call_expression
|
|
114
|
+
function: (name) @method
|
|
115
|
+
arguments: (arguments) @args) @call
|
|
116
|
+
(member_call_expression
|
|
117
|
+
object: (_) @receiver
|
|
118
|
+
name: (name) @method
|
|
119
|
+
arguments: (arguments) @args) @call
|
|
120
|
+
(scoped_call_expression
|
|
121
|
+
scope: (_) @receiver
|
|
122
|
+
name: (name) @method
|
|
123
|
+
arguments: (arguments) @args) @call
|
|
124
|
+
(object_creation_expression
|
|
125
|
+
(name) @method
|
|
126
|
+
(arguments) @args) @call
|
|
127
|
+
`,
|
|
128
|
+
|
|
129
|
+
/*
|
|
130
|
+
* C. Two shapes only, because C has two: a bare call, and a call through a
|
|
131
|
+
* struct member. Every node type and field name below was read off the real
|
|
132
|
+
* grammar with a query probe rather than assumed - the same discipline the
|
|
133
|
+
* PHP entry learned the hard way, when `if_statement` turned out to use
|
|
134
|
+
* `body` where every other grammar uses `consequence`.
|
|
135
|
+
*/
|
|
136
|
+
c: C_CALL_QUERY,
|
|
137
|
+
|
|
138
|
+
/*
|
|
139
|
+
* C++ is C plus namespaces. `std::system(cmd)` parses as a
|
|
140
|
+
* qualified_identifier, NOT an identifier, so a query that only matched the
|
|
141
|
+
* C shapes would miss it - and reaching system() through <cstdlib> is the
|
|
142
|
+
* stylistically CORRECT way to do it in C++, which means the well-written
|
|
143
|
+
* code would be the code that slipped through.
|
|
144
|
+
*/
|
|
145
|
+
cpp: `
|
|
146
|
+
${C_CALL_QUERY}
|
|
147
|
+
(call_expression
|
|
148
|
+
function: (qualified_identifier name: (identifier) @method)
|
|
149
|
+
arguments: (argument_list) @args) @call
|
|
150
|
+
`,
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
/* ---------------------------------------------------------------------------
|
|
154
|
+
* ASSIGNMENTS: "a name is being bound to a value"
|
|
155
|
+
*
|
|
156
|
+
* Deliberately broad: it covers `x = v`, `const x = v`, object/dict entries
|
|
157
|
+
* `{ key: v }`, and keyword arguments `f(key=v)`. Hardcoded credentials hide in
|
|
158
|
+
* all of those, and so does `element.innerHTML = ...`.
|
|
159
|
+
* ------------------------------------------------------------------------ */
|
|
160
|
+
|
|
161
|
+
const JS_ASSIGN_QUERY = `
|
|
162
|
+
(assignment_expression left: (_) @target right: (_) @value) @assign
|
|
163
|
+
(variable_declarator name: (_) @target value: (_) @value) @assign
|
|
164
|
+
(pair key: (_) @target value: (_) @value) @assign
|
|
165
|
+
(augmented_assignment_expression left: (_) @target right: (_) @value) @assign
|
|
166
|
+
`;
|
|
167
|
+
|
|
168
|
+
const C_ASSIGN_QUERY = `
|
|
169
|
+
(assignment_expression left: (_) @target right: (_) @value) @assign
|
|
170
|
+
(init_declarator declarator: (_) @target value: (_) @value) @assign
|
|
171
|
+
`;
|
|
172
|
+
|
|
173
|
+
const ASSIGNMENT_QUERIES: Record<LanguageId, string> = {
|
|
174
|
+
php: `
|
|
175
|
+
(assignment_expression left: (_) @target right: (_) @value) @assign
|
|
176
|
+
(property_element (variable_name) @target (_) @value) @assign
|
|
177
|
+
(echo_statement "echo" @target (_) @value) @assign
|
|
178
|
+
`,
|
|
179
|
+
javascript: JS_ASSIGN_QUERY,
|
|
180
|
+
typescript: `
|
|
181
|
+
${JS_ASSIGN_QUERY}
|
|
182
|
+
(public_field_definition name: (_) @target value: (_) @value) @assign
|
|
183
|
+
`,
|
|
184
|
+
python: `
|
|
185
|
+
(assignment left: (_) @target right: (_) @value) @assign
|
|
186
|
+
(pair key: (_) @target value: (_) @value) @assign
|
|
187
|
+
(keyword_argument name: (identifier) @target value: (_) @value) @assign
|
|
188
|
+
`,
|
|
189
|
+
java: `
|
|
190
|
+
(variable_declarator name: (identifier) @target value: (_) @value) @assign
|
|
191
|
+
(assignment_expression left: (_) @target right: (_) @value) @assign
|
|
192
|
+
`,
|
|
193
|
+
go: `
|
|
194
|
+
(short_var_declaration left: (expression_list) @target right: (expression_list) @value) @assign
|
|
195
|
+
(assignment_statement left: (expression_list) @target right: (expression_list) @value) @assign
|
|
196
|
+
(var_spec name: (identifier) @target value: (expression_list) @value) @assign
|
|
197
|
+
(const_spec name: (identifier) @target value: (expression_list) @value) @assign
|
|
198
|
+
(keyed_element (literal_element (identifier) @target) (literal_element (_) @value)) @assign
|
|
199
|
+
`,
|
|
200
|
+
|
|
201
|
+
/*
|
|
202
|
+
* C and C++ share these. `init_declarator` is the declaration-with-value
|
|
203
|
+
* form (`char *p = getenv(...)`), and its @target captures the DECLARATOR
|
|
204
|
+
* rather than a bare name - so `*name = getenv("USER")` yields a target of
|
|
205
|
+
* `*name`, pointer star included. The tracer strips it; see POINTER_PREFIX
|
|
206
|
+
* in taint/tracer.ts. Recorded here because it is the kind of detail that
|
|
207
|
+
* silently halves a language's coverage if nobody notices it.
|
|
208
|
+
*/
|
|
209
|
+
c: C_ASSIGN_QUERY,
|
|
210
|
+
cpp: C_ASSIGN_QUERY,
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
/* ------------------------------------------------------------------------ */
|
|
214
|
+
|
|
215
|
+
/** Compiled queries are expensive to build and cheap to reuse. Cache per grammar. */
|
|
216
|
+
const queryCache = new Map<string, Query>();
|
|
217
|
+
|
|
218
|
+
async function compile(grammar: string, key: string, source: string): Promise<Query> {
|
|
219
|
+
const cacheKey = `${grammar}:${key}`;
|
|
220
|
+
const cached = queryCache.get(cacheKey);
|
|
221
|
+
if (cached) return cached;
|
|
222
|
+
const language = await getLanguage(grammar);
|
|
223
|
+
const query = new Query(language, source);
|
|
224
|
+
queryCache.set(cacheKey, query);
|
|
225
|
+
return query;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function collapse(text: string | null | undefined): string {
|
|
229
|
+
return (text ?? '').replace(/\s+/g, ' ').trim();
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** Pull one capture out of a match. Returns null when the capture is optional. */
|
|
233
|
+
function captured(match: QueryMatch, name: string): Node | null {
|
|
234
|
+
for (const capture of match.captures) {
|
|
235
|
+
if (capture.name === name) return capture.node;
|
|
236
|
+
}
|
|
237
|
+
return null;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Argument lists contain punctuation as well as expressions. `namedChildren`
|
|
242
|
+
* gives us only the meaningful ones - see the "named vs anonymous" note in
|
|
243
|
+
* astDump.ts.
|
|
244
|
+
*/
|
|
245
|
+
function argumentNodes(argsNode: Node | null): Node[] {
|
|
246
|
+
if (!argsNode) return [];
|
|
247
|
+
return argsNode.namedChildren
|
|
248
|
+
.filter((n): n is Node => n !== null)
|
|
249
|
+
// PHP wraps every argument in an `argument` node, so the expression a rule
|
|
250
|
+
// wants to examine sits one level down. Without this unwrap every PHP
|
|
251
|
+
// argument reads as the node type `argument` and no content check matches.
|
|
252
|
+
.map((n) => (n.type === 'argument' ? (n.namedChildren.find((c) => c !== null) ?? n) : n));
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Go writes `a, b := f(), g()` - both sides are lists. When the lists line up
|
|
257
|
+
* we pair them; when they don't (a multi-return call) we bind every name to the
|
|
258
|
+
* single value node, which is honest enough for signature matching and is
|
|
259
|
+
* flagged as a known imprecision in the Go coverage notes.
|
|
260
|
+
*/
|
|
261
|
+
function goPairs(target: Node, value: Node): Array<{ t: Node; v: Node }> {
|
|
262
|
+
const targets = target.type === 'expression_list' ? target.namedChildren : [target];
|
|
263
|
+
const values = value.type === 'expression_list' ? value.namedChildren : [value];
|
|
264
|
+
const pairs: Array<{ t: Node; v: Node }> = [];
|
|
265
|
+
for (let i = 0; i < targets.length; i++) {
|
|
266
|
+
const t = targets[i];
|
|
267
|
+
if (!t) continue;
|
|
268
|
+
const v = values.length === targets.length ? values[i] : values[0];
|
|
269
|
+
if (v) pairs.push({ t, v });
|
|
270
|
+
}
|
|
271
|
+
return pairs;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** Last dotted segment: "config.db.password" -> "password". */
|
|
275
|
+
function lastSegment(text: string): string {
|
|
276
|
+
const cleaned = text.replace(/\[["']?|["']?\]/g, '.').replace(/\.+$/, '');
|
|
277
|
+
const parts = cleaned.split('.');
|
|
278
|
+
return (parts[parts.length - 1] ?? cleaned).trim();
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Lower one parsed file into the flat list of shapes rules will see.
|
|
283
|
+
* Order is source order, which makes reports read top-to-bottom like the file.
|
|
284
|
+
*/
|
|
285
|
+
export async function extractShapes(file: ParsedFile, language: LanguageId): Promise<Shape[]> {
|
|
286
|
+
const shapes: Shape[] = [];
|
|
287
|
+
const seen = new Set<number>();
|
|
288
|
+
|
|
289
|
+
const callQuery = await compile(file.grammar, 'calls', CALL_QUERIES[language]);
|
|
290
|
+
for (const match of callQuery.matches(file.root)) {
|
|
291
|
+
const call = captured(match, 'call');
|
|
292
|
+
const method = captured(match, 'method');
|
|
293
|
+
if (!call || !method) continue;
|
|
294
|
+
// Java's two method_invocation patterns overlap (object is optional), so the
|
|
295
|
+
// same node can match twice. Dedupe by node id.
|
|
296
|
+
if (seen.has(call.id)) continue;
|
|
297
|
+
seen.add(call.id);
|
|
298
|
+
|
|
299
|
+
const receiver = captured(match, 'receiver');
|
|
300
|
+
const receiverText = collapse(receiver?.text);
|
|
301
|
+
const calleeName = collapse(method.text);
|
|
302
|
+
// Java's `method_invocation` has no `function` field - the receiver and the
|
|
303
|
+
// name are separate children - so we rebuild "receiver.name" by hand there.
|
|
304
|
+
const functionNode = call.childForFieldName('function') ?? call.childForFieldName('constructor');
|
|
305
|
+
const site: CallSite = {
|
|
306
|
+
kind: 'call',
|
|
307
|
+
node: call,
|
|
308
|
+
calleeName,
|
|
309
|
+
receiverText,
|
|
310
|
+
calleeText: collapse(
|
|
311
|
+
functionNode?.text ?? (receiverText ? `${receiverText}.${calleeName}` : calleeName),
|
|
312
|
+
),
|
|
313
|
+
args: argumentNodes(captured(match, 'args')),
|
|
314
|
+
};
|
|
315
|
+
shapes.push(site);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const assignQuery = await compile(file.grammar, 'assignments', ASSIGNMENT_QUERIES[language]);
|
|
319
|
+
for (const match of assignQuery.matches(file.root)) {
|
|
320
|
+
const node = captured(match, 'assign');
|
|
321
|
+
const target = captured(match, 'target');
|
|
322
|
+
const value = captured(match, 'value');
|
|
323
|
+
if (!node || !target || !value) continue;
|
|
324
|
+
if (seen.has(node.id)) continue;
|
|
325
|
+
seen.add(node.id);
|
|
326
|
+
|
|
327
|
+
const pairs = language === 'go' ? goPairs(target, value) : [{ t: target, v: value }];
|
|
328
|
+
for (const pair of pairs) {
|
|
329
|
+
const targetText = collapse(pair.t.text);
|
|
330
|
+
shapes.push({
|
|
331
|
+
kind: 'assignment',
|
|
332
|
+
node,
|
|
333
|
+
targetText,
|
|
334
|
+
targetName: lastSegment(targetText).replace(/^["']|["']$/g, ''),
|
|
335
|
+
value: pair.v,
|
|
336
|
+
} satisfies Assignment);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
shapes.sort((a, b) => a.node.startIndex - b.node.startIndex);
|
|
341
|
+
return shapes;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/** Exposed so a self-check can compile every query against every grammar. */
|
|
345
|
+
export const SHAPE_QUERY_SOURCES = {
|
|
346
|
+
calls: CALL_QUERIES,
|
|
347
|
+
assignments: ASSIGNMENT_QUERIES,
|
|
348
|
+
} as const;
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FILE DISCOVERY
|
|
3
|
+
*
|
|
4
|
+
* Turning "scan this path" into "here is the list of files". Boring code, but
|
|
5
|
+
* two decisions in it matter:
|
|
6
|
+
*
|
|
7
|
+
* 1. We skip dependency and build directories. Scanning node_modules finds
|
|
8
|
+
* thousands of "vulnerabilities" in other people's code that you cannot fix
|
|
9
|
+
* and did not write. Dependency risk is a real and separate problem
|
|
10
|
+
* (Software Composition Analysis) and it deserves its own tool, not noise
|
|
11
|
+
* inside this one.
|
|
12
|
+
*
|
|
13
|
+
* 2. We record the extensions we skipped. That list feeds the coverage report,
|
|
14
|
+
* so a scan of a repo that is 70% Ruby says "we did not look at 412 .rb
|
|
15
|
+
* files" instead of quietly reporting "no issues found".
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { readdir, stat } from 'node:fs/promises';
|
|
19
|
+
import path from 'node:path';
|
|
20
|
+
import { runtimeFileKind } from '../platform/runtime-version.js';
|
|
21
|
+
|
|
22
|
+
const IGNORED_DIRECTORIES = new Set([
|
|
23
|
+
'.git', '.hg', '.svn',
|
|
24
|
+
'node_modules', 'bower_components', 'jspm_packages',
|
|
25
|
+
'dist', 'build', 'out', 'target', 'bin', 'obj',
|
|
26
|
+
'vendor', 'third_party',
|
|
27
|
+
'.venv', 'venv', 'env', '__pycache__', '.tox', 'site-packages', '.mypy_cache',
|
|
28
|
+
'.next', '.nuxt', '.svelte-kit', '.turbo', '.parcel-cache',
|
|
29
|
+
'coverage', '.nyc_output', '.gradle', '.m2',
|
|
30
|
+
'.idea', '.vscode', '.cache', '.pytest_cache',
|
|
31
|
+
]);
|
|
32
|
+
|
|
33
|
+
/** 2 MB. Anything larger is almost always generated, minified or a data blob. */
|
|
34
|
+
const MAX_FILE_BYTES = 2 * 1024 * 1024;
|
|
35
|
+
|
|
36
|
+
export interface WalkResult {
|
|
37
|
+
readonly files: readonly string[];
|
|
38
|
+
/** Extension -> how many files with it we passed over. Feeds the coverage report. */
|
|
39
|
+
readonly skippedByExtension: ReadonlyMap<string, number>;
|
|
40
|
+
readonly oversizedFiles: readonly string[];
|
|
41
|
+
/** How many files --exclude removed. Printed, never silent. */
|
|
42
|
+
readonly excludedCount: number;
|
|
43
|
+
/**
|
|
44
|
+
* Directories --exclude pruned. Counted separately because we stop walking
|
|
45
|
+
* them, so we honestly do not know how many files were inside. Saying
|
|
46
|
+
* "0 files excluded" after skipping a whole tree would be its own small lie.
|
|
47
|
+
*/
|
|
48
|
+
readonly excludedDirectories: readonly string[];
|
|
49
|
+
/**
|
|
50
|
+
* Files that declare which PHP the project runs on - Dockerfiles, compose
|
|
51
|
+
* files, composer.json, `.php-version`. Not source code, so not in `files`;
|
|
52
|
+
* the runtime version check reads them (platform/runtime-version.ts). The
|
|
53
|
+
* same directories are skipped as for code, so a dependency's composer.json
|
|
54
|
+
* under vendor/ is never mistaken for the project's own.
|
|
55
|
+
*/
|
|
56
|
+
readonly runtimeFiles: readonly string[];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface WalkOptions {
|
|
60
|
+
/** Extensions we are able to parse. Everything else is counted and skipped. */
|
|
61
|
+
readonly scannable: readonly string[];
|
|
62
|
+
readonly maxBytes?: number;
|
|
63
|
+
/** Glob-ish patterns from --exclude. */
|
|
64
|
+
readonly exclude?: readonly string[];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Turn a glob into a regular expression.
|
|
69
|
+
*
|
|
70
|
+
* Supported, deliberately a small subset:
|
|
71
|
+
* ** any number of path segments
|
|
72
|
+
* * anything except a path separator
|
|
73
|
+
* ? one character
|
|
74
|
+
* A pattern with no wildcards and no separator (e.g. `vendor`) matches any path
|
|
75
|
+
* SEGMENT with that name, which is what people mean by `--exclude=vendor`.
|
|
76
|
+
*
|
|
77
|
+
* Everything is matched against the path with forward slashes, so the same
|
|
78
|
+
* pattern works on Windows and Unix.
|
|
79
|
+
*/
|
|
80
|
+
export function globToRegExp(pattern: string): RegExp {
|
|
81
|
+
const cleaned = pattern.trim().replace(/\\/g, '/').replace(/\/+$/, '');
|
|
82
|
+
const bare = !/[*?/]/.test(cleaned);
|
|
83
|
+
|
|
84
|
+
const escaped = cleaned
|
|
85
|
+
.replace(/[.+^${}()|[\]\\]/g, '\\$&')
|
|
86
|
+
.replace(/\*\*/g, '\u0000')
|
|
87
|
+
.replace(/\*/g, '[^/]*')
|
|
88
|
+
.replace(/\?/g, '[^/]')
|
|
89
|
+
.replace(/\u0000/g, '.*');
|
|
90
|
+
|
|
91
|
+
// `vendor` -> matches the segment anywhere in the path.
|
|
92
|
+
if (bare) return new RegExp(`(^|/)${escaped}(/|$)`);
|
|
93
|
+
// `src/**/test.js` or `*.spec.ts` -> match anywhere the shape fits.
|
|
94
|
+
return new RegExp(`(^|/)${escaped}$|^${escaped}$`);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export async function collectFiles(target: string, options: WalkOptions): Promise<WalkResult> {
|
|
98
|
+
const scannable = new Set(options.scannable.map((e) => e.toLowerCase()));
|
|
99
|
+
const maxBytes = options.maxBytes ?? MAX_FILE_BYTES;
|
|
100
|
+
const excludePatterns = (options.exclude ?? []).map(globToRegExp);
|
|
101
|
+
let excludedCount = 0;
|
|
102
|
+
const excludedDirectories: string[] = [];
|
|
103
|
+
|
|
104
|
+
/** Matched against the path relative to the scan target, slashes normalised. */
|
|
105
|
+
const isExcluded = (fullPath: string): boolean => {
|
|
106
|
+
if (excludePatterns.length === 0) return false;
|
|
107
|
+
const relative = path.relative(target, fullPath).split(path.sep).join('/');
|
|
108
|
+
return excludePatterns.some((pattern) => pattern.test(relative));
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const files: string[] = [];
|
|
112
|
+
const skipped = new Map<string, number>();
|
|
113
|
+
const oversized: string[] = [];
|
|
114
|
+
const runtimeFiles: string[] = [];
|
|
115
|
+
|
|
116
|
+
const info = await stat(target);
|
|
117
|
+
if (info.isFile()) {
|
|
118
|
+
// An explicitly named file is always scanned - if the user pointed at it,
|
|
119
|
+
// they meant it, even if it is huge or in node_modules.
|
|
120
|
+
files.push(target);
|
|
121
|
+
return {
|
|
122
|
+
files,
|
|
123
|
+
skippedByExtension: skipped,
|
|
124
|
+
oversizedFiles: oversized,
|
|
125
|
+
excludedCount,
|
|
126
|
+
excludedDirectories,
|
|
127
|
+
runtimeFiles: runtimeFileKind(path.basename(target)) ? [target] : [],
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const queue: string[] = [target];
|
|
132
|
+
while (queue.length > 0) {
|
|
133
|
+
const dir = queue.pop();
|
|
134
|
+
if (!dir) break;
|
|
135
|
+
|
|
136
|
+
let entries;
|
|
137
|
+
try {
|
|
138
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
139
|
+
} catch {
|
|
140
|
+
continue; // unreadable directory - permissions, broken symlink, race
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
for (const entry of entries) {
|
|
144
|
+
const full = path.join(dir, entry.name);
|
|
145
|
+
|
|
146
|
+
if (entry.isDirectory()) {
|
|
147
|
+
if (IGNORED_DIRECTORIES.has(entry.name)) continue;
|
|
148
|
+
if (entry.name.startsWith('.') && entry.name !== '.') continue;
|
|
149
|
+
// Pruning an excluded directory here saves walking its whole subtree.
|
|
150
|
+
if (isExcluded(full)) {
|
|
151
|
+
excludedDirectories.push(path.relative(target, full).split(path.sep).join('/'));
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
queue.push(full);
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
if (!entry.isFile()) continue;
|
|
158
|
+
|
|
159
|
+
if (isExcluded(full)) {
|
|
160
|
+
excludedCount++;
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (runtimeFileKind(entry.name)) runtimeFiles.push(full);
|
|
165
|
+
|
|
166
|
+
const ext = path.extname(entry.name).toLowerCase();
|
|
167
|
+
if (!scannable.has(ext)) {
|
|
168
|
+
if (ext) skipped.set(ext, (skipped.get(ext) ?? 0) + 1);
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
try {
|
|
173
|
+
const fileInfo = await stat(full);
|
|
174
|
+
if (fileInfo.size > maxBytes) {
|
|
175
|
+
oversized.push(full);
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
} catch {
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
files.push(full);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
files.sort();
|
|
187
|
+
excludedDirectories.sort();
|
|
188
|
+
return {
|
|
189
|
+
files,
|
|
190
|
+
skippedByExtension: skipped,
|
|
191
|
+
oversizedFiles: oversized,
|
|
192
|
+
excludedCount,
|
|
193
|
+
excludedDirectories,
|
|
194
|
+
runtimeFiles,
|
|
195
|
+
};
|
|
196
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A human-readable printer for syntax trees.
|
|
3
|
+
*
|
|
4
|
+
* This exists purely so you can SEE what the parser sees. Run:
|
|
5
|
+
* npm run ast -- somefile.js
|
|
6
|
+
* and compare the output to the source. Once the shape of the tree stops
|
|
7
|
+
* looking mysterious, writing detection rules becomes ordinary programming.
|
|
8
|
+
*
|
|
9
|
+
* Two concepts show up in the output:
|
|
10
|
+
*
|
|
11
|
+
* NAMED vs ANONYMOUS nodes. `db.query(x)` contains the parentheses and the
|
|
12
|
+
* dot as nodes too, but they carry no meaning of their own - tree-sitter calls
|
|
13
|
+
* those "anonymous". By default we hide them, because rules almost never care.
|
|
14
|
+
*
|
|
15
|
+
* FIELDS. Some children have a role, not just a position. In
|
|
16
|
+
* `binary_expression`, the children are labelled `left`, `operator`, `right`.
|
|
17
|
+
* Fields are how a rule says "the thing being called" instead of "child #0",
|
|
18
|
+
* which is what makes rules survive grammar updates.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import type { Node } from 'web-tree-sitter';
|
|
22
|
+
|
|
23
|
+
export interface AstDumpOptions {
|
|
24
|
+
/** Show punctuation/keyword nodes too. Default false. */
|
|
25
|
+
readonly includeAnonymous?: boolean;
|
|
26
|
+
/** Stop after this depth. Default 12. */
|
|
27
|
+
readonly maxDepth?: number;
|
|
28
|
+
/** Stop after this many lines, so a huge file doesn't flood the terminal. */
|
|
29
|
+
readonly maxLines?: number;
|
|
30
|
+
/** Show the source text of leaf nodes. Default true. */
|
|
31
|
+
readonly showText?: boolean;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function dumpAst(root: Node, options: AstDumpOptions = {}): string {
|
|
35
|
+
const includeAnonymous = options.includeAnonymous ?? false;
|
|
36
|
+
const maxDepth = options.maxDepth ?? 12;
|
|
37
|
+
const maxLines = options.maxLines ?? 400;
|
|
38
|
+
const showText = options.showText ?? true;
|
|
39
|
+
|
|
40
|
+
const lines: string[] = [];
|
|
41
|
+
|
|
42
|
+
const walk = (node: Node, depth: number, fieldName: string | null, prefix: string, isLast: boolean): void => {
|
|
43
|
+
if (lines.length >= maxLines) return;
|
|
44
|
+
|
|
45
|
+
const connector = depth === 0 ? '' : isLast ? '└─ ' : '├─ ';
|
|
46
|
+
const label = fieldName ? `${fieldName}: ${node.type}` : node.type;
|
|
47
|
+
const position = `[${node.startPosition.row + 1}:${node.startPosition.column + 1}]`;
|
|
48
|
+
|
|
49
|
+
// Show source text only for leaves - printing it for a whole function is noise.
|
|
50
|
+
let text = '';
|
|
51
|
+
if (showText && node.namedChildCount === 0) {
|
|
52
|
+
const raw = (node.text ?? '').replace(/\s+/g, ' ').trim();
|
|
53
|
+
if (raw.length > 0) text = ` "${raw.length > 48 ? `${raw.slice(0, 47)}…` : raw}"`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
lines.push(`${prefix}${connector}${label} ${position}${text}`);
|
|
57
|
+
|
|
58
|
+
if (depth >= maxDepth) {
|
|
59
|
+
if (node.namedChildCount > 0) {
|
|
60
|
+
lines.push(`${prefix}${isLast ? ' ' : '│ '}└─ … (${node.namedChildCount} more, depth limit)`);
|
|
61
|
+
}
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const children: Array<{ node: Node; field: string | null }> = [];
|
|
66
|
+
for (let i = 0; i < node.childCount; i++) {
|
|
67
|
+
const child = node.child(i);
|
|
68
|
+
if (!child) continue;
|
|
69
|
+
if (!includeAnonymous && !child.isNamed) continue;
|
|
70
|
+
children.push({ node: child, field: node.fieldNameForChild(i) });
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const childPrefix = depth === 0 ? '' : prefix + (isLast ? ' ' : '│ ');
|
|
74
|
+
children.forEach((child, index) => {
|
|
75
|
+
walk(child.node, depth + 1, child.field, childPrefix, index === children.length - 1);
|
|
76
|
+
});
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
walk(root, 0, null, '', true);
|
|
80
|
+
|
|
81
|
+
if (lines.length >= maxLines) {
|
|
82
|
+
lines.push(`… output truncated at ${maxLines} lines (use --max-lines to raise)`);
|
|
83
|
+
}
|
|
84
|
+
return lines.join('\n');
|
|
85
|
+
}
|