@westbayberry/dg 1.0.52 → 1.0.56
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/README.md +5 -1
- package/dist/index.mjs +349 -168
- package/dist/packages/cli/src/alt-screen.js +36 -0
- package/dist/packages/cli/src/api.js +322 -0
- package/dist/packages/cli/src/auth.js +218 -0
- package/dist/packages/cli/src/bin.js +386 -0
- package/dist/packages/cli/src/config.js +228 -0
- package/dist/packages/cli/src/discover.js +126 -0
- package/dist/packages/cli/src/first-run.js +135 -0
- package/dist/packages/cli/src/hook.js +360 -0
- package/dist/packages/cli/src/lockfile.js +303 -0
- package/dist/packages/cli/src/npm-wrapper.js +218 -0
- package/dist/packages/cli/src/pip-wrapper.js +273 -0
- package/dist/packages/cli/src/sanitize.js +38 -0
- package/dist/packages/cli/src/scan-core.js +144 -0
- package/dist/packages/cli/src/setup-status.js +46 -0
- package/dist/packages/cli/src/static-output.js +625 -0
- package/dist/packages/cli/src/telemetry.js +141 -0
- package/dist/packages/cli/src/ui/App.js +137 -0
- package/dist/packages/cli/src/ui/InitApp.js +391 -0
- package/dist/packages/cli/src/ui/LoginApp.js +51 -0
- package/dist/packages/cli/src/ui/NpmWrapperApp.js +73 -0
- package/dist/packages/cli/src/ui/PipWrapperApp.js +72 -0
- package/dist/packages/cli/src/ui/components/ConfirmPrompt.js +24 -0
- package/dist/packages/cli/src/ui/components/DemoScanAnimation.js +26 -0
- package/dist/packages/cli/src/ui/components/DurationLine.js +7 -0
- package/dist/packages/cli/src/ui/components/ErrorView.js +30 -0
- package/dist/packages/cli/src/ui/components/FileSavePrompt.js +210 -0
- package/dist/packages/cli/src/ui/components/InteractiveResultsView.js +557 -0
- package/dist/packages/cli/src/ui/components/Mascot.js +33 -0
- package/dist/packages/cli/src/ui/components/ProgressBar.js +51 -0
- package/dist/packages/cli/src/ui/components/ProgressDots.js +35 -0
- package/dist/packages/cli/src/ui/components/ProjectSelector.js +60 -0
- package/dist/packages/cli/src/ui/components/ResultsView.js +105 -0
- package/dist/packages/cli/src/ui/components/ScanResultCard.js +54 -0
- package/dist/packages/cli/src/ui/components/ScoreHeader.js +142 -0
- package/dist/packages/cli/src/ui/components/SetupBanner.js +17 -0
- package/dist/packages/cli/src/ui/components/Spinner.js +11 -0
- package/dist/packages/cli/src/ui/hooks/useExpandAnimation.js +44 -0
- package/dist/packages/cli/src/ui/hooks/useInit.js +341 -0
- package/dist/packages/cli/src/ui/hooks/useLogin.js +121 -0
- package/dist/packages/cli/src/ui/hooks/useNpmWrapper.js +192 -0
- package/dist/packages/cli/src/ui/hooks/usePipWrapper.js +195 -0
- package/dist/packages/cli/src/ui/hooks/useScan.js +202 -0
- package/dist/packages/cli/src/ui/hooks/useTerminalSize.js +29 -0
- package/dist/packages/cli/src/update-check.js +152 -0
- package/dist/packages/cli/src/wizard-demo-data.js +63 -0
- package/dist/src/ecosystem.js +2 -0
- package/dist/src/lockfile/diff.js +38 -0
- package/dist/src/lockfile/parse_package_json.js +41 -0
- package/dist/src/lockfile/parse_package_lock.js +55 -0
- package/dist/src/lockfile/parse_pipfile_lock.js +69 -0
- package/dist/src/lockfile/parse_pnpm_lock.js +62 -0
- package/dist/src/lockfile/parse_poetry_lock.js +71 -0
- package/dist/src/lockfile/parse_requirements.js +83 -0
- package/dist/src/lockfile/parse_yarn_lock.js +66 -0
- package/dist/src/logger.js +21 -0
- package/dist/src/npm/h2pool.js +161 -0
- package/dist/src/npm/registry.js +299 -0
- package/dist/src/npm/tarball.js +274 -0
- package/dist/src/pypi/registry.js +299 -0
- package/dist/src/pypi/tarball.js +361 -0
- package/dist/src/types.js +2 -0
- package/package.json +6 -3
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.fetchPackageMetadata = fetchPackageMetadata;
|
|
4
|
+
exports.fetchAllPackageMetadata = fetchAllPackageMetadata;
|
|
5
|
+
exports.clearDownloadCache = clearDownloadCache;
|
|
6
|
+
exports.setDownloadCachePersistence = setDownloadCachePersistence;
|
|
7
|
+
exports.fetchWeeklyDownloads = fetchWeeklyDownloads;
|
|
8
|
+
exports.prefetchDownloadCounts = prefetchDownloadCounts;
|
|
9
|
+
exports.prefetchScopedDownloadCounts = prefetchScopedDownloadCounts;
|
|
10
|
+
exports.getTarballUrl = getTarballUrl;
|
|
11
|
+
const logger_1 = require("../logger");
|
|
12
|
+
const h2pool_1 = require("./h2pool");
|
|
13
|
+
const FETCH_TIMEOUT_MS = 30000; // 30s timeout for npm registry calls
|
|
14
|
+
const NPM_TOKEN = process.env.NPM_TOKEN;
|
|
15
|
+
function npmDownloadHeaders() {
|
|
16
|
+
const headers = { "User-Agent": "dependency-guardian" };
|
|
17
|
+
if (NPM_TOKEN)
|
|
18
|
+
headers["Authorization"] = `Bearer ${NPM_TOKEN}`;
|
|
19
|
+
return headers;
|
|
20
|
+
}
|
|
21
|
+
function npmRegistryUrl(name) {
|
|
22
|
+
if (name.startsWith("@")) {
|
|
23
|
+
return `https://registry.npmjs.org/${name.replace("/", "%2F")}`;
|
|
24
|
+
}
|
|
25
|
+
return `https://registry.npmjs.org/${name}`;
|
|
26
|
+
}
|
|
27
|
+
async function fetchPackageMetadata(packageName) {
|
|
28
|
+
try {
|
|
29
|
+
const url = npmRegistryUrl(packageName);
|
|
30
|
+
const response = await fetch(url, {
|
|
31
|
+
headers: { "User-Agent": "dependency-guardian" },
|
|
32
|
+
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
|
33
|
+
});
|
|
34
|
+
if (!response.ok)
|
|
35
|
+
return null;
|
|
36
|
+
return (await response.json());
|
|
37
|
+
}
|
|
38
|
+
catch (err) {
|
|
39
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
40
|
+
logger_1.logger.warning(`Failed to fetch metadata for ${packageName}: ${msg}`);
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Fetch metadata for all packages in bulk over a single HTTP/2 connection.
|
|
46
|
+
* 152 requests multiplex over 1 TCP connection instead of 6 concurrent HTTP/1.1 sockets.
|
|
47
|
+
*/
|
|
48
|
+
async function fetchAllPackageMetadata(packageNames) {
|
|
49
|
+
const result = new Map();
|
|
50
|
+
if (packageNames.length === 0)
|
|
51
|
+
return result;
|
|
52
|
+
const urls = packageNames.map(npmRegistryUrl);
|
|
53
|
+
const responses = await h2pool_1.h2pool.requestAll(urls, {
|
|
54
|
+
headers: { "user-agent": "dependency-guardian" },
|
|
55
|
+
timeout: FETCH_TIMEOUT_MS,
|
|
56
|
+
});
|
|
57
|
+
for (let i = 0; i < packageNames.length; i++) {
|
|
58
|
+
try {
|
|
59
|
+
if (responses[i].status === 200) {
|
|
60
|
+
result.set(packageNames[i], JSON.parse(responses[i].body));
|
|
61
|
+
}
|
|
62
|
+
else {
|
|
63
|
+
result.set(packageNames[i], null);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
result.set(packageNames[i], null);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return result;
|
|
71
|
+
}
|
|
72
|
+
const downloadCache = new Map();
|
|
73
|
+
const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours (fresh)
|
|
74
|
+
const STALE_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days (stale fallback)
|
|
75
|
+
const MAX_CACHE_SIZE = 10000;
|
|
76
|
+
function clearDownloadCache() {
|
|
77
|
+
downloadCache.clear();
|
|
78
|
+
}
|
|
79
|
+
let persistence = null;
|
|
80
|
+
function setDownloadCachePersistence(p) {
|
|
81
|
+
persistence = p;
|
|
82
|
+
}
|
|
83
|
+
function cacheSet(key, entry) {
|
|
84
|
+
if (downloadCache.size >= MAX_CACHE_SIZE) {
|
|
85
|
+
// Evict entries beyond stale TTL first
|
|
86
|
+
const now = Date.now();
|
|
87
|
+
for (const [k, v] of downloadCache) {
|
|
88
|
+
if (now - v.fetchedAt >= STALE_TTL_MS) {
|
|
89
|
+
downloadCache.delete(k);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
// If still at capacity, evict oldest (Map preserves insertion order)
|
|
93
|
+
if (downloadCache.size >= MAX_CACHE_SIZE) {
|
|
94
|
+
const firstKey = downloadCache.keys().next().value;
|
|
95
|
+
if (firstKey !== undefined)
|
|
96
|
+
downloadCache.delete(firstKey);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
downloadCache.set(key, entry);
|
|
100
|
+
}
|
|
101
|
+
async function fetchWeeklyDownloads(packageName) {
|
|
102
|
+
const cached = downloadCache.get(packageName);
|
|
103
|
+
const now = Date.now();
|
|
104
|
+
// Fresh cache hit — return immediately without API call
|
|
105
|
+
if (cached !== undefined && now - cached.fetchedAt < CACHE_TTL_MS) {
|
|
106
|
+
return cached.count;
|
|
107
|
+
}
|
|
108
|
+
// Stale entry may exist — will be used as fallback on API failure
|
|
109
|
+
const staleValue = cached !== undefined && now - cached.fetchedAt < STALE_TTL_MS
|
|
110
|
+
? cached.count
|
|
111
|
+
: null;
|
|
112
|
+
const encoded = packageName.startsWith("@")
|
|
113
|
+
? packageName.replace("/", "%2F")
|
|
114
|
+
: packageName;
|
|
115
|
+
const url = `https://api.npmjs.org/downloads/point/last-week/${encoded}`;
|
|
116
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
117
|
+
try {
|
|
118
|
+
const response = await fetch(url, {
|
|
119
|
+
headers: npmDownloadHeaders(),
|
|
120
|
+
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
|
121
|
+
});
|
|
122
|
+
if (response.status === 429) {
|
|
123
|
+
// Rate limited — short backoff then retry once
|
|
124
|
+
await new Promise((r) => setTimeout(r, 2000));
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
if (!response.ok)
|
|
128
|
+
return staleValue;
|
|
129
|
+
const data = (await response.json());
|
|
130
|
+
const count = data.downloads ?? null;
|
|
131
|
+
if (count !== null) {
|
|
132
|
+
cacheSet(packageName, { count, fetchedAt: now });
|
|
133
|
+
// Write-through to persistent layer (fire-and-forget)
|
|
134
|
+
persistence?.setMany({ [packageName]: count }).catch(() => { });
|
|
135
|
+
}
|
|
136
|
+
return count;
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
if (attempt < 1) {
|
|
140
|
+
await new Promise((r) => setTimeout(r, 1000));
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
return staleValue;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return staleValue;
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Bulk pre-fetch download counts for many packages at once.
|
|
150
|
+
* Uses npm's bulk API for unscoped packages (up to 128 per request)
|
|
151
|
+
* and falls back to individual fetches for scoped packages.
|
|
152
|
+
* Results are stored in the in-process cache so subsequent
|
|
153
|
+
* fetchWeeklyDownloads() calls get instant hits.
|
|
154
|
+
*
|
|
155
|
+
* On cold start (empty in-process cache), primes from the persistent
|
|
156
|
+
* layer (Redis) first — this avoids hammering the npm API after every
|
|
157
|
+
* process restart.
|
|
158
|
+
*/
|
|
159
|
+
async function prefetchDownloadCounts(packageNames) {
|
|
160
|
+
const now = Date.now();
|
|
161
|
+
// Step 1: Prime from persistent layer (Redis) on cold start.
|
|
162
|
+
// Any package not in the in-process cache but present in Redis gets
|
|
163
|
+
// loaded as a "stale" entry — good enough to prevent null-download
|
|
164
|
+
// false positives while the npm API catches up.
|
|
165
|
+
if (persistence) {
|
|
166
|
+
const coldMisses = packageNames.filter((name) => !downloadCache.has(name));
|
|
167
|
+
if (coldMisses.length > 0) {
|
|
168
|
+
try {
|
|
169
|
+
const persisted = await persistence.getMany(coldMisses);
|
|
170
|
+
for (const [name, count] of Object.entries(persisted)) {
|
|
171
|
+
// Load as fetchedAt=0 so the fresh TTL is expired (triggers
|
|
172
|
+
// npm API refresh) but the stale TTL still applies as fallback.
|
|
173
|
+
if (!downloadCache.has(name)) {
|
|
174
|
+
cacheSet(name, { count, fetchedAt: 0 });
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
catch {
|
|
179
|
+
// Non-fatal — proceed without persistent cache
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
// Step 2: Identify packages needing a fresh npm API fetch
|
|
184
|
+
const needed = packageNames.filter((name) => {
|
|
185
|
+
const cached = downloadCache.get(name);
|
|
186
|
+
return !(cached && now - cached.fetchedAt < CACHE_TTL_MS);
|
|
187
|
+
});
|
|
188
|
+
if (needed.length === 0)
|
|
189
|
+
return;
|
|
190
|
+
// Split into scoped vs unscoped
|
|
191
|
+
const scoped = needed.filter((n) => n.startsWith("@"));
|
|
192
|
+
const unscoped = needed.filter((n) => !n.startsWith("@"));
|
|
193
|
+
// Bulk fetch unscoped in batches of 128
|
|
194
|
+
const BULK_SIZE = 128;
|
|
195
|
+
const bulkBatches = [];
|
|
196
|
+
for (let i = 0; i < unscoped.length; i += BULK_SIZE) {
|
|
197
|
+
bulkBatches.push(unscoped.slice(i, i + BULK_SIZE));
|
|
198
|
+
}
|
|
199
|
+
const writeThrough = {};
|
|
200
|
+
await Promise.all(bulkBatches.map(async (batch) => {
|
|
201
|
+
try {
|
|
202
|
+
const names = batch.join(",");
|
|
203
|
+
const url = `https://api.npmjs.org/downloads/point/last-week/${names}`;
|
|
204
|
+
const response = await fetch(url, {
|
|
205
|
+
headers: npmDownloadHeaders(),
|
|
206
|
+
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
|
207
|
+
});
|
|
208
|
+
if (response.ok) {
|
|
209
|
+
const data = (await response.json());
|
|
210
|
+
const fetchedAt = Date.now();
|
|
211
|
+
for (const [name, info] of Object.entries(data)) {
|
|
212
|
+
if (info && typeof info.downloads === "number") {
|
|
213
|
+
cacheSet(name, { count: info.downloads, fetchedAt });
|
|
214
|
+
writeThrough[name] = info.downloads;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
catch {
|
|
220
|
+
// Non-fatal — individual fetchWeeklyDownloads will retry
|
|
221
|
+
}
|
|
222
|
+
}));
|
|
223
|
+
// Scoped packages are handled separately via prefetchScopedDownloadCounts()
|
|
224
|
+
// because they use the search API on registry.npmjs.org (same H2 connection
|
|
225
|
+
// as metadata) and must run AFTER metadata to avoid stream contention.
|
|
226
|
+
// Write-through to persistent layer for all newly-fetched counts.
|
|
227
|
+
// Scoped packages were already written individually via fetchWeeklyDownloads.
|
|
228
|
+
if (persistence && Object.keys(writeThrough).length > 0) {
|
|
229
|
+
persistence.setMany(writeThrough).catch(() => { });
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* Fetch download counts for scoped packages via npm search API.
|
|
234
|
+
* Uses registry.npmjs.org/-/v1/search (same H2 connection as metadata).
|
|
235
|
+
* Must be called AFTER metadata fetch completes to avoid H2 stream contention.
|
|
236
|
+
* Retries failed packages once after a short delay.
|
|
237
|
+
*/
|
|
238
|
+
async function prefetchScopedDownloadCounts(packageNames) {
|
|
239
|
+
const now = Date.now();
|
|
240
|
+
const scoped = packageNames
|
|
241
|
+
.filter((n) => n.startsWith("@"))
|
|
242
|
+
.filter((name) => {
|
|
243
|
+
const cached = downloadCache.get(name);
|
|
244
|
+
return !(cached && now - cached.fetchedAt < CACHE_TTL_MS);
|
|
245
|
+
});
|
|
246
|
+
if (scoped.length === 0)
|
|
247
|
+
return;
|
|
248
|
+
const writeThrough = {};
|
|
249
|
+
const fetchBatch = async (names) => {
|
|
250
|
+
const urls = names.map((name) => `https://registry.npmjs.org/-/v1/search?text=${encodeURIComponent(name)}&size=1`);
|
|
251
|
+
const responses = await h2pool_1.h2pool.requestAll(urls, {
|
|
252
|
+
headers: { "user-agent": "dependency-guardian" },
|
|
253
|
+
timeout: FETCH_TIMEOUT_MS,
|
|
254
|
+
});
|
|
255
|
+
const fetchedAt = Date.now();
|
|
256
|
+
const failed = [];
|
|
257
|
+
for (let i = 0; i < names.length; i++) {
|
|
258
|
+
try {
|
|
259
|
+
if (responses[i].status === 200 && responses[i].body) {
|
|
260
|
+
const data = JSON.parse(responses[i].body);
|
|
261
|
+
const match = data.objects?.find((o) => o.package.name === names[i]);
|
|
262
|
+
if (match && typeof match.downloads?.weekly === "number") {
|
|
263
|
+
cacheSet(names[i], { count: match.downloads.weekly, fetchedAt });
|
|
264
|
+
writeThrough[names[i]] = match.downloads.weekly;
|
|
265
|
+
continue;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
failed.push(names[i]);
|
|
269
|
+
}
|
|
270
|
+
catch {
|
|
271
|
+
failed.push(names[i]);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
return failed;
|
|
275
|
+
};
|
|
276
|
+
// Fetch in batches of 30 via H2. Retry failures up to 3 times with delays —
|
|
277
|
+
// Cloudflare may throttle the search endpoint under concurrent load.
|
|
278
|
+
const SEARCH_BATCH = 30;
|
|
279
|
+
let remaining = scoped;
|
|
280
|
+
for (let attempt = 0; attempt < 3 && remaining.length > 0; attempt++) {
|
|
281
|
+
if (attempt > 0)
|
|
282
|
+
await new Promise((r) => setTimeout(r, 1000 * attempt));
|
|
283
|
+
const failed = [];
|
|
284
|
+
for (let i = 0; i < remaining.length; i += SEARCH_BATCH) {
|
|
285
|
+
if (i > 0)
|
|
286
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
287
|
+
const batch = remaining.slice(i, i + SEARCH_BATCH);
|
|
288
|
+
const batchFailed = await fetchBatch(batch);
|
|
289
|
+
failed.push(...batchFailed);
|
|
290
|
+
}
|
|
291
|
+
remaining = failed;
|
|
292
|
+
}
|
|
293
|
+
if (persistence && Object.keys(writeThrough).length > 0) {
|
|
294
|
+
persistence.setMany(writeThrough).catch(() => { });
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
function getTarballUrl(meta, version) {
|
|
298
|
+
return meta.versions?.[version]?.dist?.tarball ?? null;
|
|
299
|
+
}
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.downloadAndExtract = downloadAndExtract;
|
|
37
|
+
exports.cleanup = cleanup;
|
|
38
|
+
const logger_1 = require("../logger");
|
|
39
|
+
const crypto = __importStar(require("crypto"));
|
|
40
|
+
const fs = __importStar(require("fs"));
|
|
41
|
+
const path = __importStar(require("path"));
|
|
42
|
+
const os = __importStar(require("os"));
|
|
43
|
+
const https = __importStar(require("https"));
|
|
44
|
+
const tar = __importStar(require("tar"));
|
|
45
|
+
// Size caps. Env-tunable so ops can raise/lower without a rebuild. Defaults
|
|
46
|
+
// chosen to cover 99.5% of published packages (native-binary and ML-model
|
|
47
|
+
// packages can blow past these — see MAX_TARBALL_MB=5000 to scan those too).
|
|
48
|
+
// The streaming download and tar extraction both pipe entries through without
|
|
49
|
+
// buffering the whole archive, so raising these does NOT linearly raise
|
|
50
|
+
// memory — per-file caps bound RSS instead.
|
|
51
|
+
const MAX_TARBALL_BYTES = (() => {
|
|
52
|
+
const mb = parseInt(process.env.MAX_TARBALL_MB || "2000", 10);
|
|
53
|
+
return (Number.isFinite(mb) ? mb : 2000) * 1024 * 1024;
|
|
54
|
+
})(); // default 2 GB — catches kilocode/cli-* and friends, skips only truly pathological archives
|
|
55
|
+
const MAX_EXTRACTED_BYTES = (() => {
|
|
56
|
+
const mb = parseInt(process.env.MAX_EXTRACTED_MB || "4000", 10);
|
|
57
|
+
return (Number.isFinite(mb) ? mb : 4000) * 1024 * 1024;
|
|
58
|
+
})(); // 4 GB — zip-bomb ceiling, 2× the download cap
|
|
59
|
+
const MAX_EXTRACTED_FILES = parseInt(process.env.MAX_EXTRACTED_FILES || "25000", 10);
|
|
60
|
+
// npm packages should never contain these entry types — npm pack strips symlinks,
|
|
61
|
+
// and device/FIFO entries have no legitimate use. Block them to prevent symlink-based
|
|
62
|
+
// path escape attacks (defense-in-depth on top of tar@7's post-CVE mitigations).
|
|
63
|
+
const BLOCKED_ENTRY_TYPES = new Set([
|
|
64
|
+
"SymbolicLink", "Link", "CharacterDevice", "BlockDevice", "FIFO",
|
|
65
|
+
]);
|
|
66
|
+
const MAX_REDIRECTS = 5;
|
|
67
|
+
const ALLOWED_TARBALL_HOSTS = new Set([
|
|
68
|
+
"registry.npmjs.org",
|
|
69
|
+
"registry.yarnpkg.com",
|
|
70
|
+
"registry.npmmirror.com",
|
|
71
|
+
]);
|
|
72
|
+
// Shared agent reuses TCP+TLS connections across downloads (saves ~150ms per non-first request)
|
|
73
|
+
const downloadAgent = new https.Agent({
|
|
74
|
+
keepAlive: true,
|
|
75
|
+
maxSockets: 100,
|
|
76
|
+
maxFreeSockets: 20,
|
|
77
|
+
});
|
|
78
|
+
// Persistent tarball cache — set TARBALL_CACHE_PATH to enable (e.g. /storage/dg-tarball-cache)
|
|
79
|
+
const TARBALL_CACHE_DIR = process.env.TARBALL_CACHE_PATH || "";
|
|
80
|
+
function tarballCacheKey(packageName, version) {
|
|
81
|
+
// safe filename: replace @ and / with double underscore
|
|
82
|
+
return `${packageName.replace(/[@/]/g, "__")}-${version}.tgz`;
|
|
83
|
+
}
|
|
84
|
+
function getCachedTarball(packageName, version) {
|
|
85
|
+
if (!TARBALL_CACHE_DIR)
|
|
86
|
+
return null;
|
|
87
|
+
const cached = path.join(TARBALL_CACHE_DIR, tarballCacheKey(packageName, version));
|
|
88
|
+
try {
|
|
89
|
+
if (fs.existsSync(cached))
|
|
90
|
+
return cached;
|
|
91
|
+
}
|
|
92
|
+
catch { /* ignore */ }
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
function saveTarballToCache(tarPath, packageName, version) {
|
|
96
|
+
if (!TARBALL_CACHE_DIR)
|
|
97
|
+
return;
|
|
98
|
+
try {
|
|
99
|
+
fs.mkdirSync(TARBALL_CACHE_DIR, { recursive: true });
|
|
100
|
+
const dest = path.join(TARBALL_CACHE_DIR, tarballCacheKey(packageName, version));
|
|
101
|
+
fs.copyFileSync(tarPath, dest);
|
|
102
|
+
}
|
|
103
|
+
catch { /* best effort */ }
|
|
104
|
+
}
|
|
105
|
+
async function downloadAndExtract(tarballUrl, packageName, version, expectedShasum) {
|
|
106
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "dg-"));
|
|
107
|
+
const tarPath = path.join(tmpDir, "pkg.tgz");
|
|
108
|
+
const extractDir = path.join(tmpDir, "extracted");
|
|
109
|
+
fs.mkdirSync(extractDir, { recursive: true });
|
|
110
|
+
try {
|
|
111
|
+
// Check persistent tarball cache first
|
|
112
|
+
const cached = getCachedTarball(packageName, version);
|
|
113
|
+
if (cached) {
|
|
114
|
+
fs.copyFileSync(cached, tarPath);
|
|
115
|
+
}
|
|
116
|
+
else {
|
|
117
|
+
await downloadToFile(tarballUrl, tarPath);
|
|
118
|
+
saveTarballToCache(tarPath, packageName, version);
|
|
119
|
+
}
|
|
120
|
+
if (expectedShasum) {
|
|
121
|
+
const hash = crypto.createHash("sha1");
|
|
122
|
+
const stream = fs.createReadStream(tarPath);
|
|
123
|
+
for await (const chunk of stream)
|
|
124
|
+
hash.update(chunk);
|
|
125
|
+
const actual = hash.digest("hex");
|
|
126
|
+
if (actual !== expectedShasum) {
|
|
127
|
+
logger_1.logger.warning(`Hash mismatch for ${packageName}@${version}: expected ${expectedShasum}, got ${actual}`);
|
|
128
|
+
cleanup(tmpDir);
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
const tarballSizeBytes = fs.statSync(tarPath).size;
|
|
133
|
+
let extractedBytes = 0;
|
|
134
|
+
let extractedFiles = 0;
|
|
135
|
+
let extractionAborted = false;
|
|
136
|
+
await tar.extract({
|
|
137
|
+
file: tarPath,
|
|
138
|
+
cwd: extractDir,
|
|
139
|
+
filter: (entryPath, entry) => {
|
|
140
|
+
if (extractionAborted)
|
|
141
|
+
return false;
|
|
142
|
+
// Block symlink/hardlink/device/FIFO entries — these have no place in
|
|
143
|
+
// npm packages and can be used for path escape attacks.
|
|
144
|
+
if ("type" in entry && BLOCKED_ENTRY_TYPES.has(entry.type)) {
|
|
145
|
+
const re = entry;
|
|
146
|
+
logger_1.logger.warning(`Blocked ${re.type} entry in ${packageName}@${version}: ${entryPath}${re.linkpath ? ` -> ${re.linkpath}` : ""}`);
|
|
147
|
+
return false;
|
|
148
|
+
}
|
|
149
|
+
if (entryPath.startsWith("/") || entryPath.includes("..")) {
|
|
150
|
+
logger_1.logger.warning(`Blocked path traversal in ${packageName}@${version}: ${entryPath}`);
|
|
151
|
+
return false;
|
|
152
|
+
}
|
|
153
|
+
extractedBytes += entry.size;
|
|
154
|
+
if (extractedBytes > MAX_EXTRACTED_BYTES) {
|
|
155
|
+
extractionAborted = true;
|
|
156
|
+
logger_1.logger.warning(`Extracted content for ${packageName}@${version} exceeds 200MB — aborting (zip bomb protection)`);
|
|
157
|
+
return false;
|
|
158
|
+
}
|
|
159
|
+
extractedFiles++;
|
|
160
|
+
if (extractedFiles > MAX_EXTRACTED_FILES) {
|
|
161
|
+
extractionAborted = true;
|
|
162
|
+
logger_1.logger.warning(`File count for ${packageName}@${version} exceeds ${MAX_EXTRACTED_FILES} — aborting (many-file DoS protection)`);
|
|
163
|
+
return false;
|
|
164
|
+
}
|
|
165
|
+
return true;
|
|
166
|
+
},
|
|
167
|
+
});
|
|
168
|
+
if (extractionAborted) {
|
|
169
|
+
cleanup(tmpDir);
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
const packageDir = path.join(extractDir, "package");
|
|
173
|
+
if (fs.existsSync(packageDir)) {
|
|
174
|
+
return { packageDir, rootTmpDir: tmpDir, tarballSizeBytes, tarballPath: tarPath };
|
|
175
|
+
}
|
|
176
|
+
return { packageDir: extractDir, rootTmpDir: tmpDir, tarballSizeBytes, tarballPath: tarPath };
|
|
177
|
+
}
|
|
178
|
+
catch (err) {
|
|
179
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
180
|
+
logger_1.logger.warning(`Failed to download/extract ${packageName}@${version}: ${msg}`);
|
|
181
|
+
cleanup(tmpDir);
|
|
182
|
+
return null;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
function validateTarballUrl(url) {
|
|
186
|
+
let parsed;
|
|
187
|
+
try {
|
|
188
|
+
parsed = new URL(url);
|
|
189
|
+
}
|
|
190
|
+
catch {
|
|
191
|
+
throw new Error(`Invalid tarball URL: ${url}`);
|
|
192
|
+
}
|
|
193
|
+
if (parsed.protocol !== "https:") {
|
|
194
|
+
throw new Error(`Tarball URL must use HTTPS: ${url}`);
|
|
195
|
+
}
|
|
196
|
+
if (!ALLOWED_TARBALL_HOSTS.has(parsed.hostname)) {
|
|
197
|
+
throw new Error(`Tarball host not allowed: ${parsed.hostname}`);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
function downloadToFile(url, destPath, redirectsRemaining = MAX_REDIRECTS) {
|
|
201
|
+
return new Promise((resolve, reject) => {
|
|
202
|
+
try {
|
|
203
|
+
validateTarballUrl(url);
|
|
204
|
+
}
|
|
205
|
+
catch (err) {
|
|
206
|
+
reject(err);
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
https
|
|
210
|
+
.get(url, { agent: downloadAgent }, (response) => {
|
|
211
|
+
if ((response.statusCode === 301 || response.statusCode === 302) &&
|
|
212
|
+
response.headers.location) {
|
|
213
|
+
if (redirectsRemaining <= 0) {
|
|
214
|
+
reject(new Error(`Too many redirects for tarball download`));
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
response.resume(); // consume redirect body to free socket
|
|
218
|
+
downloadToFile(response.headers.location, destPath, redirectsRemaining - 1)
|
|
219
|
+
.then(resolve)
|
|
220
|
+
.catch(reject);
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
if (response.statusCode !== 200) {
|
|
224
|
+
reject(new Error(`HTTP ${response.statusCode} for ${url}`));
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
// Defense-in-depth: enforce size limit during streaming, not just after download
|
|
228
|
+
let bytesReceived = 0;
|
|
229
|
+
let aborted = false;
|
|
230
|
+
const file = fs.createWriteStream(destPath);
|
|
231
|
+
response.on("data", (chunk) => {
|
|
232
|
+
bytesReceived += chunk.length;
|
|
233
|
+
if (!aborted && bytesReceived > MAX_TARBALL_BYTES) {
|
|
234
|
+
aborted = true;
|
|
235
|
+
response.destroy();
|
|
236
|
+
file.destroy();
|
|
237
|
+
try {
|
|
238
|
+
fs.unlinkSync(destPath);
|
|
239
|
+
}
|
|
240
|
+
catch { }
|
|
241
|
+
reject(new Error(`Tarball download exceeds ${MAX_TARBALL_BYTES / 1024 / 1024}MB limit during streaming — aborting`));
|
|
242
|
+
}
|
|
243
|
+
});
|
|
244
|
+
response.pipe(file);
|
|
245
|
+
file.on("finish", () => {
|
|
246
|
+
if (!aborted) {
|
|
247
|
+
file.close();
|
|
248
|
+
resolve();
|
|
249
|
+
}
|
|
250
|
+
});
|
|
251
|
+
file.on("error", (err) => {
|
|
252
|
+
if (!aborted) {
|
|
253
|
+
try {
|
|
254
|
+
fs.unlinkSync(destPath);
|
|
255
|
+
}
|
|
256
|
+
catch { }
|
|
257
|
+
reject(err);
|
|
258
|
+
}
|
|
259
|
+
});
|
|
260
|
+
})
|
|
261
|
+
.on("error", reject);
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
function cleanup(dir) {
|
|
265
|
+
// Synchronous delete — blocks briefly but guarantees the temp dir is removed.
|
|
266
|
+
// The previous async fire-and-forget caused a leak: 4,022 orphaned /tmp/dg-*
|
|
267
|
+
// dirs (44GB) because processes restarted before fs.rm completed.
|
|
268
|
+
try {
|
|
269
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
270
|
+
}
|
|
271
|
+
catch {
|
|
272
|
+
// Non-fatal — prefer moving forward over blocking on cleanup errors
|
|
273
|
+
}
|
|
274
|
+
}
|