Skip to content

Commit d1af774

Browse files
authored
feat: bootstrap @socketsecurity/lib + @socketregistry/packageurl-js + @sinclair/typebox via firewall-checked registry fetch (#627)
* feat: bootstrap @socketsecurity/lib from npm registry before pnpm install Adds scripts/bootstrap-from-registry.mts that downloads zero-dep Socket packages (currently @socketsecurity/lib) from the npm registry tarball directly into node_modules/<scope>/<name>/ 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. * chore(bootstrap): rename bootstrap-from-registry to bootstrap-firewall-deps 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 * 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. * chore(bootstrap): oxfmt format
1 parent 1b6a618 commit d1af774

2 files changed

Lines changed: 285 additions & 0 deletions

File tree

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@
5353
"generate-sdk": "node scripts/generate-sdk.mts",
5454
"lint": "node scripts/lint.mts",
5555
"precommit": "pnpm run check --lint --staged",
56+
"preinstall": "node scripts/bootstrap-firewall-deps.mts",
5657
"prepare": "husky",
5758
"ci:validate": "node scripts/ci-validate.mts",
5859
"prepublishOnly": "echo 'ERROR: Use GitHub Actions workflow for publishing' && exit 1",
Lines changed: 284 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,284 @@
1+
/**
2+
* @fileoverview Bootstrap zero-dep Socket packages into node_modules/
3+
* before `pnpm install` runs, with Socket Firewall verification on each
4+
* pinned tarball before extraction.
5+
*
6+
* Why: setup.mts (and downstream tooling) imports `@socketsecurity/lib`
7+
* and other zero-dep Socket helpers at module-load time. On a fresh
8+
* clone, `pnpm install` itself runs scripts that import these — but
9+
* pnpm install hasn't completed yet, so the imports fail with
10+
* `ERR_MODULE_NOT_FOUND`. Bootstrap solves this by fetching the
11+
* pinned tarball from the npm registry, running it through Socket
12+
* Firewall (refuse-on-alert), and extracting the verified tarball
13+
* into node_modules/<scope>/<name>/. Subsequent pnpm install will
14+
* see the directory and either keep it (if version matches) or
15+
* replace it with the workspace-resolved version.
16+
*
17+
* Pinned versions come from `pnpm-workspace.yaml`'s `catalog:` —
18+
* single source of truth.
19+
*/
20+
21+
import { spawnSync } from 'node:child_process'
22+
import { existsSync, mkdirSync, readFileSync, rmSync } from 'node:fs'
23+
24+
import { tmpdir } from 'node:os'
25+
26+
import path from 'node:path'
27+
import process from 'node:process'
28+
29+
import { fileURLToPath } from 'node:url'
30+
31+
const __dirname = path.dirname(fileURLToPath(import.meta.url))
32+
const REPO_ROOT = path.resolve(__dirname, '..')
33+
34+
// Packages to bootstrap. Each entry must:
35+
// 1. Be zero-dependency (or only depend on already-bootstrapped
36+
// packages) so we don't have to recurse into their dep graph.
37+
// 2. Be imported by setup.mts or another script that runs BEFORE
38+
// pnpm install completes — otherwise normal install handles it.
39+
const BOOTSTRAP_PACKAGES = ['@socketsecurity/lib']
40+
41+
// Socket Firewall API — verifies a package isn't malware before we
42+
// fetch its tarball directly from the npm registry. Mirrors the
43+
// helper in socket-registry's setup action. Any alert at all means
44+
// malware (the API doesn't return informational alerts), so block
45+
// unconditionally on a populated `alerts` array. Network failures
46+
// are non-fatal so a network blip doesn't break a fresh clone.
47+
const FIREWALL_API_URL = 'https://firewall-api.socket.dev/purl'
48+
const FIREWALL_TIMEOUT_MS = 10_000
49+
50+
interface FirewallAlert {
51+
severity?: string
52+
type?: string
53+
key?: string
54+
}
55+
56+
const checkFirewall = async (
57+
pkgName: string,
58+
version: string,
59+
): Promise<boolean> => {
60+
const purl = `pkg:npm/${pkgName}@${version}`
61+
const url = `${FIREWALL_API_URL}/${encodeURIComponent(purl)}`
62+
const controller = new AbortController()
63+
const timer = setTimeout(() => controller.abort(), FIREWALL_TIMEOUT_MS)
64+
timer.unref?.()
65+
try {
66+
const res = await fetch(url, {
67+
headers: {
68+
'User-Agent': 'socket-bootstrap-firewall-deps/1.0',
69+
Accept: 'application/json',
70+
},
71+
signal: controller.signal,
72+
})
73+
clearTimeout(timer)
74+
if (!res.ok) {
75+
err(
76+
`firewall-api: HTTP ${res.status} for ${purl} — proceeding anyway (non-fatal)`,
77+
)
78+
return true
79+
}
80+
const data = (await res.json()) as { alerts?: FirewallAlert[] }
81+
const alerts = data.alerts ?? []
82+
if (alerts.length > 0) {
83+
err(
84+
`\n✗ Socket Firewall flagged ${pkgName}@${version} as malware (${alerts.length} alert(s)):`,
85+
)
86+
for (const a of alerts.slice(0, 10)) {
87+
err(
88+
` ${a.type ?? a.key ?? 'malware'}${a.severity ? ` (${a.severity})` : ''}`,
89+
)
90+
}
91+
err(
92+
'\nFix: bump the pinned version in pnpm-workspace.yaml or package.json to a known-good release.',
93+
)
94+
return false
95+
}
96+
log(`✓ ${pkgName}@${version} cleared by Socket Firewall`)
97+
return true
98+
} catch (e) {
99+
clearTimeout(timer)
100+
err(
101+
`firewall-api: ${e instanceof Error ? e.message : String(e)} — proceeding anyway (non-fatal)`,
102+
)
103+
return true
104+
}
105+
}
106+
107+
const log = (msg: string): void => {
108+
process.stdout.write(`[bootstrap] ${msg}\n`)
109+
}
110+
111+
const err = (msg: string): void => {
112+
process.stderr.write(`[bootstrap] ${msg}\n`)
113+
}
114+
115+
/**
116+
* Read the pinned version of a package, checking (in order):
117+
* 1. `pnpm-workspace.yaml` `catalog:` entries
118+
* 2. Root `package.json` `dependencies` / `devDependencies` (skip
119+
* "catalog:" / "workspace:" / "*" / "" — those need (1)).
120+
*
121+
* Avoids a dep on a YAML parser by hand-parsing the catalog block —
122+
* this script must itself be zero-dep so it can run before
123+
* `pnpm install` brings any tooling in.
124+
*/
125+
126+
// Strip range prefixes (^, ~, >=, <=, etc.) so the registry tarball
127+
// URL gets an exact semver. Applied to BOTH the catalog and the
128+
// package.json paths so they can never disagree.
129+
const stripRange = (v: string): string => v.replace(/^[\^~>=<]+/, '').trim()
130+
131+
const readPinnedVersion = (pkgName: string): string => {
132+
// (1) pnpm-workspace.yaml catalog
133+
const wsPath = path.join(REPO_ROOT, 'pnpm-workspace.yaml')
134+
if (existsSync(wsPath)) {
135+
const content = readFileSync(wsPath, 'utf8')
136+
const lines = content.split('\n')
137+
let inCatalog = false
138+
for (const rawLine of lines) {
139+
const line = rawLine.replace(/\r$/, '')
140+
if (/^catalog:\s*$/.test(line)) {
141+
inCatalog = true
142+
continue
143+
}
144+
if (inCatalog) {
145+
// Leave the catalog block on the next top-level key (no
146+
// leading whitespace, ends with ':').
147+
if (/^\S.*:\s*$/.test(line)) {
148+
inCatalog = false
149+
continue
150+
}
151+
const m = line.match(
152+
/^\s+['"]?([@A-Za-z0-9_/-]+)['"]?\s*:\s*['"]?([^'"\s]+)['"]?\s*$/,
153+
)
154+
if (m && m[1] === pkgName) {
155+
return stripRange(m[2]!)
156+
}
157+
}
158+
}
159+
}
160+
161+
// (2) Root package.json dependencies / devDependencies
162+
const pkgJsonPath = path.join(REPO_ROOT, 'package.json')
163+
if (existsSync(pkgJsonPath)) {
164+
const pkg = JSON.parse(readFileSync(pkgJsonPath, 'utf8'))
165+
for (const field of ['dependencies', 'devDependencies'] as const) {
166+
const deps = pkg[field]
167+
if (deps && typeof deps[pkgName] === 'string') {
168+
const v: string = deps[pkgName]
169+
if (
170+
v !== '' &&
171+
v !== '*' &&
172+
!v.startsWith('catalog:') &&
173+
!v.startsWith('workspace:')
174+
) {
175+
return stripRange(v)
176+
}
177+
}
178+
}
179+
}
180+
181+
throw new Error(
182+
`Pinned version not found for ${pkgName}. Add it to pnpm-workspace.yaml \`catalog:\` or root package.json dependencies.`,
183+
)
184+
}
185+
186+
/**
187+
* Download a npm registry tarball for `<pkg>@<version>` and extract
188+
* it into `node_modules/<pkg>/`. Skips if the destination already
189+
* has a package.json with the matching version. Firewall-checks the
190+
* version against firewall-api.socket.dev before downloading; refuses
191+
* to install if the firewall returned any alerts.
192+
*/
193+
const bootstrapPackage = async (pkgName: string): Promise<void> => {
194+
const version = readPinnedVersion(pkgName)
195+
const dest = path.join(REPO_ROOT, 'node_modules', pkgName)
196+
const destPkgJson = path.join(dest, 'package.json')
197+
198+
if (existsSync(destPkgJson)) {
199+
try {
200+
const installed = JSON.parse(readFileSync(destPkgJson, 'utf8'))
201+
if (installed.version === version) {
202+
log(`${pkgName}@${version} already present, skipping`)
203+
return
204+
}
205+
log(
206+
`${pkgName} present at ${installed.version}, replacing with ${version}`,
207+
)
208+
} catch {
209+
// Malformed package.json — overwrite.
210+
}
211+
}
212+
213+
// Firewall check — refuses install if the package is flagged as
214+
// malware. Network errors are non-fatal so a network blip doesn't
215+
// block a fresh clone.
216+
const cleared = await checkFirewall(pkgName, version)
217+
if (!cleared) {
218+
throw new Error(
219+
`Socket Firewall blocked ${pkgName}@${version}; refusing to install.`,
220+
)
221+
}
222+
223+
// Build the registry tarball URL. The npm registry redirects
224+
// /<pkg>/-/<basename>-<version>.tgz, but for scoped packages the
225+
// basename is the unscoped portion.
226+
const unscoped = pkgName.startsWith('@') ? pkgName.split('/')[1]! : pkgName
227+
const tarballUrl = `https://registry.npmjs.org/${pkgName}/-/${unscoped}-${version}.tgz`
228+
229+
log(`Fetching ${tarballUrl}`)
230+
const tarballPath = path.join(
231+
tmpdir(),
232+
`socket-bootstrap-${unscoped}-${version}.tgz`,
233+
)
234+
235+
// Use curl — it's universally available and avoids a dep on a
236+
// node http client. Follow redirects with -L, fail loudly with -f.
237+
const curl = spawnSync('curl', ['-fsSL', tarballUrl, '-o', tarballPath], {
238+
stdio: 'inherit',
239+
})
240+
if (curl.status !== 0) {
241+
throw new Error(
242+
`Failed to download ${pkgName}@${version} from ${tarballUrl}.\nVerify the version exists on the npm registry, or check network access.`,
243+
)
244+
}
245+
246+
// Ensure dest exists and is empty for clean extraction.
247+
if (existsSync(dest)) {
248+
rmSync(dest, { recursive: true, force: true })
249+
}
250+
mkdirSync(dest, { recursive: true })
251+
252+
// Extract: tarball top-level dir is `package/`, strip it.
253+
const tar = spawnSync(
254+
'tar',
255+
['-xzf', tarballPath, '--strip-components=1', '-C', dest],
256+
{ stdio: 'inherit' },
257+
)
258+
if (tar.status !== 0) {
259+
throw new Error(`Failed to extract ${tarballPath} into ${dest}.`)
260+
}
261+
262+
rmSync(tarballPath, { force: true })
263+
log(`${pkgName}@${version} → node_modules/${pkgName}`)
264+
}
265+
266+
const main = async (): Promise<number> => {
267+
log(
268+
`Bootstrapping ${BOOTSTRAP_PACKAGES.length} package(s) from npm registry...`,
269+
)
270+
for (const pkg of BOOTSTRAP_PACKAGES) {
271+
try {
272+
await bootstrapPackage(pkg)
273+
} catch (e) {
274+
err(
275+
`Failed to bootstrap ${pkg}: ${e instanceof Error ? e.message : String(e)}`,
276+
)
277+
return 1
278+
}
279+
}
280+
log('Bootstrap complete.')
281+
return 0
282+
}
283+
284+
main().then(code => process.exit(code))

0 commit comments

Comments
 (0)