@7nsane/zift 4.0.0 โ 4.2.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/README.md +10 -9
- package/bin/zift.js +8 -1
- package/package.json +1 -1
- package/src/collector.js +341 -29
- package/src/engine.js +16 -6
- package/src/rules/definitions.js +133 -3
- package/src/scanner.js +65 -18
package/README.md
CHANGED
|
@@ -1,20 +1,21 @@
|
|
|
1
|
-
# ๐ก๏ธ Zift (v4.
|
|
1
|
+
# ๐ก๏ธ Zift (v4.2.0)
|
|
2
2
|
|
|
3
3
|
[](https://www.npmjs.com/package/@7nsane/zift)
|
|
4
4
|
[](https://www.npmjs.com/package/@7nsane/zift)
|
|
5
5
|
[](https://github.com/7nsane/zift)
|
|
6
6
|
|
|
7
|
-
**The
|
|
7
|
+
**The Symbolically-Intelligent Ecosystem Security Engine for JavaScript.**
|
|
8
8
|
|
|
9
|
-
Zift v4.
|
|
9
|
+
Zift v4.2 is the "Deep Hardening" release, introducing advanced behavioral rules for Wiper detection, Worm prevention, and OS-specific targeting analysis.
|
|
10
10
|
|
|
11
|
-
## ๐ Key Advancements (v4.
|
|
11
|
+
## ๐ Key Advancements (v4.2.0)
|
|
12
12
|
|
|
13
|
-
-
|
|
14
|
-
-
|
|
15
|
-
-
|
|
16
|
-
-
|
|
17
|
-
-
|
|
13
|
+
- **๐ง Symbolic Taint Analysis**: Tracks data through destructuring (`const { key } = process.env`) and deep property access (`obj.a.b.c`).
|
|
14
|
+
- **๐งฌ Transformation Tracking**: Automatically follows taint through encoding methods like `Buffer.from(data).toString('base64')` or `hex`.
|
|
15
|
+
- **๐ Worm & Propagation Defense**: Detects the chain of credential theft, data exfiltration, and self-publishing (registry hijacking).
|
|
16
|
+
- **๐ก๏ธ Deep Behavioral Hardening**: Flags wipers (recursive deletions), CI/CD secret theft, and unauthorized module/git tampering.
|
|
17
|
+
- **๐ก OS Fingerprinting Detection**: Identifies system targeting behaviors (os.platform, arch) coupled with network activity.
|
|
18
|
+
- **๐ฆ Lifecycle-Specific Intelligence**: Detects remote fetches and binary drops occurring during sensitive contexts like `preinstall`.
|
|
18
19
|
|
|
19
20
|
## ๐ฆ Quick Start
|
|
20
21
|
|
package/bin/zift.js
CHANGED
|
@@ -28,6 +28,13 @@ async function main() {
|
|
|
28
28
|
return;
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
+
if (args[0] === 'scan') {
|
|
32
|
+
target = args[1] || '.';
|
|
33
|
+
} else if (args[0] && !args[0].startsWith('-')) {
|
|
34
|
+
// If first arg isn't a command or flag, it might be a target
|
|
35
|
+
target = args[0];
|
|
36
|
+
}
|
|
37
|
+
|
|
31
38
|
// 2. Detection for bun/pnpm usage
|
|
32
39
|
if (args.includes('--bun')) installer = 'bun';
|
|
33
40
|
if (args.includes('--pnpm')) installer = 'pnpm';
|
|
@@ -58,7 +65,7 @@ async function main() {
|
|
|
58
65
|
}
|
|
59
66
|
|
|
60
67
|
// 6. Execution
|
|
61
|
-
const isLocal = fs.existsSync(target)
|
|
68
|
+
const isLocal = fs.existsSync(target); // Check existence, dir/file handled by scanner
|
|
62
69
|
|
|
63
70
|
if (isLocal) {
|
|
64
71
|
await runLocalScan(target, format);
|
package/package.json
CHANGED
package/src/collector.js
CHANGED
|
@@ -29,7 +29,19 @@ class ASTCollector {
|
|
|
29
29
|
EXPORTS: [],
|
|
30
30
|
IMPORTS: [],
|
|
31
31
|
OPAQUE_STRING_SKIP: [],
|
|
32
|
-
NON_DETERMINISTIC_SINK: []
|
|
32
|
+
NON_DETERMINISTIC_SINK: [],
|
|
33
|
+
CREDENTIAL_FILE_ACCESS: [],
|
|
34
|
+
DISCORD_STORAGE_ACCESS: [],
|
|
35
|
+
WEBHOOK_SINK: [],
|
|
36
|
+
EVASION_ENVIRONMENT_CHECK: [],
|
|
37
|
+
WALLET_HOOK: [],
|
|
38
|
+
CICD_SECRET_ACCESS: [],
|
|
39
|
+
WIPER_OPERATION: [],
|
|
40
|
+
REGISTRY_TAMPER: [],
|
|
41
|
+
MODULE_TAMPER: [],
|
|
42
|
+
REVERSE_SHELL_BEHAVIOR: [],
|
|
43
|
+
FINGERPRINT_SIGNAL: [],
|
|
44
|
+
PUBLISH_SINK: []
|
|
33
45
|
};
|
|
34
46
|
const flows = [];
|
|
35
47
|
const sourceCode = code;
|
|
@@ -124,6 +136,82 @@ class ASTCollector {
|
|
|
124
136
|
type: 'default'
|
|
125
137
|
});
|
|
126
138
|
},
|
|
139
|
+
Identifier: (node) => {
|
|
140
|
+
const evasionIds = ['v8debug'];
|
|
141
|
+
if (evasionIds.includes(node.name)) {
|
|
142
|
+
facts.EVASION_ENVIRONMENT_CHECK.push({
|
|
143
|
+
file: filePath,
|
|
144
|
+
line: node.loc.start.line,
|
|
145
|
+
context: node.name
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
},
|
|
149
|
+
MemberExpression: (node, state, ancestors) => {
|
|
150
|
+
const memberCode = sourceCode.substring(node.start, node.end);
|
|
151
|
+
|
|
152
|
+
// 1. Anti-Analysis / Evasion Check
|
|
153
|
+
const evasionPatterns = ['debugPort', 'v8debug', 'NODE_OPTIONS'];
|
|
154
|
+
if (evasionPatterns.some(p => memberCode.includes(p))) {
|
|
155
|
+
facts.EVASION_ENVIRONMENT_CHECK.push({
|
|
156
|
+
file: filePath,
|
|
157
|
+
line: node.loc.start.line,
|
|
158
|
+
context: memberCode
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// 2. Wallet DRAINER Hook detection
|
|
163
|
+
const walletPatterns = ['ethereum', 'solana', 'phantom'];
|
|
164
|
+
if (walletPatterns.some(p => memberCode.includes(p))) {
|
|
165
|
+
facts.WALLET_HOOK.push({
|
|
166
|
+
file: filePath,
|
|
167
|
+
line: node.loc.start.line,
|
|
168
|
+
context: memberCode
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// 3. CI/CD Secret Access
|
|
173
|
+
const cicdPatterns = ['GITHUB_TOKEN', 'CIRCLECI_TOKEN', 'AZURE_TOKEN', 'TRAVIS_TOKEN', 'GITLAB_TOKEN'];
|
|
174
|
+
if (cicdPatterns.some(p => memberCode.includes(p))) {
|
|
175
|
+
facts.CICD_SECRET_ACCESS.push({
|
|
176
|
+
file: filePath,
|
|
177
|
+
line: node.loc.start.line,
|
|
178
|
+
variable: memberCode
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// 4. OS Fingerprinting (platform, arch, release)
|
|
183
|
+
const fingerPatterns = ['process.platform', 'process.arch', 'os.platform', 'os.arch', 'os.release', 'os.type'];
|
|
184
|
+
if (fingerPatterns.some(p => memberCode.includes(p))) {
|
|
185
|
+
// Avoid duplicate if it's part of a CallExpression (will be caught there)
|
|
186
|
+
const parent = ancestors[ancestors.length - 2];
|
|
187
|
+
if (parent && parent.type === 'CallExpression' && parent.callee === node) return;
|
|
188
|
+
|
|
189
|
+
facts.FINGERPRINT_SIGNAL.push({
|
|
190
|
+
file: filePath,
|
|
191
|
+
line: node.loc.start.line,
|
|
192
|
+
context: memberCode
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// 4. process.env access (Moved from redundant visitor)
|
|
197
|
+
const objectCode = sourceCode.substring(node.object.start, node.object.end);
|
|
198
|
+
if (objectCode === 'process.env' || objectCode === 'process["env"]' || objectCode === "process['env']") {
|
|
199
|
+
const property = node.property.name || (node.property.type === 'Literal' ? node.property.value : null);
|
|
200
|
+
const whitelist = ['NODE_ENV', 'TIMING', 'DEBUG', 'VERBOSE', 'CI', 'APPDATA', 'HOME', 'USERPROFILE', 'PATH', 'PWD'];
|
|
201
|
+
if (whitelist.includes(property)) return;
|
|
202
|
+
|
|
203
|
+
envAccessCount++;
|
|
204
|
+
facts.ENV_READ.push({
|
|
205
|
+
file: filePath,
|
|
206
|
+
line: node.loc.start.line,
|
|
207
|
+
variable: property ? `process.env.${property}` : 'process.env'
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
if (envAccessCount > 5) {
|
|
211
|
+
facts.MASS_ENV_ACCESS.push({ file: filePath, line: node.loc.start.line, count: envAccessCount });
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
},
|
|
127
215
|
CallExpression: (node, state, ancestors) => {
|
|
128
216
|
const calleeCode = sourceCode.substring(node.callee.start, node.callee.end);
|
|
129
217
|
|
|
@@ -153,7 +241,6 @@ class ASTCollector {
|
|
|
153
241
|
}
|
|
154
242
|
}
|
|
155
243
|
|
|
156
|
-
// De-obfuscation Trigger
|
|
157
244
|
const evaluated = this.tryEvaluate(node, sourceCode);
|
|
158
245
|
if (evaluated) {
|
|
159
246
|
if (this.getNetworkType(evaluated) || this.isShellSink(evaluated) || evaluated === 'eval' || evaluated === 'Function') {
|
|
@@ -176,6 +263,23 @@ class ASTCollector {
|
|
|
176
263
|
line: node.loc.start.line,
|
|
177
264
|
callee: calleeCode
|
|
178
265
|
});
|
|
266
|
+
|
|
267
|
+
// Check for Webhook Sinks
|
|
268
|
+
if (netType === 'NETWORK_SINK') {
|
|
269
|
+
node.arguments.forEach(arg => {
|
|
270
|
+
if (arg.type === 'Literal' && typeof arg.value === 'string') {
|
|
271
|
+
const val = arg.value.toLowerCase();
|
|
272
|
+
const webhooks = ['discord.com/api/webhooks', 'pipedream.net', 'webhook.site', 'burpcollaborator.net'];
|
|
273
|
+
if (webhooks.some(w => val.includes(w))) {
|
|
274
|
+
facts.WEBHOOK_SINK.push({
|
|
275
|
+
file: filePath,
|
|
276
|
+
line: node.loc.start.line,
|
|
277
|
+
url: val
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
});
|
|
282
|
+
}
|
|
179
283
|
}
|
|
180
284
|
|
|
181
285
|
if (this.isShellSink(calleeCode)) {
|
|
@@ -207,10 +311,30 @@ class ASTCollector {
|
|
|
207
311
|
}
|
|
208
312
|
|
|
209
313
|
if (this.isSensitiveFileRead(calleeCode, node, sourceCode)) {
|
|
210
|
-
|
|
314
|
+
const arg = node.arguments[0];
|
|
315
|
+
const pathValue = arg && arg.type === 'Literal' ? String(arg.value).toLowerCase() : '';
|
|
316
|
+
const isCredential = ['.aws', '.ssh', '.npmrc', 'aws_access_key', 'shadow'].some(s => pathValue.includes(s));
|
|
317
|
+
|
|
318
|
+
if (isCredential) {
|
|
319
|
+
facts.CREDENTIAL_FILE_ACCESS.push({
|
|
320
|
+
file: filePath,
|
|
321
|
+
line: node.loc.start.line,
|
|
322
|
+
path: pathValue
|
|
323
|
+
});
|
|
324
|
+
} else {
|
|
325
|
+
facts.FILE_READ_SENSITIVE.push({
|
|
326
|
+
file: filePath,
|
|
327
|
+
line: node.loc.start.line,
|
|
328
|
+
path: sourceCode.substring(node.arguments[0].start, node.arguments[0].end)
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
if (this.isDiscordStorageAccess(calleeCode, node, sourceCode)) {
|
|
334
|
+
facts.DISCORD_STORAGE_ACCESS.push({
|
|
211
335
|
file: filePath,
|
|
212
336
|
line: node.loc.start.line,
|
|
213
|
-
path:
|
|
337
|
+
path: sourceCode.substring(node.arguments[0].start, node.arguments[0].end)
|
|
214
338
|
});
|
|
215
339
|
}
|
|
216
340
|
|
|
@@ -222,6 +346,54 @@ class ASTCollector {
|
|
|
222
346
|
});
|
|
223
347
|
}
|
|
224
348
|
|
|
349
|
+
if (this.isWiperOperation(calleeCode, node, sourceCode)) {
|
|
350
|
+
facts.WIPER_OPERATION.push({
|
|
351
|
+
file: filePath,
|
|
352
|
+
line: node.loc.start.line,
|
|
353
|
+
path: node.arguments[0] ? sourceCode.substring(node.arguments[0].start, node.arguments[0].end) : 'unknown'
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
if (this.isRegistryTamper(calleeCode, node, sourceCode)) {
|
|
358
|
+
facts.REGISTRY_TAMPER.push({
|
|
359
|
+
file: filePath,
|
|
360
|
+
line: node.loc.start.line,
|
|
361
|
+
path: node.arguments[0] ? sourceCode.substring(node.arguments[0].start, node.arguments[0].end) : 'unknown'
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
if (this.isModuleTamper(calleeCode, node, sourceCode)) {
|
|
366
|
+
facts.MODULE_TAMPER.push({
|
|
367
|
+
file: filePath,
|
|
368
|
+
line: node.loc.start.line,
|
|
369
|
+
path: node.arguments[0] ? sourceCode.substring(node.arguments[0].start, node.arguments[0].end) : 'unknown'
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
if (this.isReverseShellBehavior(calleeCode, node, sourceCode)) {
|
|
374
|
+
facts.REVERSE_SHELL_BEHAVIOR.push({
|
|
375
|
+
file: filePath,
|
|
376
|
+
line: node.loc.start.line,
|
|
377
|
+
context: calleeCode
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
if (this.isPublishSink(calleeCode, node, sourceCode)) {
|
|
382
|
+
facts.PUBLISH_SINK.push({
|
|
383
|
+
file: filePath,
|
|
384
|
+
line: node.loc.start.line,
|
|
385
|
+
callee: calleeCode
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
if (this.isOSFingerprint(calleeCode, node, sourceCode)) {
|
|
390
|
+
facts.FINGERPRINT_SIGNAL.push({
|
|
391
|
+
file: filePath,
|
|
392
|
+
line: node.loc.start.line,
|
|
393
|
+
context: calleeCode
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
|
|
225
397
|
node.arguments.forEach((arg, index) => {
|
|
226
398
|
const argCode = sourceCode.substring(arg.start, arg.end);
|
|
227
399
|
const isArgTainted = argCode.includes('process.env') || flows.some(f => {
|
|
@@ -254,35 +426,28 @@ class ASTCollector {
|
|
|
254
426
|
}
|
|
255
427
|
}
|
|
256
428
|
});
|
|
257
|
-
},
|
|
258
|
-
MemberExpression: (node) => {
|
|
259
|
-
const objectCode = sourceCode.substring(node.object.start, node.object.end);
|
|
260
|
-
if (objectCode === 'process.env' || objectCode === 'process["env"]' || objectCode === "process['env']") {
|
|
261
|
-
const property = node.property.name || (node.property.type === 'Literal' ? node.property.value : null);
|
|
262
|
-
const whitelist = ['NODE_ENV', 'TIMING', 'DEBUG', 'VERBOSE', 'CI', 'APPDATA', 'HOME', 'USERPROFILE', 'PATH', 'PWD'];
|
|
263
|
-
if (whitelist.includes(property)) return;
|
|
264
429
|
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
430
|
+
// v5.0 Symbolic Transformers: Buffer/Base64/Hex
|
|
431
|
+
if (calleeCode.includes('Buffer.from') || calleeCode.includes('.toString')) {
|
|
432
|
+
const parent = ancestors[ancestors.length - 2];
|
|
433
|
+
if (parent && parent.type === 'VariableDeclarator' && parent.id.type === 'Identifier') {
|
|
434
|
+
const arg = node.arguments[0] ? sourceCode.substring(node.arguments[0].start, node.arguments[0].end) : null;
|
|
435
|
+
if (arg) {
|
|
436
|
+
flows.push({
|
|
437
|
+
fromVar: arg,
|
|
438
|
+
toVar: parent.id.name,
|
|
439
|
+
file: filePath,
|
|
440
|
+
line: node.loc.start.line,
|
|
441
|
+
transformation: calleeCode.includes('base64') ? 'base64' : (calleeCode.includes('hex') ? 'hex' : 'buffer')
|
|
442
|
+
});
|
|
443
|
+
}
|
|
274
444
|
}
|
|
275
445
|
}
|
|
276
446
|
},
|
|
277
447
|
VariableDeclarator: (node) => {
|
|
278
|
-
if (node.init
|
|
448
|
+
if (node.init) {
|
|
279
449
|
const from = sourceCode.substring(node.init.start, node.init.end);
|
|
280
|
-
flows.
|
|
281
|
-
fromVar: from,
|
|
282
|
-
toVar: node.id.name,
|
|
283
|
-
file: filePath,
|
|
284
|
-
line: node.loc.start.line
|
|
285
|
-
});
|
|
450
|
+
this.handlePattern(node.id, from, flows, filePath, node.loc.start.line);
|
|
286
451
|
}
|
|
287
452
|
},
|
|
288
453
|
AssignmentExpression: (node) => {
|
|
@@ -306,6 +471,9 @@ class ASTCollector {
|
|
|
306
471
|
file: filePath,
|
|
307
472
|
line: node.loc.start.line
|
|
308
473
|
});
|
|
474
|
+
} else if (node.left.type === 'ObjectPattern' || node.left.type === 'ArrayPattern') {
|
|
475
|
+
const from = sourceCode.substring(node.right.start, node.right.end);
|
|
476
|
+
this.handlePattern(node.left, from, flows, filePath, node.loc.start.line);
|
|
309
477
|
}
|
|
310
478
|
},
|
|
311
479
|
ObjectExpression: (node, state, ancestors) => {
|
|
@@ -389,8 +557,16 @@ class ASTCollector {
|
|
|
389
557
|
|
|
390
558
|
if (node.arguments.length > 0 && node.arguments[0].type === 'Literal') {
|
|
391
559
|
const pathValue = String(node.arguments[0].value);
|
|
392
|
-
const sensitive = ['.ssh', '.env', 'shadow', 'passwd', 'credentials', 'token', '_netrc', 'aws_access_key'];
|
|
393
|
-
|
|
560
|
+
const sensitive = ['.ssh', '.env', 'shadow', 'passwd', 'credentials', 'token', '_netrc', 'aws_access_key', '.npmrc'];
|
|
561
|
+
if (sensitive.some((s) => pathValue.includes(s))) return true;
|
|
562
|
+
|
|
563
|
+
// Deep check: if argument is a variable, check if it was initialized with a sensitive string
|
|
564
|
+
const arg = node.arguments[0];
|
|
565
|
+
if (arg && arg.type === 'Identifier') {
|
|
566
|
+
const varName = arg.name;
|
|
567
|
+
// This is a bit complex for a one-liner, but we can check if it's in our local flows
|
|
568
|
+
// For now, let's just stick to the code string includes, which already handles BinaryExpressions of literals
|
|
569
|
+
}
|
|
394
570
|
}
|
|
395
571
|
return false;
|
|
396
572
|
}
|
|
@@ -408,6 +584,21 @@ class ASTCollector {
|
|
|
408
584
|
return false;
|
|
409
585
|
}
|
|
410
586
|
|
|
587
|
+
isDiscordStorageAccess(calleeCode, node, sourceCode) {
|
|
588
|
+
if (typeof calleeCode !== 'string') return false;
|
|
589
|
+
if (!calleeCode.includes('fs.readFile') && !calleeCode.includes('fs.readFileSync')) return false;
|
|
590
|
+
|
|
591
|
+
if (node.arguments.length > 0) {
|
|
592
|
+
const arg = node.arguments[0];
|
|
593
|
+
const argCode = sourceCode.substring(arg.start, arg.end).toLowerCase();
|
|
594
|
+
|
|
595
|
+
// Detection: check if the argument code OR any part of the expression contains 'discord' and 'local storage'
|
|
596
|
+
// We also check identifiers if their names are suspicious (simple heuristic)
|
|
597
|
+
return argCode.includes('discord') && (argCode.includes('local storage') || argCode.includes('leveldb') || argCode.includes('token'));
|
|
598
|
+
}
|
|
599
|
+
return false;
|
|
600
|
+
}
|
|
601
|
+
|
|
411
602
|
tryEvaluate(node, sourceCode) {
|
|
412
603
|
try {
|
|
413
604
|
const code = sourceCode.substring(node.start, node.end);
|
|
@@ -422,6 +613,127 @@ class ASTCollector {
|
|
|
422
613
|
return null;
|
|
423
614
|
}
|
|
424
615
|
}
|
|
616
|
+
|
|
617
|
+
handlePattern(pattern, initCode, flows, filePath, line) {
|
|
618
|
+
if (pattern.type === 'Identifier') {
|
|
619
|
+
flows.push({ fromVar: initCode, toVar: pattern.name, file: filePath, line });
|
|
620
|
+
} else if (pattern.type === 'ObjectPattern') {
|
|
621
|
+
pattern.properties.forEach(prop => {
|
|
622
|
+
if (prop.type === 'Property') {
|
|
623
|
+
const key = prop.key.type === 'Identifier' ? prop.key.name :
|
|
624
|
+
(prop.key.type === 'Literal' ? prop.key.value : null);
|
|
625
|
+
if (key) {
|
|
626
|
+
this.handlePattern(prop.value, `${initCode}.${key}`, flows, filePath, line);
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
});
|
|
630
|
+
} else if (pattern.type === 'ArrayPattern') {
|
|
631
|
+
pattern.elements.forEach((el, index) => {
|
|
632
|
+
if (el) {
|
|
633
|
+
this.handlePattern(el, `${initCode}[${index}]`, flows, filePath, line);
|
|
634
|
+
}
|
|
635
|
+
});
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
isWiperOperation(calleeCode, node, sourceCode) {
|
|
640
|
+
if (typeof calleeCode !== 'string') return false;
|
|
641
|
+
const wiperFuncs = ['fs.rm', 'fs.rmSync', 'fs.rmdir', 'fs.rmdirSync', 'fs.unlink', 'fs.unlinkSync'];
|
|
642
|
+
const isWiperFunc = wiperFuncs.some(f => calleeCode === f || calleeCode.endsWith('.' + f));
|
|
643
|
+
if (!isWiperFunc) return false;
|
|
644
|
+
|
|
645
|
+
const arg = node.arguments[0];
|
|
646
|
+
if (!arg) return false;
|
|
647
|
+
|
|
648
|
+
const argCode = sourceCode.substring(arg.start, arg.end).toLowerCase();
|
|
649
|
+
const sensitivePaths = ['/root', '/home', '/etc', '/var/log', '/usr/bin', '/bin', 'c:\\windows', 'c:\\users'];
|
|
650
|
+
const isSensitivePath = sensitivePaths.some(p => argCode.includes(p));
|
|
651
|
+
|
|
652
|
+
const hasRecursive = sourceCode.substring(node.start, node.end).includes('recursive: true');
|
|
653
|
+
return isSensitivePath || hasRecursive;
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
isRegistryTamper(calleeCode, node, sourceCode) {
|
|
657
|
+
if (typeof calleeCode !== 'string') return false;
|
|
658
|
+
if (!calleeCode.includes('fs.writeFile') && !calleeCode.includes('fs.writeFileSync') && !calleeCode.includes('fs.appendFile')) return false;
|
|
659
|
+
|
|
660
|
+
if (node.arguments.length > 0) {
|
|
661
|
+
const arg = node.arguments[0];
|
|
662
|
+
const argCode = sourceCode.substring(arg.start, arg.end).toLowerCase();
|
|
663
|
+
return argCode.includes('.npmrc') || argCode.includes('registry') || argCode.includes('npm-registry');
|
|
664
|
+
}
|
|
665
|
+
return false;
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
isModuleTamper(calleeCode, node, sourceCode) {
|
|
669
|
+
if (typeof calleeCode !== 'string') return false;
|
|
670
|
+
if (!calleeCode.includes('fs.writeFile') && !calleeCode.includes('fs.writeFileSync') && !calleeCode.includes('fs.mkdir')) return false;
|
|
671
|
+
|
|
672
|
+
if (node.arguments.length > 0) {
|
|
673
|
+
const arg = node.arguments[0];
|
|
674
|
+
const argCode = sourceCode.substring(arg.start, arg.end).toLowerCase();
|
|
675
|
+
return argCode.includes('node_modules') || argCode.includes('.git');
|
|
676
|
+
}
|
|
677
|
+
return false;
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
isReverseShellBehavior(calleeCode, node, sourceCode) {
|
|
681
|
+
if (typeof calleeCode !== 'string') return false;
|
|
682
|
+
if (calleeCode.endsWith('.pipe')) {
|
|
683
|
+
const arg = node.arguments[0];
|
|
684
|
+
if (arg) {
|
|
685
|
+
const argCode = sourceCode.substring(arg.start, arg.end);
|
|
686
|
+
return ['process.stdin', 'process.stdout', 'sh', 'bash', 'cmd', 'pwsh'].some(s => argCode.includes(s));
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
return false;
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
isPublishSink(calleeCode, node, sourceCode) {
|
|
693
|
+
if (typeof calleeCode !== 'string') return false;
|
|
694
|
+
|
|
695
|
+
// 1. Direct Shell Commands
|
|
696
|
+
if (this.isShellSink(calleeCode)) {
|
|
697
|
+
const arg = node.arguments[0];
|
|
698
|
+
if (arg && arg.type === 'Literal' && typeof arg.value === 'string') {
|
|
699
|
+
const val = arg.value.toLowerCase();
|
|
700
|
+
if (val.includes('npm publish') || val.includes('npm login') || val.includes('npm adduser')) return true;
|
|
701
|
+
if (val.includes('pnpm publish') || val.includes('yarn publish')) return true;
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
// 2. Registry API calls (e.g. put to /-/package/)
|
|
706
|
+
const networkSinks = ['fetch', 'axios', 'request', 'http.request', 'https.request'];
|
|
707
|
+
const isNet = networkSinks.some(s => calleeCode === s || calleeCode.endsWith('.' + s));
|
|
708
|
+
if (isNet && node.arguments.length > 0) {
|
|
709
|
+
const arg = node.arguments[0];
|
|
710
|
+
if (arg && arg.type === 'Literal' && typeof arg.value === 'string') {
|
|
711
|
+
const val = arg.value.toLowerCase();
|
|
712
|
+
if (val.includes('registry.npmjs.org') && (val.includes('/-/user/') || val.includes('/-/package/'))) return true;
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
return false;
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
isOSFingerprint(calleeCode, node, sourceCode) {
|
|
720
|
+
if (typeof calleeCode !== 'string') return false;
|
|
721
|
+
|
|
722
|
+
// 1. OS Module Methods
|
|
723
|
+
const osMethods = ['os.platform', 'os.arch', 'os.release', 'os.type', 'os.cpus', 'os.networkInterfaces', 'os.userInfo'];
|
|
724
|
+
if (osMethods.some(m => calleeCode === m || calleeCode.endsWith('.' + m))) return true;
|
|
725
|
+
|
|
726
|
+
// 2. File Reads of OS metadata
|
|
727
|
+
if (calleeCode.includes('fs.readFile') || calleeCode.includes('fs.readFileSync')) {
|
|
728
|
+
const arg = node.arguments[0];
|
|
729
|
+
if (arg && arg.type === 'Literal' && typeof arg.value === 'string') {
|
|
730
|
+
const val = arg.value.toLowerCase();
|
|
731
|
+
if (val.includes('/etc/os-release') || val.includes('/etc/issue') || val.includes('/proc/version')) return true;
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
return false;
|
|
736
|
+
}
|
|
425
737
|
}
|
|
426
738
|
|
|
427
739
|
module.exports = ASTCollector;
|
package/src/engine.js
CHANGED
|
@@ -32,9 +32,19 @@ class SafetyEngine {
|
|
|
32
32
|
for (const req of rule.requires) {
|
|
33
33
|
let matchedFacts = facts[req] || [];
|
|
34
34
|
|
|
35
|
-
//
|
|
36
|
-
|
|
37
|
-
|
|
35
|
+
// Handle virtual requirements (LIFECYCLE_CONTEXT)
|
|
36
|
+
if (req === 'LIFECYCLE_CONTEXT' && matchedFacts.length === 0) {
|
|
37
|
+
// If any trigger so far is in a lifecycle file, we satisfy the virtual requirement
|
|
38
|
+
const virtualMatch = triggers.some(t => {
|
|
39
|
+
if (lifecycleFiles instanceof Set) return lifecycleFiles.has(t.file);
|
|
40
|
+
if (Array.isArray(lifecycleFiles)) return lifecycleFiles.includes(t.file);
|
|
41
|
+
return false;
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
if (virtualMatch) {
|
|
45
|
+
matchedFacts = [{ type: 'LIFECYCLE_CONTEXT', virtual: true }];
|
|
46
|
+
}
|
|
47
|
+
}
|
|
38
48
|
|
|
39
49
|
if (matchedFacts.length === 0) return null; // Rule not matched
|
|
40
50
|
triggers.push(...matchedFacts.map(f => ({ ...f, type: req })));
|
|
@@ -65,7 +75,7 @@ class SafetyEngine {
|
|
|
65
75
|
|
|
66
76
|
// Cluster Bonus: Source + Sink
|
|
67
77
|
const hasSource = triggers.some(t => t.type.includes('READ') || t.type.includes('ACCESS'));
|
|
68
|
-
const hasSink = triggers.some(t => t.type.includes('SINK') || t.type === 'DYNAMIC_EXECUTION' || t.type === 'SHELL_EXECUTION' || t.type === 'DYNAMIC_REQUIRE');
|
|
78
|
+
const hasSink = triggers.some(t => t.type.includes('SINK') || t.type === 'DYNAMIC_EXECUTION' || t.type === 'SHELL_EXECUTION' || t.type === 'DYNAMIC_REQUIRE' || t.type === 'WIPER_OPERATION' || t.type === 'REVERSE_SHELL_BEHAVIOR');
|
|
69
79
|
if (hasSource && hasSink) {
|
|
70
80
|
baseScore += 40;
|
|
71
81
|
}
|
|
@@ -73,8 +83,8 @@ class SafetyEngine {
|
|
|
73
83
|
let finalScore = baseScore * multiplier;
|
|
74
84
|
|
|
75
85
|
// Severe Cluster: SENSITIVE_READ + Dangerous Sink + lifecycleContext = Critical (100)
|
|
76
|
-
const isSensitiveRead = triggers.some(t => t.type === 'ENV_READ' || t.type === 'FILE_READ_SENSITIVE');
|
|
77
|
-
const isDangerousSink = triggers.some(t => t.type === 'NETWORK_SINK' || t.type === 'DNS_SINK' || t.type === 'RAW_SOCKET_SINK' || t.type === 'SHELL_EXECUTION');
|
|
86
|
+
const isSensitiveRead = triggers.some(t => t.type === 'ENV_READ' || t.type === 'FILE_READ_SENSITIVE' || t.type === 'CICD_SECRET_ACCESS');
|
|
87
|
+
const isDangerousSink = triggers.some(t => t.type === 'NETWORK_SINK' || t.type === 'DNS_SINK' || t.type === 'RAW_SOCKET_SINK' || t.type === 'SHELL_EXECUTION' || t.type === 'WEBHOOK_SINK' || t.type === 'WIPER_OPERATION' || t.type === 'REVERSE_SHELL_BEHAVIOR');
|
|
78
88
|
if (isSensitiveRead && isDangerousSink && isInLifecycle) {
|
|
79
89
|
finalScore = 100;
|
|
80
90
|
}
|
package/src/rules/definitions.js
CHANGED
|
@@ -137,13 +137,143 @@ const RULES = [
|
|
|
137
137
|
priority: 1,
|
|
138
138
|
baseScore: 25,
|
|
139
139
|
description: 'Detection of very large high-entropy strings that exceed scanning limits.'
|
|
140
|
+
},
|
|
141
|
+
{
|
|
142
|
+
id: 'ZFT-016',
|
|
143
|
+
alias: 'CREDENTIAL_STEAL_ATTEMPT',
|
|
144
|
+
name: 'Credential Theft Attempt',
|
|
145
|
+
requires: ['CREDENTIAL_FILE_ACCESS', 'NETWORK_SINK'],
|
|
146
|
+
priority: 1,
|
|
147
|
+
baseScore: 85,
|
|
148
|
+
description: 'Detection of access to sensitive credential files (.aws, .ssh, .npmrc) and exfiltration.'
|
|
149
|
+
},
|
|
150
|
+
{
|
|
151
|
+
id: 'ZFT-017',
|
|
152
|
+
alias: 'ANTI_ANALYSIS_EVASION',
|
|
153
|
+
name: 'Anti-Analysis / Evasion',
|
|
154
|
+
requires: ['EVASION_ENVIRONMENT_CHECK'],
|
|
155
|
+
optional: ['DYNAMIC_EXECUTION'],
|
|
156
|
+
priority: 2,
|
|
157
|
+
baseScore: 50,
|
|
158
|
+
description: 'Detection of code that checks for VM, Debugger, or Sandbox environments to evade analysis.'
|
|
159
|
+
},
|
|
160
|
+
{
|
|
161
|
+
id: 'ZFT-018',
|
|
162
|
+
alias: 'CRYPTO_WALLET_DRAINER',
|
|
163
|
+
name: 'Crypto-Wallet Drainer Hook',
|
|
164
|
+
requires: ['WALLET_HOOK'],
|
|
165
|
+
priority: 1,
|
|
166
|
+
baseScore: 95,
|
|
167
|
+
description: 'Detection of hooks on browser-based crypto wallets (window.ethereum, Solana).'
|
|
168
|
+
},
|
|
169
|
+
{
|
|
170
|
+
id: 'ZFT-019',
|
|
171
|
+
alias: 'DISCORD_TOKEN_STEALER',
|
|
172
|
+
name: 'Discord Token Stealer',
|
|
173
|
+
requires: ['DISCORD_STORAGE_ACCESS', 'NETWORK_SINK'],
|
|
174
|
+
priority: 1,
|
|
175
|
+
baseScore: 80,
|
|
176
|
+
description: 'Detection of Discord local storage access followed by network activity.'
|
|
177
|
+
},
|
|
178
|
+
{
|
|
179
|
+
id: 'ZFT-020',
|
|
180
|
+
alias: 'HIGH_RISK_WEBHOOK_SINK',
|
|
181
|
+
name: 'High-Risk Webhook Exfiltration',
|
|
182
|
+
requires: ['WEBHOOK_SINK'],
|
|
183
|
+
optional: ['ENV_READ', 'ENCODER_USE'],
|
|
184
|
+
priority: 2,
|
|
185
|
+
baseScore: 60,
|
|
186
|
+
description: 'Detection of data being sent to known high-risk exfiltration domains (Discord Webhooks, Pipedream).'
|
|
187
|
+
},
|
|
188
|
+
{
|
|
189
|
+
id: 'ZFT-021',
|
|
190
|
+
alias: 'WIPER_MODULE_DETECTED',
|
|
191
|
+
name: 'Destructive Wiper Module',
|
|
192
|
+
requires: ['WIPER_OPERATION'],
|
|
193
|
+
priority: 1,
|
|
194
|
+
baseScore: 100,
|
|
195
|
+
description: 'Detection of recursive deletion operations on sensitive directory structures (Home, Root, Documents).'
|
|
196
|
+
},
|
|
197
|
+
{
|
|
198
|
+
id: 'ZFT-022',
|
|
199
|
+
alias: 'CICD_SECRET_EXFILTRATION',
|
|
200
|
+
name: 'CI/CD Secret Exfiltration',
|
|
201
|
+
requires: ['CICD_SECRET_ACCESS', 'NETWORK_SINK'],
|
|
202
|
+
priority: 1,
|
|
203
|
+
baseScore: 90,
|
|
204
|
+
description: 'Detection of CI/CD secrets (GITHUB_TOKEN, CIRCLECI_TOKEN) being accessed and exfiltrated.'
|
|
205
|
+
},
|
|
206
|
+
{
|
|
207
|
+
id: 'ZFT-023',
|
|
208
|
+
alias: 'REGISTRY_POISONING_ATTEMPT',
|
|
209
|
+
name: 'Registry Poisoning Attempt',
|
|
210
|
+
requires: ['REGISTRY_TAMPER'],
|
|
211
|
+
priority: 2,
|
|
212
|
+
baseScore: 70,
|
|
213
|
+
description: 'Detection of unauthorized modifications to .npmrc or registry configuration.'
|
|
214
|
+
},
|
|
215
|
+
{
|
|
216
|
+
id: 'ZFT-024',
|
|
217
|
+
alias: 'MODULE_REPOS_HIJACKING',
|
|
218
|
+
name: 'Module/Repository Hijacking',
|
|
219
|
+
requires: ['MODULE_TAMPER'],
|
|
220
|
+
priority: 2,
|
|
221
|
+
baseScore: 75,
|
|
222
|
+
description: 'Detection of unauthorized write operations into node_modules or .git directories.'
|
|
223
|
+
},
|
|
224
|
+
{
|
|
225
|
+
id: 'ZFT-025',
|
|
226
|
+
alias: 'REVERSE_SHELL_PATTERN',
|
|
227
|
+
name: 'Reverse Shell Behavior',
|
|
228
|
+
requires: ['REVERSE_SHELL_BEHAVIOR'],
|
|
229
|
+
priority: 1,
|
|
230
|
+
baseScore: 95,
|
|
231
|
+
description: 'Detection of network sockets being piped directly into system shells (Reverse Shell pattern).'
|
|
232
|
+
},
|
|
233
|
+
{
|
|
234
|
+
id: 'ZFT-026',
|
|
235
|
+
alias: 'REGISTRY_PUBLISH_ATTEMPT',
|
|
236
|
+
name: 'Registry Publication Attempt',
|
|
237
|
+
requires: ['PUBLISH_SINK'],
|
|
238
|
+
priority: 1,
|
|
239
|
+
baseScore: 85,
|
|
240
|
+
description: 'Detection of attempts to run npm publish or interact with registry upload APIs (Worm behavior).'
|
|
241
|
+
},
|
|
242
|
+
{
|
|
243
|
+
id: 'ZFT-027',
|
|
244
|
+
alias: 'FINGERPRINT_OS_TARGETING',
|
|
245
|
+
name: 'OS Fingerprinting & Targeting',
|
|
246
|
+
requires: ['FINGERPRINT_SIGNAL'],
|
|
247
|
+
optional: ['NETWORK_SINK', 'SHELL_EXECUTION'],
|
|
248
|
+
priority: 2,
|
|
249
|
+
baseScore: 55,
|
|
250
|
+
description: 'Detection of OS metadata collection (platform, release, arch) potentially for targeted payload delivery.'
|
|
251
|
+
},
|
|
252
|
+
{
|
|
253
|
+
id: 'ZFT-028',
|
|
254
|
+
alias: 'WORM_PROPAGATION_CHAIN',
|
|
255
|
+
name: 'Automated Worm Propagation',
|
|
256
|
+
requires: ['CREDENTIAL_FILE_ACCESS', 'NETWORK_SINK', 'PUBLISH_SINK'],
|
|
257
|
+
priority: 1,
|
|
258
|
+
baseScore: 100,
|
|
259
|
+
description: 'Detection of the full worm cycle: Harvest tokens -> Exfiltrate -> Self-publish.'
|
|
260
|
+
},
|
|
261
|
+
{
|
|
262
|
+
id: 'ZFT-029',
|
|
263
|
+
alias: 'LIFECYCLE_BINARY_FETCH',
|
|
264
|
+
name: 'Lifecycle Binary Drop',
|
|
265
|
+
requires: ['REMOTE_FETCH_SIGNAL', 'LIFECYCLE_CONTEXT'],
|
|
266
|
+
optional: ['SHELL_EXECUTION'],
|
|
267
|
+
priority: 1,
|
|
268
|
+
baseScore: 90,
|
|
269
|
+
description: 'Detection of remote payload fetching specifically during package install/lifecycle scripts.'
|
|
140
270
|
}
|
|
141
271
|
];
|
|
142
272
|
|
|
143
273
|
const CATEGORIES = {
|
|
144
|
-
SOURCES: ['ENV_READ', 'FILE_READ_SENSITIVE', 'MASS_ENV_ACCESS'],
|
|
145
|
-
SINKS: ['NETWORK_SINK', 'DNS_SINK', 'RAW_SOCKET_SINK', 'DYNAMIC_EXECUTION', 'SHELL_EXECUTION', 'DYNAMIC_REQUIRE'],
|
|
146
|
-
SIGNALS: ['OBFUSCATION', 'ENCODER_USE', 'REMOTE_FETCH_SIGNAL', 'PIPE_TO_SHELL_SIGNAL', 'NATIVE_BINARY_DETECTED', 'OPAQUE_STRING_SKIP', 'NON_DETERMINISTIC_SINK'],
|
|
274
|
+
SOURCES: ['ENV_READ', 'FILE_READ_SENSITIVE', 'MASS_ENV_ACCESS', 'CREDENTIAL_FILE_ACCESS', 'DISCORD_STORAGE_ACCESS', 'CICD_SECRET_ACCESS'],
|
|
275
|
+
SINKS: ['NETWORK_SINK', 'DNS_SINK', 'RAW_SOCKET_SINK', 'DYNAMIC_EXECUTION', 'SHELL_EXECUTION', 'DYNAMIC_REQUIRE', 'WEBHOOK_SINK', 'WIPER_OPERATION', 'REGISTRY_TAMPER', 'MODULE_TAMPER', 'REVERSE_SHELL_BEHAVIOR', 'PUBLISH_SINK'],
|
|
276
|
+
SIGNALS: ['OBFUSCATION', 'ENCODER_USE', 'REMOTE_FETCH_SIGNAL', 'PIPE_TO_SHELL_SIGNAL', 'NATIVE_BINARY_DETECTED', 'OPAQUE_STRING_SKIP', 'NON_DETERMINISTIC_SINK', 'EVASION_ENVIRONMENT_CHECK', 'WALLET_HOOK', 'FINGERPRINT_SIGNAL'],
|
|
147
277
|
PERSISTENCE: ['FILE_WRITE_STARTUP'],
|
|
148
278
|
CONTEXT: ['LIFECYCLE_CONTEXT']
|
|
149
279
|
};
|
package/src/scanner.js
CHANGED
|
@@ -45,7 +45,19 @@ class PackageScanner {
|
|
|
45
45
|
EXPORTS: [],
|
|
46
46
|
IMPORTS: [],
|
|
47
47
|
NATIVE_BINARY_DETECTED: [],
|
|
48
|
-
OPAQUE_STRING_SKIP: []
|
|
48
|
+
OPAQUE_STRING_SKIP: [],
|
|
49
|
+
CREDENTIAL_FILE_ACCESS: [],
|
|
50
|
+
DISCORD_STORAGE_ACCESS: [],
|
|
51
|
+
WEBHOOK_SINK: [],
|
|
52
|
+
EVASION_ENVIRONMENT_CHECK: [],
|
|
53
|
+
WALLET_HOOK: [],
|
|
54
|
+
CICD_SECRET_ACCESS: [],
|
|
55
|
+
WIPER_OPERATION: [],
|
|
56
|
+
REGISTRY_TAMPER: [],
|
|
57
|
+
MODULE_TAMPER: [],
|
|
58
|
+
REVERSE_SHELL_BEHAVIOR: [],
|
|
59
|
+
FINGERPRINT_SIGNAL: [],
|
|
60
|
+
PUBLISH_SINK: []
|
|
49
61
|
},
|
|
50
62
|
flows: []
|
|
51
63
|
};
|
|
@@ -117,13 +129,13 @@ class PackageScanner {
|
|
|
117
129
|
facts.EXPORTS.forEach(exp => {
|
|
118
130
|
if (!exportMap.has(exp.file)) exportMap.set(exp.file, new Map());
|
|
119
131
|
|
|
120
|
-
// Check if localName is tainted
|
|
121
|
-
const
|
|
122
|
-
const isNamedTainted = flows.some(f => f.file === exp.file && f.toVar === exp.name && f.fromVar.includes('process.env'));
|
|
132
|
+
// Check if localName is tainted (recursively)
|
|
133
|
+
const resolvedTaint = this.isVariableTainted(exp.local || exp.name, exp.file, flows);
|
|
123
134
|
|
|
124
135
|
exportMap.get(exp.file).set(exp.name, {
|
|
125
136
|
local: exp.local,
|
|
126
|
-
isTainted:
|
|
137
|
+
isTainted: !!resolvedTaint,
|
|
138
|
+
taintPath: resolvedTaint
|
|
127
139
|
});
|
|
128
140
|
});
|
|
129
141
|
|
|
@@ -132,7 +144,7 @@ class PackageScanner {
|
|
|
132
144
|
let resolvedPath;
|
|
133
145
|
if (imp.source.startsWith('.')) {
|
|
134
146
|
resolvedPath = path.resolve(path.dirname(imp.file), imp.source);
|
|
135
|
-
if (!resolvedPath.endsWith('.js')) resolvedPath += '.js';
|
|
147
|
+
if (!resolvedPath.endsWith('.js') && fs.existsSync(resolvedPath + '.js')) resolvedPath += '.js';
|
|
136
148
|
}
|
|
137
149
|
|
|
138
150
|
if (resolvedPath && exportMap.has(resolvedPath)) {
|
|
@@ -140,18 +152,50 @@ class PackageScanner {
|
|
|
140
152
|
const matchedExport = targetExports.get(imp.imported);
|
|
141
153
|
|
|
142
154
|
if (matchedExport && matchedExport.isTainted) {
|
|
143
|
-
// Mark as a virtual ENV_READ in the importing file
|
|
144
155
|
facts.ENV_READ.push({
|
|
145
156
|
file: imp.file,
|
|
146
157
|
line: imp.line,
|
|
147
|
-
variable: `[Cross-File] ${imp.local} (from ${imp.source})`,
|
|
158
|
+
variable: `[Symbolic Cross-File] ${imp.local} <- ${matchedExport.taintPath} (from ${imp.source})`,
|
|
148
159
|
isCrossFile: true
|
|
149
160
|
});
|
|
161
|
+
|
|
162
|
+
// Propagate further as a flow in this file
|
|
163
|
+
flows.push({
|
|
164
|
+
fromVar: matchedExport.taintPath,
|
|
165
|
+
toVar: imp.local,
|
|
166
|
+
file: imp.file,
|
|
167
|
+
line: imp.line,
|
|
168
|
+
type: 'cross-file-import'
|
|
169
|
+
});
|
|
150
170
|
}
|
|
151
171
|
}
|
|
152
172
|
});
|
|
153
173
|
}
|
|
154
174
|
|
|
175
|
+
isVariableTainted(varName, filePath, flows, visited = new Set()) {
|
|
176
|
+
if (!varName) return null;
|
|
177
|
+
const key = `${filePath}:${varName}`;
|
|
178
|
+
if (visited.has(key)) return null;
|
|
179
|
+
visited.add(key);
|
|
180
|
+
|
|
181
|
+
if (varName.includes('process.env')) return varName;
|
|
182
|
+
|
|
183
|
+
const incoming = flows.filter(f => f.file === filePath && f.toVar === varName);
|
|
184
|
+
for (const flow of incoming) {
|
|
185
|
+
const result = this.isVariableTainted(flow.fromVar, filePath, flows, visited);
|
|
186
|
+
if (result) return result;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Check for property access patterns if base object is tainted
|
|
190
|
+
if (varName.includes('.')) {
|
|
191
|
+
const base = varName.split('.')[0];
|
|
192
|
+
const result = this.isVariableTainted(base, filePath, flows, visited);
|
|
193
|
+
if (result) return `${result}.${varName.split('.').slice(1).join('.')}`;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return null;
|
|
197
|
+
}
|
|
198
|
+
|
|
155
199
|
async getFiles() {
|
|
156
200
|
// Load .ziftignore
|
|
157
201
|
const ziftIgnorePath = path.join(this.packageDir, '.ziftignore');
|
|
@@ -161,23 +205,26 @@ class PackageScanner {
|
|
|
161
205
|
ignoreLines = [...ignoreLines, ...content.split('\n').map(l => l.trim()).filter(l => l && !l.startsWith('#'))];
|
|
162
206
|
}
|
|
163
207
|
|
|
164
|
-
const getJsFiles = (
|
|
208
|
+
const getJsFiles = (dirOrFile) => {
|
|
209
|
+
const stats = fs.statSync(dirOrFile);
|
|
210
|
+
if (!stats.isDirectory()) {
|
|
211
|
+
if (dirOrFile.endsWith('.js') || dirOrFile.endsWith('.node')) {
|
|
212
|
+
return [dirOrFile];
|
|
213
|
+
}
|
|
214
|
+
return [];
|
|
215
|
+
}
|
|
216
|
+
|
|
165
217
|
const results = [];
|
|
166
|
-
const list = fs.readdirSync(
|
|
218
|
+
const list = fs.readdirSync(dirOrFile);
|
|
167
219
|
for (const file of list) {
|
|
168
|
-
const fullPath = path.join(
|
|
220
|
+
const fullPath = path.join(dirOrFile, file);
|
|
169
221
|
const relativePath = path.relative(this.packageDir, fullPath);
|
|
170
222
|
|
|
171
223
|
// Simple ignore check
|
|
172
224
|
if (ignoreLines.some(pattern => relativePath.includes(pattern) || file === pattern)) continue;
|
|
173
225
|
if (file.startsWith('.') && file !== '.ziftignore') continue;
|
|
174
226
|
|
|
175
|
-
|
|
176
|
-
if (stat && stat.isDirectory()) {
|
|
177
|
-
results.push(...getJsFiles(fullPath));
|
|
178
|
-
} else if (file.endsWith('.js') || file.endsWith('.node')) {
|
|
179
|
-
results.push(fullPath);
|
|
180
|
-
}
|
|
227
|
+
results.push(...getJsFiles(fullPath));
|
|
181
228
|
}
|
|
182
229
|
return results;
|
|
183
230
|
};
|
|
@@ -201,7 +248,7 @@ class PackageScanner {
|
|
|
201
248
|
type: t.type,
|
|
202
249
|
file: path.relative(this.packageDir, t.file),
|
|
203
250
|
line: t.line,
|
|
204
|
-
context: t.reason || t.callee || t.variable || t.path
|
|
251
|
+
context: t.reason || t.callee || t.variable || t.path || t.url || t.context
|
|
205
252
|
}))
|
|
206
253
|
};
|
|
207
254
|
}),
|