@7nsane/zift 2.1.0 → 2.2.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@7nsane/zift",
3
- "version": "2.1.0",
3
+ "version": "2.2.1",
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
@@ -12,16 +12,23 @@ class ASTCollector {
12
12
  collect(code, filePath) {
13
13
  const facts = {
14
14
  ENV_READ: [],
15
+ MASS_ENV_ACCESS: [],
15
16
  FILE_READ_SENSITIVE: [],
16
17
  NETWORK_SINK: [],
18
+ DNS_SINK: [],
19
+ RAW_SOCKET_SINK: [],
17
20
  DYNAMIC_EXECUTION: [],
21
+ DYNAMIC_REQUIRE: [],
18
22
  OBFUSCATION: [],
19
23
  FILE_WRITE_STARTUP: [],
20
24
  SHELL_EXECUTION: [],
21
- ENCODER_USE: []
25
+ ENCODER_USE: [],
26
+ REMOTE_FETCH_SIGNAL: [],
27
+ PIPE_TO_SHELL_SIGNAL: []
22
28
  };
23
29
  const flows = [];
24
30
  const sourceCode = code;
31
+ let envAccessCount = 0;
25
32
 
26
33
  let ast;
27
34
  try {
@@ -60,16 +67,16 @@ class ASTCollector {
60
67
  }
61
68
 
62
69
  if (calleeCode === 'require' && node.arguments.length > 0 && node.arguments[0].type !== 'Literal') {
63
- facts.DYNAMIC_EXECUTION.push({
70
+ facts.DYNAMIC_REQUIRE.push({
64
71
  file: filePath,
65
72
  line: node.loc.start.line,
66
- type: 'dynamic_require',
67
73
  variable: sourceCode.substring(node.arguments[0].start, node.arguments[0].end)
68
74
  });
69
75
  }
70
76
 
71
- if (this.isNetworkSink(calleeCode)) {
72
- facts.NETWORK_SINK.push({
77
+ const netType = this.getNetworkType(calleeCode);
78
+ if (netType) {
79
+ facts[netType].push({
73
80
  file: filePath,
74
81
  line: node.loc.start.line,
75
82
  callee: calleeCode
@@ -82,6 +89,19 @@ class ASTCollector {
82
89
  line: node.loc.start.line,
83
90
  callee: calleeCode
84
91
  });
92
+
93
+ // Signal Analysis: Dropper Patterns
94
+ node.arguments.forEach(arg => {
95
+ if (arg.type === 'Literal' && typeof arg.value === 'string') {
96
+ const val = arg.value.toLowerCase();
97
+ if ((val.includes('curl') || val.includes('wget') || val.includes('fetch')) && (val.includes('http') || val.includes('//'))) {
98
+ facts.REMOTE_FETCH_SIGNAL.push({ file: filePath, line: node.loc.start.line, context: val });
99
+ }
100
+ if (val.includes('| sh') || val.includes('| bash') || val.includes('| cmd') || val.includes('| pwsh')) {
101
+ facts.PIPE_TO_SHELL_SIGNAL.push({ file: filePath, line: node.loc.start.line, context: val });
102
+ }
103
+ }
104
+ });
85
105
  }
86
106
 
87
107
  if (this.isEncoder(calleeCode)) {
@@ -110,7 +130,6 @@ class ASTCollector {
110
130
 
111
131
  node.arguments.forEach((arg, index) => {
112
132
  const argCode = sourceCode.substring(arg.start, arg.end);
113
- // Improved check: Does the expression contain any variable we know is tainted?
114
133
  const isArgTainted = argCode.includes('process.env') || flows.some(f => {
115
134
  const regex = new RegExp(`\\b${f.toVar}\\b`);
116
135
  return regex.test(argCode);
@@ -137,11 +156,16 @@ class ASTCollector {
137
156
  const whitelist = ['NODE_ENV', 'TIMING', 'DEBUG', 'VERBOSE', 'CI', 'APPDATA', 'HOME', 'USERPROFILE', 'PATH', 'PWD'];
138
157
  if (whitelist.includes(property)) return;
139
158
 
159
+ envAccessCount++;
140
160
  facts.ENV_READ.push({
141
161
  file: filePath,
142
162
  line: node.loc.start.line,
143
163
  variable: property ? `process.env.${property}` : 'process.env'
144
164
  });
165
+
166
+ if (envAccessCount > 5) {
167
+ facts.MASS_ENV_ACCESS.push({ file: filePath, line: node.loc.start.line, count: envAccessCount });
168
+ }
145
169
  }
146
170
  },
147
171
  VariableDeclarator: (node) => {
@@ -191,20 +215,21 @@ class ASTCollector {
191
215
  return { facts, flows };
192
216
  }
193
217
 
194
- isNetworkSink(calleeCode) {
195
- const methodSinks = [
196
- 'http.request', 'https.request', 'http.get', 'https.get',
197
- 'net.connect', 'net.createConnection', 'dns.lookup', 'dns.resolve', 'dns.resolve4', 'dns.resolve6',
198
- 'fetch', 'axios', 'request'
199
- ];
200
- // Improved matching for require('https').get patterns
201
- return methodSinks.some(sink => {
218
+ getNetworkType(calleeCode) {
219
+ const dnsSinks = ['dns.lookup', 'dns.resolve', 'dns.resolve4', 'dns.resolve6'];
220
+ const rawSocketSinks = ['net.connect', 'net.createConnection'];
221
+ const networkSinks = ['http.request', 'https.request', 'http.get', 'https.get', 'fetch', 'axios', 'request'];
222
+
223
+ if (dnsSinks.some(sink => calleeCode === sink || calleeCode.endsWith('.' + sink))) return 'DNS_SINK';
224
+ if (rawSocketSinks.some(sink => calleeCode === sink || calleeCode.endsWith('.' + sink))) return 'RAW_SOCKET_SINK';
225
+ if (networkSinks.some(sink => {
202
226
  if (calleeCode === sink) return true;
203
227
  if (calleeCode.endsWith('.' + sink)) return true;
204
- // Catch cases like require('https').get
205
228
  if (sink.includes('.') && calleeCode.endsWith(sink.split('.')[1]) && calleeCode.includes(sink.split('.')[0])) return true;
206
229
  return false;
207
- }) && !calleeCode.includes('IdleCallback');
230
+ })) return 'NETWORK_SINK';
231
+
232
+ return null;
208
233
  }
209
234
 
210
235
  isShellSink(calleeCode) {
package/src/engine.js CHANGED
@@ -6,7 +6,7 @@ class SafetyEngine {
6
6
  }
7
7
 
8
8
  evaluate(packageFacts, lifecycleFiles) {
9
- const findings = [];
9
+ let findings = [];
10
10
 
11
11
  // Process each rule
12
12
  for (const rule of RULES) {
@@ -16,6 +16,9 @@ class SafetyEngine {
16
16
  }
17
17
  }
18
18
 
19
+ // Sort by score (desc) and then by priority (desc)
20
+ findings.sort((a, b) => (b.score - a.score) || (b.priority - a.priority));
21
+
19
22
  return findings;
20
23
  }
21
24
 
@@ -29,34 +32,11 @@ class SafetyEngine {
29
32
  for (const req of rule.requires) {
30
33
  let matchedFacts = facts[req] || [];
31
34
 
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
- }
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.
38
38
 
39
39
  if (matchedFacts.length === 0) return null; // Rule not matched
40
-
41
- // Specialist Rule: DNS Exfiltration (ZFT-007) requires a DNS-specific sink
42
- if (rule.alias === 'DNS_EXFILTRATION' && req === 'NETWORK_SINK') {
43
- matchedFacts = matchedFacts.filter(f => f.callee && f.callee.includes('dns'));
44
- if (matchedFacts.length === 0) return null;
45
- }
46
-
47
- // Specialist Rule: Raw Socket Tunnel (ZFT-011) requires net.connect or similar
48
- if (rule.alias === 'RAW_SOCKET_TUNNEL' && req === 'NETWORK_SINK') {
49
- matchedFacts = matchedFacts.filter(f => f.callee && (f.callee.includes('net.connect') || f.callee.includes('net.createConnection')));
50
- if (matchedFacts.length === 0) return null;
51
- }
52
-
53
- // Specialist Rule: Startup Mod (ZFT-012) requires specific file paths
54
- if (rule.alias === 'STARTUP_SCRIPT_MOD' && req === 'FILE_WRITE_STARTUP') {
55
- const startupFiles = ['package.json', '.npmrc', '.bashrc', '.zshrc'];
56
- matchedFacts = matchedFacts.filter(f => f.path && startupFiles.some(s => f.path.includes(s)));
57
- if (matchedFacts.length === 0) return null;
58
- }
59
-
60
40
  triggers.push(...matchedFacts.map(f => ({ ...f, type: req })));
61
41
  }
62
42
 
@@ -71,10 +51,10 @@ class SafetyEngine {
71
51
  }
72
52
  }
73
53
 
74
- // Apply Lifecycle Multiplier (2.0x for V2)
75
- const isInLifecycle = triggers.some(t => lifecycleFiles.has(t.file));
54
+ // Check for Lifecycle Context Fact (Virtual or Actual)
55
+ const isInLifecycle = triggers.some(t => lifecycleFiles.has(t.file)) || (facts['LIFECYCLE_CONTEXT'] && facts['LIFECYCLE_CONTEXT'].length > 0);
76
56
  if (isInLifecycle) {
77
- multiplier = 2.0;
57
+ multiplier *= 2.0;
78
58
  }
79
59
 
80
60
  // Encoder Multiplier (1.5x)
@@ -84,18 +64,18 @@ class SafetyEngine {
84
64
  }
85
65
 
86
66
  // Cluster Bonus: Source + Sink
87
- const hasSource = triggers.some(t => t.type.includes('READ'));
88
- const hasSink = triggers.some(t => t.type.includes('SINK') || t.type === 'DYNAMIC_EXECUTION' || t.type === 'SHELL_EXECUTION');
67
+ 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');
89
69
  if (hasSource && hasSink) {
90
70
  baseScore += 40;
91
71
  }
92
72
 
93
73
  let finalScore = baseScore * multiplier;
94
74
 
95
- // Severe Cluster: ENV_READ + (NETWORK_SINK | SHELL_EXECUTION) + lifecycleContext = Critical (100)
96
- const isEnvRead = triggers.some(t => t.type === 'ENV_READ');
97
- const isDangerousSink = triggers.some(t => t.type === 'NETWORK_SINK' || t.type === 'SHELL_EXECUTION');
98
- if (isEnvRead && isDangerousSink && isInLifecycle) {
75
+ // 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');
78
+ if (isSensitiveRead && isDangerousSink && isInLifecycle) {
99
79
  finalScore = 100;
100
80
  }
101
81
 
@@ -103,6 +83,7 @@ class SafetyEngine {
103
83
  id: rule.id,
104
84
  alias: rule.alias,
105
85
  name: rule.name,
86
+ priority: rule.priority || 1,
106
87
  score: Math.min(finalScore, 100),
107
88
  triggers: triggers,
108
89
  description: rule.description,
@@ -5,6 +5,7 @@ const RULES = [
5
5
  name: 'Environment Variable Exfiltration',
6
6
  requires: ['ENV_READ', 'NETWORK_SINK'],
7
7
  optional: ['OBFUSCATION'],
8
+ priority: 1,
8
9
  baseScore: 40,
9
10
  description: 'Detection of environment variables being read and sent over the network.'
10
11
  },
@@ -13,6 +14,7 @@ const RULES = [
13
14
  alias: 'SENSITIVE_FILE_EXFILTRATION',
14
15
  name: 'Sensitive File Exfiltration',
15
16
  requires: ['FILE_READ_SENSITIVE', 'NETWORK_SINK'],
17
+ priority: 1,
16
18
  baseScore: 50,
17
19
  description: 'Detection of sensitive files (e.g., .ssh, .env) being read and sent over the network.'
18
20
  },
@@ -21,6 +23,7 @@ const RULES = [
21
23
  alias: 'PERSISTENCE_ATTEMPT',
22
24
  name: 'Persistence Attempt',
23
25
  requires: ['FILE_WRITE_STARTUP'],
26
+ priority: 2,
24
27
  baseScore: 60,
25
28
  description: 'Detection of attempts to write to system startup directories.'
26
29
  },
@@ -29,6 +32,7 @@ const RULES = [
29
32
  alias: 'OBFUSCATED_EXECUTION',
30
33
  name: 'Obfuscated Execution',
31
34
  requires: ['OBFUSCATION', 'DYNAMIC_EXECUTION'],
35
+ priority: 2,
32
36
  baseScore: 40,
33
37
  description: 'Detection of high-entropy strings being executed via eval or Function constructor.'
34
38
  },
@@ -38,6 +42,7 @@ const RULES = [
38
42
  name: 'Shell Command Execution',
39
43
  requires: ['SHELL_EXECUTION'],
40
44
  optional: ['ENV_READ', 'FILE_READ_SENSITIVE'],
45
+ priority: 1,
41
46
  baseScore: 50,
42
47
  description: 'Detection of shell command execution (child_process).'
43
48
  },
@@ -45,7 +50,8 @@ const RULES = [
45
50
  id: 'ZFT-006',
46
51
  alias: 'DYNAMIC_REQUIRE_DEPENDENCY',
47
52
  name: 'Dynamic Require Dependency',
48
- requires: ['DYNAMIC_EXECUTION'],
53
+ requires: ['DYNAMIC_REQUIRE'],
54
+ priority: 1,
49
55
  baseScore: 30,
50
56
  description: 'Detection of dynamic require calls where the dependency name is a variable.'
51
57
  },
@@ -53,7 +59,8 @@ const RULES = [
53
59
  id: 'ZFT-007',
54
60
  alias: 'DNS_EXFILTRATION',
55
61
  name: 'DNS-Based Exfiltration',
56
- requires: ['ENV_READ', 'NETWORK_SINK'], // Engine will check for dns.resolve in callee
62
+ requires: ['ENV_READ', 'DNS_SINK'],
63
+ priority: 2,
57
64
  baseScore: 45,
58
65
  description: 'Stealthy environment variable exfiltration via DNS lookups.'
59
66
  },
@@ -61,33 +68,37 @@ const RULES = [
61
68
  id: 'ZFT-008',
62
69
  alias: 'SUSPICIOUS_COLLECTION',
63
70
  name: 'Suspicious Information Collection',
64
- requires: ['ENV_READ'],
71
+ requires: ['MASS_ENV_ACCESS'],
65
72
  optional: ['FILE_READ_SENSITIVE'],
73
+ priority: 1,
66
74
  baseScore: 20,
67
- description: 'Massive environment or file reading without immediate network activity (potential harvesting).'
75
+ description: 'Massive environment reading without immediate network activity (potential harvesting).'
68
76
  },
69
77
  {
70
78
  id: 'ZFT-009',
71
79
  alias: 'REMOTE_DROPPER_PATTERN',
72
80
  name: 'Remote Script Dropper',
73
- requires: ['SHELL_EXECUTION'],
74
- optional: ['OBFUSCATION'],
81
+ requires: ['SHELL_EXECUTION', 'REMOTE_FETCH_SIGNAL'],
82
+ optional: ['OBFUSCATION', 'PIPE_TO_SHELL_SIGNAL'],
83
+ priority: 3,
75
84
  baseScore: 55,
76
85
  description: 'Detection of remote script download and execution (curl | sh) patterns.'
77
86
  },
78
87
  {
79
88
  id: 'ZFT-010',
80
- alias: 'ENCRYPTED_EXFILTRATION',
81
- name: 'Encrypted Data Exfiltration',
82
- requires: ['ENCODER_USE', 'NETWORK_SINK'],
83
- baseScore: 50,
84
- description: 'Data being encoded/encrypted before being sent over the network.'
89
+ alias: 'ENCODED_EXFILTRATION',
90
+ name: 'Encoded Data Exfiltration',
91
+ requires: ['ENV_READ', 'NETWORK_SINK', 'ENCODER_USE'],
92
+ priority: 3,
93
+ baseScore: 70,
94
+ description: 'Sensitive data encoded before transmission to evade detection.'
85
95
  },
86
96
  {
87
97
  id: 'ZFT-011',
88
98
  alias: 'RAW_SOCKET_TUNNEL',
89
99
  name: 'Raw Socket Tunneling',
90
- requires: ['NETWORK_SINK'], // Engine will check for net.connect/net.createConnection
100
+ requires: ['RAW_SOCKET_SINK'],
101
+ priority: 2,
91
102
  baseScore: 45,
92
103
  description: 'Use of raw network sockets instead of http/dns, often used for reverse shells.'
93
104
  },
@@ -95,17 +106,19 @@ const RULES = [
95
106
  id: 'ZFT-012',
96
107
  alias: 'STARTUP_SCRIPT_MOD',
97
108
  name: 'Startup Script Modification',
98
- requires: ['FILE_WRITE_STARTUP'], // Will check for package.json or .npmrc
109
+ requires: ['FILE_WRITE_STARTUP'],
110
+ priority: 2,
99
111
  baseScore: 60,
100
112
  description: 'Detection of attempts to modify package.json scripts or npm configuration.'
101
113
  }
102
114
  ];
103
115
 
104
116
  const CATEGORIES = {
105
- SOURCES: ['ENV_READ', 'FILE_READ_SENSITIVE'],
106
- SINKS: ['NETWORK_SINK', 'DYNAMIC_EXECUTION', 'SHELL_EXECUTION'],
107
- SIGNALS: ['OBFUSCATION', 'ENCODER_USE'],
108
- PERSISTENCE: ['FILE_WRITE_STARTUP']
117
+ SOURCES: ['ENV_READ', 'FILE_READ_SENSITIVE', 'MASS_ENV_ACCESS'],
118
+ SINKS: ['NETWORK_SINK', 'DNS_SINK', 'RAW_SOCKET_SINK', 'DYNAMIC_EXECUTION', 'SHELL_EXECUTION', 'DYNAMIC_REQUIRE'],
119
+ SIGNALS: ['OBFUSCATION', 'ENCODER_USE', 'REMOTE_FETCH_SIGNAL', 'PIPE_TO_SHELL_SIGNAL'],
120
+ PERSISTENCE: ['FILE_WRITE_STARTUP'],
121
+ CONTEXT: ['LIFECYCLE_CONTEXT']
109
122
  };
110
123
 
111
124
  module.exports = { RULES, CATEGORIES };
package/src/scanner.js CHANGED
@@ -27,13 +27,20 @@ class PackageScanner {
27
27
  let allFacts = {
28
28
  facts: {
29
29
  ENV_READ: [],
30
+ MASS_ENV_ACCESS: [],
30
31
  FILE_READ_SENSITIVE: [],
31
32
  NETWORK_SINK: [],
33
+ DNS_SINK: [],
34
+ RAW_SOCKET_SINK: [],
32
35
  DYNAMIC_EXECUTION: [],
36
+ DYNAMIC_REQUIRE: [],
33
37
  OBFUSCATION: [],
34
38
  FILE_WRITE_STARTUP: [],
35
39
  SHELL_EXECUTION: [],
36
- ENCODER_USE: []
40
+ ENCODER_USE: [],
41
+ REMOTE_FETCH_SIGNAL: [],
42
+ PIPE_TO_SHELL_SIGNAL: [],
43
+ LIFECYCLE_CONTEXT: []
37
44
  },
38
45
  flows: []
39
46
  };
@@ -78,6 +85,11 @@ class PackageScanner {
78
85
  }
79
86
 
80
87
  // Merge facts (Synchronized)
88
+ if (lifecycleFiles.has(file)) {
89
+ facts.LIFECYCLE_CONTEXT = facts.LIFECYCLE_CONTEXT || [];
90
+ facts.LIFECYCLE_CONTEXT.push({ file, reason: 'Lifecycle script context detected' });
91
+ }
92
+
81
93
  for (const category in facts) {
82
94
  if (allFacts.facts[category]) {
83
95
  allFacts.facts[category].push(...facts[category]);