@7nsane/zift 1.1.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 +144 -72
- package/package.json +2 -2
- package/src/collector.js +149 -35
- 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/audit-axios/axios-1.13.6.tgz +0 -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
|
@@ -12,22 +12,34 @@ async function main() {
|
|
|
12
12
|
let target = '.';
|
|
13
13
|
let format = 'text';
|
|
14
14
|
let isInstallMode = false;
|
|
15
|
+
let installer = 'npm';
|
|
15
16
|
|
|
17
|
+
// 1. Setup & Init Commands
|
|
16
18
|
if (args[0] === 'setup') {
|
|
17
19
|
await runSetup();
|
|
18
20
|
return;
|
|
19
21
|
}
|
|
22
|
+
if (args[0] === 'init') {
|
|
23
|
+
runInit();
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// 2. Detection for bun/pnpm usage
|
|
28
|
+
if (args.includes('--bun')) installer = 'bun';
|
|
29
|
+
if (args.includes('--pnpm')) installer = 'pnpm';
|
|
20
30
|
|
|
21
|
-
|
|
31
|
+
// 3. Installation Verbs
|
|
32
|
+
if (args[0] === 'install' || args[0] === 'i' || args[0] === 'add') {
|
|
22
33
|
isInstallMode = true;
|
|
23
34
|
target = args.find((a, i) => i > 0 && !a.startsWith('-')) || '.';
|
|
24
35
|
}
|
|
25
36
|
|
|
26
37
|
if (args.includes('--zift')) {
|
|
27
38
|
isInstallMode = true;
|
|
28
|
-
target = args.find(a => !a.startsWith('-') && !['install', 'i', 'npm'].includes(a)) || '.';
|
|
39
|
+
target = args.find(a => !a.startsWith('-') && !['install', 'i', 'add', 'npm', 'bun', 'pnpm'].includes(a)) || '.';
|
|
29
40
|
}
|
|
30
41
|
|
|
42
|
+
// 4. Flags
|
|
31
43
|
for (let i = 0; i < args.length; i++) {
|
|
32
44
|
if (args[i] === '--format' && args[i + 1]) {
|
|
33
45
|
format = args[i + 1];
|
|
@@ -35,26 +47,28 @@ async function main() {
|
|
|
35
47
|
}
|
|
36
48
|
}
|
|
37
49
|
|
|
50
|
+
// 5. No Args? Show Help
|
|
38
51
|
if (args.length === 0) {
|
|
39
52
|
showHelp();
|
|
40
53
|
return;
|
|
41
54
|
}
|
|
42
55
|
|
|
56
|
+
// 6. Execution
|
|
43
57
|
const isLocal = fs.existsSync(target) && fs.lstatSync(target).isDirectory();
|
|
44
58
|
|
|
45
59
|
if (isLocal) {
|
|
46
60
|
await runLocalScan(target, format);
|
|
47
61
|
} else {
|
|
48
|
-
await runRemoteAudit(target, format,
|
|
62
|
+
await runRemoteAudit(target, format, installer);
|
|
49
63
|
}
|
|
50
64
|
}
|
|
51
65
|
|
|
52
66
|
async function runSetup() {
|
|
53
67
|
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
54
|
-
console.log(chalk.blue.bold('\nš”ļø Zift Secure Alias Setup'));
|
|
55
|
-
console.log(chalk.gray('Configure
|
|
68
|
+
console.log(chalk.blue.bold('\nš”ļø Zift Secure Alias Setup (Universal)'));
|
|
69
|
+
console.log(chalk.gray('Configure secure wrappers for npm, bun, and pnpm.\n'));
|
|
56
70
|
|
|
57
|
-
const question = chalk.white('Add
|
|
71
|
+
const question = chalk.white('Add secure wrappers to your shell profile? (y/n): ');
|
|
58
72
|
|
|
59
73
|
rl.question(question, (answer) => {
|
|
60
74
|
rl.close();
|
|
@@ -66,74 +80,56 @@ async function runSetup() {
|
|
|
66
80
|
} else {
|
|
67
81
|
reloadCmd = setupUnix();
|
|
68
82
|
}
|
|
69
|
-
console.log(chalk.green('\nā
Setup complete!
|
|
70
|
-
console.log(chalk.yellow.bold(`\nTo activate IMMEDIATELY, run
|
|
71
|
-
console.log(chalk.cyan.inverse(` ${reloadCmd} \n`));
|
|
72
|
-
console.log(chalk.gray('Alternatively, simply restart your terminal.'));
|
|
83
|
+
console.log(chalk.green('\nā
Setup complete! All package managers are now secured.'));
|
|
84
|
+
console.log(chalk.yellow.bold(`\nTo activate IMMEDIATELY, run: `) + chalk.cyan.inverse(` ${reloadCmd} `));
|
|
73
85
|
} catch (e) {
|
|
74
86
|
console.error(chalk.red('\nā Setup failed: ') + e.message);
|
|
75
87
|
}
|
|
76
|
-
} else {
|
|
77
|
-
console.log(chalk.yellow('\nSetup cancelled.'));
|
|
78
88
|
}
|
|
79
89
|
});
|
|
80
90
|
}
|
|
81
91
|
|
|
82
92
|
function setupWindows() {
|
|
83
93
|
const psFunction = `
|
|
84
|
-
# Zift Secure
|
|
85
|
-
function npm {
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
Write-Host "\nš”ļø Zift: Intercepting installation for audit...\n" -ForegroundColor Green
|
|
89
|
-
npx @7nsane/zift@latest install $pkg
|
|
90
|
-
} else {
|
|
91
|
-
& (Get-Command npm.cmd).Definition @args
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
+
# Zift Secure Wrappers
|
|
95
|
+
function npm { if ($args -contains "--zift") { npx @7nsane/zift@latest install ($args | Where-Object { $_ -ne "install" -and $_ -ne "i" -and $_ -ne "--zift" } | Select-Object -First 1) } else { & (Get-Command npm.cmd).Definition @args } }
|
|
96
|
+
function bun { if ($args -contains "--zift") { npx @7nsane/zift@latest install ($args | Where-Object { $_ -ne "add" -and $_ -ne "install" -and $_ -ne "--zift" } | Select-Object -First 1) --bun } else { & (Get-Command bun.exe).Definition @args } }
|
|
97
|
+
function pnpm { if ($args -contains "--zift") { npx @7nsane/zift@latest install ($args | Where-Object { $_ -ne "add" -and $_ -ne "install" -and $_ -ne "i" -and $_ -ne "--zift" } | Select-Object -First 1) --pnpm } else { & (Get-Command pnpm.cmd).Definition @args } }
|
|
94
98
|
`;
|
|
95
99
|
const profilePath = cp.execSync('powershell -NoProfile -Command "echo $PROFILE"').toString().trim();
|
|
96
|
-
const profileDir = path.dirname(profilePath);
|
|
97
|
-
if (!fs.existsSync(profileDir)) fs.mkdirSync(profileDir, { recursive: true });
|
|
98
100
|
fs.appendFileSync(profilePath, psFunction);
|
|
99
101
|
return '. $PROFILE';
|
|
100
102
|
}
|
|
101
103
|
|
|
102
104
|
function setupUnix() {
|
|
103
|
-
const
|
|
104
|
-
|
|
105
|
-
npm() {
|
|
105
|
+
const wrapperPattern = (cmd, aliasCmd) => `
|
|
106
|
+
${cmd}() {
|
|
106
107
|
if [[ "$*" == *"--zift"* ]]; then
|
|
107
|
-
pkg=$(echo "$@" | sed 's/install//g; s/ i //g; s/--zift//g' | xargs)
|
|
108
|
-
npx @7nsane/zift@latest install $pkg
|
|
108
|
+
pkg=$(echo "$@" | sed 's/install//g; s/add//g; s/ i //g; s/--zift//g' | xargs)
|
|
109
|
+
npx @7nsane/zift@latest install $pkg --${cmd}
|
|
109
110
|
else
|
|
110
|
-
command
|
|
111
|
+
command ${cmd} "$@"
|
|
111
112
|
fi
|
|
112
113
|
}
|
|
113
114
|
`;
|
|
115
|
+
const shellFunctions = wrapperPattern('npm') + wrapperPattern('bun') + wrapperPattern('pnpm');
|
|
114
116
|
const home = os.homedir();
|
|
115
117
|
const profiles = [path.join(home, '.bashrc'), path.join(home, '.zshrc')];
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
if (fs.existsSync(p)) {
|
|
119
|
-
fs.appendFileSync(p, bashFunction);
|
|
120
|
-
if (p.endsWith('.bashrc')) reloadTarget = '~/.bashrc';
|
|
121
|
-
}
|
|
122
|
-
});
|
|
123
|
-
return `source ${reloadTarget}`;
|
|
118
|
+
profiles.forEach(p => { if (fs.existsSync(p)) fs.appendFileSync(p, shellFunctions); });
|
|
119
|
+
return 'source ~/.zshrc # or ~/.bashrc';
|
|
124
120
|
}
|
|
125
121
|
|
|
126
|
-
async function
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
}
|
|
122
|
+
async function runRemoteAudit(packageName, format, installer) {
|
|
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
|
+
}
|
|
134
132
|
|
|
135
|
-
async function runRemoteAudit(packageName, format, installOnSuccess) {
|
|
136
|
-
if (format === 'text') console.log(chalk.blue(`\nš Remote Audit: Pre-scanning package '${packageName}'...`));
|
|
137
133
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'zift-audit-'));
|
|
138
134
|
try {
|
|
139
135
|
cp.execSync(`npm pack ${packageName}`, { cwd: tmpDir, stdio: 'ignore' });
|
|
@@ -144,50 +140,114 @@ async function runRemoteAudit(packageName, format, installOnSuccess) {
|
|
|
144
140
|
const findings = await scanner.scan();
|
|
145
141
|
handleFindings(findings, format, scanPath, true);
|
|
146
142
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
rl.
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
});
|
|
162
|
-
} else { cleanupAndExit(tmpDir, 0); }
|
|
143
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
144
|
+
const promptText = findings.length > 0
|
|
145
|
+
? chalk.yellow(`\nā ļø Suspicious patterns found. Still install '${packageName}' with ${installer}? (y/n): `)
|
|
146
|
+
: chalk.blue(`\nAudit passed. Proceed with installation of '${packageName}' via ${installer}? (y/n): `);
|
|
147
|
+
|
|
148
|
+
rl.question(promptText, (answer) => {
|
|
149
|
+
rl.close();
|
|
150
|
+
if (['y', 'yes'].includes(answer.toLowerCase())) {
|
|
151
|
+
console.log(chalk.blue(`\nš¦ Running '${installer} install ${packageName}'...`));
|
|
152
|
+
const installCmd = installer === 'bun' ? `bun add ${packageName}` : installer === 'pnpm' ? `pnpm add ${packageName}` : `npm install ${packageName}`;
|
|
153
|
+
try { cp.execSync(installCmd, { stdio: 'inherit' }); } catch (err) { }
|
|
154
|
+
}
|
|
155
|
+
cleanupAndExit(tmpDir, 0);
|
|
156
|
+
});
|
|
163
157
|
} catch (err) { cleanupAndExit(tmpDir, 1); }
|
|
164
158
|
}
|
|
165
159
|
|
|
166
|
-
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
|
+
|
|
167
176
|
if (format === 'json') {
|
|
168
|
-
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));
|
|
169
184
|
if (!skipExit) process.exit(findings.some(f => f.score >= 90) ? 1 : 0);
|
|
170
185
|
return;
|
|
171
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
|
+
|
|
172
205
|
if (findings.length === 0) {
|
|
173
|
-
if (!skipExit) {
|
|
206
|
+
if (!skipExit) {
|
|
207
|
+
console.log(chalk.green('\nā
No suspicious AST patterns detected.'));
|
|
208
|
+
process.exit(0);
|
|
209
|
+
}
|
|
174
210
|
return;
|
|
175
211
|
}
|
|
212
|
+
|
|
213
|
+
console.log(chalk.bold('\nš Behavioral AST Findings:'));
|
|
176
214
|
findings.forEach(f => {
|
|
177
215
|
const color = { 'Critical': chalk.red.bold, 'High': chalk.red, 'Medium': chalk.yellow, 'Low': chalk.blue }[f.classification];
|
|
178
216
|
console.log(color(`[${f.classification}] ${f.id} ${f.name} (Score: ${f.score})`));
|
|
179
217
|
f.triggers.forEach(t => console.log(chalk.white(` - ${t.type} in ${t.file}:${t.line} [${t.context}]`)));
|
|
180
218
|
console.log('');
|
|
181
219
|
});
|
|
182
|
-
|
|
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)'));
|
|
183
238
|
}
|
|
184
239
|
|
|
185
240
|
function showHelp() {
|
|
186
|
-
console.log(chalk.blue.bold('\nš”ļø Zift -
|
|
241
|
+
console.log(chalk.blue.bold('\nš”ļø Zift v2.0.0 - Intelligent Pre-install Security Gate\n'));
|
|
187
242
|
console.log('Usage:');
|
|
188
|
-
console.log(' zift setup
|
|
189
|
-
console.log(' zift
|
|
190
|
-
console.log(' zift
|
|
243
|
+
console.log(' zift setup Secure npm, bun, and pnpm');
|
|
244
|
+
console.log(' zift init Initialize configuration');
|
|
245
|
+
console.log(' zift install <pkg> Scan and install package');
|
|
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');
|
|
191
251
|
}
|
|
192
252
|
|
|
193
253
|
function cleanupAndExit(dir, code) {
|
|
@@ -200,4 +260,16 @@ function handleError(err, format) {
|
|
|
200
260
|
process.exit(1);
|
|
201
261
|
}
|
|
202
262
|
|
|
263
|
+
function getSummary(findings) {
|
|
264
|
+
const s = { Critical: 0, High: 0, Medium: 0, Low: 0 };
|
|
265
|
+
findings.forEach(f => s[f.classification]++);
|
|
266
|
+
return s;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function printSummary(findings) {
|
|
270
|
+
const s = getSummary(findings);
|
|
271
|
+
console.log(chalk.bold('Severity Summary:'));
|
|
272
|
+
console.log(chalk.red(` Critical: ${s.Critical}\n High: ${s.High}`));
|
|
273
|
+
}
|
|
274
|
+
|
|
203
275
|
main();
|
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,45 +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
|
-
// Detect eval / Function
|
|
52
54
|
if (calleeCode === 'eval' || calleeCode === 'Function') {
|
|
53
|
-
|
|
55
|
+
facts.DYNAMIC_EXECUTION.push({
|
|
54
56
|
file: filePath,
|
|
55
57
|
line: node.loc.start.line,
|
|
56
58
|
type: calleeCode
|
|
57
59
|
});
|
|
58
60
|
}
|
|
59
61
|
|
|
60
|
-
|
|
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
|
+
|
|
61
71
|
if (this.isNetworkSink(calleeCode)) {
|
|
62
|
-
|
|
72
|
+
facts.NETWORK_SINK.push({
|
|
63
73
|
file: filePath,
|
|
64
74
|
line: node.loc.start.line,
|
|
65
75
|
callee: calleeCode
|
|
66
76
|
});
|
|
67
77
|
}
|
|
68
78
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
this.facts.FILE_READ_SENSITIVE.push({
|
|
79
|
+
if (this.isShellSink(calleeCode)) {
|
|
80
|
+
facts.SHELL_EXECUTION.push({
|
|
72
81
|
file: filePath,
|
|
73
82
|
line: node.loc.start.line,
|
|
74
|
-
|
|
83
|
+
callee: calleeCode
|
|
75
84
|
});
|
|
76
85
|
}
|
|
86
|
+
|
|
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({
|
|
97
|
+
file: filePath,
|
|
98
|
+
line: node.loc.start.line,
|
|
99
|
+
path: node.arguments[0] ? sourceCode.substring(node.arguments[0].start, node.arguments[0].end) : 'unknown'
|
|
100
|
+
});
|
|
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
|
+
});
|
|
77
124
|
},
|
|
78
125
|
MemberExpression: (node) => {
|
|
79
|
-
const objectCode =
|
|
80
|
-
// Detect process.env
|
|
126
|
+
const objectCode = sourceCode.substring(node.object.start, node.object.end);
|
|
81
127
|
if (objectCode === 'process.env' || objectCode === 'process["env"]' || objectCode === "process['env']") {
|
|
82
128
|
const property = node.property.name || (node.property.type === 'Literal' ? node.property.value : null);
|
|
83
129
|
const whitelist = ['NODE_ENV', 'TIMING', 'DEBUG', 'VERBOSE', 'CI', 'APPDATA', 'HOME', 'USERPROFILE', 'PATH', 'PWD'];
|
|
84
|
-
if (whitelist.includes(property)) return;
|
|
130
|
+
if (whitelist.includes(property)) return;
|
|
85
131
|
|
|
86
|
-
|
|
132
|
+
facts.ENV_READ.push({
|
|
87
133
|
file: filePath,
|
|
88
134
|
line: node.loc.start.line,
|
|
89
135
|
variable: property ? `process.env.${property}` : 'process.env'
|
|
@@ -92,37 +138,105 @@ class ASTCollector {
|
|
|
92
138
|
},
|
|
93
139
|
VariableDeclarator: (node) => {
|
|
94
140
|
if (node.init && node.id.type === 'Identifier') {
|
|
95
|
-
const from =
|
|
96
|
-
|
|
141
|
+
const from = sourceCode.substring(node.init.start, node.init.end);
|
|
142
|
+
flows.push({
|
|
97
143
|
fromVar: from,
|
|
98
144
|
toVar: node.id.name,
|
|
99
145
|
file: filePath,
|
|
100
146
|
line: node.loc.start.line
|
|
101
147
|
});
|
|
102
148
|
}
|
|
149
|
+
},
|
|
150
|
+
AssignmentExpression: (node) => {
|
|
151
|
+
if (node.left.type === 'MemberExpression' && node.right.type === 'Identifier') {
|
|
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({
|
|
155
|
+
fromVar: from,
|
|
156
|
+
toVar: to,
|
|
157
|
+
file: filePath,
|
|
158
|
+
line: node.loc.start.line
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
},
|
|
162
|
+
ObjectExpression: (node, state, ancestors) => {
|
|
163
|
+
const parent = ancestors[ancestors.length - 2];
|
|
164
|
+
if (parent && parent.type === 'VariableDeclarator' && parent.id.type === 'Identifier') {
|
|
165
|
+
const objName = parent.id.name;
|
|
166
|
+
node.properties.forEach(prop => {
|
|
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({
|
|
171
|
+
fromVar: valCode,
|
|
172
|
+
toVar: `${objName}.${sourceCode.substring(prop.key.start, prop.key.end)}`,
|
|
173
|
+
file: filePath,
|
|
174
|
+
line: prop.loc.start.line
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
}
|
|
103
180
|
}
|
|
104
181
|
});
|
|
105
182
|
|
|
106
|
-
return { facts
|
|
183
|
+
return { facts, flows };
|
|
107
184
|
}
|
|
108
185
|
|
|
109
186
|
isNetworkSink(calleeCode) {
|
|
110
|
-
const methodSinks = [
|
|
111
|
-
|
|
112
|
-
|
|
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
|
|
113
193
|
return methodSinks.some(sink => {
|
|
114
|
-
|
|
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;
|
|
115
199
|
}) && !calleeCode.includes('IdleCallback');
|
|
116
200
|
}
|
|
117
201
|
|
|
118
|
-
|
|
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) {
|
|
119
233
|
if (!calleeCode.includes('fs.readFile') && !calleeCode.includes('fs.readFileSync') &&
|
|
120
234
|
!calleeCode.includes('fs.promises.readFile')) return false;
|
|
121
235
|
|
|
122
236
|
if (node.arguments.length > 0 && node.arguments[0].type === 'Literal') {
|
|
123
|
-
const
|
|
237
|
+
const pathValue = String(node.arguments[0].value);
|
|
124
238
|
const sensitive = ['.ssh', '.env', 'shadow', 'passwd', 'credentials', 'token'];
|
|
125
|
-
return sensitive.some((s) =>
|
|
239
|
+
return sensitive.some((s) => pathValue.toLowerCase().includes(s));
|
|
126
240
|
}
|
|
127
241
|
return false;
|
|
128
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 };
|
|
Binary file
|