Sandboxing Node.js on Mac

QR Code linking to this page

Written by David Evans, first published (last updated )

Node.js’ NPM package ecosystem has always been a wild-west, with developers blindly downloading and executing code from hundreds or even thousands of contributors with full user permissions on their computer, making it an easy target for supply chain attacks. This issue has been highlighted repeatedly over the past year by multiple waves of “Shai-Hulud” compromises (September, November, April, May, May again, June, August).

I’ve been pushing my own projects towards fewer dependencies (especially transitive dependencies), but there will always be the potential for compromises.

This problem is not unique to NPM or Node.js; similar issues are well known for Python, Rust, and Golang‘s package ecosystems. The risk applies anywhere there are unvetted dependencies, especially when deeply-nested transitive dependencies are common. This post focuses on Node.js in particular, but the techniques described here can be applied to other runtimes with a bit of work.

First steps

The most common attacks observed so far in the NPM ecosystem use install-time scripts. For whatever crazy reason, the default NPM configuration will eagerly execute a package-defined script whenever the package is installed. Fortunately these scripts are very rarely needed for legitimate purposes any more, and are easily disabled by adding --ignore-scripts to the npm command (or better: ignore-scripts=true to a project-level .npmrc file).

With that simple change, we block the vast majority of currently observed attacks.

But this attack vector is only used because it is the lowest-effort way to compromise most users. There is nothing to stop an attacker from going further and embedding their payload inside a package’s own code. Consider a build or test tool which is intentionally executed by the developer, or any library that might be loaded during unit tests with full system access.

We can defend ourselves a bit more by only using package versions after they have been available for a period of time. For NPM: add min-release-age=7 to .npmrc (or run with --min-release-age=7 when installing new dependencies or updating versions). Doing so allows time for security companies to scan the dependencies and potentially spot suspicious changes, or for other users to fall victim and report the compromise. Generally internet sources recommend 7 days for this, but you can set it higher if you want to be extra-safe (with a trade-off of not picking up security fixes quickly). It’s also possible to explicitly override the min-release-age for individual commands (e.g. if you know you need to update a particular package to the latest version to fix a security issue).

But even with these protections, it’s still possible to end up using a dependency which has been compromised.

Limiting the damage

If we end up downloading and executing a compromised dependency, we want to minimise any potential damage which could be done by the payload. In particular, we want to block:

But there are also some things we won’t realistically be able to prevent:

As always, the best defences are layered: dependencies should be vetted as much as is practical before being used, even on developer workstations, and credentials should be cycled if any compromise is detected, even if it appears to have been contained. The rest of this article shows an approach which limits the damage when all other precautions have failed.

How about Docker?

One way to sandbox commands is to run them inside a container. With Docker, that looks something like this:

docker run --rm -it --mount type=bind,src=.,dst=/work -w /work node:26-alpine sh

(I won’t go through explaining how to install Docker on Mac; that’s covered elsewhere, so this assumes you already have it set up)

The command leaves you in an interactive shell where you can run commands that have access to the current directory and network, but are otherwise isolated. It works as a decent sandbox, with some limitations (most notably: since the project’s hidden .git directory is accessible, it’s possible for a motivated attacker to escape the sandbox by adding a Git Hook which will run outside the container when you perform any Git actions).

Besides the limited protection, the main disadvantage is that this is quite “heavy”: behind the scenes, Docker runs a Linux Virtual Machine, then uses Linux’s layered filesystem to implement the container. This occupies a chunk of RAM and adds some overhead to all filesystem operations (though it has improved significantly over recent years and the bind mount mostly alleviates filesystem delays for project files). It also needs Docker Desktop (which requires a paid license if used in a large enough company, regardless of the number of employees actually using it).

So can we do better?

sandbox-exec

MacOS contains a comprehensive sandboxing tool which can be used to achieve a good level of isolation, but it takes a bit of configuration.

The tool is sandbox-exec, and it’s used by various system dæmons to limit the impact if a system utility is compromised. Officially the tool is deprecated and undocumented, but the recommended alternative is impractically cumbersome, and sandbox-exec is used widely enough that it is likely to stick around for a good while yet. AI companies are also increasingly using it to limit the impact of prompt injection attacks.

If you’d like to jump to a working sandbox-exec setup for Node.js, see the final Node.js policy below. Otherwise, continue reading to see how to set up a policy from scratch.

Figuring out a policy

Setting up a sandbox-exec policy for an existing process requires a bit of trial-and-error: begin with a very restrictive policy, run the program, then check the sandbox logs to see what got blocked. Repeat until the program is able to do everything it needs to do. It’s best explained by example:

Worked example for curl

To run any command at all, some basic permissions are needed. Let’s begin with a policy that blocks as much as possible, but still allows us to launch an executable:

cat > my-policy.sb <<EOF
(version 1)
(deny default)

(allow sysctl-read)
(allow process-exec)
(allow file-read*
  (require-all
    (file-mode #o0004)
    (require-any
      (literal "/")
      (literal "/dev")
      (subpath "/System")
      (subpath "/bin")
      (subpath "/usr")
      (subpath "/etc")
      (subpath "/var")
      (subpath "/private")
    )
  )
)
EOF

We can launch an arbitrary process sandboxed by this policy with:

sandbox-exec -f my-policy.sb curl example.com

Which will produce:

curl: (6) Could not resolve host: example.com

The failure tells us that the sandbox has worked, and (in this case) has correctly blocked an attempt to access the network. We can find out exactly why our curl command failed by checking the sandbox logs. The following command streams all sandbox errors as they happen:

log stream --style compact --predicate 'sender=="Sandbox" && messageType=="error"'

But it can be a bit noisy; there are lots of system processes using this sandboxing, and they frequently try to access things they’re not allowed to (presumably because the sandboxing rules were retrofitted imperfectly to existing processes, rather then built-up alongside them), for example:

[...] (Sandbox) Sandbox: findmybeaconingd(507) deny(1) mach-lookup com.apple.timed.xpc

So it’s useful to filter the output further with grep to highlight the relevant errors. I currently use this command to filter out some of the noisier system services:

log stream --style compact --predicate 'sender=="Sandbox" && messageType=="error"' --color always | grep -ve ' \(searchpartyuseragent\|spotlightknowledged\|imagent\|findmydeviced\|findmybeaconingd\|locationd\|triald\|adprivacyd\|duetexpertd\|parsec-fbf\|MessagesBlastDoorService\)('

As a convenience, I added an alias for this filtered view to my ~/.zprofile:

alias sb-debug="log stream --style compact --predicate 'sender==\"Sandbox\" && messageType==\"error\"' --color always | grep -ve ' \\(searchpartyuseragent\\|spotlightknowledged\\|imagent\\|findmydeviced\\|findmybeaconingd\\|locationd\\|triald\\|adprivacyd\\|duetexpertd\\|parsec-fbf\\|MessagesBlastDoorService\\)('"

Running the sandboxed curl command while the logs are being streamed will show something like this:

2000-01-01 00:00:00.000 E  kernel[0:35ba7] (Sandbox) Sandbox: curl(3473) deny(1) file-read-data /dev/dtracehelper
2000-01-01 00:00:00.000 E  kernel[0:35a4f] (Sandbox) Sandbox: curl(3473) deny(1) mach-lookup com.apple.system.notification_center
2000-01-01 00:00:00.000 E  kernel[0:35a4f] (Sandbox) Sandbox: curl(3473) deny(1) mach-lookup com.apple.system.opendirectoryd.libinfo
2000-01-01 00:00:00.000 E  kernel[0:35a4f] (Sandbox) 1 duplicate report for Sandbox: curl(3473) deny(1) mach-lookup com.apple.system.opendirectoryd.libinfo
2000-01-01 00:00:00.000 E  kernel[0:35a4f] (Sandbox) Sandbox: curl(3473) deny(1) mach-lookup com.apple.logd
2000-01-01 00:00:00.000 E  kernel[0:35a4f] (Sandbox) Sandbox: curl(3473) deny(1) mach-lookup com.apple.system.opendirectoryd.libinfo
2000-01-01 00:00:00.000 E  kernel[0:35ba7] (Sandbox) Sandbox: curl(3473) deny(1) file-read-metadata /Users/me
2000-01-01 00:00:00.000 E  kernel[0:35a4f] (Sandbox) Sandbox: curl(3473) deny(1) mach-lookup com.apple.diagnosticd
2000-01-01 00:00:00.000 E  kernel[0:35a4f] (Sandbox) 4 duplicate reports for Sandbox: curl(3473) deny(1) mach-lookup com.apple.diagnosticd
2000-01-01 00:00:00.000 E  kernel[0:35a4f] (Sandbox) Sandbox: curl(3473) deny(1) mach-lookup com.apple.system.opendirectoryd.libinfo
2000-01-01 00:00:00.000 E  kernel[0:35a4f] (Sandbox) Sandbox: curl(3473) deny(1) mach-lookup com.apple.diagnosticd
2000-01-01 00:00:00.000 E  kernel[0:35a4f] (Sandbox) 3 duplicate reports for Sandbox: curl(3473) deny(1) mach-lookup com.apple.diagnosticd
2000-01-01 00:00:00.000 E  kernel[0:35a4f] (Sandbox) Sandbox: curl(3473) deny(1) mach-lookup com.apple.system.opendirectoryd.libinfo
2000-01-01 00:00:00.000 E  kernel[0:35ba8] (Sandbox) Sandbox: curl(3473) deny(1) file-read-data /Library/Preferences/.GlobalPreferences.plist
2000-01-01 00:00:00.000 E  kernel[0:35a4f] (Sandbox) Sandbox: curl(3473) deny(1) mach-lookup com.apple.diagnosticd
2000-01-01 00:00:00.000 E  kernel[0:3502d] (Sandbox) 1 duplicate report for Sandbox: curl(3473) deny(1) mach-lookup com.apple.diagnosticd
2000-01-01 00:00:00.000 E  kernel[0:3502d] (Sandbox) Sandbox: curl(3473) deny(1) mach-lookup com.apple.SystemConfiguration.configd
2000-01-01 00:00:00.000 E  kernel[0:3502d] (Sandbox) Sandbox: curl(3473) deny(1) mach-lookup com.apple.system.opendirectoryd.libinfo
2000-01-01 00:00:00.000 E  kernel[0:3502d] (Sandbox) Sandbox: curl(3473) deny(1) mach-lookup com.apple.diagnosticd
2000-01-01 00:00:00.000 E  kernel[0:35baa] (Sandbox) Sandbox: curl(3473) deny(1) file-read-data /Library/Preferences/com.apple.networkd.plist
2000-01-01 00:00:00.000 E  kernel[0:3502d] (Sandbox) Sandbox: curl(3473) deny(1) mach-lookup com.apple.diagnosticd
2000-01-01 00:00:00.000 E  kernel[0:35baa] (Sandbox) Sandbox: curl(3473) deny(1) file-read-data /Library/Preferences/com.apple.networkd.plist
2000-01-01 00:00:00.000 E  kernel[0:3502d] (Sandbox) Sandbox: curl(3473) deny(1) mach-lookup com.apple.diagnosticd
2000-01-01 00:00:00.000 E  kernel[0:35baa] (Sandbox) 2 duplicate reports for Sandbox: curl(3473) deny(1) mach-lookup com.apple.diagnosticd
2000-01-01 00:00:00.000 E  kernel[0:35baa] (Sandbox) Sandbox: curl(3473) deny(1) network-outbound /private/var/run/mDNSResponder
2000-01-01 00:00:00.000 E  kernel[0:3502d] (Sandbox) Sandbox: curl(3473) deny(1) mach-lookup com.apple.diagnosticd

Stripping out the irrelevent information and duplicates, we see that the following actions were denied:

file-read-data /dev/dtracehelper
file-read-data /Library/Preferences/.GlobalPreferences.plist
file-read-data /Library/Preferences/com.apple.networkd.plist
file-read-metadata /Users/me
mach-lookup com.apple.diagnosticd
mach-lookup com.apple.logd
mach-lookup com.apple.system.notification_center
mach-lookup com.apple.system.opendirectoryd.libinfo
mach-lookup com.apple.SystemConfiguration.configd
network-outbound /private/var/run/mDNSResponder

We can decide which of those we actually want to allow, and add them to the policy. For example, let’s allow everything it tried to do:

cat >> my-policy.sb <<EOF
(allow file-read-data
  (literal "/dev/dtracehelper")
  (literal "/Library/Preferences/.GlobalPreferences.plist")
  (literal "/Library/Preferences/com.apple.networkd.plist")
)
(allow file-read-metadata (subpath "/Users"))
(allow mach-lookup
  (global-name "com.apple.diagnosticd")
  (global-name "com.apple.logd")
  (global-name "com.apple.system.notification_center")
  (global-name "com.apple.system.opendirectoryd.libinfo")
  (global-name "com.apple.SystemConfiguration.configd")
)
(allow network-outbound (literal "/private/var/run/mDNSResponder"))
EOF

The syntax is readable enough. Whitespace and newlines are ignored, and parentheses () are used to group sections. Each section begins with allow or deny, then an action (which we can get from the logs), then optionally one or more requirements (variously called predicates or filters). The exact requirements depend on the type of action, but it is usually a path or identifier.

Running our sandboxed curl command now gives us a new error:

curl: (7) Failed to connect to example.com port 80 after 67 ms: Couldn't connect to server

The DNS lookup has succeeded, but the actual connection was blocked. Again, we can look at the sandbox logs to see what happened:

[...] Sandbox: curl(3575) deny(1) file-write-data /dev/dtracehelper
[...] Sandbox: curl(3575) deny(1) ipc-posix-shm-read-data apple.shm.notification_center
[...] 5 duplicate reports for Sandbox: curl(3575) deny(1) ipc-posix-shm-read-data apple.shm.notification_center
[...] Sandbox: curl(3575) deny(1) file-read-data /dev/autofs_nowait
[...] Sandbox: curl(3575) deny(1) file-read-data /Users/me/.CFUserTextEncoding
[...] Sandbox: curl(3575) deny(1) ipc-posix-shm-read-data apple.shm.notification_center
[...] Sandbox: curl(3575) deny(1) file-read-data /dev/autofs_nowait
[...] Sandbox: curl(3575) deny(1) file-read-data /Users/me/.CFUserTextEncoding
[...] Sandbox: curl(3575) deny(1) ipc-posix-shm-read-data apple.shm.notification_center
[...] 16 duplicate reports for Sandbox: curl(3575) deny(1) ipc-posix-shm-read-data apple.shm.notification_center
[...] Sandbox: curl(3575) deny(1) file-read-data /Users/me/Library/Preferences/ByHost/.GlobalPreferences.[snip].plist
[...] Sandbox: curl(3575) deny(1) ipc-posix-shm-read-data apple.shm.notification_center
[...] 2 duplicate reports for Sandbox: curl(3575) deny(1) ipc-posix-shm-read-data apple.shm.notification_center
[...] Sandbox: curl(3575) deny(1) file-read-data /Users/me/Library/Preferences/.GlobalPreferences.plist
[...] Sandbox: curl(3575) deny(1) ipc-posix-shm-read-data apple.shm.notification_center
[...] Sandbox: curl(3575) deny(1) file-read-data /Users/me/Library/Preferences/.GlobalPreferences_m.plist
[...] Sandbox: curl(3575) deny(1) ipc-posix-shm-read-data apple.shm.notification_center
[...] 6 duplicate reports for Sandbox: curl(3575) deny(1) ipc-posix-shm-read-data apple.shm.notification_center
[...] Sandbox: curl(3575) deny(1) system-socket domain:32 type:2 protocol:2
[...] Sandbox: curl(3575) deny(1) ipc-posix-shm-read-data apple.shm.notification_center
[...] Sandbox: curl(3575) deny(1) system-socket domain:32 type:2 protocol:2
[...] Sandbox: curl(3575) deny(1) ipc-posix-shm-read-data apple.shm.notification_center
[...] Sandbox: curl(3575) deny(1) network-outbound remote:*:80

Simplified and deduplicated:

file-read-data /dev/autofs_nowait
file-read-data /Users/me/.CFUserTextEncoding
file-read-data /Users/me/Library/Preferences/.GlobalPreferences_m.plist
file-read-data /Users/me/Library/Preferences/.GlobalPreferences.plist
file-read-data /Users/me/Library/Preferences/ByHost/.GlobalPreferences.[snip].plist
file-write-data /dev/dtracehelper
ipc-posix-shm-read-data apple.shm.notification_center
network-outbound remote:*:80
system-socket domain:32 type:2 protocol:2

A lot of these are noisy things that curl doesn’t strictly need. We could allow everything it wants, but for now let’s just give it the network access:

cat >> my-policy.sb <<EOF
(allow network-outbound)
(allow system-socket
  (require-all
    (socket-domain AF_SYSTEM)
    (socket-protocol 2)
  )
)
EOF

Now our curl command works!

<!doctype html><html lang="en"><head><title>Example Domain</title>[...]

It will still produce a lot of sandbox warnings when it runs, because we didn’t allow everything that it tries to do. It’s up to us to decide how much of the optional functionality we need or want to enable. If we decide to deny some actions, we can make that choice explicit to avoid the log noise:

cat >> my-policy.sb <<EOF
(deny file-write-data
  (literal "/dev/dtracehelper")
  (with no-log)
)
EOF

Explicitly denying and not logging these unnecessary actions is beneficial because it makes it easier to spot if the command suddenly starts trying to access something unexpected; an indication of a potential compromise.

Allowing access to the current working directory

Many commands (Node.js included) need access to files in the current working directory and the ability to traverse (if not read) the parent folders. Tools also commonly try to read configuration files from the current user’s home directory. sandbox-exec has no way to express these needs, but we can use a helper shell function to amend our policy:

sb-substitute() {
  # replace placeholders
  sed -e "s|PWD|$PWD|g" -e "s|HOME|$HOME|g" "$1";

  # allow access to list contents of parent directories
  echo '(allow file-read-metadata';
  local DIR="$PWD";
  while [ "$DIR" != "/" ]; do
    echo '(literal "'"$DIR"'")'
    DIR="$(dirname "$DIR")";
  done
  echo ')';
}

sb() {
  sandbox-exec -p "$(sb-substitute "$HOME/my-policy.sb")" "$@";
}

(these can be defined in ~/.zprofile if using the default zsh shell to make them globally available).

Now we can update our policy to allow read/write access to files in the current directory, and we can selectively exclude sub-directories (.git in this case):

cat >> my-policy.sb <<EOF
(allow file-read* file-write* (subpath "PWD"))
(deny file* file-read* file-write* (regex "\\.git(/|$)"))
EOF

And the wrapper function means we can execute commands inside our sandbox with:

sb curl example.com

Node.js policy

Node.js is a much bigger executable than curl[citation needed], but by following the same process explained above, I have derived this policy for running common Node.js programs. Depending on which features of Node.js you’re using, you may need to extend it.

(version 1)
(deny default)

; allow launching executables
(allow process-exec)
(allow process-fork)
(allow signal (target children))

; reading system information (e.g. OS detection) & shared preferences
(allow sysctl-read)
(allow file-read*
  (literal "/Library/Preferences/.GlobalPreferences.plist")
  (literal "/Library/Preferences/com.apple.networkd.plist")
  (literal "HOME/Library/Preferences/.GlobalPreferences.plist")
  (literal "HOME/Library/Preferences/.GlobalPreferences_m.plist")
  (literal "HOME/.CFUserTextEncoding")
)
(allow user-preference-read
  (preference-domain "kcfpreferencesanyapplication")
)

; adding extra restrictions
(allow file-chroot)

; shared memory access (Node.js wants this, but does work without it)
(allow ipc-posix-shm-read-data)

; listening on local ports and connecting to local services
(allow network-bind (local ip "localhost:*"))
(allow network-inbound (local ip "localhost:*"))
(allow network-outbound
  (remote ip "localhost:*")
  (remote unix-socket (path-literal "/private/var/run/mDNSResponder"))
)
(allow system-socket (require-all (socket-domain AF_SYSTEM) (socket-protocol 2)))

; configuring terminal
(allow file-read-data (require-all (file-mode #o0004) (regex "^/dev/tty")))
(allow file-ioctl file-write-data (require-all (file-mode #o0002) (regex "^/dev/tty")))
(allow file-ioctl file-read-data file-write-data (regex "^/dev/ttys"))

; common devices
(allow file-read*
  (literal "/dev/random")
  (literal "/dev/urandom")
  (literal "/dev/autofs_nowait")
)
(allow file-read* file-write-data
  (literal "/dev/null")
  (literal "/dev/zero")
)

; reading files in most places (as long as they are guest-readable)
(allow file-read* (require-all (file-mode #o0004) (require-any
  (literal "/")
  (literal "/dev")
  (subpath "/System")
  (subpath "/Library/Preferences/Logging")
  (subpath "/bin")
  (subpath "/sbin")
  (subpath "/usr")
  (subpath "/etc")
  (subpath "/var")
  (subpath "/tmp")
  (subpath "/private")
)))
(allow file-read-metadata (subpath "/dev"))
(allow file-read-metadata (require-all (file-mode #o0001) (require-any
  (literal "/opt")
  (literal "/Library")
  (literal "/Users")
)))

; temp files
(allow file-read* file-write* (subpath "/private/var/folders"))
(deny file-write* (literal "/private/var/folders"))

; libraries used by Node.js
(allow mach-lookup
  (global-name "com.apple.coreservices.launchservicesd")
  (global-name "com.apple.diagnosticd")
  (global-name "com.apple.FSEvents")
  (global-name "com.apple.logd")
  (global-name "com.apple.SystemConfiguration.DNSConfiguration")
  (global-name "com.apple.system.notification_center")
  (global-name "com.apple.system.opendirectoryd.libinfo")
  (global-name "com.apple.SystemConfiguration.DNSConfiguration")
)
(allow file-ioctl file-read* file-write-data (literal "/dev/dtracehelper"))

; access to npm-related files and folders
(allow file-read-metadata (literal "HOME") (subpath "HOME/.npm"))
(allow file-read-data (literal "HOME/.npm/_logs"))
(allow file-read* file-write* (subpath "HOME/.npm/_logs") (subpath "HOME/.npm/_cacache"))
(allow file-read* (subpath "/usr/local/lib/node_modules"))

; read/write access to all files in the current directory, except .git
(allow file-read* file-write* (subpath "PWD"))
(deny file* file-read* file-write* (regex "\\.git(/|$)"))

; explicitly deny access to some locations, even if the files are accidentally world-readable
(deny file* file-read* file-write*
  (subpath "HOME/.ssh")
  (literal "HOME/.npmrc")
  (literal "HOME/.gitconfig")
  (with no-log) ; we expect legitimate tasks to try reading these files sometimes
)

As explained in the section above, this policy has some placeholders that need to be substituted. To do this, I’ve added the following scripts to my ~/.zprofile file, which expects to find the above policy saved as ~/nodejs-policy.sb:

sb-substitute() {
  sed -e "s|PWD|$PWD|g" -e "s|HOME|$HOME|g" "$1";

  # allow access to list contents of parent directories
  echo '(allow file-read-metadata';
  local DIR="$PWD";
  while [ "$DIR" != "/" ]; do
    echo '(literal "'"$DIR"'")'
    DIR="$(dirname "$DIR")";
  done
  echo ')';
}

# Sandbox without network access
sb() {
  sandbox-exec -p "$(sb-substitute "$HOME/nodejs-policy.sb")" "$@";
}

# Sandbox with network access (e.g. for npm install)
sb-net() {
  sandbox-exec -p "$(sb-substitute "$HOME/nodejs-policy.sb") (allow network-outbound)" "$@";
}

This defines two commands:

Important: Both sb and sb-net block access to ~/.npmrc (which may contain secrets), so if you’re relying on your global .npmrc file to set the --ignore-scripts flag or other security features, you’ll need to switch to putting that in per-project files (recommended), or adding it explicitly when you run the command.

With this setup, I’ve got into the habit of running:

sb-net npm install
sb npm test
sb npm start
# etc

Limitations

Once a process is inside a sandbox, it cannot get out by any means. This is a good thing for security, but means you cannot perform activities like launching a browser (e.g. for tests). With the necessary permissions, it is possible to communicate with existing processes (such as a docker dæmon), which may provide a suitable escape-hatch without compromising security too much (the exact balance will depend on your situation).

More information / further reading