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