muaddib-scanner 2.4.4 → 2.4.5

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,224 +1,224 @@
1
- const { fetchPackageMetadata, getLatestVersions } = require('./temporal-analysis.js');
2
-
3
- // Patterns that indicate generic/suspicious maintainer names
4
- const GENERIC_NAME_PATTERNS = [
5
- /^npm-user-\w+$/i,
6
- /^user\d+$/i,
7
- /^test$/i,
8
- /^admin$/i,
9
- /^root$/i,
10
- /^default$/i,
11
- /^temp$/i,
12
- /^tmp$/i,
13
- /^owner$/i,
14
- /^maintainer$/i
15
- ];
16
-
17
- const MIN_NAME_LENGTH = 3;
18
- const DIGIT_RATIO_THRESHOLD = 0.5;
19
-
20
- /**
21
- * Extract current maintainers from package metadata.
22
- * @param {object} metadata - Full registry metadata from fetchPackageMetadata()
23
- * @returns {{ current: Array<{name: string, email: string}>, count: number }}
24
- */
25
- function getMaintainersHistory(metadata) {
26
- if (!metadata || !metadata.maintainers || !Array.isArray(metadata.maintainers)) {
27
- return { current: [], count: 0 };
28
- }
29
-
30
- const current = metadata.maintainers.map(m => ({
31
- name: m.name || '',
32
- email: m.email || ''
33
- }));
34
-
35
- return { current, count: current.length };
36
- }
37
-
38
- /**
39
- * Evaluate the risk level of a maintainer based on their name.
40
- * @param {{ name: string, email: string }} maintainer
41
- * @returns {{ riskLevel: string, reasons: string[] }}
42
- */
43
- function analyzeMaintainerRisk(maintainer) {
44
- const reasons = [];
45
- const name = (maintainer && maintainer.name) || '';
46
-
47
- if (!name) {
48
- return { riskLevel: 'HIGH', reasons: ['Empty maintainer name'] };
49
- }
50
-
51
- // Check generic name patterns
52
- for (const pattern of GENERIC_NAME_PATTERNS) {
53
- if (pattern.test(name)) {
54
- reasons.push(`Generic name pattern: "${name}"`);
55
- break;
56
- }
57
- }
58
-
59
- // Check very short name
60
- if (name.length < MIN_NAME_LENGTH) {
61
- reasons.push(`Very short name (${name.length} chars)`);
62
- }
63
-
64
- // Check digit ratio
65
- const digitCount = (name.match(/\d/g) || []).length;
66
- const digitRatio = digitCount / name.length;
67
- if (digitRatio >= DIGIT_RATIO_THRESHOLD && name.length >= MIN_NAME_LENGTH) {
68
- reasons.push(`High digit ratio (${(digitRatio * 100).toFixed(0)}% digits)`);
69
- }
70
-
71
- if (reasons.length > 0) {
72
- return { riskLevel: 'HIGH', reasons };
73
- }
74
-
75
- return { riskLevel: 'LOW', reasons: [] };
76
- }
77
-
78
- /**
79
- * Get maintainers associated with a specific version from metadata.
80
- * Uses _npmUser (who published) and maintainers list from the version entry.
81
- * @param {object} versionData - metadata.versions[version]
82
- * @returns {{ publisher: {name: string, email: string}|null, maintainers: Array<{name: string, email: string}> }}
83
- */
84
- function getVersionMaintainers(versionData) {
85
- if (!versionData) return { publisher: null, maintainers: [] };
86
-
87
- const publisher = versionData._npmUser
88
- ? { name: versionData._npmUser.name || '', email: versionData._npmUser.email || '' }
89
- : null;
90
-
91
- const maintainers = Array.isArray(versionData.maintainers)
92
- ? versionData.maintainers.map(m => ({ name: m.name || '', email: m.email || '' }))
93
- : [];
94
-
95
- return { publisher, maintainers };
96
- }
97
-
98
- /**
99
- * Detect maintainer changes between two versions.
100
- * @param {string} packageName - npm package name
101
- * @returns {Promise<object>} Detection result
102
- */
103
- async function detectMaintainerChange(packageName) {
104
- const metadata = await fetchPackageMetadata(packageName);
105
- const maintainersInfo = getMaintainersHistory(metadata);
106
- const latest = getLatestVersions(metadata, 2);
107
-
108
- if (latest.length < 2) {
109
- return {
110
- packageName,
111
- suspicious: false,
112
- findings: [],
113
- maintainers: maintainersInfo
114
- };
115
- }
116
-
117
- const [newestEntry, previousEntry] = latest;
118
- const versions = metadata.versions || {};
119
- const newestData = versions[newestEntry.version];
120
- const previousData = versions[previousEntry.version];
121
-
122
- const newestMaint = getVersionMaintainers(newestData);
123
- const previousMaint = getVersionMaintainers(previousData);
124
-
125
- const findings = [];
126
-
127
- // Build name sets for comparison
128
- const previousNames = new Set(previousMaint.maintainers.map(m => m.name.toLowerCase()));
129
- const currentNames = new Set(newestMaint.maintainers.map(m => m.name.toLowerCase()));
130
-
131
- // Detect NEW_MAINTAINER: maintainers in newest that weren't in previous
132
- for (const m of newestMaint.maintainers) {
133
- if (m.name && !previousNames.has(m.name.toLowerCase())) {
134
- const risk = analyzeMaintainerRisk(m);
135
- findings.push({
136
- type: 'new_maintainer',
137
- severity: risk.riskLevel === 'HIGH' ? 'CRITICAL' : 'HIGH',
138
- maintainer: m,
139
- riskAssessment: risk,
140
- description: `New maintainer '${m.name}' added between v${previousEntry.version} and v${newestEntry.version}`
141
- });
142
- }
143
- }
144
-
145
- // Detect SUSPICIOUS_MAINTAINER: current maintainers with HIGH risk names
146
- for (const m of maintainersInfo.current) {
147
- const risk = analyzeMaintainerRisk(m);
148
- if (risk.riskLevel === 'HIGH') {
149
- // Avoid duplicate if already reported as new_maintainer
150
- const alreadyReported = findings.some(
151
- f => f.type === 'new_maintainer' && f.maintainer.name === m.name
152
- );
153
- if (!alreadyReported) {
154
- findings.push({
155
- type: 'suspicious_maintainer',
156
- severity: 'HIGH',
157
- maintainer: m,
158
- riskAssessment: risk,
159
- description: `Suspicious maintainer '${m.name}': ${risk.reasons.join(', ')}`
160
- });
161
- }
162
- }
163
- }
164
-
165
- // Detect SOLE_MAINTAINER_CHANGE: the only maintainer changed
166
- if (previousMaint.maintainers.length === 1 && newestMaint.maintainers.length === 1) {
167
- const prevName = previousMaint.maintainers[0].name.toLowerCase();
168
- const newName = newestMaint.maintainers[0].name.toLowerCase();
169
- if (prevName && newName && prevName !== newName) {
170
- const risk = analyzeMaintainerRisk(newestMaint.maintainers[0]);
171
- // Avoid duplicate if already reported as new_maintainer
172
- const alreadyReported = findings.some(
173
- f => f.type === 'new_maintainer' && f.maintainer.name.toLowerCase() === newName
174
- );
175
- if (!alreadyReported) {
176
- findings.push({
177
- type: 'sole_maintainer_change',
178
- severity: risk.riskLevel === 'HIGH' ? 'CRITICAL' : 'HIGH',
179
- maintainer: newestMaint.maintainers[0],
180
- previousMaintainer: previousMaint.maintainers[0],
181
- riskAssessment: risk,
182
- description: `Sole maintainer changed from '${previousMaint.maintainers[0].name}' to '${newestMaint.maintainers[0].name}'`
183
- });
184
- }
185
- }
186
- }
187
-
188
- // Detect publisher change (different _npmUser between versions)
189
- if (newestMaint.publisher && previousMaint.publisher) {
190
- const prevPublisher = previousMaint.publisher.name.toLowerCase();
191
- const newPublisher = newestMaint.publisher.name.toLowerCase();
192
- if (prevPublisher && newPublisher && prevPublisher !== newPublisher) {
193
- // Check if the new publisher is in the previous maintainers list
194
- if (!previousNames.has(newPublisher)) {
195
- const risk = analyzeMaintainerRisk(newestMaint.publisher);
196
- findings.push({
197
- type: 'new_publisher',
198
- severity: risk.riskLevel === 'HIGH' ? 'CRITICAL' : 'HIGH',
199
- maintainer: newestMaint.publisher,
200
- previousPublisher: previousMaint.publisher,
201
- riskAssessment: risk,
202
- description: `New publisher '${newestMaint.publisher.name}' (previously '${previousMaint.publisher.name}')`
203
- });
204
- }
205
- }
206
- }
207
-
208
- return {
209
- packageName,
210
- suspicious: findings.length > 0,
211
- findings,
212
- maintainers: maintainersInfo
213
- };
214
- }
215
-
216
- module.exports = {
217
- getMaintainersHistory,
218
- analyzeMaintainerRisk,
219
- detectMaintainerChange,
220
- getVersionMaintainers,
221
- GENERIC_NAME_PATTERNS,
222
- MIN_NAME_LENGTH,
223
- DIGIT_RATIO_THRESHOLD
224
- };
1
+ const { fetchPackageMetadata, getLatestVersions } = require('./temporal-analysis.js');
2
+
3
+ // Patterns that indicate generic/suspicious maintainer names
4
+ const GENERIC_NAME_PATTERNS = [
5
+ /^npm-user-\w+$/i,
6
+ /^user\d+$/i,
7
+ /^test$/i,
8
+ /^admin$/i,
9
+ /^root$/i,
10
+ /^default$/i,
11
+ /^temp$/i,
12
+ /^tmp$/i,
13
+ /^owner$/i,
14
+ /^maintainer$/i
15
+ ];
16
+
17
+ const MIN_NAME_LENGTH = 3;
18
+ const DIGIT_RATIO_THRESHOLD = 0.5;
19
+
20
+ /**
21
+ * Extract current maintainers from package metadata.
22
+ * @param {object} metadata - Full registry metadata from fetchPackageMetadata()
23
+ * @returns {{ current: Array<{name: string, email: string}>, count: number }}
24
+ */
25
+ function getMaintainersHistory(metadata) {
26
+ if (!metadata || !metadata.maintainers || !Array.isArray(metadata.maintainers)) {
27
+ return { current: [], count: 0 };
28
+ }
29
+
30
+ const current = metadata.maintainers.map(m => ({
31
+ name: m.name || '',
32
+ email: m.email || ''
33
+ }));
34
+
35
+ return { current, count: current.length };
36
+ }
37
+
38
+ /**
39
+ * Evaluate the risk level of a maintainer based on their name.
40
+ * @param {{ name: string, email: string }} maintainer
41
+ * @returns {{ riskLevel: string, reasons: string[] }}
42
+ */
43
+ function analyzeMaintainerRisk(maintainer) {
44
+ const reasons = [];
45
+ const name = (maintainer && maintainer.name) || '';
46
+
47
+ if (!name) {
48
+ return { riskLevel: 'HIGH', reasons: ['Empty maintainer name'] };
49
+ }
50
+
51
+ // Check generic name patterns
52
+ for (const pattern of GENERIC_NAME_PATTERNS) {
53
+ if (pattern.test(name)) {
54
+ reasons.push(`Generic name pattern: "${name}"`);
55
+ break;
56
+ }
57
+ }
58
+
59
+ // Check very short name
60
+ if (name.length < MIN_NAME_LENGTH) {
61
+ reasons.push(`Very short name (${name.length} chars)`);
62
+ }
63
+
64
+ // Check digit ratio
65
+ const digitCount = (name.match(/\d/g) || []).length;
66
+ const digitRatio = digitCount / name.length;
67
+ if (digitRatio >= DIGIT_RATIO_THRESHOLD && name.length >= MIN_NAME_LENGTH) {
68
+ reasons.push(`High digit ratio (${(digitRatio * 100).toFixed(0)}% digits)`);
69
+ }
70
+
71
+ if (reasons.length > 0) {
72
+ return { riskLevel: 'HIGH', reasons };
73
+ }
74
+
75
+ return { riskLevel: 'LOW', reasons: [] };
76
+ }
77
+
78
+ /**
79
+ * Get maintainers associated with a specific version from metadata.
80
+ * Uses _npmUser (who published) and maintainers list from the version entry.
81
+ * @param {object} versionData - metadata.versions[version]
82
+ * @returns {{ publisher: {name: string, email: string}|null, maintainers: Array<{name: string, email: string}> }}
83
+ */
84
+ function getVersionMaintainers(versionData) {
85
+ if (!versionData) return { publisher: null, maintainers: [] };
86
+
87
+ const publisher = versionData._npmUser
88
+ ? { name: versionData._npmUser.name || '', email: versionData._npmUser.email || '' }
89
+ : null;
90
+
91
+ const maintainers = Array.isArray(versionData.maintainers)
92
+ ? versionData.maintainers.map(m => ({ name: m.name || '', email: m.email || '' }))
93
+ : [];
94
+
95
+ return { publisher, maintainers };
96
+ }
97
+
98
+ /**
99
+ * Detect maintainer changes between two versions.
100
+ * @param {string} packageName - npm package name
101
+ * @returns {Promise<object>} Detection result
102
+ */
103
+ async function detectMaintainerChange(packageName) {
104
+ const metadata = await fetchPackageMetadata(packageName);
105
+ const maintainersInfo = getMaintainersHistory(metadata);
106
+ const latest = getLatestVersions(metadata, 2);
107
+
108
+ if (latest.length < 2) {
109
+ return {
110
+ packageName,
111
+ suspicious: false,
112
+ findings: [],
113
+ maintainers: maintainersInfo
114
+ };
115
+ }
116
+
117
+ const [newestEntry, previousEntry] = latest;
118
+ const versions = metadata.versions || {};
119
+ const newestData = versions[newestEntry.version];
120
+ const previousData = versions[previousEntry.version];
121
+
122
+ const newestMaint = getVersionMaintainers(newestData);
123
+ const previousMaint = getVersionMaintainers(previousData);
124
+
125
+ const findings = [];
126
+
127
+ // Build name sets for comparison
128
+ const previousNames = new Set(previousMaint.maintainers.map(m => m.name.toLowerCase()));
129
+ const currentNames = new Set(newestMaint.maintainers.map(m => m.name.toLowerCase()));
130
+
131
+ // Detect NEW_MAINTAINER: maintainers in newest that weren't in previous
132
+ for (const m of newestMaint.maintainers) {
133
+ if (m.name && !previousNames.has(m.name.toLowerCase())) {
134
+ const risk = analyzeMaintainerRisk(m);
135
+ findings.push({
136
+ type: 'new_maintainer',
137
+ severity: risk.riskLevel === 'HIGH' ? 'CRITICAL' : 'HIGH',
138
+ maintainer: m,
139
+ riskAssessment: risk,
140
+ description: `New maintainer '${m.name}' added between v${previousEntry.version} and v${newestEntry.version}`
141
+ });
142
+ }
143
+ }
144
+
145
+ // Detect SUSPICIOUS_MAINTAINER: current maintainers with HIGH risk names
146
+ for (const m of maintainersInfo.current) {
147
+ const risk = analyzeMaintainerRisk(m);
148
+ if (risk.riskLevel === 'HIGH') {
149
+ // Avoid duplicate if already reported as new_maintainer
150
+ const alreadyReported = findings.some(
151
+ f => f.type === 'new_maintainer' && f.maintainer.name === m.name
152
+ );
153
+ if (!alreadyReported) {
154
+ findings.push({
155
+ type: 'suspicious_maintainer',
156
+ severity: 'HIGH',
157
+ maintainer: m,
158
+ riskAssessment: risk,
159
+ description: `Suspicious maintainer '${m.name}': ${risk.reasons.join(', ')}`
160
+ });
161
+ }
162
+ }
163
+ }
164
+
165
+ // Detect SOLE_MAINTAINER_CHANGE: the only maintainer changed
166
+ if (previousMaint.maintainers.length === 1 && newestMaint.maintainers.length === 1) {
167
+ const prevName = previousMaint.maintainers[0].name.toLowerCase();
168
+ const newName = newestMaint.maintainers[0].name.toLowerCase();
169
+ if (prevName && newName && prevName !== newName) {
170
+ const risk = analyzeMaintainerRisk(newestMaint.maintainers[0]);
171
+ // Avoid duplicate if already reported as new_maintainer
172
+ const alreadyReported = findings.some(
173
+ f => f.type === 'new_maintainer' && f.maintainer.name.toLowerCase() === newName
174
+ );
175
+ if (!alreadyReported) {
176
+ findings.push({
177
+ type: 'sole_maintainer_change',
178
+ severity: risk.riskLevel === 'HIGH' ? 'CRITICAL' : 'HIGH',
179
+ maintainer: newestMaint.maintainers[0],
180
+ previousMaintainer: previousMaint.maintainers[0],
181
+ riskAssessment: risk,
182
+ description: `Sole maintainer changed from '${previousMaint.maintainers[0].name}' to '${newestMaint.maintainers[0].name}'`
183
+ });
184
+ }
185
+ }
186
+ }
187
+
188
+ // Detect publisher change (different _npmUser between versions)
189
+ if (newestMaint.publisher && previousMaint.publisher) {
190
+ const prevPublisher = previousMaint.publisher.name.toLowerCase();
191
+ const newPublisher = newestMaint.publisher.name.toLowerCase();
192
+ if (prevPublisher && newPublisher && prevPublisher !== newPublisher) {
193
+ // Check if the new publisher is in the previous maintainers list
194
+ if (!previousNames.has(newPublisher)) {
195
+ const risk = analyzeMaintainerRisk(newestMaint.publisher);
196
+ findings.push({
197
+ type: 'new_publisher',
198
+ severity: risk.riskLevel === 'HIGH' ? 'CRITICAL' : 'HIGH',
199
+ maintainer: newestMaint.publisher,
200
+ previousPublisher: previousMaint.publisher,
201
+ riskAssessment: risk,
202
+ description: `New publisher '${newestMaint.publisher.name}' (previously '${previousMaint.publisher.name}')`
203
+ });
204
+ }
205
+ }
206
+ }
207
+
208
+ return {
209
+ packageName,
210
+ suspicious: findings.length > 0,
211
+ findings,
212
+ maintainers: maintainersInfo
213
+ };
214
+ }
215
+
216
+ module.exports = {
217
+ getMaintainersHistory,
218
+ analyzeMaintainerRisk,
219
+ detectMaintainerChange,
220
+ getVersionMaintainers,
221
+ GENERIC_NAME_PATTERNS,
222
+ MIN_NAME_LENGTH,
223
+ DIGIT_RATIO_THRESHOLD
224
+ };