Skip to content

Commit 19f9af2

Browse files
authored
feat: bootstrap @socketsecurity/lib + @socketregistry/packageurl-js + @sinclair/typebox via firewall-checked registry fetch (#1282)
1 parent 9bbe87f commit 19f9af2

2 files changed

Lines changed: 313 additions & 0 deletions

File tree

package.json

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

0 commit comments

Comments
 (0)