@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,774 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE PROJECT INDEX (cross-file resolution)
|
|
3
|
+
* ==========================================
|
|
4
|
+
*
|
|
5
|
+
* Up to now the tracer could follow a value anywhere inside one file and no
|
|
6
|
+
* further. `helper(dirty)` where `helper` lives in `./helpers.js` was the end of
|
|
7
|
+
* the road, and on real code that is where most of the road is: applications put
|
|
8
|
+
* their database access in `db.js`, their escaping in `util/html.py`, their
|
|
9
|
+
* command runners in `Shell.java`.
|
|
10
|
+
*
|
|
11
|
+
* This module answers one question: **"file F calls a function named N - which
|
|
12
|
+
* file, if any, is that?"**
|
|
13
|
+
*
|
|
14
|
+
* THE RULE IT PLAYS BY: answer only when the answer is unambiguous.
|
|
15
|
+
*
|
|
16
|
+
* If two imported modules both define `sanitize`, we say nothing and record
|
|
17
|
+
* that we declined. Picking one at random would let the tool print a
|
|
18
|
+
* step-by-step "proof" through a function that never runs - which is worse than
|
|
19
|
+
* printing nothing, because it looks like evidence. The count of declined
|
|
20
|
+
* resolutions is reported in every scan.
|
|
21
|
+
*
|
|
22
|
+
* HOW IMPORTS ARE FOUND, per language:
|
|
23
|
+
*
|
|
24
|
+
* JS/TS `import x from './a'` and `require('./a')`, resolved relative to the
|
|
25
|
+
* importing file, trying the usual extensions and `/index`.
|
|
26
|
+
* Python `from .a import x`, `from pkg.a import x`, `import pkg.a` - resolved
|
|
27
|
+
* against the files actually present in the scan.
|
|
28
|
+
* Java `import com.x.Bar;` resolves to a file named `Bar.java`, PLUS every
|
|
29
|
+
* file in the same directory, because Java classes in the same
|
|
30
|
+
* package see each other with no import at all.
|
|
31
|
+
* Go every file in the same directory, because that is one package. Go
|
|
32
|
+
* imports name packages rather than files, and resolving those needs
|
|
33
|
+
* module metadata we do not read.
|
|
34
|
+
*
|
|
35
|
+
* WHAT IT DOES NOT DO, stated here and in the scan report:
|
|
36
|
+
* - It does not check that the function is actually EXPORTED. A module-private
|
|
37
|
+
* helper with the same name as an exported one could be entered.
|
|
38
|
+
* - It does not follow re-exports (`export * from './a'`).
|
|
39
|
+
* - It does not resolve node_modules, third-party packages, or aliases from
|
|
40
|
+
* tsconfig `paths` / webpack config.
|
|
41
|
+
* - Dynamic imports and computed requires are invisible.
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
import type { Node } from 'web-tree-sitter';
|
|
45
|
+
import { basenameOf, dirnameOf, joinPath, resolveFrom, toPosix } from '../core/paths.js';
|
|
46
|
+
import type { LanguageId } from '../parse/languages.js';
|
|
47
|
+
import { nodeAtRange, type TreeStore } from '../parse/tree-store.js';
|
|
48
|
+
import { collectFunctions, type CrossFileResolver } from './tracer.js';
|
|
49
|
+
import { hasModifier } from './java-scope.js';
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* A function recorded as COORDINATES rather than as a syntax node.
|
|
53
|
+
*
|
|
54
|
+
* This is the change that lets the memory ceiling go away. The index used to
|
|
55
|
+
* hold a live Node for every function body in the project, and a Node keeps its
|
|
56
|
+
* whole tree alive - so indexing the project pinned every tree in it, whether
|
|
57
|
+
* or not anything ever traced into them. Holding two integers and a string
|
|
58
|
+
* instead lets the trees be freed, at the cost of parsing a file again on the
|
|
59
|
+
* rare occasion the tracer actually descends into it.
|
|
60
|
+
*
|
|
61
|
+
* bodyType is recorded alongside the offsets because a byte range alone is not
|
|
62
|
+
* always unique: a node can share its exact extent with its parent. The type is
|
|
63
|
+
* what tells them apart, and a lookup that cannot match both is refused.
|
|
64
|
+
*/
|
|
65
|
+
export interface IndexedFunction {
|
|
66
|
+
readonly name: string;
|
|
67
|
+
readonly params: readonly string[];
|
|
68
|
+
/** The argument range the function accepts - see LocalFunction.minArgs. */
|
|
69
|
+
readonly minArgs?: number | undefined;
|
|
70
|
+
readonly maxArgs?: number | undefined;
|
|
71
|
+
readonly bodyStart: number;
|
|
72
|
+
readonly bodyEnd: number;
|
|
73
|
+
readonly bodyType: string;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* What buildProjectIndex needs from each file. Everything here is extracted
|
|
78
|
+
* while the file's tree is hot during the first pass, so the index itself never
|
|
79
|
+
* touches a tree and never holds one.
|
|
80
|
+
*/
|
|
81
|
+
export interface IndexedFileInput {
|
|
82
|
+
readonly path: string;
|
|
83
|
+
readonly language: LanguageId;
|
|
84
|
+
readonly functions: ReadonlyMap<string, IndexedFunction>;
|
|
85
|
+
/** Raw module specifiers, already read out of the tree. */
|
|
86
|
+
readonly importSpecifiers: readonly string[];
|
|
87
|
+
/** Java only - see javaTypeFacts(). */
|
|
88
|
+
readonly javaTypes?: JavaTypeFacts | undefined;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* What a Java file says about the types it declares and names, read from the
|
|
93
|
+
* top level of its tree in the first pass. staticMethod() below needs these to
|
|
94
|
+
* answer "which class does `Utils` mean here?" without holding the tree.
|
|
95
|
+
*/
|
|
96
|
+
export interface JavaTypeFacts {
|
|
97
|
+
/** From `package a.b;` - the empty string for the unnamed package. */
|
|
98
|
+
readonly packageName: string;
|
|
99
|
+
/** Top-level type declarations of every kind - class, interface, enum, record, annotation. */
|
|
100
|
+
readonly types: readonly { readonly name: string; readonly kind: string }[];
|
|
101
|
+
/** `import a.b.C;` as `a.b.C`. Not `import a.b.*;`, not `import static`. */
|
|
102
|
+
readonly singleTypeImports: readonly string[];
|
|
103
|
+
/** The last name of each `import static a.b.C.m;` - a member that is then in scope by that name. */
|
|
104
|
+
readonly staticImports: readonly string[];
|
|
105
|
+
/** Whether any `import static a.b.C.*;` brings in members nobody here can list. */
|
|
106
|
+
readonly staticOnDemand: boolean;
|
|
107
|
+
/** Every type declared anywhere in the file, nested ones included. */
|
|
108
|
+
readonly allTypeNames: readonly string[];
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const JAVA_TYPE_DECLARATIONS = new Set([
|
|
112
|
+
'class_declaration', 'interface_declaration', 'enum_declaration', 'record_declaration',
|
|
113
|
+
'annotation_type_declaration',
|
|
114
|
+
]);
|
|
115
|
+
|
|
116
|
+
export function javaTypeFacts(root: Node): JavaTypeFacts {
|
|
117
|
+
/*
|
|
118
|
+
* A dotted name read from its identifiers, not its text - so a comment
|
|
119
|
+
* between `import` and the name is not part of the name. A regex over the
|
|
120
|
+
* line missed such an import entirely, and the class it named lost to one in
|
|
121
|
+
* the same package that the import outranks (tests/probes/p08).
|
|
122
|
+
*/
|
|
123
|
+
const dotted = (n: Node | null | undefined): string => {
|
|
124
|
+
const parts: string[] = [];
|
|
125
|
+
const visit = (x: Node): void => {
|
|
126
|
+
if (x.type === 'identifier') parts.push(x.text ?? '');
|
|
127
|
+
for (const c of x.namedChildren) if (c && c.type !== 'block_comment' && c.type !== 'line_comment') visit(c);
|
|
128
|
+
};
|
|
129
|
+
if (n) visit(n);
|
|
130
|
+
return parts.join('.');
|
|
131
|
+
};
|
|
132
|
+
let packageName = '';
|
|
133
|
+
const types: { name: string; kind: string }[] = [];
|
|
134
|
+
const singleTypeImports: string[] = [];
|
|
135
|
+
const staticImports: string[] = [];
|
|
136
|
+
let staticOnDemand = false;
|
|
137
|
+
for (const child of root.namedChildren) {
|
|
138
|
+
if (!child) continue;
|
|
139
|
+
if (child.type === 'package_declaration') {
|
|
140
|
+
packageName = dotted(child.namedChildren.find((c) => c?.type === 'scoped_identifier' || c?.type === 'identifier'));
|
|
141
|
+
} else if (JAVA_TYPE_DECLARATIONS.has(child.type)) {
|
|
142
|
+
const name = child.childForFieldName('name')?.text;
|
|
143
|
+
if (name) types.push({ name, kind: child.type });
|
|
144
|
+
} else if (child.type === 'import_declaration') {
|
|
145
|
+
const isStatic = child.children.some((c) => c?.type === 'static');
|
|
146
|
+
const onDemand = child.namedChildren.some((c) => c?.type === 'asterisk');
|
|
147
|
+
const name = dotted(child.namedChildren.find((c) => c?.type === 'scoped_identifier' || c?.type === 'identifier'));
|
|
148
|
+
if (isStatic && onDemand) staticOnDemand = true;
|
|
149
|
+
else if (isStatic) staticImports.push(name.split('.').pop() ?? '');
|
|
150
|
+
else if (!onDemand && name) singleTypeImports.push(name);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
/*
|
|
154
|
+
* Nested types count for "is this type in the scan?": a superclass that is
|
|
155
|
+
* a nested class of this or another file is as able to supply members as a
|
|
156
|
+
* top-level one (tests/probes/j05, j06).
|
|
157
|
+
*/
|
|
158
|
+
const allTypeNames = root
|
|
159
|
+
.descendantsOfType([...JAVA_TYPE_DECLARATIONS])
|
|
160
|
+
.map((n) => n?.childForFieldName('name')?.text ?? '')
|
|
161
|
+
.filter(Boolean);
|
|
162
|
+
return { packageName, types, singleTypeImports, staticImports, staticOnDemand, allTypeNames };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Turn a file's functions into coordinates, using the tracer's own definition. */
|
|
166
|
+
export function indexFunctions(
|
|
167
|
+
root: Node,
|
|
168
|
+
language: LanguageId,
|
|
169
|
+
): Map<string, IndexedFunction> {
|
|
170
|
+
const indexed = new Map<string, IndexedFunction>();
|
|
171
|
+
for (const [name, fn] of collectFunctions(root, language)) {
|
|
172
|
+
indexed.set(name, {
|
|
173
|
+
name: fn.name,
|
|
174
|
+
params: fn.params,
|
|
175
|
+
minArgs: fn.minArgs,
|
|
176
|
+
maxArgs: fn.maxArgs,
|
|
177
|
+
bodyStart: fn.body.startIndex,
|
|
178
|
+
bodyEnd: fn.body.endIndex,
|
|
179
|
+
bodyType: fn.body.type,
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
return indexed;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
interface IndexedFile {
|
|
186
|
+
/** The path exactly as the shell gave it to us - what reports will show. */
|
|
187
|
+
readonly path: string;
|
|
188
|
+
/** The same path, slashes normalised. All comparisons use this one. */
|
|
189
|
+
readonly posix: string;
|
|
190
|
+
readonly directory: string;
|
|
191
|
+
readonly language: LanguageId;
|
|
192
|
+
readonly functions: ReadonlyMap<string, IndexedFunction>;
|
|
193
|
+
/** Absolute paths of files this one imports, as far as we could resolve them. */
|
|
194
|
+
readonly imports: Set<string>;
|
|
195
|
+
readonly javaTypes?: JavaTypeFacts | undefined;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export interface ProjectIndexStats {
|
|
199
|
+
readonly filesIndexed: number;
|
|
200
|
+
readonly functionsIndexed: number;
|
|
201
|
+
readonly importEdges: number;
|
|
202
|
+
/**
|
|
203
|
+
* Import statements we READ but could not point at a file in this scan.
|
|
204
|
+
*
|
|
205
|
+
* Counted because it is the difference between "this code imports nothing"
|
|
206
|
+
* and "this code imports 117 things and you gave me none of them". A beginner
|
|
207
|
+
* scanned juice-shop's server.ts on its own, got zero findings, and concluded
|
|
208
|
+
* the tool was broken. It was not: server.ts is a wiring file whose 117
|
|
209
|
+
* imports all pointed outside the scan, so every trace stopped at the first
|
|
210
|
+
* module boundary. The same repository's routes/ directory produces seven
|
|
211
|
+
* findings, two of them flow-verified.
|
|
212
|
+
*
|
|
213
|
+
* The report had the evidence - importEdges was 0 - and never said what it
|
|
214
|
+
* meant. An undeclared blind spot is exactly what this project refuses to
|
|
215
|
+
* ship, so it is declared now.
|
|
216
|
+
*/
|
|
217
|
+
readonly importsUnresolved: number;
|
|
218
|
+
/** Times a cross-file call WAS followed. */
|
|
219
|
+
readonly resolved: number;
|
|
220
|
+
/** Times several files defined the name and we refused to choose. */
|
|
221
|
+
readonly ambiguous: number;
|
|
222
|
+
/**
|
|
223
|
+
* Times a function was found in the index but could not be located again in
|
|
224
|
+
* the re-parsed tree, and the resolution was declined rather than guessed.
|
|
225
|
+
*
|
|
226
|
+
* This should be zero forever: the parse is deterministic, so a node recorded
|
|
227
|
+
* at bytes 400-980 as a `block` is a `block` at bytes 400-980 every time the
|
|
228
|
+
* same file is parsed with the same grammar. It is counted anyway, because
|
|
229
|
+
* "should be impossible" is how a tool ends up tracing into the wrong
|
|
230
|
+
* function and printing a confident proof of it.
|
|
231
|
+
*/
|
|
232
|
+
readonly relocationsFailed: number;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export interface ProjectIndex extends CrossFileResolver {
|
|
236
|
+
readonly stats: () => ProjectIndexStats;
|
|
237
|
+
/**
|
|
238
|
+
* The one static method `receiver.method(...)` can run, from a Java file -
|
|
239
|
+
* or null whenever that is in any doubt. See the note in the body.
|
|
240
|
+
*/
|
|
241
|
+
readonly staticMethod: (
|
|
242
|
+
fromPath: string,
|
|
243
|
+
receiver: string,
|
|
244
|
+
method: string,
|
|
245
|
+
/** Simple names of the types the call sits inside inherit from - see strings.ts. */
|
|
246
|
+
supertypes?: readonly string[],
|
|
247
|
+
/** The caller created the object with `new X(...)`, so an instance method is exact too. */
|
|
248
|
+
allowInstance?: boolean,
|
|
249
|
+
) => { readonly path: string; readonly method: Node } | null;
|
|
250
|
+
/** Is a Java type of this simple name - top-level or nested - declared anywhere in the scan? */
|
|
251
|
+
readonly javaTypeInScan: (simpleName: string) => boolean;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/* -------------------------------------------------------------------------- *
|
|
255
|
+
* Import extraction - from the syntax tree, not from a text search, for the
|
|
256
|
+
* same reason the rules use the tree: a `require('./db')` inside a comment or a
|
|
257
|
+
* string is not an import.
|
|
258
|
+
* -------------------------------------------------------------------------- */
|
|
259
|
+
|
|
260
|
+
const IMPORT_NODES: Record<LanguageId, readonly string[]> = {
|
|
261
|
+
javascript: ['import_statement', 'call_expression'],
|
|
262
|
+
typescript: ['import_statement', 'call_expression'],
|
|
263
|
+
python: ['import_statement', 'import_from_statement'],
|
|
264
|
+
/*
|
|
265
|
+
* `scoped_type_identifier` is here because an import statement is not the
|
|
266
|
+
* only way Java names another file.
|
|
267
|
+
*
|
|
268
|
+
* new org.owasp.benchmark.helpers.SeparateClassRequest(request)
|
|
269
|
+
*
|
|
270
|
+
* names a class as precisely as any import does and produces no
|
|
271
|
+
* import_declaration at all, so the edge was never built and the call was
|
|
272
|
+
* never followed.
|
|
273
|
+
*
|
|
274
|
+
* This was not a corner. It was EVERY ONE of the 69 cases the OWASP
|
|
275
|
+
* benchmark scored as a complete miss - 48 xss, 20 cmdi, 1 sqli, all of them
|
|
276
|
+
* a request wrapped in a helper class referenced by its full name. Proven by
|
|
277
|
+
* adding a single import line to one test case and changing nothing else,
|
|
278
|
+
* which turned a silent file into a finding.
|
|
279
|
+
*
|
|
280
|
+
* A complete miss is the only failure mode that gets somebody hacked, and
|
|
281
|
+
* this one was a hundred percent of ours.
|
|
282
|
+
*/
|
|
283
|
+
java: ['import_declaration', 'scoped_type_identifier'],
|
|
284
|
+
go: [],
|
|
285
|
+
// PHP's require/include are function-like statements, and Composer autoloading
|
|
286
|
+
// resolves classes with no import line at all. Cross-file tracing in PHP is
|
|
287
|
+
// therefore NOT implemented - stated here rather than half-attempted.
|
|
288
|
+
php: [],
|
|
289
|
+
/*
|
|
290
|
+
* C and C++ resolve calls at LINK time, not through anything written in the
|
|
291
|
+
* source, and that is a harder problem than it first looks.
|
|
292
|
+
*
|
|
293
|
+
* `#include` brings in DECLARATIONS, not definitions - the body of the
|
|
294
|
+
* function lives in a .c file the header never names, chosen by a Makefile
|
|
295
|
+
* we do not read. So following an include would lead to a prototype and stop.
|
|
296
|
+
*
|
|
297
|
+
* Same-directory resolution, which is what makes Java and Go work above, is
|
|
298
|
+
* actively unsafe here: `static` gives a C function FILE scope, and every
|
|
299
|
+
* large C project has a dozen different `static int init(void)` definitions
|
|
300
|
+
* sitting in the same folder. Matching by name would let the tracer walk into
|
|
301
|
+
* a function the caller provably cannot see, and print a step-by-step proof
|
|
302
|
+
* through it. That is the exact failure this project exists to refuse, so
|
|
303
|
+
* cross-file tracing in the C family is NOT implemented and says so.
|
|
304
|
+
*/
|
|
305
|
+
c: [],
|
|
306
|
+
cpp: [],
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
function unquote(raw: string): string {
|
|
310
|
+
return raw.replace(/^[a-zA-Z]?["'`]/, '').replace(/["'`]$/, '');
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** The raw module specifiers this file mentions, e.g. "./db", "app.helpers". */
|
|
314
|
+
export function importSpecifiers(root: Node, language: LanguageId): string[] {
|
|
315
|
+
const wanted = new Set(IMPORT_NODES[language]);
|
|
316
|
+
if (wanted.size === 0) return [];
|
|
317
|
+
const specifiers: string[] = [];
|
|
318
|
+
|
|
319
|
+
/*
|
|
320
|
+
* Six levels is enough for every language whose edges are declarations at
|
|
321
|
+
* the top of the file. Java's are not: a fully-qualified type reference sits
|
|
322
|
+
* inside a method body, which is twenty-odd levels down, so the walk stopped
|
|
323
|
+
* short of the very thing it needed to see.
|
|
324
|
+
*
|
|
325
|
+
* 48 is past any hand-written nesting and still far under the tracer's own
|
|
326
|
+
* MAX_AST_DEPTH of 400, and it applies only to Java - nothing else pays for
|
|
327
|
+
* it.
|
|
328
|
+
*/
|
|
329
|
+
const maxDepth = language === 'java' ? 48 : 6;
|
|
330
|
+
|
|
331
|
+
const visit = (node: Node, depth: number): void => {
|
|
332
|
+
if (depth > maxDepth) return;
|
|
333
|
+
|
|
334
|
+
if (wanted.has(node.type)) {
|
|
335
|
+
if (node.type === 'scoped_type_identifier') {
|
|
336
|
+
/*
|
|
337
|
+
* `a.b.C` parses as scoped_type_identifier nested inside
|
|
338
|
+
* scoped_type_identifier: the whole name, then `a.b`, then `a`. Only
|
|
339
|
+
* the outermost is the class being named; the inner ones are package
|
|
340
|
+
* fragments and would each try to resolve as a class of their own.
|
|
341
|
+
*
|
|
342
|
+
* The resolver below already requires exactly one file to match the
|
|
343
|
+
* final segment, so a fragment that matches nothing is harmless and a
|
|
344
|
+
* name that matches two files is declined rather than guessed. This
|
|
345
|
+
* check is about not asking the question, rather than about surviving
|
|
346
|
+
* the answer.
|
|
347
|
+
*/
|
|
348
|
+
if (node.parent?.type !== 'scoped_type_identifier') {
|
|
349
|
+
specifiers.push((node.text ?? '').trim());
|
|
350
|
+
}
|
|
351
|
+
} else if (node.type === 'call_expression') {
|
|
352
|
+
// require('./db') - only when the callee really is `require`.
|
|
353
|
+
const callee = node.childForFieldName('function');
|
|
354
|
+
if (callee?.text === 'require') {
|
|
355
|
+
const args = node.childForFieldName('arguments');
|
|
356
|
+
const first = args?.namedChildren.find((c): c is Node => c !== null);
|
|
357
|
+
if (first) specifiers.push(unquote(first.text ?? ''));
|
|
358
|
+
}
|
|
359
|
+
} else if (
|
|
360
|
+
node.type === 'import_from_statement' &&
|
|
361
|
+
/^\.+$/.test(node.childForFieldName('module_name')?.text ?? '')
|
|
362
|
+
) {
|
|
363
|
+
/*
|
|
364
|
+
* `from . import runner, shell` names no module in the `from` part: the
|
|
365
|
+
* imported NAMES are the modules (runner.py, shell/__init__.py). Read
|
|
366
|
+
* as just ".", it resolved to the package folder and never to a file -
|
|
367
|
+
* so every edge in a package written this way was lost. One specifier
|
|
368
|
+
* per name, `.runner`, resolves them like `from .runner import x`.
|
|
369
|
+
* ponytail: a name defined in the package's own __init__.py rather than
|
|
370
|
+
* a module of that name stays unresolved; add a fallback when it shows.
|
|
371
|
+
*/
|
|
372
|
+
const dots = node.childForFieldName('module_name')?.text ?? '.';
|
|
373
|
+
const names = node.childrenForFieldName('name').filter((n): n is Node => n !== null);
|
|
374
|
+
if (names.length === 0) specifiers.push(dots); // `from . import *`: the package itself
|
|
375
|
+
for (const name of names) {
|
|
376
|
+
const module = name.type === 'aliased_import' ? name.childForFieldName('name') : name;
|
|
377
|
+
if (module?.text) specifiers.push(`${dots}${module.text}`);
|
|
378
|
+
}
|
|
379
|
+
} else {
|
|
380
|
+
const source =
|
|
381
|
+
node.childForFieldName('source') ??
|
|
382
|
+
node.childForFieldName('module_name') ??
|
|
383
|
+
node.childForFieldName('name') ??
|
|
384
|
+
null;
|
|
385
|
+
const raw = (source ?? node).text ?? '';
|
|
386
|
+
specifiers.push(unquote(raw.replace(/^import\s+|;$/g, '').trim()));
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
for (const child of node.namedChildren) if (child) visit(child, depth + 1);
|
|
391
|
+
};
|
|
392
|
+
visit(root, 0);
|
|
393
|
+
return specifiers.filter(Boolean);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
const JS_EXTENSIONS = ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.mts', '.cts'];
|
|
397
|
+
|
|
398
|
+
/** Turn a specifier into a path we actually have, or null. */
|
|
399
|
+
/*
|
|
400
|
+
* Every known path by its file name, built once per scan. Java resolves each
|
|
401
|
+
* `a.b.C` by looking for C.java, and BenchmarkJava names a class in full
|
|
402
|
+
* thousands of times - filtering all 2,766 paths for every one of those was
|
|
403
|
+
* the single largest cost in its scan.
|
|
404
|
+
*/
|
|
405
|
+
const basenameIndex = new WeakMap<ReadonlySet<string>, Map<string, string[]>>();
|
|
406
|
+
function byBasename(known: ReadonlySet<string>): Map<string, string[]> {
|
|
407
|
+
let index = basenameIndex.get(known);
|
|
408
|
+
if (!index) {
|
|
409
|
+
index = new Map();
|
|
410
|
+
for (const p of known) {
|
|
411
|
+
const name = basenameOf(p);
|
|
412
|
+
const list = index.get(name);
|
|
413
|
+
if (list) list.push(p);
|
|
414
|
+
else index.set(name, [p]);
|
|
415
|
+
}
|
|
416
|
+
basenameIndex.set(known, index);
|
|
417
|
+
}
|
|
418
|
+
return index;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function resolveSpecifier(
|
|
422
|
+
specifier: string,
|
|
423
|
+
fromFile: IndexedFile,
|
|
424
|
+
known: ReadonlySet<string>,
|
|
425
|
+
): string | null {
|
|
426
|
+
const tryAll = (base: string): string | null => {
|
|
427
|
+
if (known.has(base)) return base;
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* TypeScript's ESM convention: source says `import './languages.js'` but the
|
|
431
|
+
* file on disk is `languages.ts`. The compiler rewrites nothing, so a
|
|
432
|
+
* resolver that only tries the literal specifier finds nothing - which is
|
|
433
|
+
* exactly what happened the first time Defuse scanned its own source and
|
|
434
|
+
* reported zero import edges.
|
|
435
|
+
*/
|
|
436
|
+
const rewritten = base.replace(/\.(js|mjs|cjs)$/, '');
|
|
437
|
+
if (rewritten !== base) {
|
|
438
|
+
for (const extension of JS_EXTENSIONS) {
|
|
439
|
+
if (known.has(rewritten + extension)) return rewritten + extension;
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
for (const extension of JS_EXTENSIONS) {
|
|
444
|
+
if (known.has(base + extension)) return base + extension;
|
|
445
|
+
if (known.has(joinPath(base, `index${extension}`))) {
|
|
446
|
+
return joinPath(base, `index${extension}`);
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
return null;
|
|
450
|
+
};
|
|
451
|
+
|
|
452
|
+
if (fromFile.language === 'javascript' || fromFile.language === 'typescript') {
|
|
453
|
+
// Only relative specifiers. A bare 'express' is a package, not our code.
|
|
454
|
+
if (!specifier.startsWith('.')) return null;
|
|
455
|
+
return tryAll(resolveFrom(fromFile.directory, specifier));
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
if (fromFile.language === 'python') {
|
|
459
|
+
const leadingDots = /^\.+/.exec(specifier)?.[0].length ?? 0;
|
|
460
|
+
const body = specifier.slice(leadingDots).replace(/\./g, '/');
|
|
461
|
+
// A module is a file (`shell.py`) or a package folder (`shell/__init__.py`).
|
|
462
|
+
if (leadingDots > 0) {
|
|
463
|
+
let base = fromFile.directory;
|
|
464
|
+
for (let i = 1; i < leadingDots; i++) base = dirnameOf(base);
|
|
465
|
+
const candidate = body ? joinPath(base, body) : base;
|
|
466
|
+
if (body && known.has(`${candidate}.py`)) return `${candidate}.py`;
|
|
467
|
+
return known.has(`${candidate}/__init__.py`) ? `${candidate}/__init__.py` : null;
|
|
468
|
+
}
|
|
469
|
+
// Absolute-ish: match any known file whose path ends with the module path.
|
|
470
|
+
const asFile = `${body}.py`;
|
|
471
|
+
const asPackage = `${body}/__init__.py`;
|
|
472
|
+
const matches = [...known].filter(
|
|
473
|
+
(p) => p === asFile || p === asPackage || p.endsWith(`/${asFile}`) || p.endsWith(`/${asPackage}`),
|
|
474
|
+
);
|
|
475
|
+
return matches.length === 1 ? (matches[0] ?? null) : null;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
if (fromFile.language === 'java') {
|
|
479
|
+
// `com.example.Helper` -> a file called Helper.java, if there is exactly one.
|
|
480
|
+
const className = specifier.split('.').pop() ?? '';
|
|
481
|
+
if (!className || className === '*') return null;
|
|
482
|
+
const matches = byBasename(known).get(`${className}.java`) ?? [];
|
|
483
|
+
return matches.length === 1 ? (matches[0] ?? null) : null;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
return null;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/**
|
|
490
|
+
* @param files one record per scanned file, with functions and import
|
|
491
|
+
* specifiers ALREADY extracted from its tree.
|
|
492
|
+
* @param store where trees come from. Only touched when a resolution actually
|
|
493
|
+
* succeeds, which on real projects is a small fraction of files -
|
|
494
|
+
* the index itself is built without a single tree in hand.
|
|
495
|
+
*/
|
|
496
|
+
export function buildProjectIndex(
|
|
497
|
+
files: readonly IndexedFileInput[],
|
|
498
|
+
store: TreeStore,
|
|
499
|
+
): ProjectIndex {
|
|
500
|
+
const byPath = new Map<string, IndexedFile>();
|
|
501
|
+
// Resolution happens in normalised space; lookups map back to the original.
|
|
502
|
+
const known = new Set(files.map((f) => toPosix(f.path)));
|
|
503
|
+
const originalOf = new Map(files.map((f) => [toPosix(f.path), f.path]));
|
|
504
|
+
|
|
505
|
+
for (const file of files) {
|
|
506
|
+
byPath.set(file.path, {
|
|
507
|
+
path: file.path,
|
|
508
|
+
posix: toPosix(file.path),
|
|
509
|
+
directory: dirnameOf(file.path),
|
|
510
|
+
language: file.language,
|
|
511
|
+
functions: file.functions,
|
|
512
|
+
imports: new Set<string>(),
|
|
513
|
+
javaTypes: file.javaTypes,
|
|
514
|
+
});
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
// Second pass: resolve specifiers now that every path is known.
|
|
518
|
+
let importEdges = 0;
|
|
519
|
+
let importsUnresolved = 0;
|
|
520
|
+
for (const file of files) {
|
|
521
|
+
const entry = byPath.get(file.path);
|
|
522
|
+
if (!entry) continue;
|
|
523
|
+
for (const specifier of file.importSpecifiers) {
|
|
524
|
+
const resolvedPosix = resolveSpecifier(specifier, entry, known);
|
|
525
|
+
const resolved = resolvedPosix ? originalOf.get(resolvedPosix) : undefined;
|
|
526
|
+
if (resolved && resolved !== file.path) {
|
|
527
|
+
entry.imports.add(resolved);
|
|
528
|
+
importEdges++;
|
|
529
|
+
} else if (!/^[a-z@][\w@/.-]*$/i.test(specifier) || specifier.startsWith('.')) {
|
|
530
|
+
// A RELATIVE import that resolved to nothing means the file it names is
|
|
531
|
+
// outside the scan. A bare specifier (`express`, `@angular/core`) is a
|
|
532
|
+
// package and is expected not to resolve - counting those would drown
|
|
533
|
+
// the real signal in node_modules noise.
|
|
534
|
+
importsUnresolved++;
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
// Same-package visibility: Java and Go files in one directory see each other
|
|
540
|
+
// with no import statement at all, so the directory IS the import edge.
|
|
541
|
+
const byDirectory = new Map<string, string[]>();
|
|
542
|
+
for (const entry of byPath.values()) {
|
|
543
|
+
if (entry.language !== 'java' && entry.language !== 'go') continue;
|
|
544
|
+
const list = byDirectory.get(entry.directory) ?? [];
|
|
545
|
+
list.push(entry.path);
|
|
546
|
+
byDirectory.set(entry.directory, list);
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/**
|
|
550
|
+
* Function name -> the files that define it.
|
|
551
|
+
*
|
|
552
|
+
* The first version of resolve() walked every sibling file in the directory
|
|
553
|
+
* looking for the name. That is fine for a normal package and catastrophic
|
|
554
|
+
* for OWASP's benchmark, which puts 2,766 Java files in ONE directory: every
|
|
555
|
+
* unresolved call scanned all 2,766 siblings, and a 12-second scan became 60.
|
|
556
|
+
*
|
|
557
|
+
* Indexing by name first turns that inner loop from "all files in the
|
|
558
|
+
* package" into "the handful of files that define this exact name".
|
|
559
|
+
*/
|
|
560
|
+
const byFunctionName = new Map<string, IndexedFile[]>();
|
|
561
|
+
for (const entry of byPath.values()) {
|
|
562
|
+
for (const name of entry.functions.keys()) {
|
|
563
|
+
const list = byFunctionName.get(name);
|
|
564
|
+
if (list) list.push(entry);
|
|
565
|
+
else byFunctionName.set(name, [entry]);
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
/** Every Java type name declared in the scan, of every kind, nested ones included. */
|
|
570
|
+
const typeNames = new Set<string>();
|
|
571
|
+
for (const entry of byPath.values()) for (const t of entry.javaTypes?.allTypeNames ?? []) typeNames.add(t);
|
|
572
|
+
|
|
573
|
+
let resolved = 0;
|
|
574
|
+
let ambiguous = 0;
|
|
575
|
+
let relocationsFailed = 0;
|
|
576
|
+
let functionsIndexed = 0;
|
|
577
|
+
for (const entry of byPath.values()) functionsIndexed += entry.functions.size;
|
|
578
|
+
|
|
579
|
+
return {
|
|
580
|
+
resolve(fromPath: string, name: string) {
|
|
581
|
+
const from = byPath.get(fromPath);
|
|
582
|
+
if (!from) return null;
|
|
583
|
+
|
|
584
|
+
// Start from "who defines this name at all", then keep only the ones
|
|
585
|
+
// this file can actually see.
|
|
586
|
+
const definers = byFunctionName.get(name);
|
|
587
|
+
if (!definers) return null;
|
|
588
|
+
|
|
589
|
+
const samePackage = from.language === 'java' || from.language === 'go';
|
|
590
|
+
const hits = definers.filter(
|
|
591
|
+
(candidate) =>
|
|
592
|
+
candidate.path !== fromPath &&
|
|
593
|
+
candidate.language === from.language &&
|
|
594
|
+
(from.imports.has(candidate.path) ||
|
|
595
|
+
(samePackage && candidate.directory === from.directory)),
|
|
596
|
+
);
|
|
597
|
+
|
|
598
|
+
if (hits.length === 0) return null;
|
|
599
|
+
if (hits.length > 1) {
|
|
600
|
+
ambiguous++;
|
|
601
|
+
return null; // several files define it - we do not guess.
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
const winner = hits[0];
|
|
605
|
+
const indexed = winner?.functions.get(name);
|
|
606
|
+
if (!winner || !indexed) return null;
|
|
607
|
+
|
|
608
|
+
/* ---- materialise: coordinates back into a live syntax node ----
|
|
609
|
+
*
|
|
610
|
+
* This is the only place in the scan that can pull a tree back off disk,
|
|
611
|
+
* and it is deliberately the LAST thing that happens - after the file has
|
|
612
|
+
* been chosen and the ambiguity check has passed. Resolving first and
|
|
613
|
+
* parsing second means a project with thousands of files that never
|
|
614
|
+
* resolve into each other never re-parses anything.
|
|
615
|
+
*
|
|
616
|
+
* The pin lasts until the caller releases it, because the Node handed
|
|
617
|
+
* back here points into this tree's memory and nothing else keeps it
|
|
618
|
+
* alive. */
|
|
619
|
+
store.pin(winner.path);
|
|
620
|
+
const tree = store.get(winner.path);
|
|
621
|
+
const body = nodeAtRange(
|
|
622
|
+
tree.rootNode,
|
|
623
|
+
indexed.bodyStart,
|
|
624
|
+
indexed.bodyEnd,
|
|
625
|
+
indexed.bodyType,
|
|
626
|
+
);
|
|
627
|
+
if (!body) {
|
|
628
|
+
relocationsFailed++;
|
|
629
|
+
return null;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
resolved++;
|
|
633
|
+
return {
|
|
634
|
+
path: winner.path,
|
|
635
|
+
fn: { name: indexed.name, params: indexed.params, minArgs: indexed.minArgs, maxArgs: indexed.maxArgs, body },
|
|
636
|
+
};
|
|
637
|
+
},
|
|
638
|
+
|
|
639
|
+
/*
|
|
640
|
+
* `Utils.getOSCommandString("echo")` - A STATIC CALL, NAMED THROUGH ITS CLASS.
|
|
641
|
+
*
|
|
642
|
+
* resolve() above answers by function NAME, which is right for the tracer:
|
|
643
|
+
* it only ever follows taint INTO a function, and entering the wrong one
|
|
644
|
+
* can at worst report a flow that looks odd. The constant proof asks the
|
|
645
|
+
* opposite question - "is what comes back fixed?" - and a wrong answer
|
|
646
|
+
* there deletes a finding. So this answers only when Java itself leaves no
|
|
647
|
+
* choice:
|
|
648
|
+
*
|
|
649
|
+
* - the receiver names a class: `a.b.Utils` by its full name - where
|
|
650
|
+
* `a` must look like a package and not a type - or `Utils` through a
|
|
651
|
+
* single-type import or, failing that, the caller's own package. An
|
|
652
|
+
* import-on-demand (`import a.b.*;`) is not followed;
|
|
653
|
+
* - exactly ONE file in the scan declares that package and a top-level
|
|
654
|
+
* class of that name - two copies is two candidates, and neither wins;
|
|
655
|
+
* - that class declares exactly one method of the name, and it is
|
|
656
|
+
* static. An overload is chosen by argument types, which nobody here
|
|
657
|
+
* reads; an inherited method lives in a class this does not look for.
|
|
658
|
+
*
|
|
659
|
+
* The caller's side - a local variable named `Utils` hides the class - is
|
|
660
|
+
* checked where the call is, in rules/lib/strings.ts.
|
|
661
|
+
*/
|
|
662
|
+
staticMethod(
|
|
663
|
+
fromPath: string,
|
|
664
|
+
receiver: string,
|
|
665
|
+
methodName: string,
|
|
666
|
+
supertypes: readonly string[] = [],
|
|
667
|
+
allowInstance = false,
|
|
668
|
+
) {
|
|
669
|
+
const from = byPath.get(fromPath);
|
|
670
|
+
const facts = from?.javaTypes;
|
|
671
|
+
if (!from || from.language !== 'java' || !facts) return null;
|
|
672
|
+
const dotted = receiver.replace(/\s+/g, '');
|
|
673
|
+
if (!/^[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*$/.test(dotted)) return null;
|
|
674
|
+
const segments = dotted.split('.');
|
|
675
|
+
const first = segments[0] ?? '';
|
|
676
|
+
const className = segments[segments.length - 1] ?? '';
|
|
677
|
+
|
|
678
|
+
/*
|
|
679
|
+
* Names that reach this file without a declaration in it: a static import
|
|
680
|
+
* of a member called `first` (a nested type or a field - tests/probes/p04,
|
|
681
|
+
* p05), any `import static a.b.C.*;`, and members inherited from a
|
|
682
|
+
* supertype declared in the scan (p06). Each could be what `first` means.
|
|
683
|
+
* A supertype from OUTSIDE the scan - HttpServlet, say - is not read; the
|
|
684
|
+
* SQL rule's limitations say so.
|
|
685
|
+
*/
|
|
686
|
+
if (facts.staticOnDemand || facts.staticImports.includes(first)) return null;
|
|
687
|
+
if (supertypes.some((t) => t.split('.').some((part) => typeNames.has(part)))) return null;
|
|
688
|
+
|
|
689
|
+
let packageName: string;
|
|
690
|
+
if (segments.length > 1) {
|
|
691
|
+
/*
|
|
692
|
+
* `Wrapper.Inner` is a package Wrapper's class Inner ONLY if no type
|
|
693
|
+
* called Wrapper is in scope - a type outranks a package (JLS 6.5.2),
|
|
694
|
+
* and then Inner is Wrapper's nested class. Types in scope include
|
|
695
|
+
* library ones this scan never sees, so the first segment must look
|
|
696
|
+
* like a package: not capitalised (the convention every package
|
|
697
|
+
* follows), not a type of ANY kind anywhere in the scan (an interface
|
|
698
|
+
* called `app` - p07), not imported by name.
|
|
699
|
+
*/
|
|
700
|
+
if (
|
|
701
|
+
/^[A-Z]/.test(first) ||
|
|
702
|
+
typeNames.has(first) ||
|
|
703
|
+
facts.singleTypeImports.some((i) => i === first || i.endsWith(`.${first}`))
|
|
704
|
+
) {
|
|
705
|
+
return null;
|
|
706
|
+
}
|
|
707
|
+
packageName = segments.slice(0, -1).join('.');
|
|
708
|
+
} else {
|
|
709
|
+
const imported = facts.singleTypeImports.filter((i) => i.endsWith(`.${className}`));
|
|
710
|
+
if (imported.length > 1) return null;
|
|
711
|
+
packageName = imported[0]
|
|
712
|
+
? imported[0].slice(0, imported[0].length - className.length - 1)
|
|
713
|
+
: facts.packageName;
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
// Every kind of type counts as a candidate: a class and an interface
|
|
717
|
+
// with one full name are two candidates, and neither wins (p09).
|
|
718
|
+
const hits = [...byPath.values()].filter(
|
|
719
|
+
(f) =>
|
|
720
|
+
f.language === 'java' &&
|
|
721
|
+
f.javaTypes?.packageName === packageName &&
|
|
722
|
+
f.javaTypes.types.some((t) => t.name === className),
|
|
723
|
+
);
|
|
724
|
+
if (hits.length !== 1 || !hits[0]) return null;
|
|
725
|
+
const winner = hits[0];
|
|
726
|
+
const declared = winner.javaTypes?.types.filter((t) => t.name === className) ?? [];
|
|
727
|
+
if (declared.length !== 1 || declared[0]?.kind !== 'class_declaration') return null;
|
|
728
|
+
|
|
729
|
+
store.pin(winner.path);
|
|
730
|
+
const root = store.get(winner.path).rootNode;
|
|
731
|
+
const klass = root.namedChildren.find(
|
|
732
|
+
(c): c is Node => c?.type === 'class_declaration' && c.childForFieldName('name')?.text === className,
|
|
733
|
+
);
|
|
734
|
+
if (!klass) {
|
|
735
|
+
relocationsFailed++;
|
|
736
|
+
return null;
|
|
737
|
+
}
|
|
738
|
+
/*
|
|
739
|
+
* A class that inherits from anything can inherit a method of this name
|
|
740
|
+
* - a more specific overload, or the only one that takes this many
|
|
741
|
+
* arguments - and that is the one that runs (p01, p02, p03).
|
|
742
|
+
*/
|
|
743
|
+
if (klass.childForFieldName('superclass')) return null;
|
|
744
|
+
// An instance method can also come from an interface's default method.
|
|
745
|
+
if (allowInstance && klass.childForFieldName('interfaces')) return null;
|
|
746
|
+
const methods = (klass.childForFieldName('body')?.namedChildren ?? []).filter(
|
|
747
|
+
(m): m is Node => m?.type === 'method_declaration' && m.childForFieldName('name')?.text === methodName,
|
|
748
|
+
);
|
|
749
|
+
const method = methods.length === 1 ? methods[0] : undefined;
|
|
750
|
+
// Static - or, when the caller holds a `new X(...)` and so knows the
|
|
751
|
+
// object is exactly an X, an instance method (strings.ts, heldCreation).
|
|
752
|
+
if (!method || (!allowInstance && !hasModifier(method, 'static'))) return null;
|
|
753
|
+
// A private method cannot be called from another class at all.
|
|
754
|
+
if (winner.path !== fromPath && hasModifier(method, 'private')) return null;
|
|
755
|
+
return { path: winner.path, method };
|
|
756
|
+
},
|
|
757
|
+
|
|
758
|
+
javaTypeInScan: (simpleName: string) => typeNames.has(simpleName),
|
|
759
|
+
|
|
760
|
+
noteAmbiguous() {
|
|
761
|
+
ambiguous++;
|
|
762
|
+
},
|
|
763
|
+
|
|
764
|
+
stats: () => ({
|
|
765
|
+
filesIndexed: byPath.size,
|
|
766
|
+
functionsIndexed,
|
|
767
|
+
importEdges,
|
|
768
|
+
importsUnresolved,
|
|
769
|
+
resolved,
|
|
770
|
+
ambiguous,
|
|
771
|
+
relocationsFailed,
|
|
772
|
+
}),
|
|
773
|
+
};
|
|
774
|
+
}
|