3131 */
3232
3333import { WIN32 } from '../constants/platform'
34+ import { SOCKET_LIB_USER_AGENT } from '../constants/socket'
3435import { generateCacheKey } from './cache'
3536import Arborist from '../external/@npmcli/arborist'
3637import libnpmexec from '../external/libnpmexec'
3738import npmPackageArg from '../external/npm-package-arg'
3839import { readJsonSync , safeMkdir } from '../fs'
40+ import { httpJson } from '../http-request'
3941import { normalizePath } from '../paths/normalize'
4042import { getSocketCacacheDir , getSocketDlxDir } from '../paths/socket'
4143import { 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:
0 commit comments