@7nsane/zift 1.2.0 → 2.0.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,6 +1,6 @@
1
- # Zift šŸ›”ļø
1
+ # šŸ›”ļø Zift (v2.0.0)
2
2
 
3
- **Zift** is an elite, high-performance security scanner designed to detect malicious patterns in npm packages before they are executed. By using deterministic AST analysis and lightweight variable propagation, Zift identifies potential credential exfiltration, malicious persistence, and obfuscated execution with extreme precision.
3
+ **The Deterministic Pre-install Security Gate for JavaScript Projects.** By using deterministic AST analysis and lightweight variable propagation, Zift identifies potential credential exfiltration, malicious persistence, and obfuscated execution with extreme precision.
4
4
 
5
5
  ## Installation
6
6
 
@@ -23,6 +23,20 @@ zift setup
23
23
  npm install <package-name> --zift
24
24
  ```
25
25
 
26
+ ## šŸ” Limitations & Blind Spots (v2.0.0)
27
+
28
+ To maintain a zero-false-positive baseline and high performance, Zift v2 focus on **deterministic behavioral patterns**. It does NOT currently cover:
29
+
30
+ - **Cross-file Taint**: Taint tracking is limited to intra-file propagation.
31
+ - **Runtime Decryption**: Logic that decrypts and executes memory-only payloads at runtime.
32
+ - **VM-based Execution**: Malicious payloads executed inside isolated virtual machine environments.
33
+ - **Multi-stage Loaders**: Sophisticated multi-hop obfuscation that reconstructs logic over several cycles.
34
+ - **Post-install Generation**: Malicious code generated or downloaded *after* the initial install/preinstall phase.
35
+
36
+ **Positioning**: Zift is a *Deterministic Pre-install Behavioral Security Gate*. It is designed to catch the most common and damaging malware patterns instantly, not to serve as a complete, multi-layer supply-chain defense.
37
+
38
+ ## License
39
+ MIT
26
40
  ## Usage
27
41
 
28
42
  ### šŸš€ Secure Installer Mode
package/bin/zift.js CHANGED
@@ -14,11 +14,15 @@ async function main() {
14
14
  let isInstallMode = false;
15
15
  let installer = 'npm';
16
16
 
17
- // 1. Setup Command
17
+ // 1. Setup & Init Commands
18
18
  if (args[0] === 'setup') {
19
19
  await runSetup();
20
20
  return;
21
21
  }
22
+ if (args[0] === 'init') {
23
+ runInit();
24
+ return;
25
+ }
22
26
 
23
27
  // 2. Detection for bun/pnpm usage
24
28
  if (args.includes('--bun')) installer = 'bun';
@@ -117,6 +121,15 @@ ${cmd}() {
117
121
 
118
122
  async function runRemoteAudit(packageName, format, installer) {
119
123
  if (format === 'text') console.log(chalk.blue(`\nšŸŒ Remote Audit [via ${installer}]: Pre-scanning '${packageName}'...`));
124
+
125
+ // Typosquat Check
126
+ const { checkTyposquat } = require('../src/utils/typo');
127
+ const typoMatch = checkTyposquat(packageName);
128
+ if (typoMatch && format === 'text') {
129
+ console.log(chalk.red.bold(`\nāš ļø TYPOSQUAT WARNING: '${packageName}' is very similar to '${typoMatch.target}'!`));
130
+ console.log(chalk.red(` If you meant '${typoMatch.target}', stop now.\n`));
131
+ }
132
+
120
133
  const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'zift-audit-'));
121
134
  try {
122
135
  cp.execSync(`npm pack ${packageName}`, { cwd: tmpDir, stdio: 'ignore' });
@@ -144,31 +157,97 @@ async function runRemoteAudit(packageName, format, installer) {
144
157
  } catch (err) { cleanupAndExit(tmpDir, 1); }
145
158
  }
146
159
 
147
- function handleFindings(findings, format, targetDir, skipExit = false) {
160
+ async function runLocalScan(target, format) {
161
+ if (format === 'text') console.log(chalk.blue(`\nšŸ” Scanning local directory: ${path.resolve(target)}`));
162
+ const scanner = new PackageScanner(target);
163
+ const results = await scanner.scan();
164
+
165
+ // Auditing lockfiles
166
+ const LockfileAuditor = require('../src/lockfile');
167
+ const auditor = new LockfileAuditor(target);
168
+ const lockfileFindings = auditor.audit();
169
+
170
+ handleFindings({ ...results, lockfileFindings }, format, target);
171
+ }
172
+
173
+ function handleFindings(data, format, targetDir, skipExit = false) {
174
+ const { results: findings, lifecycleScripts, lockfileFindings = [] } = data;
175
+
148
176
  if (format === 'json') {
149
- process.stdout.write(JSON.stringify({ target: targetDir, findings, summary: { Critical: findings.filter(f => f.classification === 'Critical').length, High: findings.filter(f => f.classification === 'High').length, Medium: findings.filter(f => f.classification === 'Medium').length, Low: findings.filter(f => f.classification === 'Low').length } }, null, 2));
177
+ process.stdout.write(JSON.stringify({
178
+ target: targetDir,
179
+ findings,
180
+ lifecycleScripts,
181
+ lockfileFindings,
182
+ summary: getSummary(findings)
183
+ }, null, 2));
150
184
  if (!skipExit) process.exit(findings.some(f => f.score >= 90) ? 1 : 0);
151
185
  return;
152
186
  }
187
+
188
+ // Lifecycle Summary
189
+ if (Object.keys(lifecycleScripts).length > 0) {
190
+ console.log(chalk.bold('\nšŸ“¦ Detected Lifecycle Scripts:'));
191
+ for (const [hook, cmd] of Object.entries(lifecycleScripts)) {
192
+ console.log(chalk.yellow(` - ${hook}: `) + chalk.white(cmd));
193
+ }
194
+ }
195
+
196
+ // Lockfile Summary
197
+ if (lockfileFindings.length > 0) {
198
+ console.log(chalk.bold('\nšŸ”’ Lockfile Security Audit:'));
199
+ lockfileFindings.forEach(f => {
200
+ const color = f.severity === 'High' ? chalk.red : chalk.yellow;
201
+ console.log(color(` - [${f.severity}] ${f.package}: ${f.type} (${f.source})`));
202
+ });
203
+ }
204
+
153
205
  if (findings.length === 0) {
154
- if (!skipExit) { console.log(chalk.green('\nāœ… No suspicious patterns detected. All modules safe.')); process.exit(0); }
206
+ if (!skipExit) {
207
+ console.log(chalk.green('\nāœ… No suspicious AST patterns detected.'));
208
+ process.exit(0);
209
+ }
155
210
  return;
156
211
  }
212
+
213
+ console.log(chalk.bold('\nšŸ” Behavioral AST Findings:'));
157
214
  findings.forEach(f => {
158
215
  const color = { 'Critical': chalk.red.bold, 'High': chalk.red, 'Medium': chalk.yellow, 'Low': chalk.blue }[f.classification];
159
216
  console.log(color(`[${f.classification}] ${f.id} ${f.name} (Score: ${f.score})`));
160
217
  f.triggers.forEach(t => console.log(chalk.white(` - ${t.type} in ${t.file}:${t.line} [${t.context}]`)));
161
218
  console.log('');
162
219
  });
163
- if (!skipExit) process.exit(findings[0].score >= 90 ? 1 : 0);
220
+
221
+ if (!skipExit) process.exit(findings.some(f => f.score >= 90) ? 1 : 0);
222
+ }
223
+
224
+ function runInit() {
225
+ const config = {
226
+ severity: {
227
+ critical: 90,
228
+ high: 70,
229
+ medium: 50
230
+ },
231
+ ignore: ['node_modules', '.git', 'test', 'dist'],
232
+ parallel: true,
233
+ cache: true
234
+ };
235
+ fs.writeFileSync(path.join(process.cwd(), '.zift.json'), JSON.stringify(config, null, 2));
236
+ fs.writeFileSync(path.join(process.cwd(), '.ziftignore'), '# Add patterns to ignore here\nnode_modules\ndist\ncoverage\n');
237
+ console.log(chalk.green('\nāœ… Initialized Zift configuration (.zift.json and .ziftignore)'));
164
238
  }
165
239
 
166
240
  function showHelp() {
167
- console.log(chalk.blue.bold('\nšŸ›”ļø Zift - Universal Security Scanner\n'));
241
+ console.log(chalk.blue.bold('\nšŸ›”ļø Zift v2.0.0 - Intelligent Pre-install Security Gate\n'));
168
242
  console.log('Usage:');
169
243
  console.log(' zift setup Secure npm, bun, and pnpm');
244
+ console.log(' zift init Initialize configuration');
170
245
  console.log(' zift install <pkg> Scan and install package');
171
- console.log(' --bun / --pnpm Use a specific installer');
246
+ console.log(' zift . Scan current directory');
247
+ console.log('\nOptions:');
248
+ console.log(' --bun Use Bun for installation');
249
+ console.log(' --pnpm Use pnpm for installation');
250
+ console.log(' --format json Output as JSON');
172
251
  }
173
252
 
174
253
  function cleanupAndExit(dir, code) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@7nsane/zift",
3
- "version": "1.2.0",
3
+ "version": "2.0.0",
4
4
  "description": "A high-performance, deterministic security scanner for npm packages.",
5
5
  "main": "src/scanner.js",
6
6
  "bin": {
@@ -8,7 +8,7 @@
8
8
  "zift-scanner": "bin/zift.js"
9
9
  },
10
10
  "scripts": {
11
- "test": "node v1_launch_pack/tests/verify.js",
11
+ "test": "echo \"Error: no test specified\" && exit 1",
12
12
  "scan": "node bin/zift"
13
13
  },
14
14
  "keywords": [
package/src/collector.js CHANGED
@@ -4,22 +4,25 @@ const { calculateEntropy } = require('./utils/entropy');
4
4
 
5
5
  class ASTCollector {
6
6
  constructor() {
7
- this.facts = {
7
+ this.entropyThreshold = 4.8;
8
+ this.maxFileSize = 512 * 1024;
9
+ this.maxStringLengthForEntropy = 2048;
10
+ }
11
+
12
+ collect(code, filePath) {
13
+ const facts = {
8
14
  ENV_READ: [],
9
15
  FILE_READ_SENSITIVE: [],
10
16
  NETWORK_SINK: [],
11
17
  DYNAMIC_EXECUTION: [],
12
18
  OBFUSCATION: [],
13
- FILE_WRITE_STARTUP: []
19
+ FILE_WRITE_STARTUP: [],
20
+ SHELL_EXECUTION: [],
21
+ ENCODER_USE: []
14
22
  };
15
- this.flows = [];
16
- this.entropyThreshold = 4.8;
17
- this.maxFileSize = 512 * 1024; // 512KB cap for static analysis
18
- this.maxStringLengthForEntropy = 2048; // Don't calculate entropy for massive blobs
19
- }
23
+ const flows = [];
24
+ const sourceCode = code;
20
25
 
21
- collect(code, filePath) {
22
- this.sourceCode = code;
23
26
  let ast;
24
27
  try {
25
28
  ast = acorn.parse(code, { ecmaVersion: 2020, sourceType: 'module', locations: true });
@@ -27,7 +30,7 @@ class ASTCollector {
27
30
  try {
28
31
  ast = acorn.parse(code, { ecmaVersion: 2020, sourceType: 'script', locations: true });
29
32
  } catch (error_) {
30
- return { facts: this.facts, flows: this.flows };
33
+ return { facts, flows };
31
34
  }
32
35
  }
33
36
 
@@ -36,7 +39,7 @@ class ASTCollector {
36
39
  if (typeof node.value === 'string' && node.value.length > 20 && node.value.length < this.maxStringLengthForEntropy) {
37
40
  const entropy = calculateEntropy(node.value);
38
41
  if (entropy > this.entropyThreshold) {
39
- this.facts.OBFUSCATION.push({
42
+ facts.OBFUSCATION.push({
40
43
  file: filePath,
41
44
  line: node.loc.start.line,
42
45
  reason: `High entropy string (${entropy.toFixed(2)})`,
@@ -45,41 +48,88 @@ class ASTCollector {
45
48
  }
46
49
  }
47
50
  },
48
- CallExpression: (node) => {
49
- const calleeCode = this.getSourceCode(node.callee);
51
+ CallExpression: (node, state, ancestors) => {
52
+ const calleeCode = sourceCode.substring(node.callee.start, node.callee.end);
50
53
 
51
54
  if (calleeCode === 'eval' || calleeCode === 'Function') {
52
- this.facts.DYNAMIC_EXECUTION.push({
55
+ facts.DYNAMIC_EXECUTION.push({
53
56
  file: filePath,
54
57
  line: node.loc.start.line,
55
58
  type: calleeCode
56
59
  });
57
60
  }
58
61
 
62
+ if (calleeCode === 'require' && node.arguments.length > 0 && node.arguments[0].type !== 'Literal') {
63
+ facts.DYNAMIC_EXECUTION.push({
64
+ file: filePath,
65
+ line: node.loc.start.line,
66
+ type: 'dynamic_require',
67
+ variable: sourceCode.substring(node.arguments[0].start, node.arguments[0].end)
68
+ });
69
+ }
70
+
59
71
  if (this.isNetworkSink(calleeCode)) {
60
- this.facts.NETWORK_SINK.push({
72
+ facts.NETWORK_SINK.push({
73
+ file: filePath,
74
+ line: node.loc.start.line,
75
+ callee: calleeCode
76
+ });
77
+ }
78
+
79
+ if (this.isShellSink(calleeCode)) {
80
+ facts.SHELL_EXECUTION.push({
61
81
  file: filePath,
62
82
  line: node.loc.start.line,
63
83
  callee: calleeCode
64
84
  });
65
85
  }
66
86
 
67
- if (this.isSensitiveFileRead(calleeCode, node)) {
68
- this.facts.FILE_READ_SENSITIVE.push({
87
+ if (this.isEncoder(calleeCode)) {
88
+ facts.ENCODER_USE.push({
89
+ file: filePath,
90
+ line: node.loc.start.line,
91
+ type: calleeCode
92
+ });
93
+ }
94
+
95
+ if (this.isSensitiveFileRead(calleeCode, node, sourceCode)) {
96
+ facts.FILE_READ_SENSITIVE.push({
69
97
  file: filePath,
70
98
  line: node.loc.start.line,
71
- path: node.arguments[0] ? this.getSourceCode(node.arguments[0]) : 'unknown'
99
+ path: node.arguments[0] ? sourceCode.substring(node.arguments[0].start, node.arguments[0].end) : 'unknown'
72
100
  });
73
101
  }
102
+
103
+ node.arguments.forEach((arg, index) => {
104
+ const argCode = sourceCode.substring(arg.start, arg.end);
105
+ // Improved check: Does the expression contain any variable we know is tainted?
106
+ const isArgTainted = argCode.includes('process.env') || flows.some(f => {
107
+ const regex = new RegExp(`\\b${f.toVar}\\b`);
108
+ return regex.test(argCode);
109
+ });
110
+
111
+ if (isArgTainted) {
112
+ const funcNode = this.findFunctionDefinition(calleeCode, ast);
113
+ if (funcNode && funcNode.params[index]) {
114
+ const paramName = funcNode.params[index].name;
115
+ flows.push({
116
+ fromVar: argCode,
117
+ toVar: `${calleeCode}:${paramName}`,
118
+ file: filePath,
119
+ line: node.loc.start.line
120
+ });
121
+ }
122
+ }
123
+ });
74
124
  },
75
125
  MemberExpression: (node) => {
76
- const objectCode = this.getSourceCode(node.object);
126
+ const objectCode = sourceCode.substring(node.object.start, node.object.end);
77
127
  if (objectCode === 'process.env' || objectCode === 'process["env"]' || objectCode === "process['env']") {
78
128
  const property = node.property.name || (node.property.type === 'Literal' ? node.property.value : null);
79
129
  const whitelist = ['NODE_ENV', 'TIMING', 'DEBUG', 'VERBOSE', 'CI', 'APPDATA', 'HOME', 'USERPROFILE', 'PATH', 'PWD'];
80
130
  if (whitelist.includes(property)) return;
81
131
 
82
- this.facts.ENV_READ.push({
132
+ facts.ENV_READ.push({
83
133
  file: filePath,
84
134
  line: node.loc.start.line,
85
135
  variable: property ? `process.env.${property}` : 'process.env'
@@ -88,8 +138,8 @@ class ASTCollector {
88
138
  },
89
139
  VariableDeclarator: (node) => {
90
140
  if (node.init && node.id.type === 'Identifier') {
91
- const from = this.getSourceCode(node.init);
92
- this.flows.push({
141
+ const from = sourceCode.substring(node.init.start, node.init.end);
142
+ flows.push({
93
143
  fromVar: from,
94
144
  toVar: node.id.name,
95
145
  file: filePath,
@@ -99,10 +149,9 @@ class ASTCollector {
99
149
  },
100
150
  AssignmentExpression: (node) => {
101
151
  if (node.left.type === 'MemberExpression' && node.right.type === 'Identifier') {
102
- // Track property assignments: obj.prop = taintedVar
103
- const from = this.getSourceCode(node.right);
104
- const to = this.getSourceCode(node.left);
105
- this.flows.push({
152
+ const from = sourceCode.substring(node.right.start, node.right.end);
153
+ const to = sourceCode.substring(node.left.start, node.left.end);
154
+ flows.push({
106
155
  fromVar: from,
107
156
  toVar: to,
108
157
  file: filePath,
@@ -111,17 +160,16 @@ class ASTCollector {
111
160
  }
112
161
  },
113
162
  ObjectExpression: (node, state, ancestors) => {
114
- // Track object literal property assignments: const x = { p: process.env }
115
163
  const parent = ancestors[ancestors.length - 2];
116
164
  if (parent && parent.type === 'VariableDeclarator' && parent.id.type === 'Identifier') {
117
165
  const objName = parent.id.name;
118
166
  node.properties.forEach(prop => {
119
- if (prop.value.type === 'MemberExpression') {
120
- const valCode = this.getSourceCode(prop.value);
121
- if (valCode.includes('process.env')) {
122
- this.flows.push({
167
+ if (prop.value.type === 'MemberExpression' || prop.value.type === 'Identifier') {
168
+ const valCode = sourceCode.substring(prop.value.start, prop.value.end);
169
+ if (valCode.includes('process.env') || flows.some(f => f.toVar === valCode)) {
170
+ flows.push({
123
171
  fromVar: valCode,
124
- toVar: `${objName}.${this.getSourceCode(prop.key)}`,
172
+ toVar: `${objName}.${sourceCode.substring(prop.key.start, prop.key.end)}`,
125
173
  file: filePath,
126
174
  line: prop.loc.start.line
127
175
  });
@@ -132,26 +180,63 @@ class ASTCollector {
132
180
  }
133
181
  });
134
182
 
135
- return { facts: this.facts, flows: this.flows };
183
+ return { facts, flows };
136
184
  }
137
185
 
138
186
  isNetworkSink(calleeCode) {
139
- const methodSinks = ['http.request', 'https.request', 'http.get', 'https.get', 'net.connect', 'dns.lookup', 'dns.resolve', 'fetch', 'axios'];
140
-
141
- // Match methods like http.request but avoid requestIdleCallback or local 'request' variables
187
+ const methodSinks = [
188
+ 'http.request', 'https.request', 'http.get', 'https.get',
189
+ 'net.connect', 'dns.lookup', 'dns.resolve', 'dns.resolve4', 'dns.resolve6',
190
+ 'fetch', 'axios', 'request'
191
+ ];
192
+ // Improved matching for require('https').get patterns
142
193
  return methodSinks.some(sink => {
143
- return calleeCode === sink || calleeCode.endsWith('.' + sink);
194
+ if (calleeCode === sink) return true;
195
+ if (calleeCode.endsWith('.' + sink)) return true;
196
+ // Catch cases like require('https').get
197
+ if (sink.includes('.') && calleeCode.endsWith(sink.split('.')[1]) && calleeCode.includes(sink.split('.')[0])) return true;
198
+ return false;
144
199
  }) && !calleeCode.includes('IdleCallback');
145
200
  }
146
201
 
147
- isSensitiveFileRead(calleeCode, node) {
202
+ isShellSink(calleeCode) {
203
+ const shellSinks = ['child_process.exec', 'child_process.spawn', 'child_process.execSync', 'exec', 'spawn', 'execSync'];
204
+ return shellSinks.some(sink => {
205
+ if (calleeCode === sink) return true;
206
+ if (calleeCode.endsWith('.' + sink)) return true;
207
+ if (sink.includes('.') && calleeCode.endsWith(sink.split('.')[1]) && calleeCode.includes(sink.split('.')[0])) return true;
208
+ return false;
209
+ });
210
+ }
211
+
212
+ isEncoder(calleeCode) {
213
+ const encoders = ['Buffer.from', 'btoa', 'atob'];
214
+ return encoders.some(enc => calleeCode === enc || calleeCode.endsWith('.' + enc));
215
+ }
216
+
217
+ findFunctionDefinition(name, ast) {
218
+ let found = null;
219
+ walk.simple(ast, {
220
+ FunctionDeclaration: (node) => {
221
+ if (node.id.name === name) found = node;
222
+ },
223
+ VariableDeclarator: (node) => {
224
+ if (node.id.name === name && node.init && (node.init.type === 'ArrowFunctionExpression' || node.init.type === 'FunctionExpression')) {
225
+ found = node.init;
226
+ }
227
+ }
228
+ });
229
+ return found;
230
+ }
231
+
232
+ isSensitiveFileRead(calleeCode, node, sourceCode) {
148
233
  if (!calleeCode.includes('fs.readFile') && !calleeCode.includes('fs.readFileSync') &&
149
234
  !calleeCode.includes('fs.promises.readFile')) return false;
150
235
 
151
236
  if (node.arguments.length > 0 && node.arguments[0].type === 'Literal') {
152
- const path = String(node.arguments[0].value);
237
+ const pathValue = String(node.arguments[0].value);
153
238
  const sensitive = ['.ssh', '.env', 'shadow', 'passwd', 'credentials', 'token'];
154
- return sensitive.some((s) => path.toLowerCase().includes(s));
239
+ return sensitive.some((s) => pathValue.toLowerCase().includes(s));
155
240
  }
156
241
  return false;
157
242
  }
package/src/engine.js CHANGED
@@ -27,7 +27,15 @@ class SafetyEngine {
27
27
 
28
28
  // Check required facts
29
29
  for (const req of rule.requires) {
30
- const matchedFacts = facts[req] || [];
30
+ let matchedFacts = facts[req] || [];
31
+
32
+ // Special case for dynamic require (which shares DYNAMIC_EXECUTION fact type)
33
+ if (rule.alias === 'DYNAMIC_REQUIRE_DEPENDENCY') {
34
+ matchedFacts = matchedFacts.filter(f => f.type === 'dynamic_require');
35
+ } else if (req === 'DYNAMIC_EXECUTION') {
36
+ matchedFacts = matchedFacts.filter(f => f.type !== 'dynamic_require');
37
+ }
38
+
31
39
  if (matchedFacts.length === 0) return null; // Rule not matched
32
40
  triggers.push(...matchedFacts.map(f => ({ ...f, type: req })));
33
41
  }
@@ -38,31 +46,37 @@ class SafetyEngine {
38
46
  const matchedOpts = facts[opt] || [];
39
47
  if (matchedOpts.length > 0) {
40
48
  triggers.push(...matchedOpts.map(f => ({ ...f, type: opt })));
41
- baseScore += 20; // Bonus for optional matches (e.g., obfuscation)
49
+ baseScore += 20;
42
50
  }
43
51
  }
44
52
  }
45
53
 
46
- // Apply Lifecycle Multiplier (1.8x)
54
+ // Apply Lifecycle Multiplier (2.0x for V2)
47
55
  const isInLifecycle = triggers.some(t => lifecycleFiles.has(t.file));
48
56
  if (isInLifecycle) {
49
- multiplier = 1.8;
57
+ multiplier = 2.0;
58
+ }
59
+
60
+ // Encoder Multiplier (1.5x)
61
+ const hasEncoder = facts['ENCODER_USE'] && facts['ENCODER_USE'].length > 0;
62
+ if (hasEncoder) {
63
+ multiplier *= 1.5;
50
64
  }
51
65
 
52
66
  // Cluster Bonus: Source + Sink
53
67
  const hasSource = triggers.some(t => t.type.includes('READ'));
54
- const hasSink = triggers.some(t => t.type.includes('SINK') || t.type === 'DYNAMIC_EXECUTION');
68
+ const hasSink = triggers.some(t => t.type.includes('SINK') || t.type === 'DYNAMIC_EXECUTION' || t.type === 'SHELL_EXECUTION');
55
69
  if (hasSource && hasSink) {
56
70
  baseScore += 40;
57
71
  }
58
72
 
59
73
  let finalScore = baseScore * multiplier;
60
74
 
61
- // Lifecycle Guard: ENV_READ + NETWORK_SINK + lifecycleContext = min 85 (High)
75
+ // Severe Cluster: ENV_READ + (NETWORK_SINK | SHELL_EXECUTION) + lifecycleContext = Critical (100)
62
76
  const isEnvRead = triggers.some(t => t.type === 'ENV_READ');
63
- const isNetworkSink = triggers.some(t => t.type === 'NETWORK_SINK');
64
- if (isEnvRead && isNetworkSink && isInLifecycle && finalScore < 85) {
65
- finalScore = 85;
77
+ const isDangerousSink = triggers.some(t => t.type === 'NETWORK_SINK' || t.type === 'SHELL_EXECUTION');
78
+ if (isEnvRead && isDangerousSink && isInLifecycle) {
79
+ finalScore = 100;
66
80
  }
67
81
 
68
82
  return {
package/src/lifecycle.js CHANGED
@@ -5,11 +5,12 @@ class LifecycleResolver {
5
5
  constructor(packageDir) {
6
6
  this.packageDir = packageDir;
7
7
  this.lifecycleFiles = new Set();
8
+ this.detectedScripts = {};
8
9
  }
9
10
 
10
11
  resolve() {
11
12
  const packageJsonPath = path.join(this.packageDir, 'package.json');
12
- if (!fs.existsSync(packageJsonPath)) return this.lifecycleFiles;
13
+ if (!fs.existsSync(packageJsonPath)) return { files: this.lifecycleFiles, scripts: this.detectedScripts };
13
14
 
14
15
  try {
15
16
  const pkg = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
@@ -19,6 +20,7 @@ class LifecycleResolver {
19
20
 
20
21
  for (const hook of lifecycleHooks) {
21
22
  if (scripts[hook]) {
23
+ this.detectedScripts[hook] = scripts[hook];
22
24
  this.extractFilesFromScript(scripts[hook]);
23
25
  }
24
26
  }
@@ -26,7 +28,7 @@ class LifecycleResolver {
26
28
  // Ignore parse errors
27
29
  }
28
30
 
29
- return this.lifecycleFiles;
31
+ return { files: this.lifecycleFiles, scripts: this.detectedScripts };
30
32
  }
31
33
 
32
34
  extractFilesFromScript(script) {
@@ -0,0 +1,99 @@
1
+ const fs = require('node:fs');
2
+ const path = require('node:path');
3
+
4
+ class LockfileAuditor {
5
+ constructor(packageDir) {
6
+ this.packageDir = packageDir;
7
+ this.findings = [];
8
+ }
9
+
10
+ audit() {
11
+ const lockfiles = [
12
+ { name: 'package-lock.json', type: 'npm' },
13
+ { name: 'pnpm-lock.yaml', type: 'pnpm' },
14
+ { name: 'bun.lockb', type: 'bun' }
15
+ ];
16
+
17
+ for (const lock of lockfiles) {
18
+ const fullPath = path.join(this.packageDir, lock.name);
19
+ if (fs.existsSync(fullPath)) {
20
+ this.auditLockfile(fullPath, lock.type);
21
+ }
22
+ }
23
+
24
+ return this.findings;
25
+ }
26
+
27
+ auditLockfile(filePath, type) {
28
+ const content = fs.readFileSync(filePath, 'utf8');
29
+
30
+ if (type === 'npm') {
31
+ try {
32
+ const lock = JSON.parse(content);
33
+ this.checkNpmDependencies(lock.dependencies || lock.packages || {});
34
+ } catch (e) { }
35
+ } else if (type === 'pnpm') {
36
+ // pnpm-lock.yaml regex-based scanning (to avoid heavy yaml parser)
37
+ this.scanTextForUntrustedSources(content, 'pnpm');
38
+ } else if (type === 'bun') {
39
+ // bun.lockb is binary, but often contains readable URLs or has a text counterpart
40
+ this.scanTextForUntrustedSources(content, 'bun');
41
+ }
42
+ }
43
+
44
+ scanTextForUntrustedSources(content, type) {
45
+ // Look for git+ssh, git+https, github:, or non-npm https urls
46
+ const lines = content.split('\n');
47
+ lines.forEach((line, index) => {
48
+ const gitMatch = line.match(/(git\+ssh|git\+https|github:|[a-zA-Z0-9.\-_]+\/[a-zA-Z0-9.\-_]+#[a-f0-9]+)/);
49
+ if (gitMatch) {
50
+ this.findings.push({
51
+ type: 'UNTRUSTED_GIT_SOURCE',
52
+ package: `Line ${index + 1}`,
53
+ source: gitMatch[0],
54
+ severity: 'Medium'
55
+ });
56
+ }
57
+
58
+ const httpMatch = line.match(/https?:\/\/(?!(registry\.npmjs\.org|registry\.yarnpkg\.com))[a-zA-Z0-9.\-/_]+/);
59
+ if (httpMatch) {
60
+ this.findings.push({
61
+ type: 'NON_STANDARD_REGISTRY',
62
+ package: `Line ${index + 1}`,
63
+ source: httpMatch[0],
64
+ severity: 'High'
65
+ });
66
+ }
67
+ });
68
+ }
69
+
70
+ checkNpmDependencies(deps) {
71
+ for (const [name, info] of Object.entries(deps)) {
72
+ if (!name) continue;
73
+
74
+ const resolved = info.resolved || (info.version ? info.version : '');
75
+
76
+ // Detect Git Dependencies
77
+ if (resolved.includes('git+') || resolved.includes('github:')) {
78
+ this.findings.push({
79
+ type: 'UNTRUSTED_GIT_SOURCE',
80
+ package: name,
81
+ source: resolved,
82
+ severity: 'Medium'
83
+ });
84
+ }
85
+
86
+ // Detect HTTP based installs
87
+ if (resolved.startsWith('http:') || (resolved.startsWith('https:') && !resolved.includes('registry.npmjs.org'))) {
88
+ this.findings.push({
89
+ type: 'NON_STANDARD_REGISTRY',
90
+ package: name,
91
+ source: resolved,
92
+ severity: 'High'
93
+ });
94
+ }
95
+ }
96
+ }
97
+ }
98
+
99
+ module.exports = LockfileAuditor;
@@ -31,13 +31,30 @@ const RULES = [
31
31
  requires: ['OBFUSCATION', 'DYNAMIC_EXECUTION'],
32
32
  baseScore: 40,
33
33
  description: 'Detection of high-entropy strings being executed via eval or Function constructor.'
34
+ },
35
+ {
36
+ id: 'ZFT-005',
37
+ alias: 'SHELL_COMMAND_EXECUTION',
38
+ name: 'Shell Command Execution',
39
+ requires: ['SHELL_EXECUTION'],
40
+ optional: ['ENV_READ', 'FILE_READ_SENSITIVE'],
41
+ baseScore: 50,
42
+ description: 'Detection of shell command execution (child_process).'
43
+ },
44
+ {
45
+ id: 'ZFT-006',
46
+ alias: 'DYNAMIC_REQUIRE_DEPENDENCY',
47
+ name: 'Dynamic Require Dependency',
48
+ requires: ['DYNAMIC_EXECUTION'], // Will check if type === 'dynamic_require' in engine
49
+ baseScore: 30,
50
+ description: 'Detection of dynamic require calls where the dependency name is a variable.'
34
51
  }
35
52
  ];
36
53
 
37
54
  const CATEGORIES = {
38
55
  SOURCES: ['ENV_READ', 'FILE_READ_SENSITIVE'],
39
- SINKS: ['NETWORK_SINK', 'DYNAMIC_EXECUTION'],
40
- OBFUSCATION: ['OBFUSCATION'],
56
+ SINKS: ['NETWORK_SINK', 'DYNAMIC_EXECUTION', 'SHELL_EXECUTION'],
57
+ SIGNALS: ['OBFUSCATION', 'ENCODER_USE'],
41
58
  PERSISTENCE: ['FILE_WRITE_STARTUP']
42
59
  };
43
60
 
package/src/scanner.js CHANGED
@@ -3,6 +3,7 @@ const path = require('node:path');
3
3
  const ASTCollector = require('./collector');
4
4
  const LifecycleResolver = require('./lifecycle');
5
5
  const SafetyEngine = require('./engine');
6
+ const { getHash } = require('./utils/hash');
6
7
 
7
8
  class PackageScanner {
8
9
  constructor(packageDir) {
@@ -13,8 +14,15 @@ class PackageScanner {
13
14
  }
14
15
 
15
16
  async scan() {
16
- const lifecycleFiles = this.lifecycleResolver.resolve();
17
+ const { files: lifecycleFiles, scripts } = this.lifecycleResolver.resolve();
17
18
  const files = await this.getFiles();
19
+ this.detectedLifecycleScripts = scripts; // Store for formatter
20
+
21
+ // Initialize cache directory
22
+ const cacheDir = path.join(this.packageDir, 'node_modules', '.zift-cache');
23
+ if (!fs.existsSync(cacheDir)) {
24
+ try { fs.mkdirSync(cacheDir, { recursive: true }); } catch (e) { }
25
+ }
18
26
 
19
27
  let allFacts = {
20
28
  facts: {
@@ -23,29 +31,60 @@ class PackageScanner {
23
31
  NETWORK_SINK: [],
24
32
  DYNAMIC_EXECUTION: [],
25
33
  OBFUSCATION: [],
26
- FILE_WRITE_STARTUP: []
34
+ FILE_WRITE_STARTUP: [],
35
+ SHELL_EXECUTION: [],
36
+ ENCODER_USE: []
27
37
  },
28
38
  flows: []
29
39
  };
30
40
 
31
- for (const file of files) {
32
- const relativePath = path.relative(this.packageDir, file);
41
+ const pkgVersion = require('../package.json').version;
33
42
 
34
- // Refined ignore logic
35
- const ignorePatterns = ['node_modules', '.git', 'test', 'dist', 'coverage', 'docs', '.github'];
36
- if (ignorePatterns.some(p => relativePath.includes(p)) || relativePath.startsWith('.')) continue;
43
+ // Parallel processing with limited concurrency (8 files at a time)
44
+ const concurrency = 8;
45
+ for (let i = 0; i < files.length; i += concurrency) {
46
+ const chunk = files.slice(i, i + concurrency);
47
+ await Promise.all(chunk.map(async (file) => {
48
+ const stats = fs.statSync(file);
49
+ if (stats.size > 512 * 1024) return;
37
50
 
38
- const stats = fs.statSync(file);
39
- if (stats.size > 512 * 1024) continue; // Skip files > 512KB
51
+ const code = fs.readFileSync(file, 'utf8');
52
+ const fileHash = getHash(code + pkgVersion);
53
+ const cachePath = path.join(cacheDir, fileHash + '.json');
40
54
 
41
- const code = fs.readFileSync(file, 'utf8');
42
- const { facts, flows } = this.collector.collect(code, file);
55
+ let facts = {}, flows = [];
43
56
 
44
- // Merge facts
45
- for (const category in facts) {
46
- allFacts.facts[category].push(...facts[category]);
47
- }
48
- allFacts.flows.push(...flows);
57
+ if (fs.existsSync(cachePath)) {
58
+ // Cache hit: Load metadata
59
+ try {
60
+ const cached = JSON.parse(fs.readFileSync(cachePath, 'utf8'));
61
+ facts = cached.facts || {};
62
+ flows = cached.flows || [];
63
+ } catch (e) {
64
+ // Corrupt cache: re-scan
65
+ const result = this.collector.collect(code, file);
66
+ facts = result.facts;
67
+ flows = result.flows;
68
+ }
69
+ } else {
70
+ // Cache miss: Scan and save
71
+ const result = this.collector.collect(code, file);
72
+ facts = result.facts;
73
+ flows = result.flows;
74
+
75
+ try {
76
+ fs.writeFileSync(cachePath, JSON.stringify({ facts, flows }));
77
+ } catch (e) { }
78
+ }
79
+
80
+ // Merge facts (Synchronized)
81
+ for (const category in facts) {
82
+ if (allFacts.facts[category]) {
83
+ allFacts.facts[category].push(...facts[category]);
84
+ }
85
+ }
86
+ allFacts.flows.push(...flows);
87
+ }));
49
88
  }
50
89
 
51
90
  const findings = this.engine.evaluate(allFacts, lifecycleFiles);
@@ -53,17 +92,28 @@ class PackageScanner {
53
92
  }
54
93
 
55
94
  async getFiles() {
95
+ // Load .ziftignore
96
+ const ziftIgnorePath = path.join(this.packageDir, '.ziftignore');
97
+ let ignoreLines = ['node_modules', '.git', 'dist', 'build', 'coverage', 'test', 'tests'];
98
+ if (fs.existsSync(ziftIgnorePath)) {
99
+ const content = fs.readFileSync(ziftIgnorePath, 'utf8');
100
+ ignoreLines = [...ignoreLines, ...content.split('\n').map(l => l.trim()).filter(l => l && !l.startsWith('#'))];
101
+ }
102
+
56
103
  const getJsFiles = (dir) => {
57
104
  const results = [];
58
105
  const list = fs.readdirSync(dir);
59
106
  for (const file of list) {
60
107
  const fullPath = path.join(dir, file);
108
+ const relativePath = path.relative(this.packageDir, fullPath);
109
+
110
+ // Simple ignore check
111
+ if (ignoreLines.some(pattern => relativePath.includes(pattern) || file === pattern)) continue;
112
+ if (file.startsWith('.') && file !== '.ziftignore') continue;
113
+
61
114
  const stat = fs.statSync(fullPath);
62
115
  if (stat && stat.isDirectory()) {
63
- const ignoreDirs = ['node_modules', '.git', 'dist', 'build', 'coverage', 'test', 'tests'];
64
- if (!ignoreDirs.includes(file) && !file.startsWith('.')) {
65
- results.push(...getJsFiles(fullPath));
66
- }
116
+ results.push(...getJsFiles(fullPath));
67
117
  } else if (file.endsWith('.js')) {
68
118
  results.push(fullPath);
69
119
  }
@@ -76,23 +126,26 @@ class PackageScanner {
76
126
  formatFindings(findings) {
77
127
  const sorted = findings.sort((a, b) => b.score - a.score);
78
128
 
79
- return sorted.map(f => {
80
- let classification = 'Low';
81
- if (f.score >= 90) classification = 'Critical';
82
- else if (f.score >= 70) classification = 'High';
83
- else if (f.score >= 50) classification = 'Medium';
84
-
85
- return {
86
- ...f,
87
- classification,
88
- triggers: f.triggers.map(t => ({
89
- type: t.type,
90
- file: path.relative(this.packageDir, t.file),
91
- line: t.line,
92
- context: t.reason || t.callee || t.variable || t.path
93
- }))
94
- };
95
- });
129
+ return {
130
+ results: sorted.map(f => {
131
+ let classification = 'Low';
132
+ if (f.score >= 90) classification = 'Critical';
133
+ else if (f.score >= 70) classification = 'High';
134
+ else if (f.score >= 50) classification = 'Medium';
135
+
136
+ return {
137
+ ...f,
138
+ classification,
139
+ triggers: f.triggers.map(t => ({
140
+ type: t.type,
141
+ file: path.relative(this.packageDir, t.file),
142
+ line: t.line,
143
+ context: t.reason || t.callee || t.variable || t.path
144
+ }))
145
+ };
146
+ }),
147
+ lifecycleScripts: this.detectedLifecycleScripts
148
+ };
96
149
  }
97
150
  }
98
151
 
@@ -0,0 +1,7 @@
1
+ const crypto = require('node:crypto');
2
+
3
+ function getHash(data) {
4
+ return crypto.createHash('sha256').update(data).digest('hex');
5
+ }
6
+
7
+ module.exports = { getHash };
@@ -0,0 +1,43 @@
1
+ function levenshtein(s1, s2) {
2
+ const scores = [];
3
+ for (let i = 0; i <= s1.length; i++) {
4
+ let lastValue = i;
5
+ for (let j = 0; j <= s2.length; j++) {
6
+ if (i === 0) {
7
+ scores[j] = j;
8
+ } else if (j > 0) {
9
+ let newValue = scores[j - 1];
10
+ if (s1.charAt(i - 1) !== s2.charAt(j - 1)) {
11
+ newValue = Math.min(Math.min(newValue, lastValue), scores[j]) + 1;
12
+ }
13
+ scores[j - 1] = lastValue;
14
+ lastValue = newValue;
15
+ }
16
+ }
17
+ if (i > 0) scores[s2.length] = lastValue;
18
+ }
19
+ return scores[s2.length];
20
+ }
21
+
22
+ const TOP_PACKAGES = [
23
+ 'react', 'vue', 'axios', 'express', 'lodash', 'moment', 'next', 'react-dom',
24
+ 'chalk', 'commander', 'fs-extra', 'glob', 'inquirer', 'jest', 'request',
25
+ 'typescript', 'webpack', 'babel-core', 'eslint', 'prettier'
26
+ ];
27
+
28
+ function checkTyposquat(name) {
29
+ if (!name || TOP_PACKAGES.includes(name)) return null;
30
+
31
+ for (const top of TOP_PACKAGES) {
32
+ const distance = levenshtein(name, top);
33
+ if (distance === 1 || (distance === 2 && top.length >= 5)) {
34
+ return {
35
+ target: top,
36
+ distance: distance
37
+ };
38
+ }
39
+ }
40
+ return null;
41
+ }
42
+
43
+ module.exports = { checkTyposquat };