We ran a compromised dependency under the process firewall: what it refused, and what it didn't

Short answer: on a supported Linux host, Dynv's process firewall can refuse a service's direct attempts to execute programs you haven't allowed, make TCP connections to ports you haven't allowed, and write outside paths you list. In our test a stand-in compromised dependency tried all of those, and all were refused. It is not a sandbox: the package kept running inside the allowed runtime, and we show two things it could still do.

Supply-chain incidents end with “rotate your credentials” (the playbook). The firewall is a different layer: it limits a few concrete things a service, and so a dependency inside it, can do. This post is a tested run, not a claim of containment. The scope is exactly what the firewall docs say: direct path-based exec, ordinary TCP connect and bind by port, and listed write paths, enforced on supported Linux. It does not contain arbitrary code running inside an allowed runtime.

The setup

Everything ran on 2026-11-01 on a local throwaway server with fake data, on Fedora (kernel 6.19, Landlock enabled), using the current CLI. The service is a small Node app with a fake dependency, analytics-helper, that behaves like a compromised package. At startup it tries five things and prints each result:

  1. run /bin/sh -c id
  2. run curl
  3. drop a file in an uploads directory and execute it
  4. connect to an “attacker” listener on port 6667
  5. write a file outside the allowed path

The app itself legitimately connects to a “database” on port 9501 and writes to its uploads directory. The attacker and database are local listeners that count connections.

A. No policy: everything works

[app] connect to database :9501          connected
[app] write to uploads                   ok
[analytics-helper] run /bin/sh -c id                  ran: zalkanorr
[analytics-helper] run curl                           ran: curl 8.11.1 (x86_64-redhat-linux-gnu) ...
[analytics-helper] run a dropped binary               ran: payload-ran
[analytics-helper] connect to attacker :6667          connected
[analytics-helper] write outside the allowed path     wrote it

The attacker listener saw one connection. This is the baseline: without a policy, dynv run does not restrict anything.

B. Learning mode, and a caution about it

The recommended first step is learn: observe what the service does for a normal working period, then propose a policy to review.

$ dynv firewall learn --process shop --yes
$ dynv firewall propose --process shop
Proposed policy for "shop":
  exec.allow:       /tmp/.../uploads/payload, /usr/bin/id
  net.connect_tcp:  6667, 9501
⚠ Seen during learning but NOT auto-allowed (tick only if you expect them):
  /bin/sh, node, /usr/bin/curl
  note: an allowed exec directory the service can write to is not an execution boundary ...

Read that carefully. Because the dependency was already misbehaving while learning, its attacker port (6667), its dropped payload and /usr/bin/id landed in the proposed allowlist. Shells and network tools such as /bin/sh and curl were not ticked automatically, which is by design, but the learned set still contained the attacker’s port and payload. The docs say this plainly: “a dependency already malicious during learning has its actions learned as normal; review proposals rather than treating learning as a trust boundary.” Always read the proposal; don’t apply it blind.

C. Enforce, with a policy written by hand

We wrote the policy ourselves: the Node binary and the app directory may be executed; only port 9501 may be connected to; writes are allowed only to the uploads directory (which is not in the exec list, so a dropped file there cannot be run).

{ "v": 1, "mode": "enforce",
  "exec": { "allow": ["/home/me/.nvm/versions/node/v24.15.0/bin/node", "/tmp/fw/app"], "allow_system": "interpreter" },
  "net": { "connect_tcp": [9501], "bind_tcp": [] },
  "fs": { "write": ["/tmp/fw/uploads"] },
  "on_violation": "report" }
$ dynv firewall apply --process shop --policy policy.json --yes
firewall policy applied to "shop" (document v4)

[app] connect to database :9501          connected
[app] write to uploads                   ok
[analytics-helper] run /bin/sh -c id                  refused (EACCES)
[analytics-helper] run curl                           refused (EACCES)
[analytics-helper] run a dropped binary               refused (EACCES)
[analytics-helper] connect to attacker :6667          refused (EACCES)
[analytics-helper] write outside the allowed path     refused (EACCES)

The app’s legitimate work was unaffected, every attempt by the package failed, and the attacker listener saw zero connections. The service reported what it actually applied and what it stopped:

$ dynv firewall status --process shop
  level full / requested enforce [ok]  blocked: exec 3, connect 1, bind 0, write 0
$ dynv firewall violations --process shop
  Exec /bin/sh [blocked]
  Exec /usr/bin/curl [blocked]
  Exec /tmp/.../uploads/payload [blocked]
  Connect 127.0.0.1:6667 [blocked]

Two honest notes on that report. The out-of-path write was refused (EACCES), but the report shows write 0: violation reporting is best effort, and this denial was not counted. And the status list also contained the earlier learning-mode instance; the line above is the enforcing one. The level full means every layer in the policy is armed, per the docs, and is “not a claim of full containment.” The policy is a field in your signed, encrypted service document, so the server cannot weaken it, and the server only sees counts, never paths, ports or program names.

D. What it did not stop

With the same enforcing policy we made the package try two more things:

[analytics-helper] --- things the firewall does not cover ---
[analytics-helper] run /bin/sh through the loader     ran: zalkanorr
[analytics-helper] connect to attacker host :9501     connected
  • An allowed loader can run other programs. The kernel opens a dynamic binary’s ELF interpreter for execution, so the policy allows it, and ld-linux … /usr/bin/id ran. The docs list this: “Allowed loaders and interpreters remain execution routes”, and memfd, native libraries and JIT bypass path rules too.
  • Ports are not hosts. We allowed 9501 for the database, and a connection to a different host on 9501 succeeded. “Allowing outbound 443 allows any host on 443, including an attacker’s.”

Also in the docs and worth knowing: the firewall does not cover UDP (unless you set deny_udp), DNS, SCTP or inherited and received sockets; a registered binfmt_misc handler can run an interpreter on a file you allowed; and a process with the service’s user can reset the local cache and floor that protects against policy stripping. An attacker with code execution inside the allowed runtime still has everything that runtime can do within those limits, including reading the process’s environment. Moving secrets out of files and rotating after an incident remain the controls for that.

Where this fits

  • It reduces common direct actions, such as a shell, a downloader or a connection to a port you never use. It does not make a malicious package safe.
  • It pairs with monitoring and malicious-package alerts, which tell you a package is known bad, and with rotation, which limits what a stolen secret is worth.
  • Kernel support decides the level: Linux 6.7+ with Landlock gives full; 5.13 to 6.6 gives exec_only (an enforce policy that lists ports refuses to start unless fail_open is set); Docker’s default seccomp profile and Kubernetes RuntimeDefault behave as the host; gVisor and macOS have no protection. Dynv never claims more than what the instance measured at start.
  • Want the mechanism without Dynv? See Sandbox a Node process with Landlock and seccomp.

Limits of this test

  • One kernel, one runtime, fake targets. The stand-in package is ours, written to try common behaviours, not a real sample.
  • The write-denial count and the status listing of two instances are as observed; we did not tune them.
  • Real policies need a working period in learn mode on your actual service, and a review.

Facts last verified . Spotted an error? Tell us.

New here? Start here for the guides in a sensible order.