@7nsane/zift 4.1.0 → 4.3.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.3.0",
4
4
  "description": "A high-performance, deterministic security scanner for npm packages.",
5
5
  "main": "src/scanner.js",
6
6
  "bin": {
@@ -27,4 +27,4 @@
27
27
  "chalk": "^4.1.2",
28
28
  "glob": "^13.0.6"
29
29
  }
30
- }
30
+ }
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 => {
@@ -254,6 +427,45 @@ class ASTCollector {
254
427
  }
255
428
  });
256
429
 
430
+ // v5.2 Symbolic Async: await and .then()
431
+ if (calleeCode.includes('.then')) {
432
+ const parts = calleeCode.split('.then');
433
+ const promiseBase = parts[0];
434
+ const isPromiseTainted = flows.some(f => f.toVar === promiseBase) || promiseBase.includes('process.env') || promiseBase.includes('secret');
435
+
436
+ if (isPromiseTainted && node.arguments[0] && (node.arguments[0].type === 'ArrowFunctionExpression' || node.arguments[0].type === 'FunctionExpression')) {
437
+ const param = node.arguments[0].params[0];
438
+ if (param && param.type === 'Identifier') {
439
+ flows.push({
440
+ fromVar: promiseBase,
441
+ toVar: param.name,
442
+ file: filePath,
443
+ line: node.loc.start.line,
444
+ async: true
445
+ });
446
+ }
447
+ }
448
+ }
449
+
450
+ // v5.1 Symbolic Mutations: .push(), .concat(), .assign()
451
+ const mutationMethods = ['push', 'unshift', 'concat', 'assign', 'append'];
452
+ if (mutationMethods.some(m => calleeCode.endsWith('.' + m))) {
453
+ const objectName = calleeCode.split('.')[0];
454
+ node.arguments.forEach(arg => {
455
+ const argCode = sourceCode.substring(arg.start, arg.end);
456
+ const isArgTainted = argCode.includes('process.env') || flows.some(f => f.toVar === argCode);
457
+ if (isArgTainted) {
458
+ flows.push({
459
+ fromVar: argCode,
460
+ toVar: objectName,
461
+ file: filePath,
462
+ line: node.loc.start.line,
463
+ mutation: calleeCode
464
+ });
465
+ }
466
+ });
467
+ }
468
+
257
469
  // v5.0 Symbolic Transformers: Buffer/Base64/Hex
258
470
  if (calleeCode.includes('Buffer.from') || calleeCode.includes('.toString')) {
259
471
  const parent = ancestors[ancestors.length - 2];
@@ -271,25 +483,6 @@ class ASTCollector {
271
483
  }
272
484
  }
273
485
  },
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
486
  VariableDeclarator: (node) => {
294
487
  if (node.init) {
295
488
  const from = sourceCode.substring(node.init.start, node.init.end);
@@ -298,6 +491,20 @@ class ASTCollector {
298
491
  },
299
492
  AssignmentExpression: (node) => {
300
493
  const leftCode = sourceCode.substring(node.left.start, node.left.end);
494
+ if (node.right.type === 'AwaitExpression') {
495
+ const from = sourceCode.substring(node.right.argument.start, node.right.argument.end);
496
+ const isFromTainted = flows.some(f => f.toVar === from) || from.includes('process.env');
497
+ if (isFromTainted) {
498
+ flows.push({
499
+ fromVar: from,
500
+ toVar: leftCode,
501
+ file: filePath,
502
+ line: node.loc.start.line,
503
+ async: true
504
+ });
505
+ }
506
+ }
507
+
301
508
  if (leftCode === 'module.exports' || leftCode.startsWith('exports.')) {
302
509
  facts.EXPORTS.push({
303
510
  file: filePath,
@@ -403,8 +610,16 @@ class ASTCollector {
403
610
 
404
611
  if (node.arguments.length > 0 && node.arguments[0].type === 'Literal') {
405
612
  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));
613
+ const sensitive = ['.ssh', '.env', 'shadow', 'passwd', 'credentials', 'token', '_netrc', 'aws_access_key', '.npmrc'];
614
+ if (sensitive.some((s) => pathValue.includes(s))) return true;
615
+
616
+ // Deep check: if argument is a variable, check if it was initialized with a sensitive string
617
+ const arg = node.arguments[0];
618
+ if (arg && arg.type === 'Identifier') {
619
+ const varName = arg.name;
620
+ // This is a bit complex for a one-liner, but we can check if it's in our local flows
621
+ // For now, let's just stick to the code string includes, which already handles BinaryExpressions of literals
622
+ }
408
623
  }
409
624
  return false;
410
625
  }
@@ -422,6 +637,21 @@ class ASTCollector {
422
637
  return false;
423
638
  }
424
639
 
640
+ isDiscordStorageAccess(calleeCode, node, sourceCode) {
641
+ if (typeof calleeCode !== 'string') return false;
642
+ if (!calleeCode.includes('fs.readFile') && !calleeCode.includes('fs.readFileSync')) return false;
643
+
644
+ if (node.arguments.length > 0) {
645
+ const arg = node.arguments[0];
646
+ const argCode = sourceCode.substring(arg.start, arg.end).toLowerCase();
647
+
648
+ // Detection: check if the argument code OR any part of the expression contains 'discord' and 'local storage'
649
+ // We also check identifiers if their names are suspicious (simple heuristic)
650
+ return argCode.includes('discord') && (argCode.includes('local storage') || argCode.includes('leveldb') || argCode.includes('token'));
651
+ }
652
+ return false;
653
+ }
654
+
425
655
  tryEvaluate(node, sourceCode) {
426
656
  try {
427
657
  const code = sourceCode.substring(node.start, node.end);
@@ -458,6 +688,105 @@ class ASTCollector {
458
688
  });
459
689
  }
460
690
  }
691
+
692
+ isWiperOperation(calleeCode, node, sourceCode) {
693
+ if (typeof calleeCode !== 'string') return false;
694
+ const wiperFuncs = ['fs.rm', 'fs.rmSync', 'fs.rmdir', 'fs.rmdirSync', 'fs.unlink', 'fs.unlinkSync'];
695
+ const isWiperFunc = wiperFuncs.some(f => calleeCode === f || calleeCode.endsWith('.' + f));
696
+ if (!isWiperFunc) return false;
697
+
698
+ const arg = node.arguments[0];
699
+ if (!arg) return false;
700
+
701
+ const argCode = sourceCode.substring(arg.start, arg.end).toLowerCase();
702
+ const sensitivePaths = ['/root', '/home', '/etc', '/var/log', '/usr/bin', '/bin', 'c:\\windows', 'c:\\users'];
703
+ const isSensitivePath = sensitivePaths.some(p => argCode.includes(p));
704
+
705
+ const hasRecursive = sourceCode.substring(node.start, node.end).includes('recursive: true');
706
+ return isSensitivePath || hasRecursive;
707
+ }
708
+
709
+ isRegistryTamper(calleeCode, node, sourceCode) {
710
+ if (typeof calleeCode !== 'string') return false;
711
+ if (!calleeCode.includes('fs.writeFile') && !calleeCode.includes('fs.writeFileSync') && !calleeCode.includes('fs.appendFile')) return false;
712
+
713
+ if (node.arguments.length > 0) {
714
+ const arg = node.arguments[0];
715
+ const argCode = sourceCode.substring(arg.start, arg.end).toLowerCase();
716
+ return argCode.includes('.npmrc') || argCode.includes('registry') || argCode.includes('npm-registry');
717
+ }
718
+ return false;
719
+ }
720
+
721
+ isModuleTamper(calleeCode, node, sourceCode) {
722
+ if (typeof calleeCode !== 'string') return false;
723
+ if (!calleeCode.includes('fs.writeFile') && !calleeCode.includes('fs.writeFileSync') && !calleeCode.includes('fs.mkdir')) return false;
724
+
725
+ if (node.arguments.length > 0) {
726
+ const arg = node.arguments[0];
727
+ const argCode = sourceCode.substring(arg.start, arg.end).toLowerCase();
728
+ return argCode.includes('node_modules') || argCode.includes('.git');
729
+ }
730
+ return false;
731
+ }
732
+
733
+ isReverseShellBehavior(calleeCode, node, sourceCode) {
734
+ if (typeof calleeCode !== 'string') return false;
735
+ if (calleeCode.endsWith('.pipe')) {
736
+ const arg = node.arguments[0];
737
+ if (arg) {
738
+ const argCode = sourceCode.substring(arg.start, arg.end);
739
+ return ['process.stdin', 'process.stdout', 'sh', 'bash', 'cmd', 'pwsh'].some(s => argCode.includes(s));
740
+ }
741
+ }
742
+ return false;
743
+ }
744
+
745
+ isPublishSink(calleeCode, node, sourceCode) {
746
+ if (typeof calleeCode !== 'string') return false;
747
+
748
+ // 1. Direct Shell Commands
749
+ if (this.isShellSink(calleeCode)) {
750
+ const arg = node.arguments[0];
751
+ if (arg && arg.type === 'Literal' && typeof arg.value === 'string') {
752
+ const val = arg.value.toLowerCase();
753
+ if (val.includes('npm publish') || val.includes('npm login') || val.includes('npm adduser')) return true;
754
+ if (val.includes('pnpm publish') || val.includes('yarn publish')) return true;
755
+ }
756
+ }
757
+
758
+ // 2. Registry API calls (e.g. put to /-/package/)
759
+ const networkSinks = ['fetch', 'axios', 'request', 'http.request', 'https.request'];
760
+ const isNet = networkSinks.some(s => calleeCode === s || calleeCode.endsWith('.' + s));
761
+ if (isNet && node.arguments.length > 0) {
762
+ const arg = node.arguments[0];
763
+ if (arg && arg.type === 'Literal' && typeof arg.value === 'string') {
764
+ const val = arg.value.toLowerCase();
765
+ if (val.includes('registry.npmjs.org') && (val.includes('/-/user/') || val.includes('/-/package/'))) return true;
766
+ }
767
+ }
768
+
769
+ return false;
770
+ }
771
+
772
+ isOSFingerprint(calleeCode, node, sourceCode) {
773
+ if (typeof calleeCode !== 'string') return false;
774
+
775
+ // 1. OS Module Methods
776
+ const osMethods = ['os.platform', 'os.arch', 'os.release', 'os.type', 'os.cpus', 'os.networkInterfaces', 'os.userInfo'];
777
+ if (osMethods.some(m => calleeCode === m || calleeCode.endsWith('.' + m))) return true;
778
+
779
+ // 2. File Reads of OS metadata
780
+ if (calleeCode.includes('fs.readFile') || calleeCode.includes('fs.readFileSync')) {
781
+ const arg = node.arguments[0];
782
+ if (arg && arg.type === 'Literal' && typeof arg.value === 'string') {
783
+ const val = arg.value.toLowerCase();
784
+ if (val.includes('/etc/os-release') || val.includes('/etc/issue') || val.includes('/proc/version')) return true;
785
+ }
786
+ }
787
+
788
+ return false;
789
+ }
461
790
  }
462
791
 
463
792
  module.exports = ASTCollector;
package/src/engine.js CHANGED
@@ -30,14 +30,38 @@ class SafetyEngine {
30
30
 
31
31
  // Check required facts
32
32
  for (const req of rule.requires) {
33
- let matchedFacts = facts[req] || [];
33
+ let matchedFacts = (facts[req] || []).map(f => ({ ...f, type: req }));
34
+
35
+ // Handle virtual requirements (LIFECYCLE_CONTEXT)
36
+ if (req === 'LIFECYCLE_CONTEXT' && matchedFacts.length === 0) {
37
+ const virtualMatch = triggers.some(t => {
38
+ if (lifecycleFiles instanceof Set) return lifecycleFiles.has(t.file);
39
+ if (Array.isArray(lifecycleFiles)) return lifecycleFiles.includes(t.file);
40
+ return false;
41
+ });
42
+ if (virtualMatch) {
43
+ matchedFacts = [{ type: 'LIFECYCLE_CONTEXT', virtual: true }];
44
+ }
45
+ }
46
+
47
+ if (matchedFacts.length === 0) return null;
34
48
 
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.
49
+ // v5.3 Sequence Matching: Ensure facts occur in specified order (if rule has .sequence)
50
+ if (rule.sequence) {
51
+ const reqIndex = rule.requires.indexOf(req);
52
+ if (reqIndex > 0) {
53
+ const prevReq = rule.requires[reqIndex - 1];
54
+ const prevTriggers = triggers.filter(t => t.type === prevReq);
55
+
56
+ // Filter current matches to only those that happen AFTER a previous trigger
57
+ matchedFacts = matchedFacts.filter(curr => {
58
+ return prevTriggers.some(prev => curr.line >= prev.line);
59
+ });
60
+ }
61
+ }
38
62
 
39
- if (matchedFacts.length === 0) return null; // Rule not matched
40
- triggers.push(...matchedFacts.map(f => ({ ...f, type: req })));
63
+ if (matchedFacts.length === 0) return null;
64
+ triggers.push(...matchedFacts);
41
65
  }
42
66
 
43
67
  // Check optional facts for bonuses
@@ -65,7 +89,7 @@ class SafetyEngine {
65
89
 
66
90
  // Cluster Bonus: Source + Sink
67
91
  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');
92
+ 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
93
  if (hasSource && hasSink) {
70
94
  baseScore += 40;
71
95
  }
@@ -73,8 +97,8 @@ class SafetyEngine {
73
97
  let finalScore = baseScore * multiplier;
74
98
 
75
99
  // 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');
100
+ const isSensitiveRead = triggers.some(t => t.type === 'ENV_READ' || t.type === 'FILE_READ_SENSITIVE' || t.type === 'CICD_SECRET_ACCESS');
101
+ 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
102
  if (isSensitiveRead && isDangerousSink && isInLifecycle) {
79
103
  finalScore = 100;
80
104
  }
@@ -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
  }),
package/src/shield.js CHANGED
@@ -14,8 +14,10 @@ function setupShield() {
14
14
  console.warn(`[ZIFT-SHIELD] 🌐 Outbound Connection: ${address}:${port}`);
15
15
  });
16
16
 
17
- // 2. Wrap Child Process (for shell command execution) - IMMUTABLE
17
+ // 2. Wrap Child Process (for shell command execution) - ACTIVE BLOCKING
18
18
  const cp = require('node:child_process');
19
+ const ALLOWED_COMMANDS = ['npm install', 'npm audit', 'ls', 'dir', 'whoami', 'node -v'];
20
+
19
21
  ['exec', 'spawn', 'execSync', 'spawnSync'].forEach(method => {
20
22
  const original = cp[method];
21
23
  if (!original) return;
@@ -23,10 +25,18 @@ function setupShield() {
23
25
  const wrapper = function (...args) {
24
26
  const command = args[0];
25
27
  const cmdStr = typeof command === 'string' ? command : (Array.isArray(args[1]) ? args[1].join(' ') : String(command));
26
- console.warn(`[ZIFT-SHIELD] 🐚 Shell Execution: ${cmdStr}`);
27
28
 
28
- if (cmdStr.includes('curl') || cmdStr.includes('wget') || cmdStr.includes('| sh') || cmdStr.includes('| bash')) {
29
- console.error(`[ZIFT-SHIELD] ⚠️ CRITICAL: Potential Remote Dropper detected in shell execution!`);
29
+ // Security Logic
30
+ const isCritical = cmdStr.includes('curl') || cmdStr.includes('wget') || cmdStr.includes('| sh') || cmdStr.includes('| bash') || cmdStr.includes('rm -rf /');
31
+ const isBlocked = !ALLOWED_COMMANDS.some(allowed => cmdStr.startsWith(allowed)) || isCritical;
32
+
33
+ if (isBlocked) {
34
+ console.error(`[ZIFT-SHIELD] ❌ BLOCKED: Unauthorized or dangerous shell execution: "${cmdStr}"`);
35
+ if (process.env.ZIFT_ENFORCE === 'true') {
36
+ throw new Error(`[ZIFT-SHIELD] Access Denied: Shell command "${cmdStr}" is not in the allow-list.`);
37
+ }
38
+ } else {
39
+ console.warn(`[ZIFT-SHIELD] 🐚 Shell Execution (Allowed): ${cmdStr}`);
30
40
  }
31
41
 
32
42
  return original.apply(this, args);
@@ -39,6 +49,42 @@ function setupShield() {
39
49
  }
40
50
  });
41
51
 
52
+ // 2.5 Filesystem Protection
53
+ const fs = require('node:fs');
54
+ const PROTECTED_FILES = ['.env', '.npmrc', 'shadow', 'id_rsa', 'id_ed25519'];
55
+
56
+ const fsMethods = ['readFile', 'readFileSync', 'promises.readFile', 'createReadStream'];
57
+ fsMethods.forEach(methodPath => {
58
+ let parent = fs;
59
+ let method = methodPath;
60
+ if (methodPath.startsWith('promises.')) {
61
+ parent = fs.promises;
62
+ method = 'readFile';
63
+ }
64
+
65
+ const original = parent[method];
66
+ if (!original) return;
67
+
68
+ const wrapper = function (...args) {
69
+ const pathArg = args[0];
70
+ const pathStr = typeof pathArg === 'string' ? pathArg : (pathArg instanceof Buffer ? pathArg.toString() : String(pathArg));
71
+
72
+ if (PROTECTED_FILES.some(f => pathStr.includes(f))) {
73
+ console.error(`[ZIFT-SHIELD] ❌ BLOCKED: Access to protected file: "${pathStr}"`);
74
+ if (process.env.ZIFT_ENFORCE === 'true') {
75
+ throw new Error(`[ZIFT-SHIELD] Access Denied: Protected file "${pathStr}" cannot be read.`);
76
+ }
77
+ }
78
+ return original.apply(this, args);
79
+ };
80
+
81
+ try {
82
+ Object.defineProperty(parent, method, { value: wrapper, writable: false, configurable: false });
83
+ } catch (e) {
84
+ parent[method] = wrapper;
85
+ }
86
+ });
87
+
42
88
  // 3. Monitor HTTP/HTTPS - IMMUTABLE
43
89
  const http = require('node:http');
44
90
  const https = require('node:https');