@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 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
+ }
@@ -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;
@@ -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 };