@itsl-solutions/npm-registry-shield 0.1.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.
package/src/server.ts ADDED
@@ -0,0 +1,366 @@
1
+ import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
2
+ import { watch as fsWatch, type FSWatcher } from "node:fs";
3
+ import { CONFIG_PATH, loadConfig, type ShieldConfig } from "./config.js";
4
+ import { filterPackument, isVersionQuarantined } from "./filter.js";
5
+ import {
6
+ clearCache,
7
+ fetchPackument,
8
+ fetchVersionMetadata,
9
+ getCachedPackument,
10
+ setCachedPackument,
11
+ proxyRequest,
12
+ } from "./registry.js";
13
+ import { getStats, recordRequest, recordWarning, startStatsFlush, stopStatsFlush } from "./stats.js";
14
+ import { getDashboardHtml } from "./dashboard.js";
15
+ import {
16
+ detectToolFromUserAgent,
17
+ installOriginAttribution,
18
+ readOrigin,
19
+ } from "./origin.js";
20
+
21
+ type HttpServer = ReturnType<typeof createServer>;
22
+
23
+ const NON_PACKAGE_PATHS = new Set([
24
+ "favicon.ico",
25
+ ".well-known",
26
+ "robots.txt",
27
+ "sitemap.xml",
28
+ ]);
29
+
30
+ function isTarballPath(pathname: string): boolean {
31
+ return /^\/(?:@[^/]+\/)?[^/]+\/-\/.+$/.test(pathname);
32
+ }
33
+
34
+ function isPackageSubresourcePath(pathname: string): boolean {
35
+ return (
36
+ /^\/(?:@[^/]+\/)?[^/]+\/dist-tags(?:\/.*)?$/.test(pathname) ||
37
+ /^\/(?:@[^/]+\/)?[^/]+\/-rev\/.+$/.test(pathname)
38
+ );
39
+ }
40
+
41
+ function parsePackagePath(
42
+ pathname: string
43
+ ): { packageName: string; version: string | null } | null {
44
+ if (pathname.startsWith("/_/") || pathname.startsWith("/-/")) {
45
+ return null;
46
+ }
47
+
48
+ if (isTarballPath(pathname) || isPackageSubresourcePath(pathname)) {
49
+ return null;
50
+ }
51
+
52
+ let decoded: string;
53
+ try {
54
+ decoded = decodeURIComponent(pathname);
55
+ } catch {
56
+ decoded = pathname;
57
+ }
58
+
59
+ const parts = decoded.slice(1).split("/");
60
+
61
+ if (NON_PACKAGE_PATHS.has(parts[0])) {
62
+ return null;
63
+ }
64
+
65
+ if (parts[0]?.startsWith("@") && parts[0].length > 1 && parts.length >= 2 && parts[1]) {
66
+ const packageName = `${parts[0]}/${parts[1]}`;
67
+ const version = parts[2] || null;
68
+ return { packageName, version };
69
+ }
70
+
71
+ if (parts[0] && !parts[0].startsWith("-") && !parts[0].startsWith(".")) {
72
+ const packageName = parts[0];
73
+ const version = parts[1] || null;
74
+ return { packageName, version };
75
+ }
76
+
77
+ return null;
78
+ }
79
+
80
+ function getRequestHeaders(req: IncomingMessage): Record<string, string> {
81
+ const headers: Record<string, string> = {};
82
+ for (const [key, value] of Object.entries(req.headers)) {
83
+ if (typeof value === "string") {
84
+ headers[key] = value;
85
+ }
86
+ }
87
+ return headers;
88
+ }
89
+
90
+ async function readRequestBody(req: IncomingMessage): Promise<Buffer> {
91
+ const chunks: Buffer[] = [];
92
+ for await (const chunk of req) {
93
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
94
+ }
95
+ return Buffer.concat(chunks);
96
+ }
97
+
98
+ async function handlePackument(
99
+ packageName: string,
100
+ config: ShieldConfig,
101
+ req: IncomingMessage,
102
+ res: ServerResponse
103
+ ): Promise<void> {
104
+ const origin = readOrigin(req.socket);
105
+ recordRequest(
106
+ packageName,
107
+ origin,
108
+ detectToolFromUserAgent(req.headers["user-agent"])
109
+ );
110
+
111
+ let packument = getCachedPackument(packageName, config.cacheTtlMinutes);
112
+ const fromCache = packument !== null;
113
+ if (!packument) {
114
+ packument = await fetchPackument(packageName, config.upstream);
115
+ if (!packument) {
116
+ res.writeHead(404, { "content-type": "application/json" });
117
+ res.end(JSON.stringify({ error: `Package ${packageName} not found` }));
118
+ return;
119
+ }
120
+ setCachedPackument(packageName, packument);
121
+ }
122
+
123
+ const result = filterPackument(packument, config);
124
+
125
+ if (result.blocked) {
126
+ const msg = JSON.stringify({
127
+ error: `All versions of ${packageName} are within the quarantine period (${config.quarantineDays} days). Use 'npm-registry-shield allow ${packageName}' to bypass.`,
128
+ });
129
+ res.writeHead(404, { "content-type": "application/json" });
130
+ res.end(msg);
131
+ console.log(
132
+ `[npm-registry-shield] BLOCKED ${packageName} - all ${result.filteredCount} versions quarantined`
133
+ );
134
+ return;
135
+ }
136
+
137
+ if (result.filtered && !fromCache) {
138
+ console.log(
139
+ `[npm-registry-shield] ${packageName} - filtered ${result.filteredCount} recent version(s), serving ${Object.keys(result.packument.versions).length} version(s)`
140
+ );
141
+ }
142
+
143
+ const body = JSON.stringify(result.packument);
144
+ res.writeHead(200, { "content-type": "application/json" });
145
+ res.end(body);
146
+ }
147
+
148
+ async function handleSpecificVersion(
149
+ packageName: string,
150
+ version: string,
151
+ config: ShieldConfig,
152
+ req: IncomingMessage,
153
+ res: ServerResponse
154
+ ): Promise<void> {
155
+ const origin = readOrigin(req.socket);
156
+ recordRequest(
157
+ packageName,
158
+ origin,
159
+ detectToolFromUserAgent(req.headers["user-agent"])
160
+ );
161
+
162
+ const upstream = await fetchVersionMetadata(
163
+ packageName,
164
+ version,
165
+ config.upstream
166
+ );
167
+
168
+ if (upstream.status === 200) {
169
+ try {
170
+ const meta = JSON.parse(upstream.body);
171
+ let packument = getCachedPackument(packageName, config.cacheTtlMinutes);
172
+ if (!packument) {
173
+ try {
174
+ packument = await fetchPackument(packageName, config.upstream);
175
+ if (packument) setCachedPackument(packageName, packument);
176
+ } catch {
177
+ packument = null;
178
+ }
179
+ }
180
+ const publishedAt = packument?.time?.[version];
181
+
182
+ if (publishedAt && isVersionQuarantined(publishedAt, config.quarantineDays)) {
183
+ console.warn(
184
+ `[npm-registry-shield] WARNING: Serving quarantined version ${packageName}@${version} (specific version request)`
185
+ );
186
+ recordWarning(packageName);
187
+ }
188
+
189
+ res.writeHead(200, { "content-type": upstream.contentType });
190
+ res.end(JSON.stringify(meta));
191
+ } catch {
192
+ res.writeHead(upstream.status, { "content-type": upstream.contentType });
193
+ res.end(upstream.body);
194
+ }
195
+ } else {
196
+ res.writeHead(upstream.status, { "content-type": upstream.contentType });
197
+ res.end(upstream.body);
198
+ }
199
+ }
200
+
201
+ async function handleRequest(
202
+ req: IncomingMessage,
203
+ res: ServerResponse,
204
+ config: ShieldConfig
205
+ ): Promise<void> {
206
+ const url = new URL(req.url || "/", `http://localhost:${config.port}`);
207
+ const pathname = url.pathname;
208
+ const method = req.method || "GET";
209
+
210
+ const acceptsHtml = (req.headers.accept || "").includes("text/html");
211
+
212
+ if (config.dashboard && method === "GET" && pathname === "/" && acceptsHtml) {
213
+ res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
214
+ res.end(getDashboardHtml());
215
+ return;
216
+ }
217
+
218
+ if (method === "GET" && acceptsHtml && pathname !== "/") {
219
+ res.writeHead(302, { location: "/" });
220
+ res.end();
221
+ return;
222
+ }
223
+
224
+ if (config.dashboard && pathname === "/_/stats.json") {
225
+ const stats = getStats();
226
+ res.writeHead(200, {
227
+ "content-type": "application/json",
228
+ "access-control-allow-origin": "*",
229
+ });
230
+ res.end(JSON.stringify(stats));
231
+ return;
232
+ }
233
+
234
+ if (config.dashboard && pathname === "/_/config.json") {
235
+ res.writeHead(200, {
236
+ "content-type": "application/json",
237
+ "access-control-allow-origin": "*",
238
+ });
239
+ res.end(JSON.stringify({
240
+ quarantineDays: config.quarantineDays,
241
+ passthrough: config.passthrough,
242
+ upstream: config.upstream,
243
+ }));
244
+ return;
245
+ }
246
+
247
+ if (method !== "GET") {
248
+ const body = await readRequestBody(req);
249
+ const result = await proxyRequest(
250
+ method,
251
+ req.url || "/",
252
+ config.upstream,
253
+ getRequestHeaders(req),
254
+ body
255
+ );
256
+ res.writeHead(result.status, result.headers);
257
+ res.end(result.body);
258
+ return;
259
+ }
260
+
261
+ const parsed = parsePackagePath(pathname);
262
+
263
+ if (parsed) {
264
+ if (parsed.version) {
265
+ await handleSpecificVersion(
266
+ parsed.packageName,
267
+ parsed.version,
268
+ config,
269
+ req,
270
+ res
271
+ );
272
+ return;
273
+ }
274
+
275
+ await handlePackument(parsed.packageName, config, req, res);
276
+ return;
277
+ }
278
+
279
+ const result = await proxyRequest(
280
+ method,
281
+ req.url || "/",
282
+ config.upstream,
283
+ getRequestHeaders(req)
284
+ );
285
+ res.writeHead(result.status, result.headers);
286
+ res.end(result.body);
287
+ }
288
+
289
+ type ShieldServer = HttpServer & { __configWatcher?: FSWatcher };
290
+
291
+ export function startServer(initialConfig: ShieldConfig): HttpServer {
292
+ startStatsFlush();
293
+
294
+ let config = initialConfig;
295
+ const port = initialConfig.port;
296
+
297
+ const server = createServer((req, res) => {
298
+ handleRequest(req, res, config).catch((err) => {
299
+ const message = err instanceof Error ? err.message : "Unknown error";
300
+ console.error(`[npm-registry-shield] Error handling ${req.url}: ${message}`);
301
+ if (!res.headersSent) {
302
+ res.writeHead(502, { "content-type": "application/json" });
303
+ res.end(JSON.stringify({ error: `Proxy error: ${message}` }));
304
+ }
305
+ });
306
+ }) as ShieldServer;
307
+
308
+ installOriginAttribution(server, port);
309
+
310
+ let reloadTimer: ReturnType<typeof setTimeout> | null = null;
311
+ const watcher = fsWatch(CONFIG_PATH, () => {
312
+ if (reloadTimer) clearTimeout(reloadTimer);
313
+ reloadTimer = setTimeout(() => {
314
+ try {
315
+ const next = loadConfig();
316
+ const passthroughChanged =
317
+ next.passthrough.join("|") !== config.passthrough.join("|");
318
+ const quarantineChanged = next.quarantineDays !== config.quarantineDays;
319
+ config = next;
320
+ if (passthroughChanged || quarantineChanged) {
321
+ clearCache();
322
+ console.log("[npm-registry-shield] Config changed - cache cleared, new rules active");
323
+ }
324
+ } catch (err) {
325
+ console.error(`[npm-registry-shield] Failed to reload config: ${(err as Error).message}`);
326
+ }
327
+ }, 100);
328
+ });
329
+ watcher.on("error", () => {});
330
+ server.__configWatcher = watcher;
331
+
332
+ server.on("error", (err: NodeJS.ErrnoException) => {
333
+ if (err.code === "EADDRINUSE") {
334
+ console.error(`[npm-registry-shield] Port ${port} is already in use. Is another instance running?`);
335
+ } else {
336
+ console.error(`[npm-registry-shield] Server error: ${err.message}`);
337
+ }
338
+ process.exit(1);
339
+ });
340
+
341
+ server.listen(port, "127.0.0.1", () => {
342
+ console.log(`[npm-registry-shield] Proxy running on http://127.0.0.1:${port} (loopback only)`);
343
+ console.log(`[npm-registry-shield] Upstream: ${config.upstream}`);
344
+ console.log(`[npm-registry-shield] Quarantine: ${config.quarantineDays} days`);
345
+ if (config.dashboard) {
346
+ console.log(`[npm-registry-shield] Dashboard: http://localhost:${port}/`);
347
+ }
348
+ if (config.passthrough.length > 0) {
349
+ console.log(`[npm-registry-shield] Passthrough: ${config.passthrough.join(", ")}`);
350
+ }
351
+ });
352
+
353
+ return server;
354
+ }
355
+
356
+ export function stopServer(server: HttpServer): Promise<void> {
357
+ stopStatsFlush();
358
+ const watcher = (server as ShieldServer).__configWatcher;
359
+ if (watcher) watcher.close();
360
+ return new Promise((resolve, reject) => {
361
+ server.close((err) => {
362
+ if (err) reject(err);
363
+ else resolve();
364
+ });
365
+ });
366
+ }
package/src/stats.ts ADDED
@@ -0,0 +1,235 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { CONFIG_DIR } from "./config.js";
4
+
5
+ export interface PackageStats {
6
+ requests: number;
7
+ versionsFiltered: number;
8
+ blocked: number;
9
+ warnings: number;
10
+ lastChecked: string;
11
+ origins?: Record<string, number>;
12
+ tools?: Record<string, number>;
13
+ }
14
+
15
+ export interface Stats {
16
+ totalRequests: number;
17
+ totalFiltered: number;
18
+ totalBlocked: number;
19
+ totalWarnings: number;
20
+ since: string;
21
+ packages: Record<string, PackageStats>;
22
+ }
23
+
24
+ const STATS_PATH = join(CONFIG_DIR, "stats.json");
25
+ const FLUSH_INTERVAL_MS = 30_000;
26
+ const MAX_ORIGINS_PER_PKG = 20;
27
+ const MAX_TOOLS_PER_PKG = 10;
28
+
29
+ let stats: Stats = createEmpty();
30
+ let flushTimer: ReturnType<typeof setInterval> | null = null;
31
+
32
+ function createEmpty(): Stats {
33
+ return {
34
+ totalRequests: 0,
35
+ totalFiltered: 0,
36
+ totalBlocked: 0,
37
+ totalWarnings: 0,
38
+ since: new Date().toISOString(),
39
+ packages: {},
40
+ };
41
+ }
42
+
43
+ function ensurePkg(name: string): PackageStats {
44
+ if (!stats.packages[name]) {
45
+ stats.packages[name] = {
46
+ requests: 0,
47
+ versionsFiltered: 0,
48
+ blocked: 0,
49
+ warnings: 0,
50
+ lastChecked: new Date().toISOString(),
51
+ };
52
+ }
53
+ return stats.packages[name];
54
+ }
55
+
56
+ export function recordRequest(
57
+ packageName: string,
58
+ origin?: { cwd?: string | null; root?: string | null } | null,
59
+ tool?: string
60
+ ): void {
61
+ stats.totalRequests++;
62
+ const pkg = ensurePkg(packageName);
63
+ pkg.requests++;
64
+ pkg.lastChecked = new Date().toISOString();
65
+
66
+ if (origin?.cwd) {
67
+ const key = origin.root ? `${origin.cwd} (${origin.root})` : origin.cwd;
68
+ if (!pkg.origins) pkg.origins = {};
69
+ pkg.origins[key] = (pkg.origins[key] || 0) + 1;
70
+ capRecord(pkg.origins, MAX_ORIGINS_PER_PKG);
71
+ }
72
+
73
+ if (tool) {
74
+ if (!pkg.tools) pkg.tools = {};
75
+ pkg.tools[tool] = (pkg.tools[tool] || 0) + 1;
76
+ capRecord(pkg.tools, MAX_TOOLS_PER_PKG);
77
+ }
78
+ }
79
+
80
+ function capRecord(rec: Record<string, number>, max: number): void {
81
+ const keys = Object.keys(rec);
82
+ if (keys.length <= max) return;
83
+ const sorted = keys.sort((a, b) => rec[a]! - rec[b]!);
84
+ const drop = sorted.length - max;
85
+ for (let i = 0; i < drop; i++) {
86
+ delete rec[sorted[i]!];
87
+ }
88
+ }
89
+
90
+ export function recordFiltered(packageName: string, count: number): void {
91
+ stats.totalFiltered += count;
92
+ const pkg = ensurePkg(packageName);
93
+ pkg.versionsFiltered += count;
94
+ }
95
+
96
+ export function recordBlocked(packageName: string): void {
97
+ stats.totalBlocked++;
98
+ const pkg = ensurePkg(packageName);
99
+ pkg.blocked++;
100
+ }
101
+
102
+ export function recordWarning(packageName: string): void {
103
+ stats.totalWarnings++;
104
+ const pkg = ensurePkg(packageName);
105
+ pkg.warnings++;
106
+ }
107
+
108
+ export function getStats(): Stats {
109
+ return stats;
110
+ }
111
+
112
+ export function flushStats(): void {
113
+ if (!existsSync(CONFIG_DIR)) {
114
+ mkdirSync(CONFIG_DIR, { recursive: true });
115
+ }
116
+ const tmpPath = STATS_PATH + ".tmp";
117
+ writeFileSync(tmpPath, JSON.stringify(stats, null, 2) + "\n", "utf-8");
118
+ renameSync(tmpPath, STATS_PATH);
119
+ }
120
+
121
+ export function loadStats(): void {
122
+ if (existsSync(STATS_PATH)) {
123
+ try {
124
+ const raw = readFileSync(STATS_PATH, "utf-8");
125
+ const loaded = JSON.parse(raw) as Stats;
126
+ stats = { ...createEmpty(), ...loaded };
127
+ } catch {
128
+ stats = createEmpty();
129
+ }
130
+ } else {
131
+ stats = createEmpty();
132
+ }
133
+ }
134
+
135
+ export function startStatsFlush(): void {
136
+ loadStats();
137
+ flushTimer = setInterval(flushStats, FLUSH_INTERVAL_MS);
138
+ }
139
+
140
+ export function stopStatsFlush(): void {
141
+ if (flushTimer) {
142
+ clearInterval(flushTimer);
143
+ flushTimer = null;
144
+ }
145
+ flushStats();
146
+ }
147
+
148
+ function formatSince(iso: string): string {
149
+ try {
150
+ const d = new Date(iso);
151
+ return d.toLocaleString();
152
+ } catch {
153
+ return iso;
154
+ }
155
+ }
156
+
157
+ export function formatStatsForConsole(): string {
158
+ const lines: string[] = [
159
+ `npm-registry-shield - since ${formatSince(stats.since)}`,
160
+ "",
161
+ ` Requests ${String(stats.totalRequests).padStart(8)}`,
162
+ ` Hidden vers. ${String(stats.totalFiltered).padStart(8)}`,
163
+ ` Blocked ${String(stats.totalBlocked).padStart(8)}`,
164
+ ` Warnings ${String(stats.totalWarnings).padStart(8)}`,
165
+ ];
166
+
167
+ const pkgEntries = Object.entries(stats.packages);
168
+ if (pkgEntries.length === 0) {
169
+ return lines.join("\n");
170
+ }
171
+
172
+ const sorted = pkgEntries.sort((a, b) => b[1].requests - a[1].requests);
173
+ const top = sorted.slice(0, 20);
174
+ const nameWidth = Math.max(7, ...top.map(([name]) => name.length));
175
+ const numCols: Array<{ header: string; key: keyof PackageStats }> = [
176
+ { header: "Requests", key: "requests" },
177
+ { header: "Hidden", key: "versionsFiltered" },
178
+ { header: "Blocked", key: "blocked" },
179
+ { header: "Warnings", key: "warnings" },
180
+ ];
181
+ const colWidth = 10;
182
+
183
+ const header =
184
+ " " +
185
+ "Package".padEnd(nameWidth) +
186
+ numCols.map((c) => c.header.padStart(colWidth)).join("");
187
+ const divider = " " + "-".repeat(nameWidth + colWidth * numCols.length);
188
+
189
+ lines.push("", header, divider);
190
+
191
+ for (const [name, pkg] of top) {
192
+ const row =
193
+ " " +
194
+ name.padEnd(nameWidth) +
195
+ numCols
196
+ .map((c) => {
197
+ const v = pkg[c.key];
198
+ return String(typeof v === "number" ? v : 0).padStart(colWidth);
199
+ })
200
+ .join("");
201
+ lines.push(row);
202
+
203
+ if (pkg.tools && Object.keys(pkg.tools).length > 0) {
204
+ const tools = Object.entries(pkg.tools)
205
+ .sort((a, b) => b[1] - a[1])
206
+ .map(([t, c]) => `${t} x${c}`)
207
+ .join(", ");
208
+ lines.push(` ${" ".repeat(nameWidth)} via: ${tools}`);
209
+ }
210
+
211
+ if (pkg.origins && Object.keys(pkg.origins).length > 0) {
212
+ const origins = Object.entries(pkg.origins)
213
+ .sort((a, b) => b[1] - a[1])
214
+ .slice(0, 5);
215
+ for (const [path, count] of origins) {
216
+ lines.push(` ${" ".repeat(nameWidth)} ${path} (${count})`);
217
+ }
218
+ }
219
+ }
220
+
221
+ if (sorted.length > top.length) {
222
+ lines.push(` ... and ${sorted.length - top.length} more`);
223
+ }
224
+
225
+ lines.push(
226
+ "",
227
+ " Legend:",
228
+ " Requests - proxy hits for this package's metadata",
229
+ " Hidden - quarantined versions stripped from served responses (cumulative across requests)",
230
+ " Blocked - times every version was quarantined and the install was refused (404)",
231
+ " Warnings - times a quarantined version was served anyway (passthrough rule or specific-version request)"
232
+ );
233
+
234
+ return lines.join("\n");
235
+ }