@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/filter.ts ADDED
@@ -0,0 +1,186 @@
1
+ import type { ShieldConfig } from "./config.js";
2
+ import { recordFiltered, recordBlocked, recordWarning } from "./stats.js";
3
+
4
+ export interface Packument {
5
+ name: string;
6
+ "dist-tags": Record<string, string>;
7
+ versions: Record<string, Record<string, unknown>>;
8
+ time: Record<string, string>;
9
+ [key: string]: unknown;
10
+ }
11
+
12
+ export interface FilterResult {
13
+ packument: Packument;
14
+ filtered: boolean;
15
+ filteredCount: number;
16
+ blocked: boolean;
17
+ warned: boolean;
18
+ }
19
+
20
+ export function matchPassthrough(
21
+ packageName: string,
22
+ passthrough: string[]
23
+ ): { match: boolean; versionSpecific: string[] } {
24
+ const versionSpecific: string[] = [];
25
+ let fullMatch = false;
26
+
27
+ for (const entry of passthrough) {
28
+ const atIndex = entry.lastIndexOf("@");
29
+ const hasVersionPin = atIndex > 0 && !entry.startsWith("@", atIndex - 1);
30
+
31
+ if (hasVersionPin) {
32
+ const entryPkg = entry.substring(0, atIndex);
33
+ const entryVersion = entry.substring(atIndex + 1);
34
+ if (entryPkg === packageName) {
35
+ versionSpecific.push(entryVersion);
36
+ }
37
+ continue;
38
+ }
39
+
40
+ if (entry === packageName) {
41
+ fullMatch = true;
42
+ break;
43
+ }
44
+
45
+ if (entry.endsWith("/*") && packageName.startsWith(entry.slice(0, -1))) {
46
+ fullMatch = true;
47
+ break;
48
+ }
49
+ }
50
+
51
+ return { match: fullMatch, versionSpecific };
52
+ }
53
+
54
+ export function filterPackument(
55
+ packument: Packument,
56
+ config: ShieldConfig
57
+ ): FilterResult {
58
+ const { match, versionSpecific } = matchPassthrough(
59
+ packument.name,
60
+ config.passthrough
61
+ );
62
+
63
+ if (match) {
64
+ return {
65
+ packument,
66
+ filtered: false,
67
+ filteredCount: 0,
68
+ blocked: false,
69
+ warned: false,
70
+ };
71
+ }
72
+
73
+ const now = Date.now();
74
+ const quarantineMs = config.quarantineDays * 24 * 60 * 60 * 1000;
75
+ const filteredVersions: Record<string, Record<string, unknown>> = {};
76
+ let filteredCount = 0;
77
+ let warned = false;
78
+
79
+ for (const [version, meta] of Object.entries(packument.versions)) {
80
+ const publishedAt = packument.time[version];
81
+ if (!publishedAt) {
82
+ filteredVersions[version] = meta;
83
+ continue;
84
+ }
85
+
86
+ const age = now - new Date(publishedAt).getTime();
87
+ const isQuarantined = age < quarantineMs;
88
+
89
+ if (!isQuarantined) {
90
+ filteredVersions[version] = meta;
91
+ continue;
92
+ }
93
+
94
+ if (versionSpecific.includes(version)) {
95
+ filteredVersions[version] = meta;
96
+ warned = true;
97
+ console.warn(
98
+ `[npm-registry-shield] WARNING: Allowing quarantined version ${packument.name}@${version} (published ${formatAge(age)} ago, quarantine is ${config.quarantineDays}d)`
99
+ );
100
+ recordWarning(packument.name);
101
+ continue;
102
+ }
103
+
104
+ filteredCount++;
105
+ }
106
+
107
+ if (Object.keys(filteredVersions).length === 0) {
108
+ recordBlocked(packument.name);
109
+ return {
110
+ packument,
111
+ filtered: true,
112
+ filteredCount,
113
+ blocked: true,
114
+ warned,
115
+ };
116
+ }
117
+
118
+ const filtered: Packument = {
119
+ ...packument,
120
+ versions: filteredVersions,
121
+ time: { ...packument.time },
122
+ };
123
+
124
+ const newDistTags: Record<string, string> = {};
125
+ const survivingVersions = Object.keys(filteredVersions);
126
+
127
+ for (const [tag, version] of Object.entries(packument["dist-tags"])) {
128
+ if (version in filteredVersions) {
129
+ newDistTags[tag] = version;
130
+ } else {
131
+ const newest = findNewest(survivingVersions, packument.time);
132
+ if (newest) {
133
+ newDistTags[tag] = newest;
134
+ }
135
+ }
136
+ }
137
+
138
+ filtered["dist-tags"] = newDistTags;
139
+
140
+ if (filteredCount > 0) {
141
+ recordFiltered(packument.name, filteredCount);
142
+ }
143
+
144
+ return {
145
+ packument: filtered,
146
+ filtered: filteredCount > 0,
147
+ filteredCount,
148
+ blocked: false,
149
+ warned,
150
+ };
151
+ }
152
+
153
+ export function isVersionQuarantined(
154
+ publishedAt: string,
155
+ quarantineDays: number
156
+ ): boolean {
157
+ const age = Date.now() - new Date(publishedAt).getTime();
158
+ return age < quarantineDays * 24 * 60 * 60 * 1000;
159
+ }
160
+
161
+ function findNewest(
162
+ versions: string[],
163
+ time: Record<string, string>
164
+ ): string | null {
165
+ let newest: string | null = null;
166
+ let newestTime = 0;
167
+
168
+ for (const v of versions) {
169
+ const t = time[v];
170
+ if (!t) continue;
171
+ const ts = new Date(t).getTime();
172
+ if (ts > newestTime) {
173
+ newestTime = ts;
174
+ newest = v;
175
+ }
176
+ }
177
+
178
+ return newest;
179
+ }
180
+
181
+ function formatAge(ms: number): string {
182
+ const hours = Math.floor(ms / (1000 * 60 * 60));
183
+ if (hours < 24) return `${hours}h`;
184
+ const days = Math.floor(hours / 24);
185
+ return `${days}d ${hours % 24}h`;
186
+ }
package/src/origin.ts ADDED
@@ -0,0 +1,130 @@
1
+ import { execFile } from "node:child_process";
2
+ import { platform } from "node:os";
3
+ import type { Socket } from "node:net";
4
+
5
+ export interface Origin {
6
+ pid: number;
7
+ command: string;
8
+ cwd: string | null;
9
+ root: string | null;
10
+ }
11
+
12
+ const SUPPORTED = platform() === "darwin";
13
+ const PID_CACHE = new Map<number, { value: Origin | null; expiresAt: number }>();
14
+ const PID_TTL_MS = 60_000;
15
+
16
+ function run(cmd: string, args: string[], timeoutMs = 500): Promise<string> {
17
+ return new Promise((resolve) => {
18
+ execFile(cmd, args, { timeout: timeoutMs, encoding: "utf-8" }, (err, stdout) => {
19
+ resolve(err ? "" : stdout || "");
20
+ });
21
+ });
22
+ }
23
+
24
+ function parseLsofPidByPort(output: string, port: number): number | null {
25
+ const portTag = `:${port}`;
26
+ const blocks = output.split(/(?=^p)/m);
27
+ const selfPid = process.pid;
28
+ for (const block of blocks) {
29
+ if (!block.startsWith("p")) continue;
30
+ const lines = block.split("\n");
31
+ const pid = Number(lines[0]?.slice(1).trim());
32
+ if (!Number.isFinite(pid) || pid === selfPid) continue;
33
+ const isClient = lines.some((line) => {
34
+ if (!line.startsWith("n")) return false;
35
+ const arrow = line.indexOf("->");
36
+ if (arrow === -1) return false;
37
+ return line.slice(arrow + 2).trim().endsWith(portTag);
38
+ });
39
+ if (isClient) return pid;
40
+ }
41
+ return null;
42
+ }
43
+
44
+ async function findRootAncestor(pid: number): Promise<string | null> {
45
+ let current = pid;
46
+ let lastName: string | null = null;
47
+ for (let i = 0; i < 16; i++) {
48
+ const out = await run("/bin/ps", ["-o", "ppid=,ucomm=", "-p", String(current)]);
49
+ if (!out) return lastName;
50
+ const m = out.trim().match(/^\s*(\d+)\s+(.*)$/);
51
+ if (!m) return lastName;
52
+ const ppid = m[1]!;
53
+ const name = m[2]!.trim();
54
+ lastName = name || lastName;
55
+ if (ppid === "1" || ppid === "0") return lastName;
56
+ const next = Number(ppid);
57
+ if (!Number.isFinite(next) || next === current) return lastName;
58
+ current = next;
59
+ }
60
+ return lastName;
61
+ }
62
+
63
+ async function lookupByPid(pid: number): Promise<Origin | null> {
64
+ const cached = PID_CACHE.get(pid);
65
+ if (cached && cached.expiresAt > Date.now()) return cached.value;
66
+
67
+ const [cwdOut, cmdOut, root] = await Promise.all([
68
+ run("/usr/sbin/lsof", ["-a", "-p", String(pid), "-d", "cwd", "-F", "n"]),
69
+ run("/bin/ps", ["-p", String(pid), "-o", "command="]),
70
+ findRootAncestor(pid),
71
+ ]);
72
+
73
+ const cwdLine = cwdOut.split("\n").find((l) => l.startsWith("n"));
74
+ const cwd = cwdLine ? cwdLine.slice(1).trim() || null : null;
75
+ const command = cmdOut.trim().split("\n")[0]?.trim() || "";
76
+
77
+ const origin = !cwd && !command ? null : { pid, command, cwd, root };
78
+ PID_CACHE.set(pid, { value: origin, expiresAt: Date.now() + PID_TTL_MS });
79
+ return origin;
80
+ }
81
+
82
+ async function lookupOriginForSocket(
83
+ socket: Socket,
84
+ proxyPort: number
85
+ ): Promise<Origin | null> {
86
+ const remotePort = socket.remotePort;
87
+ if (typeof remotePort !== "number") return null;
88
+
89
+ const lsofOut = await run("/usr/sbin/lsof", [
90
+ "-nP",
91
+ `-iTCP:${remotePort}`,
92
+ "-sTCP:ESTABLISHED",
93
+ "-F",
94
+ "pcfn",
95
+ ]);
96
+ const pid = parseLsofPidByPort(lsofOut, proxyPort);
97
+ return pid ? lookupByPid(pid) : null;
98
+ }
99
+
100
+ type SocketWithOrigin = Socket & { __origin?: Origin | null };
101
+
102
+ export function installOriginAttribution(server: { on: (e: "connection", h: (s: Socket) => void) => void }, proxyPort: number): void {
103
+ if (!SUPPORTED) return;
104
+ server.on("connection", (socket) => {
105
+ lookupOriginForSocket(socket, proxyPort)
106
+ .catch(() => null)
107
+ .then((origin) => {
108
+ (socket as SocketWithOrigin).__origin = origin;
109
+ });
110
+ });
111
+ }
112
+
113
+ export function readOrigin(socket: Socket): Origin | null {
114
+ return (socket as SocketWithOrigin).__origin ?? null;
115
+ }
116
+
117
+ export function detectToolFromUserAgent(ua: string | undefined): string {
118
+ if (!ua) return "unknown";
119
+ const lower = ua.toLowerCase();
120
+ if (lower.startsWith("npm/") || lower.includes(" npm/")) return "npm";
121
+ if (lower.includes("pnpm")) return "pnpm";
122
+ if (lower.includes("yarn")) return "yarn";
123
+ if (lower.includes("bun")) return "bun";
124
+ if (lower.includes("deno")) return "deno";
125
+ if (lower.includes("curl")) return "curl";
126
+ if (lower.includes("mozilla") || lower.includes("safari") || lower.includes("chrome")) {
127
+ return "browser";
128
+ }
129
+ return ua.split(/[\s/]/)[0]?.toLowerCase() || "unknown";
130
+ }
@@ -0,0 +1,124 @@
1
+ import { existsSync, readFileSync, writeFileSync, unlinkSync, copyFileSync, chmodSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+
5
+ const NPMRC_PATH = join(homedir(), ".npmrc");
6
+ const NPMRC_BACKUP = join(homedir(), ".npmrc.npm-shield-backup");
7
+ const YARNRC_PATH = join(homedir(), ".yarnrc.yml");
8
+ const YARNRC_BACKUP = join(homedir(), ".yarnrc.yml.npm-shield-backup");
9
+
10
+ export function configurePackageManagers(port: number): void {
11
+ configureNpmrc(port);
12
+ configureYarnBerry(port);
13
+ printDenoInstructions(port);
14
+ }
15
+
16
+ export function restorePackageManagers(): void {
17
+ restoreNpmrc();
18
+ restoreYarnBerry();
19
+ printDenoUnsetReminder();
20
+ }
21
+
22
+ export function hasStaleState(): boolean {
23
+ return existsSync(NPMRC_BACKUP) || existsSync(YARNRC_BACKUP);
24
+ }
25
+
26
+ export function forceRestore(): void {
27
+ if (existsSync(NPMRC_BACKUP)) {
28
+ restoreNpmrc();
29
+ }
30
+ if (existsSync(YARNRC_BACKUP)) {
31
+ restoreYarnBerry();
32
+ }
33
+ }
34
+
35
+ function configureNpmrc(port: number): void {
36
+ if (existsSync(NPMRC_PATH)) {
37
+ copyFileSync(NPMRC_PATH, NPMRC_BACKUP);
38
+ } else {
39
+ writeFileSync(NPMRC_BACKUP, "", { encoding: "utf-8", mode: 0o600 });
40
+ }
41
+ chmodSync(NPMRC_BACKUP, 0o600);
42
+
43
+ const registryLine = `registry=http://localhost:${port}`;
44
+ const replaceHostLine = `replace-registry-host=never`;
45
+ let content = "";
46
+
47
+ if (existsSync(NPMRC_PATH)) {
48
+ content = readFileSync(NPMRC_PATH, "utf-8");
49
+ const lines = content.split("\n").filter((line) => {
50
+ const trimmed = line.trim();
51
+ return (
52
+ !trimmed.startsWith("registry=") &&
53
+ !trimmed.startsWith("replace-registry-host=")
54
+ );
55
+ });
56
+ content = lines.join("\n");
57
+ if (content.length > 0 && !content.endsWith("\n")) {
58
+ content += "\n";
59
+ }
60
+ }
61
+
62
+ content += registryLine + "\n" + replaceHostLine + "\n";
63
+ writeFileSync(NPMRC_PATH, content, "utf-8");
64
+ console.log(`[npm-registry-shield] Updated ~/.npmrc (backup at ~/.npmrc.npm-shield-backup)`);
65
+ }
66
+
67
+ function restoreNpmrc(): void {
68
+ if (!existsSync(NPMRC_BACKUP)) {
69
+ console.log("[npm-registry-shield] No .npmrc backup found, skipping restore");
70
+ return;
71
+ }
72
+
73
+ const backup = readFileSync(NPMRC_BACKUP, "utf-8");
74
+ if (backup.length === 0) {
75
+ if (existsSync(NPMRC_PATH)) {
76
+ unlinkSync(NPMRC_PATH);
77
+ }
78
+ } else {
79
+ writeFileSync(NPMRC_PATH, backup, "utf-8");
80
+ }
81
+
82
+ unlinkSync(NPMRC_BACKUP);
83
+ console.log("[npm-registry-shield] Restored ~/.npmrc");
84
+ }
85
+
86
+ function configureYarnBerry(port: number): void {
87
+ if (!existsSync(YARNRC_PATH)) {
88
+ return;
89
+ }
90
+
91
+ copyFileSync(YARNRC_PATH, YARNRC_BACKUP);
92
+
93
+ let content = readFileSync(YARNRC_PATH, "utf-8");
94
+ const lines = content.split("\n").filter(
95
+ (line) => !line.trim().startsWith("npmRegistryServer:")
96
+ );
97
+ content = lines.join("\n");
98
+ if (content.length > 0 && !content.endsWith("\n")) {
99
+ content += "\n";
100
+ }
101
+ content += `npmRegistryServer: "http://localhost:${port}"\n`;
102
+
103
+ writeFileSync(YARNRC_PATH, content, "utf-8");
104
+ console.log(`[npm-registry-shield] Updated ~/.yarnrc.yml (backup at ~/.yarnrc.yml.npm-shield-backup)`);
105
+ }
106
+
107
+ function restoreYarnBerry(): void {
108
+ if (!existsSync(YARNRC_BACKUP)) {
109
+ return;
110
+ }
111
+
112
+ const backup = readFileSync(YARNRC_BACKUP, "utf-8");
113
+ writeFileSync(YARNRC_PATH, backup, "utf-8");
114
+ unlinkSync(YARNRC_BACKUP);
115
+ console.log("[npm-registry-shield] Restored ~/.yarnrc.yml");
116
+ }
117
+
118
+ function printDenoInstructions(port: number): void {
119
+ console.log(`[npm-registry-shield] For Deno, run: export DENO_NPM_REGISTRY=http://localhost:${port}`);
120
+ }
121
+
122
+ function printDenoUnsetReminder(): void {
123
+ console.log("[npm-registry-shield] If using Deno, run: unset DENO_NPM_REGISTRY");
124
+ }
@@ -0,0 +1,128 @@
1
+ import type { Packument } from "./filter.js";
2
+
3
+ interface CacheEntry {
4
+ data: Packument;
5
+ timestamp: number;
6
+ }
7
+
8
+ const FETCH_TIMEOUT_MS = 10_000;
9
+ const CACHE_MAX_ENTRIES = 1000;
10
+ const cache = new Map<string, CacheEntry>();
11
+
12
+ export function getCachedPackument(
13
+ packageName: string,
14
+ ttlMinutes: number
15
+ ): Packument | null {
16
+ const entry = cache.get(packageName);
17
+ if (!entry) return null;
18
+ const age = Date.now() - entry.timestamp;
19
+ if (age > ttlMinutes * 60 * 1000) {
20
+ cache.delete(packageName);
21
+ return null;
22
+ }
23
+ cache.delete(packageName);
24
+ cache.set(packageName, entry);
25
+ return entry.data;
26
+ }
27
+
28
+ export function setCachedPackument(
29
+ packageName: string,
30
+ data: Packument
31
+ ): void {
32
+ if (cache.has(packageName)) cache.delete(packageName);
33
+ cache.set(packageName, { data, timestamp: Date.now() });
34
+ while (cache.size > CACHE_MAX_ENTRIES) {
35
+ const oldest = cache.keys().next().value;
36
+ if (oldest === undefined) break;
37
+ cache.delete(oldest);
38
+ }
39
+ }
40
+
41
+ export function clearCache(packageName?: string): void {
42
+ if (packageName) {
43
+ cache.delete(packageName);
44
+ } else {
45
+ cache.clear();
46
+ }
47
+ }
48
+
49
+ export async function fetchPackument(
50
+ packageName: string,
51
+ upstream: string
52
+ ): Promise<Packument | null> {
53
+ const url = `${upstream}/${packageName}`;
54
+ const res = await fetch(url, {
55
+ headers: { accept: "application/json" },
56
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
57
+ });
58
+
59
+ if (res.status === 404) return null;
60
+
61
+ if (!res.ok) {
62
+ throw new Error(`Upstream returned ${res.status} for ${packageName}`);
63
+ }
64
+
65
+ return (await res.json()) as Packument;
66
+ }
67
+
68
+ export async function fetchVersionMetadata(
69
+ packageName: string,
70
+ version: string,
71
+ upstream: string
72
+ ): Promise<{ status: number; body: string; contentType: string }> {
73
+ const url = `${upstream}/${packageName}/${version}`;
74
+ const res = await fetch(url, {
75
+ headers: { accept: "application/json" },
76
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
77
+ });
78
+
79
+ return {
80
+ status: res.status,
81
+ body: await res.text(),
82
+ contentType: res.headers.get("content-type") || "application/json",
83
+ };
84
+ }
85
+
86
+ export async function proxyRequest(
87
+ method: string,
88
+ path: string,
89
+ upstream: string,
90
+ headers: Record<string, string>,
91
+ requestBody?: Buffer
92
+ ): Promise<{ status: number; body: Buffer; headers: Record<string, string> }> {
93
+ const url = `${upstream}${path}`;
94
+
95
+ const forwardHeaders: Record<string, string> = {};
96
+ for (const [key, value] of Object.entries(headers)) {
97
+ const k = key.toLowerCase();
98
+ if (k === "host" || k === "connection" || k === "content-length") continue;
99
+ forwardHeaders[key] = value;
100
+ }
101
+
102
+ const hasBody = requestBody !== undefined && requestBody.length > 0;
103
+ const res = await fetch(url, {
104
+ method,
105
+ headers: forwardHeaders,
106
+ body: hasBody ? new Uint8Array(requestBody) : undefined,
107
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
108
+ });
109
+
110
+ const arrayBuf = await res.arrayBuffer();
111
+ const responseBody = Buffer.from(arrayBuf);
112
+
113
+ const responseHeaders: Record<string, string> = {};
114
+ res.headers.forEach((value, key) => {
115
+ const k = key.toLowerCase();
116
+ if (k === "content-encoding" || k === "content-length" || k === "transfer-encoding") {
117
+ return;
118
+ }
119
+ responseHeaders[key] = value;
120
+ });
121
+ responseHeaders["content-length"] = String(responseBody.length);
122
+
123
+ return {
124
+ status: res.status,
125
+ body: responseBody,
126
+ headers: responseHeaders,
127
+ };
128
+ }