@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 +16 -2
- package/bin/zift.js +86 -7
- package/package.json +2 -2
- package/src/collector.js +125 -40
- package/src/engine.js +23 -9
- package/src/lifecycle.js +4 -2
- package/src/lockfile.js +99 -0
- package/src/rules/definitions.js +19 -2
- package/src/scanner.js +90 -37
- package/src/utils/hash.js +7 -0
- package/src/utils/typo.js +43 -0
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
# Zift
|
|
1
|
+
# š”ļø Zift (v2.0.0)
|
|
2
2
|
|
|
3
|
-
**
|
|
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
|
|
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
|
|
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({
|
|
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) {
|
|
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
|
-
|
|
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 -
|
|
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('
|
|
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": "
|
|
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": "
|
|
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.
|
|
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
|
-
|
|
16
|
-
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
68
|
-
|
|
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] ?
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
92
|
-
|
|
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
|
-
|
|
103
|
-
const
|
|
104
|
-
|
|
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 =
|
|
121
|
-
if (valCode.includes('process.env')) {
|
|
122
|
-
|
|
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}.${
|
|
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
|
|
183
|
+
return { facts, flows };
|
|
136
184
|
}
|
|
137
185
|
|
|
138
186
|
isNetworkSink(calleeCode) {
|
|
139
|
-
const methodSinks = [
|
|
140
|
-
|
|
141
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
237
|
+
const pathValue = String(node.arguments[0].value);
|
|
153
238
|
const sensitive = ['.ssh', '.env', 'shadow', 'passwd', 'credentials', 'token'];
|
|
154
|
-
return sensitive.some((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
|
-
|
|
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;
|
|
49
|
+
baseScore += 20;
|
|
42
50
|
}
|
|
43
51
|
}
|
|
44
52
|
}
|
|
45
53
|
|
|
46
|
-
// Apply Lifecycle Multiplier (
|
|
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 =
|
|
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
|
-
//
|
|
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
|
|
64
|
-
if (isEnvRead &&
|
|
65
|
-
finalScore =
|
|
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) {
|
package/src/lockfile.js
ADDED
|
@@ -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;
|
package/src/rules/definitions.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
32
|
-
const relativePath = path.relative(this.packageDir, file);
|
|
41
|
+
const pkgVersion = require('../package.json').version;
|
|
33
42
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
39
|
-
|
|
51
|
+
const code = fs.readFileSync(file, 'utf8');
|
|
52
|
+
const fileHash = getHash(code + pkgVersion);
|
|
53
|
+
const cachePath = path.join(cacheDir, fileHash + '.json');
|
|
40
54
|
|
|
41
|
-
|
|
42
|
-
const { facts, flows } = this.collector.collect(code, file);
|
|
55
|
+
let facts = {}, flows = [];
|
|
43
56
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
|
|
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
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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,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 };
|