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,206 +1,206 @@
1
- const { fetchPackageMetadata } = require('./temporal-analysis.js');
2
-
3
- const MS_PER_DAY = 24 * 60 * 60 * 1000;
4
- const MS_PER_HOUR = 60 * 60 * 1000;
5
- const BURST_WINDOW_MS = 24 * MS_PER_HOUR; // 24h
6
- const BURST_MIN_VERSIONS = 3;
7
- const RAPID_WINDOW_MS = MS_PER_HOUR; // 1h
8
- const RAPID_MIN_VERSIONS = 2;
9
- const DORMANT_THRESHOLD_MS = 180 * MS_PER_DAY; // 6 months
10
- const MIN_VERSIONS_FOR_ANALYSIS = 3;
11
-
12
- /**
13
- * Analyze the publish frequency of an npm package.
14
- * @param {object} metadata - Full registry metadata from fetchPackageMetadata()
15
- * @returns {{ totalVersions: number, avgIntervalDays: number, stdDevDays: number, lastPublishedAt: string|null, publishHistory: Array<{version: string, date: string}> }}
16
- */
17
- function analyzePublishFrequency(metadata) {
18
- const time = metadata && metadata.time;
19
- if (!time || typeof time !== 'object') {
20
- return {
21
- totalVersions: 0,
22
- avgIntervalDays: 0,
23
- stdDevDays: 0,
24
- lastPublishedAt: null,
25
- publishHistory: []
26
- };
27
- }
28
-
29
- const versions = metadata.versions || {};
30
- const entries = [];
31
- for (const [version, publishedAt] of Object.entries(time)) {
32
- if (version === 'created' || version === 'modified') continue;
33
- if (!versions[version]) continue;
34
- entries.push({ version, date: publishedAt });
35
- }
36
-
37
- // Sort chronologically (oldest first)
38
- entries.sort((a, b) => new Date(a.date) - new Date(b.date));
39
-
40
- if (entries.length === 0) {
41
- return {
42
- totalVersions: 0,
43
- avgIntervalDays: 0,
44
- stdDevDays: 0,
45
- lastPublishedAt: null,
46
- publishHistory: []
47
- };
48
- }
49
-
50
- // Calculate intervals between consecutive publications
51
- const intervals = [];
52
- for (let i = 1; i < entries.length; i++) {
53
- const diffMs = new Date(entries[i].date) - new Date(entries[i - 1].date);
54
- intervals.push(diffMs / MS_PER_DAY);
55
- }
56
-
57
- let avgIntervalDays = 0;
58
- let stdDevDays = 0;
59
-
60
- if (intervals.length > 0) {
61
- avgIntervalDays = intervals.reduce((sum, d) => sum + d, 0) / intervals.length;
62
-
63
- const variance = intervals.reduce((sum, d) => sum + (d - avgIntervalDays) ** 2, 0) / intervals.length;
64
- stdDevDays = Math.sqrt(variance);
65
- }
66
-
67
- return {
68
- totalVersions: entries.length,
69
- avgIntervalDays: Math.round(avgIntervalDays * 100) / 100,
70
- stdDevDays: Math.round(stdDevDays * 100) / 100,
71
- lastPublishedAt: entries[entries.length - 1].date,
72
- publishHistory: entries
73
- };
74
- }
75
-
76
- /**
77
- * Detect publish frequency anomalies for an npm package.
78
- * @param {string} packageName - npm package name
79
- * @returns {Promise<object>} Detection result with suspicious flag, findings, and stats
80
- */
81
- async function detectPublishAnomaly(packageName) {
82
- let metadata;
83
- try {
84
- metadata = await fetchPackageMetadata(packageName);
85
- } catch {
86
- return {
87
- packageName,
88
- suspicious: false,
89
- anomalies: [],
90
- stats: { totalVersions: 0, avgIntervalDays: 0, stdDevDays: 0, lastPublishedAt: null, publishHistory: [] }
91
- };
92
- }
93
-
94
- if (!metadata || !metadata.time || !metadata.versions) {
95
- return {
96
- packageName,
97
- suspicious: false,
98
- anomalies: [],
99
- stats: { totalVersions: 0, avgIntervalDays: 0, stdDevDays: 0, lastPublishedAt: null, publishHistory: [] }
100
- };
101
- }
102
-
103
- const stats = analyzePublishFrequency(metadata);
104
-
105
- if (stats.totalVersions < MIN_VERSIONS_FOR_ANALYSIS) {
106
- return {
107
- packageName,
108
- suspicious: false,
109
- anomalies: [],
110
- stats
111
- };
112
- }
113
-
114
- const findings = [];
115
- const history = stats.publishHistory;
116
-
117
- // --- BURST: 3+ versions published within a 24h window ---
118
- for (let i = 0; i < history.length; i++) {
119
- const windowStart = new Date(history[i].date).getTime();
120
- const windowEnd = windowStart + BURST_WINDOW_MS;
121
- const inWindow = [];
122
- for (let j = i; j < history.length; j++) {
123
- const t = new Date(history[j].date).getTime();
124
- if (t <= windowEnd) {
125
- inWindow.push(history[j]);
126
- } else {
127
- break;
128
- }
129
- }
130
- if (inWindow.length >= BURST_MIN_VERSIONS) {
131
- const spanMs = new Date(inWindow[inWindow.length - 1].date) - new Date(inWindow[0].date);
132
- const spanHours = Math.round(spanMs / MS_PER_HOUR * 10) / 10;
133
- findings.push({
134
- type: 'publish_burst',
135
- severity: 'HIGH',
136
- description: `${inWindow.length} versions published in ${spanHours} hours (avg interval: ${stats.avgIntervalDays} days)`,
137
- versions: inWindow.map(e => e.version)
138
- });
139
- break; // report only the first (largest) burst window
140
- }
141
- }
142
-
143
- // --- DORMANT_SPIKE: 6+ months without publication, then new version ---
144
- if (history.length >= 2) {
145
- const lastDate = new Date(history[history.length - 1].date).getTime();
146
- const prevDate = new Date(history[history.length - 2].date).getTime();
147
- const gapMs = lastDate - prevDate;
148
-
149
- if (gapMs >= DORMANT_THRESHOLD_MS) {
150
- const gapDays = Math.round(gapMs / MS_PER_DAY);
151
- findings.push({
152
- type: 'dormant_spike',
153
- severity: 'HIGH',
154
- description: `Package dormant for ${gapDays} days, then new version published (avg interval: ${stats.avgIntervalDays} days)`,
155
- versions: [history[history.length - 2].version, history[history.length - 1].version]
156
- });
157
- }
158
- }
159
-
160
- // --- RAPID_SUCCESSION: 2+ versions in less than 1 hour ---
161
- for (let i = 0; i < history.length; i++) {
162
- const windowStart = new Date(history[i].date).getTime();
163
- const windowEnd = windowStart + RAPID_WINDOW_MS;
164
- const inWindow = [];
165
- for (let j = i; j < history.length; j++) {
166
- const t = new Date(history[j].date).getTime();
167
- if (t <= windowEnd) {
168
- inWindow.push(history[j]);
169
- } else {
170
- break;
171
- }
172
- }
173
- if (inWindow.length >= RAPID_MIN_VERSIONS) {
174
- const spanMs = new Date(inWindow[inWindow.length - 1].date) - new Date(inWindow[0].date);
175
- const spanMinutes = Math.round(spanMs / 60000);
176
- findings.push({
177
- type: 'rapid_succession',
178
- severity: 'MEDIUM',
179
- description: `${inWindow.length} versions published within ${spanMinutes} minutes`,
180
- versions: inWindow.map(e => e.version)
181
- });
182
- break; // report only the first rapid window
183
- }
184
- }
185
-
186
- return {
187
- packageName,
188
- suspicious: findings.length > 0,
189
- anomalies: findings,
190
- stats
191
- };
192
- }
193
-
194
- module.exports = {
195
- analyzePublishFrequency,
196
- detectPublishAnomaly,
197
- // Exported for testing
198
- MS_PER_DAY,
199
- MS_PER_HOUR,
200
- BURST_WINDOW_MS,
201
- BURST_MIN_VERSIONS,
202
- RAPID_WINDOW_MS,
203
- RAPID_MIN_VERSIONS,
204
- DORMANT_THRESHOLD_MS,
205
- MIN_VERSIONS_FOR_ANALYSIS
206
- };
1
+ const { fetchPackageMetadata } = require('./temporal-analysis.js');
2
+
3
+ const MS_PER_DAY = 24 * 60 * 60 * 1000;
4
+ const MS_PER_HOUR = 60 * 60 * 1000;
5
+ const BURST_WINDOW_MS = 24 * MS_PER_HOUR; // 24h
6
+ const BURST_MIN_VERSIONS = 3;
7
+ const RAPID_WINDOW_MS = MS_PER_HOUR; // 1h
8
+ const RAPID_MIN_VERSIONS = 2;
9
+ const DORMANT_THRESHOLD_MS = 180 * MS_PER_DAY; // 6 months
10
+ const MIN_VERSIONS_FOR_ANALYSIS = 3;
11
+
12
+ /**
13
+ * Analyze the publish frequency of an npm package.
14
+ * @param {object} metadata - Full registry metadata from fetchPackageMetadata()
15
+ * @returns {{ totalVersions: number, avgIntervalDays: number, stdDevDays: number, lastPublishedAt: string|null, publishHistory: Array<{version: string, date: string}> }}
16
+ */
17
+ function analyzePublishFrequency(metadata) {
18
+ const time = metadata && metadata.time;
19
+ if (!time || typeof time !== 'object') {
20
+ return {
21
+ totalVersions: 0,
22
+ avgIntervalDays: 0,
23
+ stdDevDays: 0,
24
+ lastPublishedAt: null,
25
+ publishHistory: []
26
+ };
27
+ }
28
+
29
+ const versions = metadata.versions || {};
30
+ const entries = [];
31
+ for (const [version, publishedAt] of Object.entries(time)) {
32
+ if (version === 'created' || version === 'modified') continue;
33
+ if (!versions[version]) continue;
34
+ entries.push({ version, date: publishedAt });
35
+ }
36
+
37
+ // Sort chronologically (oldest first)
38
+ entries.sort((a, b) => new Date(a.date) - new Date(b.date));
39
+
40
+ if (entries.length === 0) {
41
+ return {
42
+ totalVersions: 0,
43
+ avgIntervalDays: 0,
44
+ stdDevDays: 0,
45
+ lastPublishedAt: null,
46
+ publishHistory: []
47
+ };
48
+ }
49
+
50
+ // Calculate intervals between consecutive publications
51
+ const intervals = [];
52
+ for (let i = 1; i < entries.length; i++) {
53
+ const diffMs = new Date(entries[i].date) - new Date(entries[i - 1].date);
54
+ intervals.push(diffMs / MS_PER_DAY);
55
+ }
56
+
57
+ let avgIntervalDays = 0;
58
+ let stdDevDays = 0;
59
+
60
+ if (intervals.length > 0) {
61
+ avgIntervalDays = intervals.reduce((sum, d) => sum + d, 0) / intervals.length;
62
+
63
+ const variance = intervals.reduce((sum, d) => sum + (d - avgIntervalDays) ** 2, 0) / intervals.length;
64
+ stdDevDays = Math.sqrt(variance);
65
+ }
66
+
67
+ return {
68
+ totalVersions: entries.length,
69
+ avgIntervalDays: Math.round(avgIntervalDays * 100) / 100,
70
+ stdDevDays: Math.round(stdDevDays * 100) / 100,
71
+ lastPublishedAt: entries[entries.length - 1].date,
72
+ publishHistory: entries
73
+ };
74
+ }
75
+
76
+ /**
77
+ * Detect publish frequency anomalies for an npm package.
78
+ * @param {string} packageName - npm package name
79
+ * @returns {Promise<object>} Detection result with suspicious flag, findings, and stats
80
+ */
81
+ async function detectPublishAnomaly(packageName) {
82
+ let metadata;
83
+ try {
84
+ metadata = await fetchPackageMetadata(packageName);
85
+ } catch {
86
+ return {
87
+ packageName,
88
+ suspicious: false,
89
+ anomalies: [],
90
+ stats: { totalVersions: 0, avgIntervalDays: 0, stdDevDays: 0, lastPublishedAt: null, publishHistory: [] }
91
+ };
92
+ }
93
+
94
+ if (!metadata || !metadata.time || !metadata.versions) {
95
+ return {
96
+ packageName,
97
+ suspicious: false,
98
+ anomalies: [],
99
+ stats: { totalVersions: 0, avgIntervalDays: 0, stdDevDays: 0, lastPublishedAt: null, publishHistory: [] }
100
+ };
101
+ }
102
+
103
+ const stats = analyzePublishFrequency(metadata);
104
+
105
+ if (stats.totalVersions < MIN_VERSIONS_FOR_ANALYSIS) {
106
+ return {
107
+ packageName,
108
+ suspicious: false,
109
+ anomalies: [],
110
+ stats
111
+ };
112
+ }
113
+
114
+ const findings = [];
115
+ const history = stats.publishHistory;
116
+
117
+ // --- BURST: 3+ versions published within a 24h window ---
118
+ for (let i = 0; i < history.length; i++) {
119
+ const windowStart = new Date(history[i].date).getTime();
120
+ const windowEnd = windowStart + BURST_WINDOW_MS;
121
+ const inWindow = [];
122
+ for (let j = i; j < history.length; j++) {
123
+ const t = new Date(history[j].date).getTime();
124
+ if (t <= windowEnd) {
125
+ inWindow.push(history[j]);
126
+ } else {
127
+ break;
128
+ }
129
+ }
130
+ if (inWindow.length >= BURST_MIN_VERSIONS) {
131
+ const spanMs = new Date(inWindow[inWindow.length - 1].date) - new Date(inWindow[0].date);
132
+ const spanHours = Math.round(spanMs / MS_PER_HOUR * 10) / 10;
133
+ findings.push({
134
+ type: 'publish_burst',
135
+ severity: 'HIGH',
136
+ description: `${inWindow.length} versions published in ${spanHours} hours (avg interval: ${stats.avgIntervalDays} days)`,
137
+ versions: inWindow.map(e => e.version)
138
+ });
139
+ break; // report only the first (largest) burst window
140
+ }
141
+ }
142
+
143
+ // --- DORMANT_SPIKE: 6+ months without publication, then new version ---
144
+ if (history.length >= 2) {
145
+ const lastDate = new Date(history[history.length - 1].date).getTime();
146
+ const prevDate = new Date(history[history.length - 2].date).getTime();
147
+ const gapMs = lastDate - prevDate;
148
+
149
+ if (gapMs >= DORMANT_THRESHOLD_MS) {
150
+ const gapDays = Math.round(gapMs / MS_PER_DAY);
151
+ findings.push({
152
+ type: 'dormant_spike',
153
+ severity: 'HIGH',
154
+ description: `Package dormant for ${gapDays} days, then new version published (avg interval: ${stats.avgIntervalDays} days)`,
155
+ versions: [history[history.length - 2].version, history[history.length - 1].version]
156
+ });
157
+ }
158
+ }
159
+
160
+ // --- RAPID_SUCCESSION: 2+ versions in less than 1 hour ---
161
+ for (let i = 0; i < history.length; i++) {
162
+ const windowStart = new Date(history[i].date).getTime();
163
+ const windowEnd = windowStart + RAPID_WINDOW_MS;
164
+ const inWindow = [];
165
+ for (let j = i; j < history.length; j++) {
166
+ const t = new Date(history[j].date).getTime();
167
+ if (t <= windowEnd) {
168
+ inWindow.push(history[j]);
169
+ } else {
170
+ break;
171
+ }
172
+ }
173
+ if (inWindow.length >= RAPID_MIN_VERSIONS) {
174
+ const spanMs = new Date(inWindow[inWindow.length - 1].date) - new Date(inWindow[0].date);
175
+ const spanMinutes = Math.round(spanMs / 60000);
176
+ findings.push({
177
+ type: 'rapid_succession',
178
+ severity: 'MEDIUM',
179
+ description: `${inWindow.length} versions published within ${spanMinutes} minutes`,
180
+ versions: inWindow.map(e => e.version)
181
+ });
182
+ break; // report only the first rapid window
183
+ }
184
+ }
185
+
186
+ return {
187
+ packageName,
188
+ suspicious: findings.length > 0,
189
+ anomalies: findings,
190
+ stats
191
+ };
192
+ }
193
+
194
+ module.exports = {
195
+ analyzePublishFrequency,
196
+ detectPublishAnomaly,
197
+ // Exported for testing
198
+ MS_PER_DAY,
199
+ MS_PER_HOUR,
200
+ BURST_WINDOW_MS,
201
+ BURST_MIN_VERSIONS,
202
+ RAPID_WINDOW_MS,
203
+ RAPID_MIN_VERSIONS,
204
+ DORMANT_THRESHOLD_MS,
205
+ MIN_VERSIONS_FOR_ANALYSIS
206
+ };