Skip to content

Commit db55e19

Browse files
committed
feat(dlx): add Socket Firewall API check before package downloads
- Split Arborist reify into buildIdealTree → firewall check → reify - Check all resolved deps against public firewall-api.socket.dev/purl - Block on critical/high severity alerts, non-fatal on API errors - Add npmPurl() for PURL construction (scoped + unscoped packages) - Add SOCKET_LIB_VERSION/USER_AGENT constants (build-time inlined via INLINED_LIB_VERSION) - Extend Arborist type definitions for idealTree/inventory access - Update default User-Agent in httpRequest to use lib identity
1 parent 68321f7 commit db55e19

8 files changed

Lines changed: 289 additions & 9 deletions

File tree

.config/esbuild.config.mjs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
* Fast JS compilation with esbuild, declarations with tsgo
44
*/
55

6+
import fs from 'node:fs'
67
import path from 'node:path'
78
import { fileURLToPath } from 'node:url'
89
import process from 'node:process'
@@ -12,6 +13,9 @@ import { envAsBoolean } from '@socketsecurity/lib-stable/env/helpers'
1213

1314
const __dirname = path.dirname(fileURLToPath(import.meta.url))
1415
const rootPath = path.join(__dirname, '..')
16+
const rootPkgJson = JSON.parse(
17+
fs.readFileSync(path.join(rootPath, 'package.json'), 'utf8'),
18+
)
1519
const srcPath = path.join(rootPath, 'src')
1620
const distPath = path.join(rootPath, 'dist')
1721

@@ -275,6 +279,8 @@ export const buildConfig = {
275279
'process.env.NODE_ENV': JSON.stringify(
276280
process.env.NODE_ENV || 'production',
277281
),
282+
'process.env.INLINED_LIB_VERSION': JSON.stringify(rootPkgJson.version),
283+
'process.env["INLINED_LIB_VERSION"]': JSON.stringify(rootPkgJson.version),
278284
},
279285

280286
// Banner for generated code

.config/vitest.config.mts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,17 @@
22
* @fileoverview Vitest configuration for socket-lib
33
*/
44

5+
import { readFileSync } from 'node:fs'
56
import path from 'node:path'
67
import { fileURLToPath } from 'node:url'
78
import process from 'node:process'
89
import { defineConfig } from 'vitest/config'
910

1011
const __dirname = path.dirname(fileURLToPath(import.meta.url))
1112
const projectRoot = path.resolve(__dirname, '..')
13+
const rootPkgJson = JSON.parse(
14+
readFileSync(path.join(projectRoot, 'package.json'), 'utf8'),
15+
)
1216

1317
// Normalize paths for cross-platform glob patterns (forward slashes on Windows)
1418
const toGlobPath = (pathLike: string): string => pathLike.replaceAll('\\', '/')
@@ -38,6 +42,9 @@ const vitestConfig = defineConfig({
3842
},
3943
},
4044
test: {
45+
env: {
46+
INLINED_LIB_VERSION: rootPkgJson.version,
47+
},
4148
globalSetup: [path.resolve(__dirname, 'vitest-global-setup.mts')],
4249
globals: false,
4350
environment: 'node',

src/constants/socket.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,13 @@ export const SOCKET_FIREWALL_APP_NAME = 'sfw'
3939
export const SOCKET_REGISTRY_APP_NAME = 'registry'
4040
export const SOCKET_APP_PREFIX = '_'
4141

42+
// Socket.dev lib.
43+
export const SOCKET_LIB_NAME = '@socketsecurity/lib'
44+
export const SOCKET_LIB_VERSION: string =
45+
process.env['INLINED_LIB_VERSION'] ?? '0.0.0'
46+
export const SOCKET_LIB_URL = 'https://github.com/SocketDev/socket-lib'
47+
export const SOCKET_LIB_USER_AGENT = `socketsecurity-lib/${SOCKET_LIB_VERSION} (${SOCKET_LIB_URL})`
48+
4249
// Socket.dev IPC.
4350
export const SOCKET_IPC_HANDSHAKE = 'SOCKET_IPC_HANDSHAKE'
4451

src/dlx/package.ts

Lines changed: 137 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -31,11 +31,13 @@
3131
*/
3232

3333
import { WIN32 } from '../constants/platform'
34+
import { SOCKET_LIB_USER_AGENT } from '../constants/socket'
3435
import { generateCacheKey } from './cache'
3536
import Arborist from '../external/@npmcli/arborist'
3637
import libnpmexec from '../external/libnpmexec'
3738
import npmPackageArg from '../external/npm-package-arg'
3839
import { readJsonSync, safeMkdir } from '../fs'
40+
import { httpJson } from '../http-request'
3941
import { normalizePath } from '../paths/normalize'
4042
import { getSocketCacacheDir, getSocketDlxDir } from '../paths/socket'
4143
import { processLock } from '../process-lock'
@@ -333,8 +335,8 @@ export async function ensurePackageInstalled(
333335
}
334336

335337
// Install package and dependencies using Arborist (like npx does).
336-
// Arborist handles everything: fetching, extracting, dependency resolution, and bin links.
337-
// This creates the proper flat node_modules structure with .bin symlinks.
338+
// Split into buildIdealTree → firewall check → reify so we can
339+
// scan all resolved packages before downloading any tarballs.
338340
try {
339341
// Arborist is imported at the top
340342
/* c8 ignore next 3 - External Arborist constructor */
@@ -356,12 +358,26 @@ export async function ensurePackageInstalled(
356358
silent: true,
357359
})
358360

359-
// Use reify with 'add' to install the package and its dependencies in one step.
360-
// This matches npx's approach: arb.reify({ add: [packageSpec] })
361+
// Step 1: Resolve dependency tree (registry metadata only, no tarballs).
362+
/* c8 ignore next - External Arborist call */
363+
await arb.buildIdealTree({ add: [packageSpec] })
364+
365+
// Step 2: Check resolved packages against Socket Firewall API (public).
366+
/* c8 ignore next - External API call */
367+
await checkFirewallPurls(arb, packageName)
368+
369+
// Step 3: Download tarballs and install. Reuses the cached idealTree.
361370
// save: true creates package.json and package-lock.json at the root (like npx).
362371
/* c8 ignore next - External Arborist call */
363-
await arb.reify({ save: true, add: [packageSpec] })
372+
await arb.reify({ save: true })
364373
} catch (e) {
374+
// Rethrow firewall block errors without wrapping.
375+
if (
376+
e instanceof Error &&
377+
e.message.startsWith('Socket Firewall blocked')
378+
) {
379+
throw e
380+
}
365381
const code = (e as any).code
366382
if (code === 'E404' || code === 'ETARGET') {
367383
throw new Error(
@@ -612,6 +628,122 @@ export function makePackageBinsExecutable(
612628
}
613629
}
614630

631+
// ── Socket Firewall API check ──
632+
633+
const FIREWALL_API_URL = 'https://firewall-api.socket.dev/purl'
634+
const FIREWALL_TIMEOUT = 10_000
635+
const FIREWALL_BLOCK_SEVERITIES: ReadonlySet<string> = new Set([
636+
'critical',
637+
'high',
638+
])
639+
640+
interface FirewallAlert {
641+
severity?: string
642+
type?: string
643+
key?: string
644+
}
645+
646+
interface FirewallResponse {
647+
alerts?: FirewallAlert[]
648+
}
649+
650+
/**
651+
* Build a PURL string for an npm package.
652+
* Follows the PURL spec for the npm type:
653+
* - Scoped: `@scope/pkg` → `pkg:npm/%40scope/pkg@version`
654+
* - Unscoped: `pkg` → `pkg:npm/pkg@version`
655+
*
656+
*/
657+
export function npmPurl(name: string, version: string): string {
658+
const encoded = name.startsWith('@') ? `%40${name.slice(1)}` : name
659+
// PURL spec: '+' in version must be encoded as %2B
660+
const encodedVersion = version.replace(/\+/g, '%2B')
661+
return `pkg:npm/${encoded}@${encodedVersion}`
662+
}
663+
664+
/**
665+
* Check all resolved packages in an Arborist ideal tree against the
666+
* Socket Firewall API (public, no auth required).
667+
* Throws if any dependency has critical or high severity alerts.
668+
*
669+
* @param arb - Arborist instance with populated idealTree
670+
* @param requestedPackage - Top-level package name (for error messages)
671+
* @private
672+
*/
673+
async function checkFirewallPurls(
674+
arb: InstanceType<typeof Arborist>,
675+
requestedPackage: string,
676+
): Promise<void> {
677+
const idealTree = arb.idealTree
678+
if (!idealTree) {
679+
return
680+
}
681+
682+
// Collect PURLs for all non-root resolved nodes.
683+
const purls: Array<{ purl: string; name: string; version: string }> = []
684+
for (const node of idealTree.inventory.values()) {
685+
if (node.isProjectRoot) {
686+
continue
687+
}
688+
const { name, version } = node.package
689+
if (!name || !version) {
690+
continue
691+
}
692+
purls.push({ purl: npmPurl(name, version), name, version })
693+
}
694+
if (purls.length === 0) {
695+
return
696+
}
697+
698+
const blocked: Array<{
699+
name: string
700+
version: string
701+
alerts: string[]
702+
}> = []
703+
704+
// Check all PURLs against the public firewall API in parallel.
705+
await Promise.allSettled(
706+
purls.map(async ({ name, purl, version }) => {
707+
try {
708+
const data = await httpJson<FirewallResponse>(
709+
`${FIREWALL_API_URL}/${encodeURIComponent(purl)}`,
710+
{
711+
headers: { 'User-Agent': SOCKET_LIB_USER_AGENT },
712+
timeout: FIREWALL_TIMEOUT,
713+
retries: 1,
714+
retryDelay: 500,
715+
},
716+
)
717+
const blocking = (data.alerts ?? []).filter(
718+
a => a.severity && FIREWALL_BLOCK_SEVERITIES.has(a.severity),
719+
)
720+
if (blocking.length > 0) {
721+
blocked.push({
722+
name,
723+
version,
724+
alerts: blocking.map(
725+
a => `${a.severity}: ${a.type ?? a.key ?? 'unknown'}`,
726+
),
727+
})
728+
}
729+
} catch {
730+
// Firewall API errors are non-fatal — allow install to proceed.
731+
}
732+
}),
733+
)
734+
735+
if (blocked.length > 0) {
736+
const details = blocked
737+
.map(b => ` ${b.name}@${b.version}: ${b.alerts.join(', ')}`)
738+
.join('\n')
739+
throw new Error(
740+
`Socket Firewall blocked installation of "${requestedPackage}".\n` +
741+
`The following dependencies have security alerts:\n${details}\n\n` +
742+
'Visit https://socket.dev for more information.',
743+
)
744+
}
745+
}
746+
615747
/**
616748
* Parse package spec into name and version using npm-package-arg.
617749
* Examples:

src/external/@npmcli/arborist.d.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,19 @@
11
declare namespace ArboristTypes {
2+
export interface ArboristNode {
3+
package: {
4+
name?: string
5+
version?: string
6+
[key: string]: unknown
7+
}
8+
isProjectRoot: boolean
9+
name: string
10+
location: string
11+
}
12+
13+
export interface IdealTree {
14+
inventory: Map<string, ArboristNode>
15+
}
16+
217
export interface Options {
318
path?: string
419
cache?: string
@@ -7,18 +22,23 @@ declare namespace ArboristTypes {
722
}
823

924
export interface BuildIdealTreeOptions {
25+
add?: string[]
1026
[key: string]: unknown
1127
}
1228

1329
export interface ReifyOptions {
1430
save?: boolean
31+
add?: string[]
1532
[key: string]: unknown
1633
}
1734
}
1835

1936
export default class Arborist {
37+
idealTree: ArboristTypes.IdealTree | null
2038
constructor(options?: ArboristTypes.Options)
21-
buildIdealTree(options?: ArboristTypes.BuildIdealTreeOptions): Promise<void>
39+
buildIdealTree(
40+
options?: ArboristTypes.BuildIdealTreeOptions,
41+
): Promise<ArboristTypes.IdealTree>
2242
reify(options?: ArboristTypes.ReifyOptions): Promise<void>
2343
}
2444

src/http-request.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616

1717
import type { Readable } from 'node:stream'
1818

19+
import { SOCKET_LIB_USER_AGENT } from './constants/socket'
1920
import { safeDelete } from './fs.js'
2021

2122
let _fs: typeof import('node:fs') | undefined
@@ -1161,7 +1162,7 @@ async function httpRequestAttempt(
11611162
: undefined
11621163

11631164
const mergedHeaders = {
1164-
'User-Agent': 'socket-registry/1.0',
1165+
'User-Agent': SOCKET_LIB_USER_AGENT,
11651166
...streamHeaders,
11661167
...headers,
11671168
}

0 commit comments

Comments
 (0)