@7nsane/zift 1.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 +59 -0
- package/bin/zift +110 -0
- package/npm-error.log +33 -0
- package/npm-publish-final.log +34 -0
- package/npm-scoped-publish.log +0 -0
- package/package.json +29 -0
- package/src/collector.js +135 -0
- package/src/engine.js +80 -0
- package/src/lifecycle.js +54 -0
- package/src/rules/definitions.js +44 -0
- package/src/scanner.js +99 -0
- package/src/utils/entropy.js +26 -0
package/README.md
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# Zift 🛡️
|
|
2
|
+
|
|
3
|
+
**Zift** is an elite, high-performance security scanner designed to detect suspicious patterns in npm packages before they are executed. By using deterministic AST analysis and lightweight variable propagation, Zift identifies potential credential exfiltration, malicious persistence, and obfuscated execution with extreme precision.
|
|
4
|
+
|
|
5
|
+
## Key Features
|
|
6
|
+
|
|
7
|
+
- **Rule-Based Scoring**: Deterministic classification (Critical, High, Medium, Low) using professional Rule IDs (e.g., `ZFT-001`).
|
|
8
|
+
- **Context-Aware Detection**: Multiplier applied for suspicious activity found in lifecycle scripts (e.g., `postinstall`).
|
|
9
|
+
- **Data-Flow Tracking**: Lightweight variable propagation to detect process.env exfiltration.
|
|
10
|
+
- **Obfuscation Detection**: Shannon entropy-based identification of high-entropy strings combined with dynamic execution.
|
|
11
|
+
- **High Performance**: Optimized AST traversal with file size caps (512KB) and skip patterns for non-source files.
|
|
12
|
+
|
|
13
|
+
## Installation
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
npm install -g @7nsane/zift
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Usage
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
# Scan current directory
|
|
23
|
+
@7nsane/zift .
|
|
24
|
+
|
|
25
|
+
# Scan a specific package or directory
|
|
26
|
+
@7nsane/zift ./node_modules/example-pkg
|
|
27
|
+
|
|
28
|
+
# Output result in JSON format for CI/CD pipelines
|
|
29
|
+
@7nsane/zift . --format json
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Rule Transparency
|
|
33
|
+
|
|
34
|
+
Zift uses a multi-phase engine:
|
|
35
|
+
1. **Collection**: Single-pass AST traversal to gather facts (sources, sinks, flows).
|
|
36
|
+
2. **Evaluation**: Deterministic rule matching against collected facts.
|
|
37
|
+
|
|
38
|
+
### Rule IDs:
|
|
39
|
+
- **ZFT-001 (ENV_EXFILTRATION)**: Detection of environment variables being read and sent over the network.
|
|
40
|
+
- **ZFT-002 (SENSITIVE_FILE_EXFILTRATION)**: Detection of sensitive files (e.g., `.ssh`, `.env`) being read and sent over the network.
|
|
41
|
+
- **ZFT-003 (PERSISTENCE_ATTEMPT)**: Detection of attempts to write to startup directories.
|
|
42
|
+
- **ZFT-004 (OBFUSCATED_EXECUTION)**: Detection of high-entropy strings executed via dynamic constructors.
|
|
43
|
+
|
|
44
|
+
## Limitations
|
|
45
|
+
|
|
46
|
+
Transparency is key to trust. As a V1 static analysis tool, Zift has the following scope boundaries:
|
|
47
|
+
|
|
48
|
+
- **No Interprocedural Flow**: Variable tracking is restricted to function scope; it does not track data across function boundaries.
|
|
49
|
+
- **No Cross-File Propagation**: Analysis is performed on a per-file basis.
|
|
50
|
+
- **No Dynamic Runtime Analysis**: Zift does not execute code; it cannot detect evasion techniques that only trigger during execution (e.g., sophisticated sandbox escapes).
|
|
51
|
+
- **Heuristic Entropy**: Entropy calculation is a signal, not a guarantee. Bundled assets may trigger medium-level warnings.
|
|
52
|
+
|
|
53
|
+
## Performance Guarantees
|
|
54
|
+
|
|
55
|
+
- **File Cap**: Files larger than **512KB** are skipped to ensure predictable scan times.
|
|
56
|
+
- **String Cap**: Entropy calculation is skipped for literal strings longer than **2048 characters**.
|
|
57
|
+
|
|
58
|
+
---
|
|
59
|
+
Built for the security-conscious developer.
|
package/bin/zift
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
const PackageScanner = require('../src/scanner');
|
|
3
|
+
const chalk = require('chalk');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
|
|
6
|
+
async function main() {
|
|
7
|
+
const args = process.argv.slice(2);
|
|
8
|
+
let targetDir = '.';
|
|
9
|
+
let format = 'text';
|
|
10
|
+
|
|
11
|
+
// Basic arg parsing
|
|
12
|
+
for (let i = 0; i < args.length; i++) {
|
|
13
|
+
if (args[i] === '--format' && args[i + 1]) {
|
|
14
|
+
format = args[i + 1];
|
|
15
|
+
i++;
|
|
16
|
+
} else if (!args[i].startsWith('-')) {
|
|
17
|
+
targetDir = args[i];
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const scanner = new PackageScanner(targetDir);
|
|
22
|
+
|
|
23
|
+
if (format === 'text') {
|
|
24
|
+
process.stdout.write(chalk.blue(`\n🔍 Scanning package at ${path.resolve(targetDir)}...\n`));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
try {
|
|
28
|
+
const findings = await scanner.scan();
|
|
29
|
+
|
|
30
|
+
if (format === 'json') {
|
|
31
|
+
console.log(JSON.stringify({
|
|
32
|
+
targetDir: path.resolve(targetDir),
|
|
33
|
+
timestamp: new Date().toISOString(),
|
|
34
|
+
findings: findings,
|
|
35
|
+
summary: getSummary(findings)
|
|
36
|
+
}, null, 2));
|
|
37
|
+
process.exit(findings.some(f => f.score >= 90) ? 1 : 0);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (findings.length === 0) {
|
|
41
|
+
console.log(chalk.green('\n✅ No suspicious patterns detected. All modules within safety thresholds.'));
|
|
42
|
+
process.exit(0);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
console.log(chalk.yellow(`\n⚠️ Scan complete. Found ${findings.length} suspicious patterns.\n`));
|
|
46
|
+
|
|
47
|
+
findings.forEach(finding => {
|
|
48
|
+
const colorMap = {
|
|
49
|
+
'Critical': chalk.red.bold,
|
|
50
|
+
'High': chalk.red,
|
|
51
|
+
'Medium': chalk.yellow,
|
|
52
|
+
'Low': chalk.blue
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const theme = colorMap[finding.classification] || chalk.white;
|
|
56
|
+
|
|
57
|
+
console.log(theme(`[${finding.classification}] ${finding.id} ${finding.name} (Risk Score: ${finding.score})`));
|
|
58
|
+
console.log(chalk.gray(`Description: ${finding.description}`));
|
|
59
|
+
|
|
60
|
+
finding.triggers.forEach(t => {
|
|
61
|
+
console.log(chalk.white(` - ${t.type} in ${t.file}:${t.line} [${t.context}]`));
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
if (finding.isLifecycle) {
|
|
65
|
+
console.log(chalk.magenta(` Context: Multiplier applied due to execution in lifecycle script.`));
|
|
66
|
+
}
|
|
67
|
+
console.log('');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
printSummary(findings);
|
|
71
|
+
|
|
72
|
+
const highestScore = findings.length > 0 ? findings[0].score : 0;
|
|
73
|
+
if (highestScore >= 90) {
|
|
74
|
+
console.log(chalk.red.bold(`\n❌ FAILED SAFETY CHECK: Critical risk detected (Score: ${highestScore})\n`));
|
|
75
|
+
process.exit(1);
|
|
76
|
+
} else {
|
|
77
|
+
console.log(chalk.green(`\n✔ Safety check completed with minor warnings.\n`));
|
|
78
|
+
process.exit(0);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
} catch (err) {
|
|
82
|
+
if (format === 'json') {
|
|
83
|
+
console.error(JSON.stringify({ error: err.message }));
|
|
84
|
+
} else {
|
|
85
|
+
console.error(chalk.red(`\n❌ Fatal Error: ${err.message}`));
|
|
86
|
+
}
|
|
87
|
+
process.exit(1);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function getSummary(findings) {
|
|
92
|
+
const summary = { Critical: 0, High: 0, Medium: 0, Low: 0 };
|
|
93
|
+
findings.forEach(f => {
|
|
94
|
+
if (summary[f.classification] !== undefined) {
|
|
95
|
+
summary[f.classification]++;
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
return summary;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function printSummary(findings) {
|
|
102
|
+
const s = getSummary(findings);
|
|
103
|
+
console.log(chalk.bold('Severity Summary:'));
|
|
104
|
+
console.log(chalk.red(` Critical: ${s.Critical}`));
|
|
105
|
+
console.log(chalk.red(` High: ${s.High}`));
|
|
106
|
+
console.log(chalk.yellow(` Medium: ${s.Medium}`));
|
|
107
|
+
console.log(chalk.blue(` Low: ${s.Low}`));
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
main();
|
package/npm-error.log
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
npm warn publish npm auto-corrected some errors in your package.json when publishing. Please run "npm pkg fix" to address these errors.
|
|
2
|
+
npm warn publish errors corrected:
|
|
3
|
+
npm warn publish "bin[zift]" script name bin/zift was invalid and removed
|
|
4
|
+
npm notice
|
|
5
|
+
npm notice 📦 zift@1.0.0
|
|
6
|
+
npm notice Tarball Contents
|
|
7
|
+
npm notice 2.8kB README.md
|
|
8
|
+
npm notice 3.4kB bin/zift
|
|
9
|
+
npm notice 0B npm-error.log
|
|
10
|
+
npm notice 576B package.json
|
|
11
|
+
npm notice 5.7kB src/collector.js
|
|
12
|
+
npm notice 2.7kB src/engine.js
|
|
13
|
+
npm notice 1.8kB src/lifecycle.js
|
|
14
|
+
npm notice 1.5kB src/rules/definitions.js
|
|
15
|
+
npm notice 3.6kB src/scanner.js
|
|
16
|
+
npm notice 630B src/utils/entropy.js
|
|
17
|
+
npm notice Tarball Details
|
|
18
|
+
npm notice name: zift
|
|
19
|
+
npm notice version: 1.0.0
|
|
20
|
+
npm notice filename: zift-1.0.0.tgz
|
|
21
|
+
npm notice package size: 7.2 kB
|
|
22
|
+
npm notice unpacked size: 22.6 kB
|
|
23
|
+
npm notice shasum: 9f50038c0bbbff54bd8badc2e2308e0ec4dee7ce
|
|
24
|
+
npm notice integrity: sha512-01SbjHW0FwGnt[...]nhL1MfvXUqo8w==
|
|
25
|
+
npm notice total files: 10
|
|
26
|
+
npm notice
|
|
27
|
+
npm notice Publishing to https://registry.npmjs.org/ with tag latest and default access
|
|
28
|
+
npm error code E403
|
|
29
|
+
npm error 403 403 Forbidden - PUT https://registry.npmjs.org/zift - Two-factor authentication or granular access token with bypass 2fa enabled is required to publish packages.
|
|
30
|
+
npm error 403 In most cases, you or one of your dependencies are requesting
|
|
31
|
+
npm error 403 a package version that is forbidden by your security policy, or
|
|
32
|
+
npm error 403 on a server you do not have access to.
|
|
33
|
+
npm error A complete log of this run can be found in: C:\Users\Afjal\AppData\Local\npm-cache\_logs\2026-02-28T22_16_43_493Z-debug-0.log
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
npm warn publish npm auto-corrected some errors in your package.json when publishing. Please run "npm pkg fix" to address these errors.
|
|
2
|
+
npm warn publish errors corrected:
|
|
3
|
+
npm warn publish "bin[zift]" script name bin/zift was invalid and removed
|
|
4
|
+
npm notice
|
|
5
|
+
npm notice 📦 zift@1.0.0
|
|
6
|
+
npm notice Tarball Contents
|
|
7
|
+
npm notice 2.8kB README.md
|
|
8
|
+
npm notice 3.4kB bin/zift
|
|
9
|
+
npm notice 1.6kB npm-error.log
|
|
10
|
+
npm notice 0B npm-publish-final.log
|
|
11
|
+
npm notice 576B package.json
|
|
12
|
+
npm notice 5.7kB src/collector.js
|
|
13
|
+
npm notice 2.7kB src/engine.js
|
|
14
|
+
npm notice 1.8kB src/lifecycle.js
|
|
15
|
+
npm notice 1.5kB src/rules/definitions.js
|
|
16
|
+
npm notice 3.6kB src/scanner.js
|
|
17
|
+
npm notice 630B src/utils/entropy.js
|
|
18
|
+
npm notice Tarball Details
|
|
19
|
+
npm notice name: zift
|
|
20
|
+
npm notice version: 1.0.0
|
|
21
|
+
npm notice filename: zift-1.0.0.tgz
|
|
22
|
+
npm notice package size: 7.9 kB
|
|
23
|
+
npm notice unpacked size: 24.3 kB
|
|
24
|
+
npm notice shasum: b90604b52056a617d4f2cab9a552b8300cfa2203
|
|
25
|
+
npm notice integrity: sha512-OYPJOIG5zANyZ[...]vHYt8KJZdJRwA==
|
|
26
|
+
npm notice total files: 11
|
|
27
|
+
npm notice
|
|
28
|
+
npm notice Publishing to https://registry.npmjs.org/ with tag latest and default access
|
|
29
|
+
npm error code E403
|
|
30
|
+
npm error 403 403 Forbidden - PUT https://registry.npmjs.org/zift - Package name too similar to existing packages sift,diff,mitt,pify,lit; try renaming your package to '@7nsane/zift' and publishing with 'npm publish --access=public' instead
|
|
31
|
+
npm error 403 In most cases, you or one of your dependencies are requesting
|
|
32
|
+
npm error 403 a package version that is forbidden by your security policy, or
|
|
33
|
+
npm error 403 on a server you do not have access to.
|
|
34
|
+
npm error A complete log of this run can be found in: C:\Users\Afjal\AppData\Local\npm-cache\_logs\2026-02-28T22_25_29_502Z-debug-0.log
|
|
File without changes
|
package/package.json
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@7nsane/zift",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "A high-performance, deterministic security scanner for npm packages.",
|
|
5
|
+
"main": "src/scanner.js",
|
|
6
|
+
"bin": {
|
|
7
|
+
"zift": "./bin/zift"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"test": "node v1_launch_pack/tests/verify.js",
|
|
11
|
+
"scan": "node bin/zift"
|
|
12
|
+
},
|
|
13
|
+
"keywords": [
|
|
14
|
+
"security",
|
|
15
|
+
"scanner",
|
|
16
|
+
"npm",
|
|
17
|
+
"malware",
|
|
18
|
+
"ast"
|
|
19
|
+
],
|
|
20
|
+
"author": "Zift Team",
|
|
21
|
+
"license": "ISC",
|
|
22
|
+
"type": "commonjs",
|
|
23
|
+
"dependencies": {
|
|
24
|
+
"acorn": "^8.16.0",
|
|
25
|
+
"acorn-walk": "^8.3.5",
|
|
26
|
+
"chalk": "^4.1.2",
|
|
27
|
+
"glob": "^13.0.6"
|
|
28
|
+
}
|
|
29
|
+
}
|
package/src/collector.js
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
const acorn = require('acorn');
|
|
2
|
+
const walk = require('acorn-walk');
|
|
3
|
+
const { calculateEntropy } = require('./utils/entropy');
|
|
4
|
+
|
|
5
|
+
class ASTCollector {
|
|
6
|
+
constructor() {
|
|
7
|
+
this.facts = {
|
|
8
|
+
ENV_READ: [],
|
|
9
|
+
FILE_READ_SENSITIVE: [],
|
|
10
|
+
NETWORK_SINK: [],
|
|
11
|
+
DYNAMIC_EXECUTION: [],
|
|
12
|
+
OBFUSCATION: [],
|
|
13
|
+
FILE_WRITE_STARTUP: []
|
|
14
|
+
};
|
|
15
|
+
this.flows = [];
|
|
16
|
+
this.entropyThreshold = 4.8;
|
|
17
|
+
this.maxFileSize = 512 * 1024; // 512KB cap for static analysis
|
|
18
|
+
this.maxStringLengthForEntropy = 2048; // Don't calculate entropy for massive blobs
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
collect(code, filePath) {
|
|
22
|
+
this.sourceCode = code;
|
|
23
|
+
let ast;
|
|
24
|
+
try {
|
|
25
|
+
ast = acorn.parse(code, { ecmaVersion: 2020, sourceType: 'module', locations: true });
|
|
26
|
+
} catch (error) {
|
|
27
|
+
try {
|
|
28
|
+
ast = acorn.parse(code, { ecmaVersion: 2020, sourceType: 'script', locations: true });
|
|
29
|
+
} catch (error_) {
|
|
30
|
+
return { facts: this.facts, flows: this.flows };
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
walk.ancestor(ast, {
|
|
35
|
+
Literal: (node) => {
|
|
36
|
+
if (typeof node.value === 'string' && node.value.length > 20 && node.value.length < this.maxStringLengthForEntropy) {
|
|
37
|
+
const entropy = calculateEntropy(node.value);
|
|
38
|
+
if (entropy > this.entropyThreshold) {
|
|
39
|
+
this.facts.OBFUSCATION.push({
|
|
40
|
+
file: filePath,
|
|
41
|
+
line: node.loc.start.line,
|
|
42
|
+
reason: `High entropy string (${entropy.toFixed(2)})`,
|
|
43
|
+
value: node.value.substring(0, 50) + (node.value.length > 50 ? '...' : '')
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
CallExpression: (node) => {
|
|
49
|
+
const calleeCode = this.getSourceCode(node.callee);
|
|
50
|
+
|
|
51
|
+
// Detect eval / Function
|
|
52
|
+
if (calleeCode === 'eval' || calleeCode === 'Function') {
|
|
53
|
+
this.facts.DYNAMIC_EXECUTION.push({
|
|
54
|
+
file: filePath,
|
|
55
|
+
line: node.loc.start.line,
|
|
56
|
+
type: calleeCode
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Detect Sinks
|
|
61
|
+
if (this.isNetworkSink(calleeCode)) {
|
|
62
|
+
this.facts.NETWORK_SINK.push({
|
|
63
|
+
file: filePath,
|
|
64
|
+
line: node.loc.start.line,
|
|
65
|
+
callee: calleeCode
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Detect Sources
|
|
70
|
+
if (this.isSensitiveFileRead(calleeCode, node)) {
|
|
71
|
+
this.facts.FILE_READ_SENSITIVE.push({
|
|
72
|
+
file: filePath,
|
|
73
|
+
line: node.loc.start.line,
|
|
74
|
+
path: node.arguments[0] ? this.getSourceCode(node.arguments[0]) : 'unknown'
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
},
|
|
78
|
+
MemberExpression: (node) => {
|
|
79
|
+
const objectCode = this.getSourceCode(node.object);
|
|
80
|
+
// Detect process.env
|
|
81
|
+
if (objectCode === 'process.env' || objectCode === 'process["env"]' || objectCode === "process['env']") {
|
|
82
|
+
const property = node.property.name || (node.property.type === 'Literal' ? node.property.value : null);
|
|
83
|
+
const whitelist = ['NODE_ENV', 'TIMING', 'DEBUG', 'VERBOSE', 'CI', 'APPDATA', 'HOME', 'USERPROFILE', 'PATH', 'PWD'];
|
|
84
|
+
if (whitelist.includes(property)) return; // Whitelist common env check
|
|
85
|
+
|
|
86
|
+
this.facts.ENV_READ.push({
|
|
87
|
+
file: filePath,
|
|
88
|
+
line: node.loc.start.line,
|
|
89
|
+
variable: property ? `process.env.${property}` : 'process.env'
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
},
|
|
93
|
+
VariableDeclarator: (node) => {
|
|
94
|
+
if (node.init && node.id.type === 'Identifier') {
|
|
95
|
+
const from = this.getSourceCode(node.init);
|
|
96
|
+
this.flows.push({
|
|
97
|
+
fromVar: from,
|
|
98
|
+
toVar: node.id.name,
|
|
99
|
+
file: filePath,
|
|
100
|
+
line: node.loc.start.line
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
return { facts: this.facts, flows: this.flows };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
isNetworkSink(calleeCode) {
|
|
110
|
+
const methodSinks = ['http.request', 'https.request', 'http.get', 'https.get', 'net.connect', 'dns.lookup', 'dns.resolve', 'fetch', 'axios'];
|
|
111
|
+
|
|
112
|
+
// Match methods like http.request but avoid requestIdleCallback or local 'request' variables
|
|
113
|
+
return methodSinks.some(sink => {
|
|
114
|
+
return calleeCode === sink || calleeCode.endsWith('.' + sink);
|
|
115
|
+
}) && !calleeCode.includes('IdleCallback');
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
isSensitiveFileRead(calleeCode, node) {
|
|
119
|
+
if (!calleeCode.includes('fs.readFile') && !calleeCode.includes('fs.readFileSync') &&
|
|
120
|
+
!calleeCode.includes('fs.promises.readFile')) return false;
|
|
121
|
+
|
|
122
|
+
if (node.arguments.length > 0 && node.arguments[0].type === 'Literal') {
|
|
123
|
+
const path = String(node.arguments[0].value);
|
|
124
|
+
const sensitive = ['.ssh', '.env', 'shadow', 'passwd', 'credentials', 'token'];
|
|
125
|
+
return sensitive.some((s) => path.toLowerCase().includes(s));
|
|
126
|
+
}
|
|
127
|
+
return false;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
getSourceCode(node) {
|
|
131
|
+
return this.sourceCode.substring(node.start, node.end);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
module.exports = ASTCollector;
|
package/src/engine.js
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
const { RULES } = require('./rules/definitions');
|
|
2
|
+
|
|
3
|
+
class SafetyEngine {
|
|
4
|
+
constructor() {
|
|
5
|
+
this.results = [];
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
evaluate(packageFacts, lifecycleFiles) {
|
|
9
|
+
const findings = [];
|
|
10
|
+
|
|
11
|
+
// Process each rule
|
|
12
|
+
for (const rule of RULES) {
|
|
13
|
+
const match = this.matchRule(rule, packageFacts, lifecycleFiles);
|
|
14
|
+
if (match) {
|
|
15
|
+
findings.push(match);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
return findings;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
matchRule(rule, packageFacts, lifecycleFiles) {
|
|
23
|
+
const { facts } = packageFacts;
|
|
24
|
+
const triggers = [];
|
|
25
|
+
let baseScore = rule.baseScore;
|
|
26
|
+
let multiplier = 1;
|
|
27
|
+
|
|
28
|
+
// Check required facts
|
|
29
|
+
for (const req of rule.requires) {
|
|
30
|
+
const matchedFacts = facts[req] || [];
|
|
31
|
+
if (matchedFacts.length === 0) return null; // Rule not matched
|
|
32
|
+
triggers.push(...matchedFacts.map(f => ({ ...f, type: req })));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Check optional facts for bonuses
|
|
36
|
+
if (rule.optional) {
|
|
37
|
+
for (const opt of rule.optional) {
|
|
38
|
+
const matchedOpts = facts[opt] || [];
|
|
39
|
+
if (matchedOpts.length > 0) {
|
|
40
|
+
triggers.push(...matchedOpts.map(f => ({ ...f, type: opt })));
|
|
41
|
+
baseScore += 20; // Bonus for optional matches (e.g., obfuscation)
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Apply Lifecycle Multiplier (1.8x)
|
|
47
|
+
const isInLifecycle = triggers.some(t => lifecycleFiles.has(t.file));
|
|
48
|
+
if (isInLifecycle) {
|
|
49
|
+
multiplier = 1.8;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Cluster Bonus: Source + Sink
|
|
53
|
+
const hasSource = triggers.some(t => t.type.includes('READ'));
|
|
54
|
+
const hasSink = triggers.some(t => t.type.includes('SINK') || t.type === 'DYNAMIC_EXECUTION');
|
|
55
|
+
if (hasSource && hasSink) {
|
|
56
|
+
baseScore += 40;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
let finalScore = baseScore * multiplier;
|
|
60
|
+
|
|
61
|
+
// Lifecycle Guard: ENV_READ + NETWORK_SINK + lifecycleContext = min 85 (High)
|
|
62
|
+
const isEnvRead = triggers.some(t => t.type === 'ENV_READ');
|
|
63
|
+
const isNetworkSink = triggers.some(t => t.type === 'NETWORK_SINK');
|
|
64
|
+
if (isEnvRead && isNetworkSink && isInLifecycle && finalScore < 85) {
|
|
65
|
+
finalScore = 85;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
id: rule.id,
|
|
70
|
+
alias: rule.alias,
|
|
71
|
+
name: rule.name,
|
|
72
|
+
score: Math.min(finalScore, 100),
|
|
73
|
+
triggers: triggers,
|
|
74
|
+
description: rule.description,
|
|
75
|
+
isLifecycle: isInLifecycle
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
module.exports = SafetyEngine;
|
package/src/lifecycle.js
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
|
|
4
|
+
class LifecycleResolver {
|
|
5
|
+
constructor(packageDir) {
|
|
6
|
+
this.packageDir = packageDir;
|
|
7
|
+
this.lifecycleFiles = new Set();
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
resolve() {
|
|
11
|
+
const packageJsonPath = path.join(this.packageDir, 'package.json');
|
|
12
|
+
if (!fs.existsSync(packageJsonPath)) return this.lifecycleFiles;
|
|
13
|
+
|
|
14
|
+
try {
|
|
15
|
+
const pkg = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
|
|
16
|
+
const scripts = pkg.scripts || {};
|
|
17
|
+
|
|
18
|
+
const lifecycleHooks = ['preinstall', 'postinstall', 'install', 'preuninstall', 'postuninstall'];
|
|
19
|
+
|
|
20
|
+
for (const hook of lifecycleHooks) {
|
|
21
|
+
if (scripts[hook]) {
|
|
22
|
+
this.extractFilesFromScript(scripts[hook]);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
} catch (e) {
|
|
26
|
+
// Ignore parse errors
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return this.lifecycleFiles;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
extractFilesFromScript(script) {
|
|
33
|
+
// Look for "node file.js" or "node ./file.js"
|
|
34
|
+
const nodeMatch = script.match(/node\s+([\w\.\/\-\\]+\.js)/g);
|
|
35
|
+
if (nodeMatch) {
|
|
36
|
+
for (const match of nodeMatch) {
|
|
37
|
+
const filePath = match.replace(/node\s+/, '').trim();
|
|
38
|
+
this.lifecycleFiles.add(path.resolve(this.packageDir, filePath));
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Also look for direct script execution if it ends in .js
|
|
43
|
+
const directMatch = script.match(/^([\w\.\/\-\\]+\.js)(\s|$)/);
|
|
44
|
+
if (directMatch) {
|
|
45
|
+
this.lifecycleFiles.add(path.resolve(this.packageDir, directMatch[1]));
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
isLifecycleFile(filePath) {
|
|
50
|
+
return this.lifecycleFiles.has(path.resolve(filePath));
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
module.exports = LifecycleResolver;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
const RULES = [
|
|
2
|
+
{
|
|
3
|
+
id: 'ZFT-001',
|
|
4
|
+
alias: 'ENV_EXFILTRATION',
|
|
5
|
+
name: 'Environment Variable Exfiltration',
|
|
6
|
+
requires: ['ENV_READ', 'NETWORK_SINK'],
|
|
7
|
+
optional: ['OBFUSCATION'],
|
|
8
|
+
baseScore: 40,
|
|
9
|
+
description: 'Detection of environment variables being read and sent over the network.'
|
|
10
|
+
},
|
|
11
|
+
{
|
|
12
|
+
id: 'ZFT-002',
|
|
13
|
+
alias: 'SENSITIVE_FILE_EXFILTRATION',
|
|
14
|
+
name: 'Sensitive File Exfiltration',
|
|
15
|
+
requires: ['FILE_READ_SENSITIVE', 'NETWORK_SINK'],
|
|
16
|
+
baseScore: 50,
|
|
17
|
+
description: 'Detection of sensitive files (e.g., .ssh, .env) being read and sent over the network.'
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
id: 'ZFT-003',
|
|
21
|
+
alias: 'PERSISTENCE_ATTEMPT',
|
|
22
|
+
name: 'Persistence Attempt',
|
|
23
|
+
requires: ['FILE_WRITE_STARTUP'],
|
|
24
|
+
baseScore: 60,
|
|
25
|
+
description: 'Detection of attempts to write to system startup directories.'
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
id: 'ZFT-004',
|
|
29
|
+
alias: 'OBFUSCATED_EXECUTION',
|
|
30
|
+
name: 'Obfuscated Execution',
|
|
31
|
+
requires: ['OBFUSCATION', 'DYNAMIC_EXECUTION'],
|
|
32
|
+
baseScore: 40,
|
|
33
|
+
description: 'Detection of high-entropy strings being executed via eval or Function constructor.'
|
|
34
|
+
}
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
const CATEGORIES = {
|
|
38
|
+
SOURCES: ['ENV_READ', 'FILE_READ_SENSITIVE'],
|
|
39
|
+
SINKS: ['NETWORK_SINK', 'DYNAMIC_EXECUTION'],
|
|
40
|
+
OBFUSCATION: ['OBFUSCATION'],
|
|
41
|
+
PERSISTENCE: ['FILE_WRITE_STARTUP']
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
module.exports = { RULES, CATEGORIES };
|
package/src/scanner.js
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
const fs = require('node:fs');
|
|
2
|
+
const path = require('node:path');
|
|
3
|
+
const ASTCollector = require('./collector');
|
|
4
|
+
const LifecycleResolver = require('./lifecycle');
|
|
5
|
+
const SafetyEngine = require('./engine');
|
|
6
|
+
|
|
7
|
+
class PackageScanner {
|
|
8
|
+
constructor(packageDir) {
|
|
9
|
+
this.packageDir = path.resolve(packageDir);
|
|
10
|
+
this.collector = new ASTCollector();
|
|
11
|
+
this.lifecycleResolver = new LifecycleResolver(this.packageDir);
|
|
12
|
+
this.engine = new SafetyEngine();
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async scan() {
|
|
16
|
+
const lifecycleFiles = this.lifecycleResolver.resolve();
|
|
17
|
+
const files = await this.getFiles();
|
|
18
|
+
|
|
19
|
+
let allFacts = {
|
|
20
|
+
facts: {
|
|
21
|
+
ENV_READ: [],
|
|
22
|
+
FILE_READ_SENSITIVE: [],
|
|
23
|
+
NETWORK_SINK: [],
|
|
24
|
+
DYNAMIC_EXECUTION: [],
|
|
25
|
+
OBFUSCATION: [],
|
|
26
|
+
FILE_WRITE_STARTUP: []
|
|
27
|
+
},
|
|
28
|
+
flows: []
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
for (const file of files) {
|
|
32
|
+
const relativePath = path.relative(this.packageDir, file);
|
|
33
|
+
|
|
34
|
+
// Refined ignore logic
|
|
35
|
+
const ignorePatterns = ['node_modules', '.git', 'test', 'dist', 'coverage', 'docs', '.github'];
|
|
36
|
+
if (ignorePatterns.some(p => relativePath.includes(p)) || relativePath.startsWith('.')) continue;
|
|
37
|
+
|
|
38
|
+
const stats = fs.statSync(file);
|
|
39
|
+
if (stats.size > 512 * 1024) continue; // Skip files > 512KB
|
|
40
|
+
|
|
41
|
+
const code = fs.readFileSync(file, 'utf8');
|
|
42
|
+
const { facts, flows } = this.collector.collect(code, file);
|
|
43
|
+
|
|
44
|
+
// Merge facts
|
|
45
|
+
for (const category in facts) {
|
|
46
|
+
allFacts.facts[category].push(...facts[category]);
|
|
47
|
+
}
|
|
48
|
+
allFacts.flows.push(...flows);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const findings = this.engine.evaluate(allFacts, lifecycleFiles);
|
|
52
|
+
return this.formatFindings(findings);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async getFiles() {
|
|
56
|
+
const getJsFiles = (dir) => {
|
|
57
|
+
const results = [];
|
|
58
|
+
const list = fs.readdirSync(dir);
|
|
59
|
+
for (const file of list) {
|
|
60
|
+
const fullPath = path.join(dir, file);
|
|
61
|
+
const stat = fs.statSync(fullPath);
|
|
62
|
+
if (stat && stat.isDirectory()) {
|
|
63
|
+
const ignoreDirs = ['node_modules', '.git', 'dist', 'build', 'coverage', 'test', 'tests'];
|
|
64
|
+
if (!ignoreDirs.includes(file) && !file.startsWith('.')) {
|
|
65
|
+
results.push(...getJsFiles(fullPath));
|
|
66
|
+
}
|
|
67
|
+
} else if (file.endsWith('.js')) {
|
|
68
|
+
results.push(fullPath);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return results;
|
|
72
|
+
};
|
|
73
|
+
return getJsFiles(this.packageDir);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
formatFindings(findings) {
|
|
77
|
+
const sorted = findings.sort((a, b) => b.score - a.score);
|
|
78
|
+
|
|
79
|
+
return sorted.map(f => {
|
|
80
|
+
let classification = 'Low';
|
|
81
|
+
if (f.score >= 90) classification = 'Critical';
|
|
82
|
+
else if (f.score >= 70) classification = 'High';
|
|
83
|
+
else if (f.score >= 50) classification = 'Medium';
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
...f,
|
|
87
|
+
classification,
|
|
88
|
+
triggers: f.triggers.map(t => ({
|
|
89
|
+
type: t.type,
|
|
90
|
+
file: path.relative(this.packageDir, t.file),
|
|
91
|
+
line: t.line,
|
|
92
|
+
context: t.reason || t.callee || t.variable || t.path
|
|
93
|
+
}))
|
|
94
|
+
};
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
module.exports = PackageScanner;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Calculates the Shannon entropy of a string.
|
|
3
|
+
* Used to detect obfuscated or encrypted payloads.
|
|
4
|
+
* @param {string} str
|
|
5
|
+
* @returns {number}
|
|
6
|
+
*/
|
|
7
|
+
function calculateEntropy(str) {
|
|
8
|
+
if (!str) return 0;
|
|
9
|
+
const len = str.length;
|
|
10
|
+
const frequencies = {};
|
|
11
|
+
|
|
12
|
+
for (let i = 0; i < len; i++) {
|
|
13
|
+
const char = str[i];
|
|
14
|
+
frequencies[char] = (frequencies[char] || 0) + 1;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
let entropy = 0;
|
|
18
|
+
for (const char in frequencies) {
|
|
19
|
+
const p = frequencies[char] / len;
|
|
20
|
+
entropy -= p * Math.log2(p);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
return entropy;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
module.exports = { calculateEntropy };
|