muaddib-scanner 2.1.2 → 2.1.4
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/iocs.json.gz +0 -0
- package/package.json +3 -2
- package/src/index.js +4 -0
- package/src/ioc/bootstrap.js +179 -0
package/iocs.json.gz
ADDED
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "muaddib-scanner",
|
|
3
|
-
"version": "2.1.
|
|
3
|
+
"version": "2.1.4",
|
|
4
4
|
"description": "Supply-chain threat detection & response for npm & PyPI/Python",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -10,7 +10,8 @@
|
|
|
10
10
|
"test": "node tests/run-tests.js",
|
|
11
11
|
"scan": "node bin/muaddib.js scan .",
|
|
12
12
|
"update": "node bin/muaddib.js update",
|
|
13
|
-
"lint": "eslint src bin --ext .js"
|
|
13
|
+
"lint": "eslint src bin --ext .js",
|
|
14
|
+
"compress-iocs": "node -e \"const fs=require('fs');const zlib=require('zlib');zlib.gzip(fs.readFileSync('src/ioc/data/iocs.json'),(e,b)=>{if(e)throw e;fs.writeFileSync('iocs.json.gz',b);console.log('Compressed: '+b.length+' bytes')})\""
|
|
14
15
|
},
|
|
15
16
|
"keywords": [
|
|
16
17
|
"security",
|
package/src/index.js
CHANGED
|
@@ -16,6 +16,7 @@ const path = require('path');
|
|
|
16
16
|
const { scanGitHubActions } = require('./scanner/github-actions.js');
|
|
17
17
|
const { detectPythonProject, normalizePythonName } = require('./scanner/python.js');
|
|
18
18
|
const { loadCachedIOCs } = require('./ioc/updater.js');
|
|
19
|
+
const { ensureIOCs } = require('./ioc/bootstrap.js');
|
|
19
20
|
const { scanEntropy } = require('./scanner/entropy.js');
|
|
20
21
|
const { detectSuddenLifecycleChange } = require('./temporal-analysis.js');
|
|
21
22
|
const { detectSuddenAstChanges } = require('./temporal-ast-diff.js');
|
|
@@ -198,6 +199,9 @@ function checkPyPITyposquatting(deps, targetPath) {
|
|
|
198
199
|
}
|
|
199
200
|
|
|
200
201
|
async function run(targetPath, options = {}) {
|
|
202
|
+
// Ensure IOCs are downloaded (first run only, graceful failure)
|
|
203
|
+
await ensureIOCs();
|
|
204
|
+
|
|
201
205
|
// Apply --exclude dirs for this scan
|
|
202
206
|
if (options.exclude && options.exclude.length > 0) {
|
|
203
207
|
setExtraExcludes(options.exclude);
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
const https = require('https');
|
|
2
|
+
const fs = require('fs');
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const os = require('os');
|
|
5
|
+
const zlib = require('zlib');
|
|
6
|
+
|
|
7
|
+
// GitHub Releases URL for pre-compressed IOC database
|
|
8
|
+
const IOCS_URL = 'https://github.com/DNSZLSK/muad-dib/releases/latest/download/iocs.json.gz';
|
|
9
|
+
|
|
10
|
+
// Local storage paths
|
|
11
|
+
const HOME_DATA_DIR = path.join(os.homedir(), '.muaddib', 'data');
|
|
12
|
+
const IOCS_PATH = path.join(HOME_DATA_DIR, 'iocs.json');
|
|
13
|
+
|
|
14
|
+
// Minimum file size to consider IOCs valid (1MB)
|
|
15
|
+
const MIN_IOCS_SIZE = 1_000_000;
|
|
16
|
+
|
|
17
|
+
// Download timeout (60 seconds — file is ~15MB)
|
|
18
|
+
const DOWNLOAD_TIMEOUT = 60_000;
|
|
19
|
+
|
|
20
|
+
// Max redirects to follow
|
|
21
|
+
const MAX_REDIRECTS = 5;
|
|
22
|
+
|
|
23
|
+
// Allowed redirect domains (SSRF protection)
|
|
24
|
+
const ALLOWED_REDIRECT_DOMAINS = [
|
|
25
|
+
'github.com',
|
|
26
|
+
'objects.githubusercontent.com',
|
|
27
|
+
'release-assets.githubusercontent.com'
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Checks if a redirect URL is allowed (SSRF protection).
|
|
32
|
+
* Only HTTPS to whitelisted domains is permitted.
|
|
33
|
+
* @param {string} redirectUrl - The redirect target URL
|
|
34
|
+
* @returns {boolean}
|
|
35
|
+
*/
|
|
36
|
+
function isAllowedRedirect(redirectUrl) {
|
|
37
|
+
try {
|
|
38
|
+
const urlObj = new URL(redirectUrl);
|
|
39
|
+
if (urlObj.protocol !== 'https:') return false;
|
|
40
|
+
return ALLOWED_REDIRECT_DOMAINS.includes(urlObj.hostname);
|
|
41
|
+
} catch {
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Download a gzipped file, decompress, and write to destPath atomically.
|
|
48
|
+
* Follows redirects with SSRF-safe domain validation.
|
|
49
|
+
* @param {string} url - Source URL (HTTPS)
|
|
50
|
+
* @param {string} destPath - Local file path to write decompressed data
|
|
51
|
+
* @returns {Promise<void>}
|
|
52
|
+
*/
|
|
53
|
+
function downloadAndDecompress(url, destPath) {
|
|
54
|
+
return new Promise((resolve, reject) => {
|
|
55
|
+
let redirectCount = 0;
|
|
56
|
+
|
|
57
|
+
const doRequest = (requestUrl) => {
|
|
58
|
+
const req = https.get(requestUrl, { timeout: DOWNLOAD_TIMEOUT }, (res) => {
|
|
59
|
+
// Handle redirects (GitHub releases redirect to objects.githubusercontent.com)
|
|
60
|
+
if (res.statusCode === 301 || res.statusCode === 302) {
|
|
61
|
+
res.resume();
|
|
62
|
+
redirectCount++;
|
|
63
|
+
if (redirectCount > MAX_REDIRECTS) {
|
|
64
|
+
return reject(new Error('Too many redirects'));
|
|
65
|
+
}
|
|
66
|
+
const location = res.headers.location;
|
|
67
|
+
if (!location) {
|
|
68
|
+
return reject(new Error('Redirect without Location header'));
|
|
69
|
+
}
|
|
70
|
+
const absoluteLocation = new URL(location, requestUrl).href;
|
|
71
|
+
if (!isAllowedRedirect(absoluteLocation)) {
|
|
72
|
+
return reject(new Error('Redirect to disallowed domain: ' + new URL(absoluteLocation).hostname));
|
|
73
|
+
}
|
|
74
|
+
return doRequest(absoluteLocation);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (res.statusCode < 200 || res.statusCode >= 300) {
|
|
78
|
+
res.resume();
|
|
79
|
+
return reject(new Error('HTTP ' + res.statusCode + ' downloading IOCs'));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Stream: response → gunzip → temp file
|
|
83
|
+
const tmpPath = destPath + '.tmp';
|
|
84
|
+
const gunzip = zlib.createGunzip();
|
|
85
|
+
const fileStream = fs.createWriteStream(tmpPath);
|
|
86
|
+
|
|
87
|
+
gunzip.on('error', (err) => {
|
|
88
|
+
fileStream.destroy();
|
|
89
|
+
try { fs.unlinkSync(tmpPath); } catch {}
|
|
90
|
+
reject(new Error('Decompression failed: ' + err.message));
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
fileStream.on('error', (err) => {
|
|
94
|
+
try { fs.unlinkSync(tmpPath); } catch {}
|
|
95
|
+
reject(err);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
fileStream.on('finish', () => {
|
|
99
|
+
// Atomic write: rename .tmp → final path
|
|
100
|
+
try {
|
|
101
|
+
fs.renameSync(tmpPath, destPath);
|
|
102
|
+
resolve();
|
|
103
|
+
} catch (err) {
|
|
104
|
+
try { fs.unlinkSync(tmpPath); } catch {}
|
|
105
|
+
reject(err);
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
res.on('error', (err) => {
|
|
110
|
+
gunzip.destroy();
|
|
111
|
+
fileStream.destroy();
|
|
112
|
+
try { fs.unlinkSync(tmpPath); } catch {}
|
|
113
|
+
reject(err);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
res.pipe(gunzip).pipe(fileStream);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
req.on('error', reject);
|
|
120
|
+
req.on('timeout', () => {
|
|
121
|
+
req.destroy();
|
|
122
|
+
reject(new Error('Download timeout after ' + DOWNLOAD_TIMEOUT + 'ms'));
|
|
123
|
+
});
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
doRequest(url);
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Ensure IOC database is available. Downloads from GitHub Releases on first run.
|
|
132
|
+
* Gracefully fails — scan still works with compact IOCs if download fails.
|
|
133
|
+
* @returns {Promise<boolean>} true if IOCs are available (cached or downloaded), false if download failed
|
|
134
|
+
*/
|
|
135
|
+
async function ensureIOCs() {
|
|
136
|
+
try {
|
|
137
|
+
// Create data directory if needed
|
|
138
|
+
if (!fs.existsSync(HOME_DATA_DIR)) {
|
|
139
|
+
fs.mkdirSync(HOME_DATA_DIR, { recursive: true });
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Skip if IOCs already exist and are large enough
|
|
143
|
+
if (fs.existsSync(IOCS_PATH)) {
|
|
144
|
+
const stat = fs.statSync(IOCS_PATH);
|
|
145
|
+
if (stat.size >= MIN_IOCS_SIZE) {
|
|
146
|
+
return true;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Download IOCs (messages go to stderr to avoid contaminating JSON/SARIF stdout)
|
|
151
|
+
process.stderr.write('[MUADDIB] First run: downloading IOC database...\n');
|
|
152
|
+
await downloadAndDecompress(IOCS_URL, IOCS_PATH);
|
|
153
|
+
|
|
154
|
+
// Verify downloaded file
|
|
155
|
+
const stat = fs.statSync(IOCS_PATH);
|
|
156
|
+
if (stat.size < MIN_IOCS_SIZE) {
|
|
157
|
+
try { fs.unlinkSync(IOCS_PATH); } catch {}
|
|
158
|
+
process.stderr.write('[WARN] Downloaded IOC file is too small, using compact IOCs\n');
|
|
159
|
+
return false;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
process.stderr.write('[MUADDIB] IOC database ready (' + Math.round(stat.size / 1024 / 1024) + ' MB)\n');
|
|
163
|
+
return true;
|
|
164
|
+
} catch (err) {
|
|
165
|
+
process.stderr.write('[WARN] Could not download IOC database: ' + err.message + '\n');
|
|
166
|
+
process.stderr.write('[WARN] Continuing with compact IOCs (limited PyPI coverage)\n');
|
|
167
|
+
return false;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
module.exports = {
|
|
172
|
+
ensureIOCs,
|
|
173
|
+
downloadAndDecompress,
|
|
174
|
+
isAllowedRedirect,
|
|
175
|
+
IOCS_URL,
|
|
176
|
+
IOCS_PATH,
|
|
177
|
+
HOME_DATA_DIR,
|
|
178
|
+
MIN_IOCS_SIZE
|
|
179
|
+
};
|