muaddib-scanner 2.4.13 → 2.4.15
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/LICENSE +20 -20
- package/iocs/builtin.yaml +131 -131
- package/iocs/packages.yaml +276 -276
- package/package.json +2 -3
- package/src/canary-tokens.js +184 -184
- package/src/ioc/bootstrap.js +181 -181
- package/src/ioc/yaml-loader.js +223 -223
- package/src/maintainer-change.js +224 -224
- package/src/output-formatter.js +192 -192
- package/src/publish-anomaly.js +206 -206
- package/src/report.js +230 -230
- package/src/sarif.js +96 -96
- package/src/scanner/ai-config.js +183 -183
- package/src/scanner/dependencies.js +223 -223
- package/src/scanner/hash.js +118 -118
- package/src/scanner/npm-registry.js +128 -128
- package/src/scanner/python.js +442 -442
- package/src/shared/analyze-helper.js +49 -49
- package/src/temporal-analysis.js +260 -260
- package/src/temporal-runner.js +139 -139
- package/src/utils.js +327 -327
- package/src/watch.js +55 -55
|
@@ -1,49 +1,49 @@
|
|
|
1
|
-
const path = require('path');
|
|
2
|
-
const { isDevFile, findJsFiles, forEachSafeFile } = require('../utils.js');
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* Shared scanner wrapper: iterates JS files, runs analyzeFileFn on original + deobfuscated code,
|
|
6
|
-
* deduplicates findings by type::message key.
|
|
7
|
-
* @param {string} targetPath - Root directory to scan
|
|
8
|
-
* @param {Function} analyzeFileFn - (content, filePath, basePath) => threats[]
|
|
9
|
-
* @param {object} [options]
|
|
10
|
-
* @param {Function} [options.deobfuscate] - Deobfuscation function
|
|
11
|
-
* @param {string[]} [options.excludedFiles] - Relative paths to skip
|
|
12
|
-
* @param {boolean} [options.skipDevFiles=true] - Whether to skip dev/test files
|
|
13
|
-
* @returns {Array} Combined threats
|
|
14
|
-
*/
|
|
15
|
-
function analyzeWithDeobfuscation(targetPath, analyzeFileFn, options = {}) {
|
|
16
|
-
const threats = [];
|
|
17
|
-
const files = findJsFiles(targetPath);
|
|
18
|
-
|
|
19
|
-
forEachSafeFile(files, (file, content) => {
|
|
20
|
-
const relativePath = path.relative(targetPath, file).replace(/\\/g, '/');
|
|
21
|
-
|
|
22
|
-
if (options.excludedFiles && options.excludedFiles.includes(relativePath)) return;
|
|
23
|
-
if (options.skipDevFiles !== false && isDevFile(relativePath)) return;
|
|
24
|
-
|
|
25
|
-
// Analyze original code first (preserves obfuscation-detection rules)
|
|
26
|
-
const fileThreats = analyzeFileFn(content, file, targetPath);
|
|
27
|
-
threats.push(...fileThreats);
|
|
28
|
-
|
|
29
|
-
// Also analyze deobfuscated code for additional findings hidden by obfuscation
|
|
30
|
-
if (typeof options.deobfuscate === 'function') {
|
|
31
|
-
try {
|
|
32
|
-
const result = options.deobfuscate(content);
|
|
33
|
-
if (result.transforms.length > 0) {
|
|
34
|
-
const deobThreats = analyzeFileFn(result.code, file, targetPath);
|
|
35
|
-
const existingKeys = new Set(fileThreats.map(t => `${t.type}::${t.message}`));
|
|
36
|
-
for (const dt of deobThreats) {
|
|
37
|
-
if (!existingKeys.has(`${dt.type}::${dt.message}`)) {
|
|
38
|
-
threats.push(dt);
|
|
39
|
-
}
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
} catch { /* deobfuscation failed — skip */ }
|
|
43
|
-
}
|
|
44
|
-
});
|
|
45
|
-
|
|
46
|
-
return threats;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
module.exports = { analyzeWithDeobfuscation };
|
|
1
|
+
const path = require('path');
|
|
2
|
+
const { isDevFile, findJsFiles, forEachSafeFile } = require('../utils.js');
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Shared scanner wrapper: iterates JS files, runs analyzeFileFn on original + deobfuscated code,
|
|
6
|
+
* deduplicates findings by type::message key.
|
|
7
|
+
* @param {string} targetPath - Root directory to scan
|
|
8
|
+
* @param {Function} analyzeFileFn - (content, filePath, basePath) => threats[]
|
|
9
|
+
* @param {object} [options]
|
|
10
|
+
* @param {Function} [options.deobfuscate] - Deobfuscation function
|
|
11
|
+
* @param {string[]} [options.excludedFiles] - Relative paths to skip
|
|
12
|
+
* @param {boolean} [options.skipDevFiles=true] - Whether to skip dev/test files
|
|
13
|
+
* @returns {Array} Combined threats
|
|
14
|
+
*/
|
|
15
|
+
function analyzeWithDeobfuscation(targetPath, analyzeFileFn, options = {}) {
|
|
16
|
+
const threats = [];
|
|
17
|
+
const files = findJsFiles(targetPath);
|
|
18
|
+
|
|
19
|
+
forEachSafeFile(files, (file, content) => {
|
|
20
|
+
const relativePath = path.relative(targetPath, file).replace(/\\/g, '/');
|
|
21
|
+
|
|
22
|
+
if (options.excludedFiles && options.excludedFiles.includes(relativePath)) return;
|
|
23
|
+
if (options.skipDevFiles !== false && isDevFile(relativePath)) return;
|
|
24
|
+
|
|
25
|
+
// Analyze original code first (preserves obfuscation-detection rules)
|
|
26
|
+
const fileThreats = analyzeFileFn(content, file, targetPath);
|
|
27
|
+
threats.push(...fileThreats);
|
|
28
|
+
|
|
29
|
+
// Also analyze deobfuscated code for additional findings hidden by obfuscation
|
|
30
|
+
if (typeof options.deobfuscate === 'function') {
|
|
31
|
+
try {
|
|
32
|
+
const result = options.deobfuscate(content);
|
|
33
|
+
if (result.transforms.length > 0) {
|
|
34
|
+
const deobThreats = analyzeFileFn(result.code, file, targetPath);
|
|
35
|
+
const existingKeys = new Set(fileThreats.map(t => `${t.type}::${t.message}`));
|
|
36
|
+
for (const dt of deobThreats) {
|
|
37
|
+
if (!existingKeys.has(`${dt.type}::${dt.message}`)) {
|
|
38
|
+
threats.push(dt);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
} catch { /* deobfuscation failed — skip */ }
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
return threats;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
module.exports = { analyzeWithDeobfuscation };
|
package/src/temporal-analysis.js
CHANGED
|
@@ -1,260 +1,260 @@
|
|
|
1
|
-
const https = require('https');
|
|
2
|
-
|
|
3
|
-
const REGISTRY_URL = 'https://registry.npmjs.org';
|
|
4
|
-
const TIMEOUT_MS = 10_000;
|
|
5
|
-
const MAX_RESPONSE_SIZE = 50 * 1024 * 1024; // 50MB (some packages have lots of versions)
|
|
6
|
-
|
|
7
|
-
const LIFECYCLE_SCRIPTS = [
|
|
8
|
-
'preinstall',
|
|
9
|
-
'install',
|
|
10
|
-
'postinstall',
|
|
11
|
-
'prepare',
|
|
12
|
-
'prepublish',
|
|
13
|
-
'prepublishOnly',
|
|
14
|
-
'prepack',
|
|
15
|
-
'postpack'
|
|
16
|
-
];
|
|
17
|
-
|
|
18
|
-
/**
|
|
19
|
-
* Fetch full package metadata from the npm registry.
|
|
20
|
-
* @param {string} packageName - npm package name (scoped or unscoped)
|
|
21
|
-
* @returns {Promise<object>} Full registry metadata (versions, time, maintainers, etc.)
|
|
22
|
-
*/
|
|
23
|
-
function fetchPackageMetadata(packageName) {
|
|
24
|
-
const encodedName = encodeURIComponent(packageName).replace('%40', '@');
|
|
25
|
-
const url = `${REGISTRY_URL}/${encodedName}`;
|
|
26
|
-
const urlObj = new URL(url);
|
|
27
|
-
|
|
28
|
-
return new Promise((resolve, reject) => {
|
|
29
|
-
const reqOptions = {
|
|
30
|
-
hostname: urlObj.hostname,
|
|
31
|
-
path: urlObj.pathname,
|
|
32
|
-
method: 'GET',
|
|
33
|
-
headers: {
|
|
34
|
-
'User-Agent': 'MUADDIB-Scanner/3.0',
|
|
35
|
-
'Accept': 'application/json'
|
|
36
|
-
}
|
|
37
|
-
};
|
|
38
|
-
|
|
39
|
-
const req = https.request(reqOptions, (res) => {
|
|
40
|
-
if (res.statusCode === 404) {
|
|
41
|
-
res.resume();
|
|
42
|
-
reject(new Error(`Package not found: ${packageName}`));
|
|
43
|
-
return;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
if (res.statusCode < 200 || res.statusCode >= 300) {
|
|
47
|
-
res.resume();
|
|
48
|
-
reject(new Error(`Registry returned HTTP ${res.statusCode} for ${packageName}`));
|
|
49
|
-
return;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
let data = '';
|
|
53
|
-
let dataSize = 0;
|
|
54
|
-
let destroyed = false;
|
|
55
|
-
|
|
56
|
-
res.on('data', chunk => {
|
|
57
|
-
if (destroyed) return;
|
|
58
|
-
dataSize += chunk.length;
|
|
59
|
-
if (dataSize > MAX_RESPONSE_SIZE) {
|
|
60
|
-
destroyed = true;
|
|
61
|
-
res.destroy();
|
|
62
|
-
reject(new Error(`Response exceeded maximum size for ${packageName}`));
|
|
63
|
-
return;
|
|
64
|
-
}
|
|
65
|
-
data += chunk;
|
|
66
|
-
});
|
|
67
|
-
|
|
68
|
-
res.on('end', () => {
|
|
69
|
-
if (destroyed) return;
|
|
70
|
-
try {
|
|
71
|
-
resolve(JSON.parse(data));
|
|
72
|
-
} catch (e) {
|
|
73
|
-
reject(new Error(`Invalid JSON from registry for ${packageName}: ${e.message}`));
|
|
74
|
-
}
|
|
75
|
-
});
|
|
76
|
-
});
|
|
77
|
-
|
|
78
|
-
req.on('error', (err) => {
|
|
79
|
-
reject(new Error(`Network error fetching ${packageName}: ${err.message}`));
|
|
80
|
-
});
|
|
81
|
-
|
|
82
|
-
req.setTimeout(TIMEOUT_MS, () => {
|
|
83
|
-
req.destroy();
|
|
84
|
-
reject(new Error(`Timeout fetching metadata for ${packageName}`));
|
|
85
|
-
});
|
|
86
|
-
|
|
87
|
-
req.end();
|
|
88
|
-
});
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
/**
|
|
92
|
-
* Extract lifecycle scripts from a package.json object.
|
|
93
|
-
* @param {object} packageJson - A package.json object (or a version entry from registry metadata)
|
|
94
|
-
* @returns {object} Only the lifecycle scripts that are present, e.g. { postinstall: "node exploit.js" }
|
|
95
|
-
*/
|
|
96
|
-
function getLifecycleScripts(packageJson) {
|
|
97
|
-
const scripts = packageJson && packageJson.scripts;
|
|
98
|
-
if (!scripts || typeof scripts !== 'object') return {};
|
|
99
|
-
|
|
100
|
-
const result = {};
|
|
101
|
-
for (const name of LIFECYCLE_SCRIPTS) {
|
|
102
|
-
if (typeof scripts[name] === 'string') {
|
|
103
|
-
result[name] = scripts[name];
|
|
104
|
-
}
|
|
105
|
-
}
|
|
106
|
-
return result;
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
/**
|
|
110
|
-
* Compare lifecycle scripts between two versions of a package.
|
|
111
|
-
* @param {string} versionA - The older version number (e.g. "1.2.0")
|
|
112
|
-
* @param {string} versionB - The newer version number (e.g. "1.2.1")
|
|
113
|
-
* @param {object} metadata - Full registry metadata from fetchPackageMetadata()
|
|
114
|
-
* @returns {{ added: Array, removed: Array, modified: Array }}
|
|
115
|
-
* - added: scripts present in versionB but not in versionA
|
|
116
|
-
* - removed: scripts present in versionA but not in versionB
|
|
117
|
-
* - modified: scripts present in both but with different values
|
|
118
|
-
*/
|
|
119
|
-
function compareLifecycleScripts(versionA, versionB, metadata) {
|
|
120
|
-
const versions = metadata && metadata.versions;
|
|
121
|
-
if (!versions) {
|
|
122
|
-
throw new Error('Invalid metadata: missing versions object');
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
const pkgA = versions[versionA];
|
|
126
|
-
const pkgB = versions[versionB];
|
|
127
|
-
|
|
128
|
-
if (!pkgA) throw new Error(`Version ${versionA} not found in metadata`);
|
|
129
|
-
if (!pkgB) throw new Error(`Version ${versionB} not found in metadata`);
|
|
130
|
-
|
|
131
|
-
const scriptsA = getLifecycleScripts(pkgA);
|
|
132
|
-
const scriptsB = getLifecycleScripts(pkgB);
|
|
133
|
-
|
|
134
|
-
const added = [];
|
|
135
|
-
const removed = [];
|
|
136
|
-
const modified = [];
|
|
137
|
-
|
|
138
|
-
// Scripts in B but not in A → added
|
|
139
|
-
// Scripts in both but different → modified
|
|
140
|
-
for (const name of Object.keys(scriptsB)) {
|
|
141
|
-
if (!(name in scriptsA)) {
|
|
142
|
-
added.push({ script: name, value: scriptsB[name] });
|
|
143
|
-
} else if (scriptsA[name] !== scriptsB[name]) {
|
|
144
|
-
modified.push({ script: name, oldValue: scriptsA[name], newValue: scriptsB[name] });
|
|
145
|
-
}
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
// Scripts in A but not in B → removed
|
|
149
|
-
for (const name of Object.keys(scriptsA)) {
|
|
150
|
-
if (!(name in scriptsB)) {
|
|
151
|
-
removed.push({ script: name, value: scriptsA[name] });
|
|
152
|
-
}
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
return { added, removed, modified };
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
const CRITICAL_SCRIPTS = ['preinstall', 'install', 'postinstall'];
|
|
159
|
-
|
|
160
|
-
/**
|
|
161
|
-
* Get the N most recent versions of a package sorted by publish date.
|
|
162
|
-
* @param {object} metadata - Full registry metadata from fetchPackageMetadata()
|
|
163
|
-
* @param {number} count - Number of versions to return (default 2)
|
|
164
|
-
* @returns {Array<{version: string, publishedAt: string}>} Most recent versions, newest first
|
|
165
|
-
*/
|
|
166
|
-
function getLatestVersions(metadata, count = 2) {
|
|
167
|
-
const time = metadata && metadata.time;
|
|
168
|
-
if (!time || typeof time !== 'object') return [];
|
|
169
|
-
|
|
170
|
-
const versions = metadata.versions || {};
|
|
171
|
-
const entries = [];
|
|
172
|
-
for (const [version, publishedAt] of Object.entries(time)) {
|
|
173
|
-
if (version === 'created' || version === 'modified') continue;
|
|
174
|
-
if (!versions[version]) continue; // skip unpublished/yanked versions
|
|
175
|
-
entries.push({ version, publishedAt });
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
entries.sort((a, b) => new Date(b.publishedAt) - new Date(a.publishedAt));
|
|
179
|
-
return entries.slice(0, count);
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
/**
|
|
183
|
-
* Detect sudden lifecycle script changes between the two most recent versions of an npm package.
|
|
184
|
-
* @param {string} packageName - npm package name
|
|
185
|
-
* @returns {Promise<object>} Detection result with suspicious flag, findings, and metadata
|
|
186
|
-
*/
|
|
187
|
-
async function detectSuddenLifecycleChange(packageName) {
|
|
188
|
-
const metadata = await fetchPackageMetadata(packageName);
|
|
189
|
-
const latest = getLatestVersions(metadata, 2);
|
|
190
|
-
|
|
191
|
-
if (latest.length < 2) {
|
|
192
|
-
return {
|
|
193
|
-
packageName,
|
|
194
|
-
latestVersion: latest.length > 0 ? latest[0].version : null,
|
|
195
|
-
previousVersion: null,
|
|
196
|
-
suspicious: false,
|
|
197
|
-
findings: [],
|
|
198
|
-
metadata: {
|
|
199
|
-
latestPublishedAt: latest.length > 0 ? latest[0].publishedAt : null,
|
|
200
|
-
previousPublishedAt: null,
|
|
201
|
-
maintainers: metadata.maintainers || [],
|
|
202
|
-
note: 'Package has fewer than 2 published versions'
|
|
203
|
-
}
|
|
204
|
-
};
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
const [newestEntry, previousEntry] = latest;
|
|
208
|
-
const diff = compareLifecycleScripts(previousEntry.version, newestEntry.version, metadata);
|
|
209
|
-
|
|
210
|
-
const findings = [];
|
|
211
|
-
|
|
212
|
-
for (const item of diff.added) {
|
|
213
|
-
findings.push({
|
|
214
|
-
type: 'lifecycle_added',
|
|
215
|
-
script: item.script,
|
|
216
|
-
value: item.value,
|
|
217
|
-
severity: CRITICAL_SCRIPTS.includes(item.script) ? 'CRITICAL' : 'HIGH'
|
|
218
|
-
});
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
for (const item of diff.modified) {
|
|
222
|
-
findings.push({
|
|
223
|
-
type: 'lifecycle_modified',
|
|
224
|
-
script: item.script,
|
|
225
|
-
oldValue: item.oldValue,
|
|
226
|
-
newValue: item.newValue,
|
|
227
|
-
severity: CRITICAL_SCRIPTS.includes(item.script) ? 'CRITICAL' : 'HIGH'
|
|
228
|
-
});
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
for (const item of diff.removed) {
|
|
232
|
-
findings.push({
|
|
233
|
-
type: 'lifecycle_removed',
|
|
234
|
-
script: item.script,
|
|
235
|
-
value: item.value,
|
|
236
|
-
severity: 'LOW'
|
|
237
|
-
});
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
return {
|
|
241
|
-
packageName,
|
|
242
|
-
latestVersion: newestEntry.version,
|
|
243
|
-
previousVersion: previousEntry.version,
|
|
244
|
-
suspicious: findings.length > 0,
|
|
245
|
-
findings,
|
|
246
|
-
metadata: {
|
|
247
|
-
latestPublishedAt: newestEntry.publishedAt,
|
|
248
|
-
previousPublishedAt: previousEntry.publishedAt,
|
|
249
|
-
maintainers: metadata.maintainers || []
|
|
250
|
-
}
|
|
251
|
-
};
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
module.exports = {
|
|
255
|
-
fetchPackageMetadata,
|
|
256
|
-
getLifecycleScripts,
|
|
257
|
-
compareLifecycleScripts,
|
|
258
|
-
getLatestVersions,
|
|
259
|
-
detectSuddenLifecycleChange
|
|
260
|
-
};
|
|
1
|
+
const https = require('https');
|
|
2
|
+
|
|
3
|
+
const REGISTRY_URL = 'https://registry.npmjs.org';
|
|
4
|
+
const TIMEOUT_MS = 10_000;
|
|
5
|
+
const MAX_RESPONSE_SIZE = 50 * 1024 * 1024; // 50MB (some packages have lots of versions)
|
|
6
|
+
|
|
7
|
+
const LIFECYCLE_SCRIPTS = [
|
|
8
|
+
'preinstall',
|
|
9
|
+
'install',
|
|
10
|
+
'postinstall',
|
|
11
|
+
'prepare',
|
|
12
|
+
'prepublish',
|
|
13
|
+
'prepublishOnly',
|
|
14
|
+
'prepack',
|
|
15
|
+
'postpack'
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Fetch full package metadata from the npm registry.
|
|
20
|
+
* @param {string} packageName - npm package name (scoped or unscoped)
|
|
21
|
+
* @returns {Promise<object>} Full registry metadata (versions, time, maintainers, etc.)
|
|
22
|
+
*/
|
|
23
|
+
function fetchPackageMetadata(packageName) {
|
|
24
|
+
const encodedName = encodeURIComponent(packageName).replace('%40', '@');
|
|
25
|
+
const url = `${REGISTRY_URL}/${encodedName}`;
|
|
26
|
+
const urlObj = new URL(url);
|
|
27
|
+
|
|
28
|
+
return new Promise((resolve, reject) => {
|
|
29
|
+
const reqOptions = {
|
|
30
|
+
hostname: urlObj.hostname,
|
|
31
|
+
path: urlObj.pathname,
|
|
32
|
+
method: 'GET',
|
|
33
|
+
headers: {
|
|
34
|
+
'User-Agent': 'MUADDIB-Scanner/3.0',
|
|
35
|
+
'Accept': 'application/json'
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const req = https.request(reqOptions, (res) => {
|
|
40
|
+
if (res.statusCode === 404) {
|
|
41
|
+
res.resume();
|
|
42
|
+
reject(new Error(`Package not found: ${packageName}`));
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (res.statusCode < 200 || res.statusCode >= 300) {
|
|
47
|
+
res.resume();
|
|
48
|
+
reject(new Error(`Registry returned HTTP ${res.statusCode} for ${packageName}`));
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
let data = '';
|
|
53
|
+
let dataSize = 0;
|
|
54
|
+
let destroyed = false;
|
|
55
|
+
|
|
56
|
+
res.on('data', chunk => {
|
|
57
|
+
if (destroyed) return;
|
|
58
|
+
dataSize += chunk.length;
|
|
59
|
+
if (dataSize > MAX_RESPONSE_SIZE) {
|
|
60
|
+
destroyed = true;
|
|
61
|
+
res.destroy();
|
|
62
|
+
reject(new Error(`Response exceeded maximum size for ${packageName}`));
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
data += chunk;
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
res.on('end', () => {
|
|
69
|
+
if (destroyed) return;
|
|
70
|
+
try {
|
|
71
|
+
resolve(JSON.parse(data));
|
|
72
|
+
} catch (e) {
|
|
73
|
+
reject(new Error(`Invalid JSON from registry for ${packageName}: ${e.message}`));
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
req.on('error', (err) => {
|
|
79
|
+
reject(new Error(`Network error fetching ${packageName}: ${err.message}`));
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
req.setTimeout(TIMEOUT_MS, () => {
|
|
83
|
+
req.destroy();
|
|
84
|
+
reject(new Error(`Timeout fetching metadata for ${packageName}`));
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
req.end();
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Extract lifecycle scripts from a package.json object.
|
|
93
|
+
* @param {object} packageJson - A package.json object (or a version entry from registry metadata)
|
|
94
|
+
* @returns {object} Only the lifecycle scripts that are present, e.g. { postinstall: "node exploit.js" }
|
|
95
|
+
*/
|
|
96
|
+
function getLifecycleScripts(packageJson) {
|
|
97
|
+
const scripts = packageJson && packageJson.scripts;
|
|
98
|
+
if (!scripts || typeof scripts !== 'object') return {};
|
|
99
|
+
|
|
100
|
+
const result = {};
|
|
101
|
+
for (const name of LIFECYCLE_SCRIPTS) {
|
|
102
|
+
if (typeof scripts[name] === 'string') {
|
|
103
|
+
result[name] = scripts[name];
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return result;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Compare lifecycle scripts between two versions of a package.
|
|
111
|
+
* @param {string} versionA - The older version number (e.g. "1.2.0")
|
|
112
|
+
* @param {string} versionB - The newer version number (e.g. "1.2.1")
|
|
113
|
+
* @param {object} metadata - Full registry metadata from fetchPackageMetadata()
|
|
114
|
+
* @returns {{ added: Array, removed: Array, modified: Array }}
|
|
115
|
+
* - added: scripts present in versionB but not in versionA
|
|
116
|
+
* - removed: scripts present in versionA but not in versionB
|
|
117
|
+
* - modified: scripts present in both but with different values
|
|
118
|
+
*/
|
|
119
|
+
function compareLifecycleScripts(versionA, versionB, metadata) {
|
|
120
|
+
const versions = metadata && metadata.versions;
|
|
121
|
+
if (!versions) {
|
|
122
|
+
throw new Error('Invalid metadata: missing versions object');
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const pkgA = versions[versionA];
|
|
126
|
+
const pkgB = versions[versionB];
|
|
127
|
+
|
|
128
|
+
if (!pkgA) throw new Error(`Version ${versionA} not found in metadata`);
|
|
129
|
+
if (!pkgB) throw new Error(`Version ${versionB} not found in metadata`);
|
|
130
|
+
|
|
131
|
+
const scriptsA = getLifecycleScripts(pkgA);
|
|
132
|
+
const scriptsB = getLifecycleScripts(pkgB);
|
|
133
|
+
|
|
134
|
+
const added = [];
|
|
135
|
+
const removed = [];
|
|
136
|
+
const modified = [];
|
|
137
|
+
|
|
138
|
+
// Scripts in B but not in A → added
|
|
139
|
+
// Scripts in both but different → modified
|
|
140
|
+
for (const name of Object.keys(scriptsB)) {
|
|
141
|
+
if (!(name in scriptsA)) {
|
|
142
|
+
added.push({ script: name, value: scriptsB[name] });
|
|
143
|
+
} else if (scriptsA[name] !== scriptsB[name]) {
|
|
144
|
+
modified.push({ script: name, oldValue: scriptsA[name], newValue: scriptsB[name] });
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Scripts in A but not in B → removed
|
|
149
|
+
for (const name of Object.keys(scriptsA)) {
|
|
150
|
+
if (!(name in scriptsB)) {
|
|
151
|
+
removed.push({ script: name, value: scriptsA[name] });
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return { added, removed, modified };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const CRITICAL_SCRIPTS = ['preinstall', 'install', 'postinstall'];
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Get the N most recent versions of a package sorted by publish date.
|
|
162
|
+
* @param {object} metadata - Full registry metadata from fetchPackageMetadata()
|
|
163
|
+
* @param {number} count - Number of versions to return (default 2)
|
|
164
|
+
* @returns {Array<{version: string, publishedAt: string}>} Most recent versions, newest first
|
|
165
|
+
*/
|
|
166
|
+
function getLatestVersions(metadata, count = 2) {
|
|
167
|
+
const time = metadata && metadata.time;
|
|
168
|
+
if (!time || typeof time !== 'object') return [];
|
|
169
|
+
|
|
170
|
+
const versions = metadata.versions || {};
|
|
171
|
+
const entries = [];
|
|
172
|
+
for (const [version, publishedAt] of Object.entries(time)) {
|
|
173
|
+
if (version === 'created' || version === 'modified') continue;
|
|
174
|
+
if (!versions[version]) continue; // skip unpublished/yanked versions
|
|
175
|
+
entries.push({ version, publishedAt });
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
entries.sort((a, b) => new Date(b.publishedAt) - new Date(a.publishedAt));
|
|
179
|
+
return entries.slice(0, count);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Detect sudden lifecycle script changes between the two most recent versions of an npm package.
|
|
184
|
+
* @param {string} packageName - npm package name
|
|
185
|
+
* @returns {Promise<object>} Detection result with suspicious flag, findings, and metadata
|
|
186
|
+
*/
|
|
187
|
+
async function detectSuddenLifecycleChange(packageName) {
|
|
188
|
+
const metadata = await fetchPackageMetadata(packageName);
|
|
189
|
+
const latest = getLatestVersions(metadata, 2);
|
|
190
|
+
|
|
191
|
+
if (latest.length < 2) {
|
|
192
|
+
return {
|
|
193
|
+
packageName,
|
|
194
|
+
latestVersion: latest.length > 0 ? latest[0].version : null,
|
|
195
|
+
previousVersion: null,
|
|
196
|
+
suspicious: false,
|
|
197
|
+
findings: [],
|
|
198
|
+
metadata: {
|
|
199
|
+
latestPublishedAt: latest.length > 0 ? latest[0].publishedAt : null,
|
|
200
|
+
previousPublishedAt: null,
|
|
201
|
+
maintainers: metadata.maintainers || [],
|
|
202
|
+
note: 'Package has fewer than 2 published versions'
|
|
203
|
+
}
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const [newestEntry, previousEntry] = latest;
|
|
208
|
+
const diff = compareLifecycleScripts(previousEntry.version, newestEntry.version, metadata);
|
|
209
|
+
|
|
210
|
+
const findings = [];
|
|
211
|
+
|
|
212
|
+
for (const item of diff.added) {
|
|
213
|
+
findings.push({
|
|
214
|
+
type: 'lifecycle_added',
|
|
215
|
+
script: item.script,
|
|
216
|
+
value: item.value,
|
|
217
|
+
severity: CRITICAL_SCRIPTS.includes(item.script) ? 'CRITICAL' : 'HIGH'
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
for (const item of diff.modified) {
|
|
222
|
+
findings.push({
|
|
223
|
+
type: 'lifecycle_modified',
|
|
224
|
+
script: item.script,
|
|
225
|
+
oldValue: item.oldValue,
|
|
226
|
+
newValue: item.newValue,
|
|
227
|
+
severity: CRITICAL_SCRIPTS.includes(item.script) ? 'CRITICAL' : 'HIGH'
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
for (const item of diff.removed) {
|
|
232
|
+
findings.push({
|
|
233
|
+
type: 'lifecycle_removed',
|
|
234
|
+
script: item.script,
|
|
235
|
+
value: item.value,
|
|
236
|
+
severity: 'LOW'
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
return {
|
|
241
|
+
packageName,
|
|
242
|
+
latestVersion: newestEntry.version,
|
|
243
|
+
previousVersion: previousEntry.version,
|
|
244
|
+
suspicious: findings.length > 0,
|
|
245
|
+
findings,
|
|
246
|
+
metadata: {
|
|
247
|
+
latestPublishedAt: newestEntry.publishedAt,
|
|
248
|
+
previousPublishedAt: previousEntry.publishedAt,
|
|
249
|
+
maintainers: metadata.maintainers || []
|
|
250
|
+
}
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
module.exports = {
|
|
255
|
+
fetchPackageMetadata,
|
|
256
|
+
getLifecycleScripts,
|
|
257
|
+
compareLifecycleScripts,
|
|
258
|
+
getLatestVersions,
|
|
259
|
+
detectSuddenLifecycleChange
|
|
260
|
+
};
|