muaddib-scanner 2.3.3 → 2.4.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.
@@ -1,319 +1,317 @@
1
- const https = require('https');
2
- const fs = require('fs');
3
- const path = require('path');
4
- const os = require('os');
5
- const acorn = require('acorn');
6
- const walk = require('acorn-walk');
7
- const { findJsFiles, forEachSafeFile, debugLog } = require('./utils.js');
8
- const { fetchPackageMetadata, getLatestVersions } = require('./temporal-analysis.js');
9
- const { downloadToFile, extractTarGz, sanitizePackageName } = require('./shared/download.js');
10
-
11
- const { MAX_FILE_SIZE, ACORN_OPTIONS } = require('./shared/constants.js');
12
-
13
- const REGISTRY_URL = 'https://registry.npmjs.org';
14
- const METADATA_TIMEOUT = 10_000;
15
-
16
- const SENSITIVE_PATHS = [
17
- '/etc/passwd', '/etc/shadow', '.env', '.npmrc', '.ssh',
18
- '.aws/credentials', '.bash_history', '.gitconfig'
19
- ];
20
-
21
- // Severity mapping for each pattern
22
- const PATTERN_SEVERITY = {
23
- child_process: 'CRITICAL',
24
- eval: 'CRITICAL',
25
- Function: 'CRITICAL',
26
- 'net.connect': 'CRITICAL',
27
- 'process.env': 'HIGH',
28
- fetch: 'HIGH',
29
- http_request: 'HIGH',
30
- https_request: 'HIGH',
31
- 'dns.lookup': 'MEDIUM',
32
- 'fs.readFile_sensitive': 'MEDIUM'
33
- };
34
-
35
- // --- HTTP helpers ---
36
-
37
- /**
38
- * Fetch version-specific metadata from npm registry.
39
- * @param {string} packageName
40
- * @param {string} version
41
- * @returns {Promise<object>}
42
- */
43
- function fetchVersionMetadata(packageName, version) {
44
- const encodedName = encodeURIComponent(packageName).replace('%40', '@');
45
- const url = `${REGISTRY_URL}/${encodedName}/${encodeURIComponent(version)}`;
46
- const urlObj = new URL(url);
47
-
48
- return new Promise((resolve, reject) => {
49
- const req = https.request({
50
- hostname: urlObj.hostname,
51
- path: urlObj.pathname,
52
- method: 'GET',
53
- headers: { 'User-Agent': 'MUADDIB-Scanner/3.0', 'Accept': 'application/json' }
54
- }, (res) => {
55
- if (res.statusCode === 404) {
56
- res.resume();
57
- return reject(new Error(`Version ${version} not found for package ${packageName}`));
58
- }
59
- if (res.statusCode < 200 || res.statusCode >= 300) {
60
- res.resume();
61
- return reject(new Error(`Registry returned HTTP ${res.statusCode} for ${packageName}@${version}`));
62
- }
63
- let data = '';
64
- res.on('data', chunk => { data += chunk; });
65
- res.on('end', () => {
66
- try { resolve(JSON.parse(data)); }
67
- catch (e) { reject(new Error(`Invalid JSON for ${packageName}@${version}: ${e.message}`)); }
68
- });
69
- });
70
- req.on('error', err => reject(new Error(`Network error fetching ${packageName}@${version}: ${err.message}`)));
71
- req.setTimeout(METADATA_TIMEOUT, () => {
72
- req.destroy();
73
- reject(new Error(`Timeout fetching metadata for ${packageName}@${version}`));
74
- });
75
- req.end();
76
- });
77
- }
78
-
79
- // --- Core functions ---
80
-
81
- /**
82
- * Fetch and extract a specific version of an npm package.
83
- * @param {string} packageName - npm package name (scoped or unscoped)
84
- * @param {string} version - Exact version string (e.g. "4.17.21")
85
- * @returns {Promise<{dir: string, cleanup: Function}>}
86
- */
87
- async function fetchPackageTarball(packageName, version) {
88
- const meta = await fetchVersionMetadata(packageName, version);
89
- const tarballUrl = meta.dist && meta.dist.tarball;
90
- if (!tarballUrl) {
91
- throw new Error(`No tarball URL found for ${packageName}@${version}`);
92
- }
93
-
94
- const safeName = sanitizePackageName(packageName);
95
- const tmpBase = path.join(os.tmpdir(), 'muaddib-ast-diff');
96
- if (!fs.existsSync(tmpBase)) fs.mkdirSync(tmpBase, { recursive: true });
97
- const tmpDir = fs.mkdtempSync(path.join(tmpBase, `${safeName}-${version}-`));
98
-
99
- let extractedDir;
100
- try {
101
- const tgzPath = path.join(tmpDir, 'package.tar.gz');
102
- await downloadToFile(tarballUrl, tgzPath);
103
- extractedDir = extractTarGz(tgzPath, tmpDir);
104
- } catch (err) {
105
- try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch (e) { debugLog('tmpDir cleanup failed:', e.message); }
106
- throw err;
107
- }
108
-
109
- const cleanup = () => {
110
- try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch (e) { debugLog('tmpDir cleanup failed:', e.message); }
111
- };
112
-
113
- return { dir: extractedDir, cleanup };
114
- }
115
-
116
- /**
117
- * Extract dangerous AST patterns from all .js files in a directory.
118
- * @param {string} directory - Path to the extracted package
119
- * @returns {Set<string>} Set of pattern names found
120
- */
121
- function extractDangerousPatterns(directory) {
122
- const patterns = new Set();
123
- const files = findJsFiles(directory);
124
- forEachSafeFile(files, (file, content) => {
125
- extractPatternsFromSource(content, patterns);
126
- });
127
- return patterns;
128
- }
129
-
130
- /**
131
- * Parse a single JS source string and add detected pattern names to the set.
132
- * @param {string} source - JS source code
133
- * @param {Set<string>} patterns - Accumulator set
134
- */
135
- function extractPatternsFromSource(source, patterns) {
136
- let ast;
137
- try {
138
- ast = acorn.parse(source, ACORN_OPTIONS);
139
- } catch { return; }
140
-
141
- walk.simple(ast, {
142
- CallExpression(node) {
143
- // eval()
144
- if (node.callee.type === 'Identifier' && node.callee.name === 'eval') {
145
- patterns.add('eval');
146
- }
147
- // Function() as a call
148
- if (node.callee.type === 'Identifier' && node.callee.name === 'Function') {
149
- patterns.add('Function');
150
- }
151
- // require('module')
152
- if (node.callee.type === 'Identifier' && node.callee.name === 'require' &&
153
- node.arguments.length > 0 && node.arguments[0].type === 'Literal') {
154
- const mod = node.arguments[0].value;
155
- if (mod === 'child_process') patterns.add('child_process');
156
- if (mod === 'http') patterns.add('http_request');
157
- if (mod === 'https') patterns.add('https_request');
158
- if (mod === 'dns') patterns.add('dns.lookup');
159
- if (mod === 'net') patterns.add('net.connect');
160
- }
161
- // fetch()
162
- if (node.callee.type === 'Identifier' && node.callee.name === 'fetch') {
163
- patterns.add('fetch');
164
- }
165
- // dns.lookup(), dns.resolve(), etc.
166
- if (node.callee.type === 'MemberExpression' &&
167
- node.callee.object.type === 'Identifier' && node.callee.object.name === 'dns') {
168
- patterns.add('dns.lookup');
169
- }
170
- // net.connect(), net.createConnection()
171
- if (node.callee.type === 'MemberExpression' &&
172
- node.callee.object.type === 'Identifier' && node.callee.object.name === 'net') {
173
- patterns.add('net.connect');
174
- }
175
- // http.request(), http.get(), https.request(), https.get()
176
- if (node.callee.type === 'MemberExpression' &&
177
- node.callee.object.type === 'Identifier') {
178
- if (node.callee.object.name === 'http') patterns.add('http_request');
179
- if (node.callee.object.name === 'https') patterns.add('https_request');
180
- }
181
- // fs.readFile/readFileSync on sensitive paths
182
- if (node.callee.type === 'MemberExpression' &&
183
- node.callee.object.type === 'Identifier' && node.callee.object.name === 'fs' &&
184
- node.callee.property &&
185
- (node.callee.property.name === 'readFile' || node.callee.property.name === 'readFileSync')) {
186
- if (node.arguments.length > 0 && node.arguments[0].type === 'Literal' &&
187
- typeof node.arguments[0].value === 'string') {
188
- const filePath = node.arguments[0].value;
189
- if (SENSITIVE_PATHS.some(s => filePath.includes(s))) {
190
- patterns.add('fs.readFile_sensitive');
191
- }
192
- }
193
- }
194
- },
195
-
196
- NewExpression(node) {
197
- // new Function()
198
- if (node.callee.type === 'Identifier' && node.callee.name === 'Function') {
199
- patterns.add('Function');
200
- }
201
- },
202
-
203
- MemberExpression(node) {
204
- // process.env
205
- if (node.object.type === 'Identifier' && node.object.name === 'process' &&
206
- node.property && node.property.name === 'env') {
207
- patterns.add('process.env');
208
- }
209
- },
210
-
211
- ImportDeclaration(node) {
212
- // import ... from 'child_process'
213
- if (node.source && node.source.type === 'Literal') {
214
- const mod = node.source.value;
215
- if (mod === 'child_process') patterns.add('child_process');
216
- if (mod === 'http') patterns.add('http_request');
217
- if (mod === 'https') patterns.add('https_request');
218
- if (mod === 'dns') patterns.add('dns.lookup');
219
- if (mod === 'net') patterns.add('net.connect');
220
- }
221
- }
222
- });
223
- }
224
-
225
- /**
226
- * Compare AST patterns between two versions of a package.
227
- * @param {string} packageName - npm package name
228
- * @param {string} versionA - Older version
229
- * @param {string} versionB - Newer version
230
- * @returns {Promise<{added: string[], removed: string[]}>}
231
- * added = patterns in versionB but NOT in versionA (new dangerous capabilities)
232
- * removed = patterns in versionA but NOT in versionB
233
- */
234
- async function compareAstPatterns(packageName, versionA, versionB) {
235
- let cleanupA = null;
236
- let cleanupB = null;
237
-
238
- try {
239
- const [resultA, resultB] = await Promise.all([
240
- fetchPackageTarball(packageName, versionA),
241
- fetchPackageTarball(packageName, versionB)
242
- ]);
243
- cleanupA = resultA.cleanup;
244
- cleanupB = resultB.cleanup;
245
-
246
- const patternsA = extractDangerousPatterns(resultA.dir);
247
- const patternsB = extractDangerousPatterns(resultB.dir);
248
-
249
- const added = [...patternsB].filter(p => !patternsA.has(p));
250
- const removed = [...patternsA].filter(p => !patternsB.has(p));
251
-
252
- return { added, removed };
253
- } finally {
254
- if (cleanupA) cleanupA();
255
- if (cleanupB) cleanupB();
256
- }
257
- }
258
-
259
- /**
260
- * Detect sudden dangerous API additions between the two most recent versions.
261
- * @param {string} packageName - npm package name
262
- * @returns {Promise<object>} Detection result
263
- */
264
- async function detectSuddenAstChanges(packageName) {
265
- const metadata = await fetchPackageMetadata(packageName);
266
- const latest = getLatestVersions(metadata, 2);
267
-
268
- if (latest.length < 2) {
269
- return {
270
- packageName,
271
- latestVersion: latest.length > 0 ? latest[0].version : null,
272
- previousVersion: null,
273
- suspicious: false,
274
- findings: [],
275
- metadata: {
276
- latestPublishedAt: latest.length > 0 ? latest[0].publishedAt : null,
277
- previousPublishedAt: null
278
- }
279
- };
280
- }
281
-
282
- const [newestEntry, previousEntry] = latest;
283
- const diff = await compareAstPatterns(packageName, previousEntry.version, newestEntry.version);
284
-
285
- const findings = [];
286
- for (const pattern of diff.added) {
287
- const severity = PATTERN_SEVERITY[pattern] || 'MEDIUM';
288
- findings.push({
289
- type: 'dangerous_api_added',
290
- pattern,
291
- severity,
292
- description: `Package now uses ${pattern} (not present in previous version)`
293
- });
294
- }
295
-
296
- return {
297
- packageName,
298
- latestVersion: newestEntry.version,
299
- previousVersion: previousEntry.version,
300
- suspicious: findings.length > 0,
301
- findings,
302
- metadata: {
303
- latestPublishedAt: newestEntry.publishedAt,
304
- previousPublishedAt: previousEntry.publishedAt
305
- }
306
- };
307
- }
308
-
309
- module.exports = {
310
- fetchPackageTarball,
311
- extractDangerousPatterns,
312
- compareAstPatterns,
313
- detectSuddenAstChanges,
314
- // Exported for testing
315
- extractPatternsFromSource,
316
- fetchVersionMetadata,
317
- SENSITIVE_PATHS,
318
- PATTERN_SEVERITY
319
- };
1
+ const https = require('https');
2
+ const fs = require('fs');
3
+ const path = require('path');
4
+ const os = require('os');
5
+ const acorn = require('acorn');
6
+ const walk = require('acorn-walk');
7
+ const { findJsFiles, forEachSafeFile, debugLog } = require('./utils.js');
8
+ const { fetchPackageMetadata, getLatestVersions } = require('./temporal-analysis.js');
9
+ const { downloadToFile, extractTarGz, sanitizePackageName } = require('./shared/download.js');
10
+
11
+ const { MAX_FILE_SIZE, ACORN_OPTIONS, safeParse } = require('./shared/constants.js');
12
+
13
+ const REGISTRY_URL = 'https://registry.npmjs.org';
14
+ const METADATA_TIMEOUT = 10_000;
15
+
16
+ const SENSITIVE_PATHS = [
17
+ '/etc/passwd', '/etc/shadow', '.env', '.npmrc', '.ssh',
18
+ '.aws/credentials', '.bash_history', '.gitconfig'
19
+ ];
20
+
21
+ // Severity mapping for each pattern
22
+ const PATTERN_SEVERITY = {
23
+ child_process: 'CRITICAL',
24
+ eval: 'CRITICAL',
25
+ Function: 'CRITICAL',
26
+ 'net.connect': 'CRITICAL',
27
+ 'process.env': 'HIGH',
28
+ fetch: 'HIGH',
29
+ http_request: 'HIGH',
30
+ https_request: 'HIGH',
31
+ 'dns.lookup': 'MEDIUM',
32
+ 'fs.readFile_sensitive': 'MEDIUM'
33
+ };
34
+
35
+ // --- HTTP helpers ---
36
+
37
+ /**
38
+ * Fetch version-specific metadata from npm registry.
39
+ * @param {string} packageName
40
+ * @param {string} version
41
+ * @returns {Promise<object>}
42
+ */
43
+ function fetchVersionMetadata(packageName, version) {
44
+ const encodedName = encodeURIComponent(packageName).replace('%40', '@');
45
+ const url = `${REGISTRY_URL}/${encodedName}/${encodeURIComponent(version)}`;
46
+ const urlObj = new URL(url);
47
+
48
+ return new Promise((resolve, reject) => {
49
+ const req = https.request({
50
+ hostname: urlObj.hostname,
51
+ path: urlObj.pathname,
52
+ method: 'GET',
53
+ headers: { 'User-Agent': 'MUADDIB-Scanner/3.0', 'Accept': 'application/json' }
54
+ }, (res) => {
55
+ if (res.statusCode === 404) {
56
+ res.resume();
57
+ return reject(new Error(`Version ${version} not found for package ${packageName}`));
58
+ }
59
+ if (res.statusCode < 200 || res.statusCode >= 300) {
60
+ res.resume();
61
+ return reject(new Error(`Registry returned HTTP ${res.statusCode} for ${packageName}@${version}`));
62
+ }
63
+ let data = '';
64
+ res.on('data', chunk => { data += chunk; });
65
+ res.on('end', () => {
66
+ try { resolve(JSON.parse(data)); }
67
+ catch (e) { reject(new Error(`Invalid JSON for ${packageName}@${version}: ${e.message}`)); }
68
+ });
69
+ });
70
+ req.on('error', err => reject(new Error(`Network error fetching ${packageName}@${version}: ${err.message}`)));
71
+ req.setTimeout(METADATA_TIMEOUT, () => {
72
+ req.destroy();
73
+ reject(new Error(`Timeout fetching metadata for ${packageName}@${version}`));
74
+ });
75
+ req.end();
76
+ });
77
+ }
78
+
79
+ // --- Core functions ---
80
+
81
+ /**
82
+ * Fetch and extract a specific version of an npm package.
83
+ * @param {string} packageName - npm package name (scoped or unscoped)
84
+ * @param {string} version - Exact version string (e.g. "4.17.21")
85
+ * @returns {Promise<{dir: string, cleanup: Function}>}
86
+ */
87
+ async function fetchPackageTarball(packageName, version) {
88
+ const meta = await fetchVersionMetadata(packageName, version);
89
+ const tarballUrl = meta.dist && meta.dist.tarball;
90
+ if (!tarballUrl) {
91
+ throw new Error(`No tarball URL found for ${packageName}@${version}`);
92
+ }
93
+
94
+ const safeName = sanitizePackageName(packageName);
95
+ const tmpBase = path.join(os.tmpdir(), 'muaddib-ast-diff');
96
+ if (!fs.existsSync(tmpBase)) fs.mkdirSync(tmpBase, { recursive: true });
97
+ const tmpDir = fs.mkdtempSync(path.join(tmpBase, `${safeName}-${version}-`));
98
+
99
+ let extractedDir;
100
+ try {
101
+ const tgzPath = path.join(tmpDir, 'package.tar.gz');
102
+ await downloadToFile(tarballUrl, tgzPath);
103
+ extractedDir = extractTarGz(tgzPath, tmpDir);
104
+ } catch (err) {
105
+ try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch (e) { debugLog('tmpDir cleanup failed:', e.message); }
106
+ throw err;
107
+ }
108
+
109
+ const cleanup = () => {
110
+ try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch (e) { debugLog('tmpDir cleanup failed:', e.message); }
111
+ };
112
+
113
+ return { dir: extractedDir, cleanup };
114
+ }
115
+
116
+ /**
117
+ * Extract dangerous AST patterns from all .js files in a directory.
118
+ * @param {string} directory - Path to the extracted package
119
+ * @returns {Set<string>} Set of pattern names found
120
+ */
121
+ function extractDangerousPatterns(directory) {
122
+ const patterns = new Set();
123
+ const files = findJsFiles(directory);
124
+ forEachSafeFile(files, (file, content) => {
125
+ extractPatternsFromSource(content, patterns);
126
+ });
127
+ return patterns;
128
+ }
129
+
130
+ /**
131
+ * Parse a single JS source string and add detected pattern names to the set.
132
+ * @param {string} source - JS source code
133
+ * @param {Set<string>} patterns - Accumulator set
134
+ */
135
+ function extractPatternsFromSource(source, patterns) {
136
+ let ast = safeParse(source);
137
+ if (!ast) return;
138
+
139
+ walk.simple(ast, {
140
+ CallExpression(node) {
141
+ // eval()
142
+ if (node.callee.type === 'Identifier' && node.callee.name === 'eval') {
143
+ patterns.add('eval');
144
+ }
145
+ // Function() as a call
146
+ if (node.callee.type === 'Identifier' && node.callee.name === 'Function') {
147
+ patterns.add('Function');
148
+ }
149
+ // require('module')
150
+ if (node.callee.type === 'Identifier' && node.callee.name === 'require' &&
151
+ node.arguments.length > 0 && node.arguments[0].type === 'Literal') {
152
+ const mod = node.arguments[0].value;
153
+ if (mod === 'child_process') patterns.add('child_process');
154
+ if (mod === 'http') patterns.add('http_request');
155
+ if (mod === 'https') patterns.add('https_request');
156
+ if (mod === 'dns') patterns.add('dns.lookup');
157
+ if (mod === 'net') patterns.add('net.connect');
158
+ }
159
+ // fetch()
160
+ if (node.callee.type === 'Identifier' && node.callee.name === 'fetch') {
161
+ patterns.add('fetch');
162
+ }
163
+ // dns.lookup(), dns.resolve(), etc.
164
+ if (node.callee.type === 'MemberExpression' &&
165
+ node.callee.object.type === 'Identifier' && node.callee.object.name === 'dns') {
166
+ patterns.add('dns.lookup');
167
+ }
168
+ // net.connect(), net.createConnection()
169
+ if (node.callee.type === 'MemberExpression' &&
170
+ node.callee.object.type === 'Identifier' && node.callee.object.name === 'net') {
171
+ patterns.add('net.connect');
172
+ }
173
+ // http.request(), http.get(), https.request(), https.get()
174
+ if (node.callee.type === 'MemberExpression' &&
175
+ node.callee.object.type === 'Identifier') {
176
+ if (node.callee.object.name === 'http') patterns.add('http_request');
177
+ if (node.callee.object.name === 'https') patterns.add('https_request');
178
+ }
179
+ // fs.readFile/readFileSync on sensitive paths
180
+ if (node.callee.type === 'MemberExpression' &&
181
+ node.callee.object.type === 'Identifier' && node.callee.object.name === 'fs' &&
182
+ node.callee.property &&
183
+ (node.callee.property.name === 'readFile' || node.callee.property.name === 'readFileSync')) {
184
+ if (node.arguments.length > 0 && node.arguments[0].type === 'Literal' &&
185
+ typeof node.arguments[0].value === 'string') {
186
+ const filePath = node.arguments[0].value;
187
+ if (SENSITIVE_PATHS.some(s => filePath.includes(s))) {
188
+ patterns.add('fs.readFile_sensitive');
189
+ }
190
+ }
191
+ }
192
+ },
193
+
194
+ NewExpression(node) {
195
+ // new Function()
196
+ if (node.callee.type === 'Identifier' && node.callee.name === 'Function') {
197
+ patterns.add('Function');
198
+ }
199
+ },
200
+
201
+ MemberExpression(node) {
202
+ // process.env
203
+ if (node.object.type === 'Identifier' && node.object.name === 'process' &&
204
+ node.property && node.property.name === 'env') {
205
+ patterns.add('process.env');
206
+ }
207
+ },
208
+
209
+ ImportDeclaration(node) {
210
+ // import ... from 'child_process'
211
+ if (node.source && node.source.type === 'Literal') {
212
+ const mod = node.source.value;
213
+ if (mod === 'child_process') patterns.add('child_process');
214
+ if (mod === 'http') patterns.add('http_request');
215
+ if (mod === 'https') patterns.add('https_request');
216
+ if (mod === 'dns') patterns.add('dns.lookup');
217
+ if (mod === 'net') patterns.add('net.connect');
218
+ }
219
+ }
220
+ });
221
+ }
222
+
223
+ /**
224
+ * Compare AST patterns between two versions of a package.
225
+ * @param {string} packageName - npm package name
226
+ * @param {string} versionA - Older version
227
+ * @param {string} versionB - Newer version
228
+ * @returns {Promise<{added: string[], removed: string[]}>}
229
+ * added = patterns in versionB but NOT in versionA (new dangerous capabilities)
230
+ * removed = patterns in versionA but NOT in versionB
231
+ */
232
+ async function compareAstPatterns(packageName, versionA, versionB) {
233
+ let cleanupA = null;
234
+ let cleanupB = null;
235
+
236
+ try {
237
+ const [resultA, resultB] = await Promise.all([
238
+ fetchPackageTarball(packageName, versionA),
239
+ fetchPackageTarball(packageName, versionB)
240
+ ]);
241
+ cleanupA = resultA.cleanup;
242
+ cleanupB = resultB.cleanup;
243
+
244
+ const patternsA = extractDangerousPatterns(resultA.dir);
245
+ const patternsB = extractDangerousPatterns(resultB.dir);
246
+
247
+ const added = [...patternsB].filter(p => !patternsA.has(p));
248
+ const removed = [...patternsA].filter(p => !patternsB.has(p));
249
+
250
+ return { added, removed };
251
+ } finally {
252
+ if (cleanupA) cleanupA();
253
+ if (cleanupB) cleanupB();
254
+ }
255
+ }
256
+
257
+ /**
258
+ * Detect sudden dangerous API additions between the two most recent versions.
259
+ * @param {string} packageName - npm package name
260
+ * @returns {Promise<object>} Detection result
261
+ */
262
+ async function detectSuddenAstChanges(packageName) {
263
+ const metadata = await fetchPackageMetadata(packageName);
264
+ const latest = getLatestVersions(metadata, 2);
265
+
266
+ if (latest.length < 2) {
267
+ return {
268
+ packageName,
269
+ latestVersion: latest.length > 0 ? latest[0].version : null,
270
+ previousVersion: null,
271
+ suspicious: false,
272
+ findings: [],
273
+ metadata: {
274
+ latestPublishedAt: latest.length > 0 ? latest[0].publishedAt : null,
275
+ previousPublishedAt: null
276
+ }
277
+ };
278
+ }
279
+
280
+ const [newestEntry, previousEntry] = latest;
281
+ const diff = await compareAstPatterns(packageName, previousEntry.version, newestEntry.version);
282
+
283
+ const findings = [];
284
+ for (const pattern of diff.added) {
285
+ const severity = PATTERN_SEVERITY[pattern] || 'MEDIUM';
286
+ findings.push({
287
+ type: 'dangerous_api_added',
288
+ pattern,
289
+ severity,
290
+ description: `Package now uses ${pattern} (not present in previous version)`
291
+ });
292
+ }
293
+
294
+ return {
295
+ packageName,
296
+ latestVersion: newestEntry.version,
297
+ previousVersion: previousEntry.version,
298
+ suspicious: findings.length > 0,
299
+ findings,
300
+ metadata: {
301
+ latestPublishedAt: newestEntry.publishedAt,
302
+ previousPublishedAt: previousEntry.publishedAt
303
+ }
304
+ };
305
+ }
306
+
307
+ module.exports = {
308
+ fetchPackageTarball,
309
+ extractDangerousPatterns,
310
+ compareAstPatterns,
311
+ detectSuddenAstChanges,
312
+ // Exported for testing
313
+ extractPatternsFromSource,
314
+ fetchVersionMetadata,
315
+ SENSITIVE_PATHS,
316
+ PATTERN_SEVERITY
317
+ };