@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 CHANGED
@@ -1,4 +1,4 @@
1
- # 🛡️ Zift (v4.1.0)
1
+ # 🛡️ Zift (v4.2.0)
2
2
 
3
3
  [![npm version](https://img.shields.io/npm/v/@7nsane/zift.svg?style=flat-square)](https://www.npmjs.com/package/@7nsane/zift)
4
4
  [![License](https://img.shields.io/npm/l/@7nsane/zift.svg?style=flat-square)](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 v5.0 is the "Intelligence" release, introducing **Symbolic Taint Analysis**. It can track sensitive data through complex code transformations, destructuring, and nested object structures across module boundaries.
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 (v5.0.0)
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
- - **🌍 Recursive Cross-File Intelligence**: Follows sensitive data even when it's re-exported through multiple intermediate files and objects.
16
- - **🛡️ Immutable Runtime Guards**: Structural protection for `http` and `child_process` sinks (v4.0 legacy).
17
- - **🧩 Opaque Payload Detection**: Flags native binaries (`.node`) and high-entropy skipped strings.
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) && fs.lstatSync(target).isDirectory();
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@7nsane/zift",
3
- "version": "4.1.0",
3
+ "version": "4.2.0",
4
4
  "description": "A high-performance, deterministic security scanner for npm packages.",
5
5
  "main": "src/scanner.js",
6
6
  "bin": {
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
- facts.FILE_READ_SENSITIVE.push({
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: node.arguments[0] ? sourceCode.substring(node.arguments[0].start, node.arguments[0].end) : 'unknown'
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
- return sensitive.some((s) => pathValue.toLowerCase().includes(s));
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
- // Specialist Rule: Startup Mod (ZFT-012) requires specific file paths (now explicit in definitions but engine may still help)
36
- // But per review, we should aim for explicit facts.
37
- // ZFT-012 now just requires FILE_WRITE_STARTUP. Simple.
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
  }
@@ -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 = (dir) => {
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(dir);
218
+ const list = fs.readdirSync(dirOrFile);
199
219
  for (const file of list) {
200
- const fullPath = path.join(dir, file);
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
- const stat = fs.statSync(fullPath);
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
  }),