@7nsane/zift 4.1.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 +7 -6
- package/bin/zift.js +8 -1
- package/package.json +1 -1
- package/src/collector.js +300 -24
- package/src/engine.js +16 -6
- package/src/rules/definitions.js +133 -3
- package/src/scanner.js +26 -11
package/README.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
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)
|
|
@@ -6,15 +6,16 @@
|
|
|
6
6
|
|
|
7
7
|
**The Symbolically-Intelligent Ecosystem Security Engine for JavaScript.**
|
|
8
8
|
|
|
9
|
-
Zift
|
|
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 (
|
|
11
|
+
## 🚀 Key Advancements (v4.2.0)
|
|
12
12
|
|
|
13
13
|
- **🧠 Symbolic Taint Analysis**: Tracks data through destructuring (`const { key } = process.env`) and deep property access (`obj.a.b.c`).
|
|
14
14
|
- **🧬 Transformation Tracking**: Automatically follows taint through encoding methods like `Buffer.from(data).toString('base64')` or `hex`.
|
|
15
|
-
-
|
|
16
|
-
- **🛡️
|
|
17
|
-
-
|
|
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
|
|
|
@@ -175,6 +263,23 @@ class ASTCollector {
|
|
|
175
263
|
line: node.loc.start.line,
|
|
176
264
|
callee: calleeCode
|
|
177
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
|
+
}
|
|
178
283
|
}
|
|
179
284
|
|
|
180
285
|
if (this.isShellSink(calleeCode)) {
|
|
@@ -206,10 +311,30 @@ class ASTCollector {
|
|
|
206
311
|
}
|
|
207
312
|
|
|
208
313
|
if (this.isSensitiveFileRead(calleeCode, node, sourceCode)) {
|
|
209
|
-
|
|
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({
|
|
210
335
|
file: filePath,
|
|
211
336
|
line: node.loc.start.line,
|
|
212
|
-
path:
|
|
337
|
+
path: sourceCode.substring(node.arguments[0].start, node.arguments[0].end)
|
|
213
338
|
});
|
|
214
339
|
}
|
|
215
340
|
|
|
@@ -221,6 +346,54 @@ class ASTCollector {
|
|
|
221
346
|
});
|
|
222
347
|
}
|
|
223
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
|
+
|
|
224
397
|
node.arguments.forEach((arg, index) => {
|
|
225
398
|
const argCode = sourceCode.substring(arg.start, arg.end);
|
|
226
399
|
const isArgTainted = argCode.includes('process.env') || flows.some(f => {
|
|
@@ -271,25 +444,6 @@ class ASTCollector {
|
|
|
271
444
|
}
|
|
272
445
|
}
|
|
273
446
|
},
|
|
274
|
-
MemberExpression: (node) => {
|
|
275
|
-
const objectCode = sourceCode.substring(node.object.start, node.object.end);
|
|
276
|
-
if (objectCode === 'process.env' || objectCode === 'process["env"]' || objectCode === "process['env']") {
|
|
277
|
-
const property = node.property.name || (node.property.type === 'Literal' ? node.property.value : null);
|
|
278
|
-
const whitelist = ['NODE_ENV', 'TIMING', 'DEBUG', 'VERBOSE', 'CI', 'APPDATA', 'HOME', 'USERPROFILE', 'PATH', 'PWD'];
|
|
279
|
-
if (whitelist.includes(property)) return;
|
|
280
|
-
|
|
281
|
-
envAccessCount++;
|
|
282
|
-
facts.ENV_READ.push({
|
|
283
|
-
file: filePath,
|
|
284
|
-
line: node.loc.start.line,
|
|
285
|
-
variable: property ? `process.env.${property}` : 'process.env'
|
|
286
|
-
});
|
|
287
|
-
|
|
288
|
-
if (envAccessCount > 5) {
|
|
289
|
-
facts.MASS_ENV_ACCESS.push({ file: filePath, line: node.loc.start.line, count: envAccessCount });
|
|
290
|
-
}
|
|
291
|
-
}
|
|
292
|
-
},
|
|
293
447
|
VariableDeclarator: (node) => {
|
|
294
448
|
if (node.init) {
|
|
295
449
|
const from = sourceCode.substring(node.init.start, node.init.end);
|
|
@@ -403,8 +557,16 @@ class ASTCollector {
|
|
|
403
557
|
|
|
404
558
|
if (node.arguments.length > 0 && node.arguments[0].type === 'Literal') {
|
|
405
559
|
const pathValue = String(node.arguments[0].value);
|
|
406
|
-
const sensitive = ['.ssh', '.env', 'shadow', 'passwd', 'credentials', 'token', '_netrc', 'aws_access_key'];
|
|
407
|
-
|
|
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
|
+
}
|
|
408
570
|
}
|
|
409
571
|
return false;
|
|
410
572
|
}
|
|
@@ -422,6 +584,21 @@ class ASTCollector {
|
|
|
422
584
|
return false;
|
|
423
585
|
}
|
|
424
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
|
+
|
|
425
602
|
tryEvaluate(node, sourceCode) {
|
|
426
603
|
try {
|
|
427
604
|
const code = sourceCode.substring(node.start, node.end);
|
|
@@ -458,6 +635,105 @@ class ASTCollector {
|
|
|
458
635
|
});
|
|
459
636
|
}
|
|
460
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
|
+
}
|
|
461
737
|
}
|
|
462
738
|
|
|
463
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
|
};
|
|
@@ -193,23 +205,26 @@ class PackageScanner {
|
|
|
193
205
|
ignoreLines = [...ignoreLines, ...content.split('\n').map(l => l.trim()).filter(l => l && !l.startsWith('#'))];
|
|
194
206
|
}
|
|
195
207
|
|
|
196
|
-
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
|
+
|
|
197
217
|
const results = [];
|
|
198
|
-
const list = fs.readdirSync(
|
|
218
|
+
const list = fs.readdirSync(dirOrFile);
|
|
199
219
|
for (const file of list) {
|
|
200
|
-
const fullPath = path.join(
|
|
220
|
+
const fullPath = path.join(dirOrFile, file);
|
|
201
221
|
const relativePath = path.relative(this.packageDir, fullPath);
|
|
202
222
|
|
|
203
223
|
// Simple ignore check
|
|
204
224
|
if (ignoreLines.some(pattern => relativePath.includes(pattern) || file === pattern)) continue;
|
|
205
225
|
if (file.startsWith('.') && file !== '.ziftignore') continue;
|
|
206
226
|
|
|
207
|
-
|
|
208
|
-
if (stat && stat.isDirectory()) {
|
|
209
|
-
results.push(...getJsFiles(fullPath));
|
|
210
|
-
} else if (file.endsWith('.js') || file.endsWith('.node')) {
|
|
211
|
-
results.push(fullPath);
|
|
212
|
-
}
|
|
227
|
+
results.push(...getJsFiles(fullPath));
|
|
213
228
|
}
|
|
214
229
|
return results;
|
|
215
230
|
};
|
|
@@ -233,7 +248,7 @@ class PackageScanner {
|
|
|
233
248
|
type: t.type,
|
|
234
249
|
file: path.relative(this.packageDir, t.file),
|
|
235
250
|
line: t.line,
|
|
236
|
-
context: t.reason || t.callee || t.variable || t.path
|
|
251
|
+
context: t.reason || t.callee || t.variable || t.path || t.url || t.context
|
|
237
252
|
}))
|
|
238
253
|
};
|
|
239
254
|
}),
|