From dbd5d0a744b4cef7a8d7a8dab62dc91da7140cf4 Mon Sep 17 00:00:00 2001 From: jdalton Date: Mon, 27 Apr 2026 11:03:06 -0400 Subject: [PATCH 1/4] feat: bootstrap @socketsecurity/lib from npm registry before pnpm install MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds scripts/bootstrap-from-registry.mts that downloads zero-dep Socket packages (currently @socketsecurity/lib) from the npm registry tarball directly into node_modules/// BEFORE pnpm install runs. Wired via package.json preinstall lifecycle hook. Why: setup.mts and other root-script importers of @socketsecurity/lib fail on a fresh clone because pnpm install hasn't run yet. Pre- seeding from the registry tarball solves the chicken-and-egg. Reads pinned version from pnpm-workspace.yaml `catalog:` OR root package.json deps/devDeps — single source of truth, no hardcoded version. A fresh clone now goes `git clone → pnpm install → working repo`, no special setup ordering required. Self-landable split from #620. --- package.json | 1 + scripts/bootstrap-from-registry.mts | 291 ++++++++++++++++++++++++++++ 2 files changed, 292 insertions(+) create mode 100644 scripts/bootstrap-from-registry.mts diff --git a/package.json b/package.json index 9ae02d43..1dd19e4d 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,7 @@ "generate-sdk": "node scripts/generate-sdk.mts", "lint": "node scripts/lint.mts", "precommit": "pnpm run check --lint --staged", + "preinstall": "node scripts/bootstrap-from-registry.mts", "prepare": "husky", "ci:validate": "node scripts/ci-validate.mts", "prepublishOnly": "echo 'ERROR: Use GitHub Actions workflow for publishing' && exit 1", diff --git a/scripts/bootstrap-from-registry.mts b/scripts/bootstrap-from-registry.mts new file mode 100644 index 00000000..7db782d3 --- /dev/null +++ b/scripts/bootstrap-from-registry.mts @@ -0,0 +1,291 @@ +/** + * @fileoverview Bootstrap zero-dep Socket packages from the npm + * registry directly into node_modules/ before pnpm install runs. + * + * Why: setup.mts (and downstream tooling) imports `@socketsecurity/lib` + * and other zero-dep Socket helpers at module-load time. On a fresh + * clone, `pnpm install` itself runs scripts that import these — but + * pnpm install hasn't completed yet, so the imports fail with + * `ERR_MODULE_NOT_FOUND`. Bootstrap solves this by fetching the + * pinned tarball directly from the npm registry and extracting it + * into node_modules///. Subsequent pnpm install will + * see the directory and either keep it (if version matches) or + * replace it with the workspace-resolved version. + * + * Pinned versions come from `pnpm-workspace.yaml`'s `catalog:` — + * single source of truth. + */ + +import { spawnSync } from 'node:child_process' +import { existsSync, mkdirSync, readFileSync, rmSync } from 'node:fs' + +import { tmpdir } from 'node:os' + +import path from 'node:path' +import process from 'node:process' + +import { fileURLToPath } from 'node:url' + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const REPO_ROOT = path.resolve(__dirname, '..') + +// Packages to bootstrap. Each entry must: +// 1. Be zero-dependency (or only depend on already-bootstrapped +// packages) so we don't have to recurse into their dep graph. +// 2. Be imported by setup.mts or another script that runs BEFORE +// pnpm install completes — otherwise normal install handles it. +const BOOTSTRAP_PACKAGES = [ + '@sinclair/typebox', + '@socketregistry/packageurl-js', + '@socketsecurity/lib', +] + +// Socket Firewall API — verifies a package isn't malware before we +// fetch its tarball directly from the npm registry. Mirrors the +// helper in socket-registry's setup action. Any alert at all means +// malware (the API doesn't return informational alerts), so block +// unconditionally on a populated `alerts` array. Network failures +// are non-fatal so a network blip doesn't break a fresh clone. +const FIREWALL_API_URL = 'https://firewall-api.socket.dev/purl' +const FIREWALL_TIMEOUT_MS = 10_000 + +interface FirewallAlert { + severity?: string + type?: string + key?: string +} + +const checkFirewall = async ( + pkgName: string, + version: string, +): Promise => { + const purl = `pkg:npm/${pkgName}@${version}` + const url = `${FIREWALL_API_URL}/${encodeURIComponent(purl)}` + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(), FIREWALL_TIMEOUT_MS) + timer.unref?.() + try { + const res = await fetch(url, { + headers: { + 'User-Agent': 'socket-bootstrap-from-registry/1.0', + Accept: 'application/json', + }, + signal: controller.signal, + }) + clearTimeout(timer) + if (!res.ok) { + err( + `firewall-api: HTTP ${res.status} for ${purl} — proceeding anyway (non-fatal)`, + ) + return true + } + const data = (await res.json()) as { alerts?: FirewallAlert[] } + const alerts = data.alerts ?? [] + if (alerts.length > 0) { + err( + `\n✗ Socket Firewall flagged ${pkgName}@${version} as malware (${alerts.length} alert(s)):`, + ) + for (const a of alerts.slice(0, 10)) { + err( + ` ${a.type ?? a.key ?? 'malware'}${a.severity ? ` (${a.severity})` : ''}`, + ) + } + err( + '\nFix: bump the pinned version in pnpm-workspace.yaml or package.json to a known-good release.', + ) + return false + } + log(`✓ ${pkgName}@${version} cleared by Socket Firewall`) + return true + } catch (e) { + clearTimeout(timer) + err( + `firewall-api: ${e instanceof Error ? e.message : String(e)} — proceeding anyway (non-fatal)`, + ) + return true + } +} + +const log = (msg: string): void => { + process.stdout.write(`[bootstrap] ${msg}\n`) +} + +const err = (msg: string): void => { + process.stderr.write(`[bootstrap] ${msg}\n`) +} + +/** + * Read the pinned version of a package, checking (in order): + * 1. `pnpm-workspace.yaml` `catalog:` entries + * 2. Root `package.json` `dependencies` / `devDependencies` (skip + * "catalog:" / "workspace:" / "*" / "" — those need (1)). + * + * Avoids a dep on a YAML parser by hand-parsing the catalog block — + * this script must itself be zero-dep so it can run before + * `pnpm install` brings any tooling in. + */ + +// Strip range prefixes (^, ~, >=, <=, etc.) so the registry tarball +// URL gets an exact semver. Applied to BOTH the catalog and the +// package.json paths so they can never disagree. +const stripRange = (v: string): string => + v.replace(/^[\^~>=<]+/, '').trim() + +const readPinnedVersion = (pkgName: string): string => { + // (1) pnpm-workspace.yaml catalog + const wsPath = path.join(REPO_ROOT, 'pnpm-workspace.yaml') + if (existsSync(wsPath)) { + const content = readFileSync(wsPath, 'utf8') + const lines = content.split('\n') + let inCatalog = false + for (const rawLine of lines) { + const line = rawLine.replace(/\r$/, '') + if (/^catalog:\s*$/.test(line)) { + inCatalog = true + continue + } + if (inCatalog) { + // Leave the catalog block on the next top-level key (no + // leading whitespace, ends with ':'). + if (/^\S.*:\s*$/.test(line)) { + inCatalog = false + continue + } + const m = line.match( + /^\s+['"]?([@A-Za-z0-9_/-]+)['"]?\s*:\s*['"]?([^'"\s]+)['"]?\s*$/, + ) + if (m && m[1] === pkgName) { + return stripRange(m[2]!) + } + } + } + } + + // (2) Root package.json dependencies / devDependencies + const pkgJsonPath = path.join(REPO_ROOT, 'package.json') + if (existsSync(pkgJsonPath)) { + const pkg = JSON.parse(readFileSync(pkgJsonPath, 'utf8')) + for (const field of ['dependencies', 'devDependencies'] as const) { + const deps = pkg[field] + if (deps && typeof deps[pkgName] === 'string') { + const v: string = deps[pkgName] + if ( + v !== '' && + v !== '*' && + !v.startsWith('catalog:') && + !v.startsWith('workspace:') + ) { + return stripRange(v) + } + } + } + } + + throw new Error( + `Pinned version not found for ${pkgName}. Add it to pnpm-workspace.yaml \`catalog:\` or root package.json dependencies.`, + ) +} + +/** + * Download a npm registry tarball for `@` and extract + * it into `node_modules//`. Skips if the destination already + * has a package.json with the matching version. Firewall-checks the + * version against firewall-api.socket.dev before downloading; refuses + * to install if the firewall returned any alerts. + */ +const bootstrapPackage = async (pkgName: string): Promise => { + const version = readPinnedVersion(pkgName) + const dest = path.join(REPO_ROOT, 'node_modules', pkgName) + const destPkgJson = path.join(dest, 'package.json') + + if (existsSync(destPkgJson)) { + try { + const installed = JSON.parse(readFileSync(destPkgJson, 'utf8')) + if (installed.version === version) { + log(`${pkgName}@${version} already present, skipping`) + return + } + log( + `${pkgName} present at ${installed.version}, replacing with ${version}`, + ) + } catch { + // Malformed package.json — overwrite. + } + } + + // Firewall check — refuses install if the package is flagged as + // malware. Network errors are non-fatal so a network blip doesn't + // block a fresh clone. + const cleared = await checkFirewall(pkgName, version) + if (!cleared) { + throw new Error( + `Socket Firewall blocked ${pkgName}@${version}; refusing to install.`, + ) + } + + // Build the registry tarball URL. The npm registry redirects + // //-/-.tgz, but for scoped packages the + // basename is the unscoped portion. + const unscoped = pkgName.startsWith('@') + ? pkgName.split('/')[1]! + : pkgName + const tarballUrl = `https://registry.npmjs.org/${pkgName}/-/${unscoped}-${version}.tgz` + + log(`Fetching ${tarballUrl}`) + const tarballPath = path.join( + tmpdir(), + `socket-bootstrap-${unscoped}-${version}.tgz`, + ) + + // Use curl — it's universally available and avoids a dep on a + // node http client. Follow redirects with -L, fail loudly with -f. + const curl = spawnSync( + 'curl', + ['-fsSL', tarballUrl, '-o', tarballPath], + { stdio: 'inherit' }, + ) + if (curl.status !== 0) { + throw new Error( + `Failed to download ${pkgName}@${version} from ${tarballUrl}.\nVerify the version exists on the npm registry, or check network access.`, + ) + } + + // Ensure dest exists and is empty for clean extraction. + if (existsSync(dest)) { + rmSync(dest, { recursive: true, force: true }) + } + mkdirSync(dest, { recursive: true }) + + // Extract: tarball top-level dir is `package/`, strip it. + const tar = spawnSync( + 'tar', + ['-xzf', tarballPath, '--strip-components=1', '-C', dest], + { stdio: 'inherit' }, + ) + if (tar.status !== 0) { + throw new Error(`Failed to extract ${tarballPath} into ${dest}.`) + } + + rmSync(tarballPath, { force: true }) + log(`${pkgName}@${version} → node_modules/${pkgName}`) +} + +const main = async (): Promise => { + log( + `Bootstrapping ${BOOTSTRAP_PACKAGES.length} package(s) from npm registry...`, + ) + for (const pkg of BOOTSTRAP_PACKAGES) { + try { + await bootstrapPackage(pkg) + } catch (e) { + err( + `Failed to bootstrap ${pkg}: ${e instanceof Error ? e.message : String(e)}`, + ) + return 1 + } + } + log('Bootstrap complete.') + return 0 +} + +main().then(code => process.exit(code)) From 8f96f7e3d6188354cf620c66aaf574588db21162 Mon Sep 17 00:00:00 2001 From: jdalton Date: Mon, 27 Apr 2026 12:13:11 -0400 Subject: [PATCH 2/4] chore(bootstrap): rename bootstrap-from-registry to bootstrap-firewall-deps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The script does more than fetch from the npm registry: it also runs each pinned tarball through Socket Firewall and refuses to install if the firewall returns any alert. The new name reflects both halves of the contract — the firewall verification is the security-critical part that "from registry" obscured. - scripts/bootstrap-from-registry.mts → scripts/bootstrap-firewall-deps.mts - Update package.json preinstall hook to point at the new path - Update User-Agent string and fileoverview to match --- package.json | 2 +- ...p-from-registry.mts => bootstrap-firewall-deps.mts} | 10 ++++++---- 2 files changed, 7 insertions(+), 5 deletions(-) rename scripts/{bootstrap-from-registry.mts => bootstrap-firewall-deps.mts} (96%) diff --git a/package.json b/package.json index 1dd19e4d..9f32e0d4 100644 --- a/package.json +++ b/package.json @@ -53,7 +53,7 @@ "generate-sdk": "node scripts/generate-sdk.mts", "lint": "node scripts/lint.mts", "precommit": "pnpm run check --lint --staged", - "preinstall": "node scripts/bootstrap-from-registry.mts", + "preinstall": "node scripts/bootstrap-firewall-deps.mts", "prepare": "husky", "ci:validate": "node scripts/ci-validate.mts", "prepublishOnly": "echo 'ERROR: Use GitHub Actions workflow for publishing' && exit 1", diff --git a/scripts/bootstrap-from-registry.mts b/scripts/bootstrap-firewall-deps.mts similarity index 96% rename from scripts/bootstrap-from-registry.mts rename to scripts/bootstrap-firewall-deps.mts index 7db782d3..1d06523c 100644 --- a/scripts/bootstrap-from-registry.mts +++ b/scripts/bootstrap-firewall-deps.mts @@ -1,13 +1,15 @@ /** - * @fileoverview Bootstrap zero-dep Socket packages from the npm - * registry directly into node_modules/ before pnpm install runs. + * @fileoverview Bootstrap zero-dep Socket packages into node_modules/ + * before `pnpm install` runs, with Socket Firewall verification on each + * pinned tarball before extraction. * * Why: setup.mts (and downstream tooling) imports `@socketsecurity/lib` * and other zero-dep Socket helpers at module-load time. On a fresh * clone, `pnpm install` itself runs scripts that import these — but * pnpm install hasn't completed yet, so the imports fail with * `ERR_MODULE_NOT_FOUND`. Bootstrap solves this by fetching the - * pinned tarball directly from the npm registry and extracting it + * pinned tarball from the npm registry, running it through Socket + * Firewall (refuse-on-alert), and extracting the verified tarball * into node_modules///. Subsequent pnpm install will * see the directory and either keep it (if version matches) or * replace it with the workspace-resolved version. @@ -67,7 +69,7 @@ const checkFirewall = async ( try { const res = await fetch(url, { headers: { - 'User-Agent': 'socket-bootstrap-from-registry/1.0', + 'User-Agent': 'socket-bootstrap-firewall-deps/1.0', Accept: 'application/json', }, signal: controller.signal, From e59b059d66ac6f249271c27c467d4aa62ec11d7d Mon Sep 17 00:00:00 2001 From: jdalton Date: Mon, 27 Apr 2026 14:54:58 -0400 Subject: [PATCH 3/4] fix(bootstrap): drop unused typebox + packageurl-js from sdk bootstrap list socket-sdk-js only uses @socketsecurity/lib; the typebox and packageurl-js entries were copied from socket-cli's bootstrap (where xport-schema.mts uses TypeBox) and broke CI here because neither has a pinned version in this repo's package.json or pnpm-workspace.yaml catalog. Restoring to a single-package bootstrap. --- scripts/bootstrap-firewall-deps.mts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/scripts/bootstrap-firewall-deps.mts b/scripts/bootstrap-firewall-deps.mts index 1d06523c..1b8bf922 100644 --- a/scripts/bootstrap-firewall-deps.mts +++ b/scripts/bootstrap-firewall-deps.mts @@ -36,11 +36,7 @@ const REPO_ROOT = path.resolve(__dirname, '..') // packages) so we don't have to recurse into their dep graph. // 2. Be imported by setup.mts or another script that runs BEFORE // pnpm install completes — otherwise normal install handles it. -const BOOTSTRAP_PACKAGES = [ - '@sinclair/typebox', - '@socketregistry/packageurl-js', - '@socketsecurity/lib', -] +const BOOTSTRAP_PACKAGES = ['@socketsecurity/lib'] // Socket Firewall API — verifies a package isn't malware before we // fetch its tarball directly from the npm registry. Mirrors the From a782acec4fb5343723daa70f57a78dc0e6de60ca Mon Sep 17 00:00:00 2001 From: jdalton Date: Mon, 27 Apr 2026 14:59:16 -0400 Subject: [PATCH 4/4] chore(bootstrap): oxfmt format --- scripts/bootstrap-firewall-deps.mts | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/scripts/bootstrap-firewall-deps.mts b/scripts/bootstrap-firewall-deps.mts index 1b8bf922..d87eddd8 100644 --- a/scripts/bootstrap-firewall-deps.mts +++ b/scripts/bootstrap-firewall-deps.mts @@ -126,8 +126,7 @@ const err = (msg: string): void => { // Strip range prefixes (^, ~, >=, <=, etc.) so the registry tarball // URL gets an exact semver. Applied to BOTH the catalog and the // package.json paths so they can never disagree. -const stripRange = (v: string): string => - v.replace(/^[\^~>=<]+/, '').trim() +const stripRange = (v: string): string => v.replace(/^[\^~>=<]+/, '').trim() const readPinnedVersion = (pkgName: string): string => { // (1) pnpm-workspace.yaml catalog @@ -224,9 +223,7 @@ const bootstrapPackage = async (pkgName: string): Promise => { // Build the registry tarball URL. The npm registry redirects // //-/-.tgz, but for scoped packages the // basename is the unscoped portion. - const unscoped = pkgName.startsWith('@') - ? pkgName.split('/')[1]! - : pkgName + const unscoped = pkgName.startsWith('@') ? pkgName.split('/')[1]! : pkgName const tarballUrl = `https://registry.npmjs.org/${pkgName}/-/${unscoped}-${version}.tgz` log(`Fetching ${tarballUrl}`) @@ -237,11 +234,9 @@ const bootstrapPackage = async (pkgName: string): Promise => { // Use curl — it's universally available and avoids a dep on a // node http client. Follow redirects with -L, fail loudly with -f. - const curl = spawnSync( - 'curl', - ['-fsSL', tarballUrl, '-o', tarballPath], - { stdio: 'inherit' }, - ) + const curl = spawnSync('curl', ['-fsSL', tarballUrl, '-o', tarballPath], { + stdio: 'inherit', + }) if (curl.status !== 0) { throw new Error( `Failed to download ${pkgName}@${version} from ${tarballUrl}.\nVerify the version exists on the npm registry, or check network access.`,