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 ADDED
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "muaddib-scanner",
3
- "version": "2.1.2",
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
+ };