Skip to content

Commit 679e307

Browse files
committed
Read only /proc, pm2 jlist, crontab -l and unit files
Binaries named in configs are no longer opened: their version comes from a running process that uses the same file, or from the path as a hint. The saved pm2 list, system crontabs, EnvironmentFile= targets and extra scan paths are not read. The pm2 daemon is found in /proc. Embedded versions count only from complete reads of ELF files.
1 parent 9c96331 commit 679e307

8 files changed

Lines changed: 630 additions & 536 deletions

File tree

‎README.md‎

Lines changed: 25 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -5,32 +5,31 @@ curl -fsSLO https://raw.githubusercontent.com/The-DevOps-Daily/node-runtime-audi
55
python3 node_runtime_audit.py # as the user that owns the Node processes; root is not needed
66
```
77

8-
Patching Node is a one-line version bump. Finding every Node your servers actually run is the work. This script lists the Node binary behind every running process and every place that will start Node later, and checks each one against the [Node.js security release of 21 October 2026](https://nodejs.org/en/blog/vulnerability/october-2026-security-releases) (fixed in 22.23.4, 24.22.1 and 26.12.1; every end-of-life line counts as vulnerable).
8+
Patching Node is a one-line version bump. Finding every Node your servers actually run is the work. This script lists the Node binary behind every running process and the places that will start Node later, and checks each one against the [Node.js security release of 21 October 2026](https://nodejs.org/en/blog/vulnerability/october-2026-security-releases) (fixed in 22.23.4, 24.22.1 and 26.12.1; every end-of-life line counts as vulnerable).
99

10-
It is one file of standard-library Python (3.8+, Linux). It only reads: it never restarts, edits or upgrades anything, and it never runs a binary it finds on the host (see Safety below).
10+
It is one file of standard-library Python (3.8+, Linux). It never restarts, edits or upgrades anything, and it reads only five things: `/proc`, `pm2 jlist`, `crontab -l`, systemd unit files, and `node --version` inside running containers.
1111

12-
Run it as the user that owns the Node processes (the pm2 user, the app user). Running it as root is not needed: it sees that user's processes, crontab, pm2 lists, version managers and units. On a host where several users run Node, run it once as each of them.
12+
Run it as the user that owns the Node processes (the pm2 user, the app user). Running it as root is not needed. On a host where several users run Node, run it once as each of them.
1313

1414
Written for the DevOps Daily post: https://devops-daily.com/posts/node-october-2026-security-release-find-every-node
1515

1616
## What it checks
1717

18-
| Where | How |
18+
| Source | What it reports |
1919
| --- | --- |
20-
| Running processes | `readlink /proc/<pid>/exe` for the real binary, then the version compiled into that file, read through `/proc/<pid>/exe`, so it works for deleted binaries and for processes inside containers. A version that only a directory name gives is shown as a hint ("from path") and stays unknown. |
21-
| pm2, live | `pm2 jlist` for your own `PM2_HOME` when its daemon is running: the daemon's binary, each entry's interpreter and script when they point into one Node install, the first `node` on the entry's saved `PATH` (resolved against the app's cwd), the daemon's Node for cluster mode (the daemon forks the workers), and the Node processes really running under the entry. |
22-
| pm2, saved | `dump.pm2`, the list `pm2 resurrect` and a reboot start. fnm multishell directories on its `PATH` are skipped, because they do not survive a reboot. |
23-
| Version managers | nvm, fnm, volta, asdf, mise, nodenv and n installs and the default alias, each judged by reading the binary. |
24-
| Crontabs, systemd units | Absolute paths to `node`, `npm`, `npx` and anything inside a versioned Node install (`.../v24.14.0/...`), including `PATH=` lines, `EnvironmentFile=` files, `/etc/cron.daily` and similar scripts, and `pm2 startup` units. `npm` and `npx` start the first `node` on `PATH`, so a fixed Node next to them only counts as unknown. |
25-
| Other files | `--scan-path DIR` searches scripts for the same paths. |
26-
| Containers | `docker exec <id> node --version` (and podman) in each running container, plus `NODE_VERSION` from the image config. `--no-container-exec` uses the image metadata only, which is a hint and stays unknown. |
20+
| `/proc` | Every running process whose binary (`readlink /proc/<pid>/exe`) is `node`, with the version compiled into that file, read through `/proc/<pid>/exe`. That works for deleted binaries and for processes inside containers. The parent chain shows `npm start` to `node`, `bun` to `next-server` and similar. |
21+
| `pm2 jlist` | For your own `PM2_HOME`, when its daemon runs: the daemon's binary, each entry's interpreter and script when they point into one Node install, the first `node` on the entry's saved `PATH` (resolved against the app's cwd), the daemon's Node for cluster mode (the daemon forks the workers), and the Node processes running under each entry. |
22+
| `crontab -l` | Absolute paths to `node`, `npm`, `npx` and anything inside a versioned Node install (`.../v24.14.0/...`), including `PATH=` lines. |
23+
| systemd unit files | The same paths in units and drop-ins owned by root or by you, for example the unit `pm2 startup` writes. An `EnvironmentFile=` of a unit that runs Node is listed as not read. |
24+
| `docker exec <id> node --version` | The Node on each running container's `PATH` (Docker and Podman). `--no-container-exec` uses the image's `NODE_VERSION` only, which stays unknown. |
2725

28-
Exit code: **1** if anything runs or will start a vulnerable Node, else **2** if anything it looked at could not be checked (an unreadable config, a failed `pm2 jlist` or `docker ps`, a binary without an embedded version), else **0**. Unknown is never reported as patched. Distribution builds that link the system Brotli library (Debian's `nodejs`) are reported as unknown, because the fix for them comes from the distribution's packages, not from the version number.
26+
Node binaries named in pm2 entries, crontabs and units are not opened. Their version comes from a running process that uses the same file; otherwise from the path as a hint: a path that names an unfixed version (`.../v24.14.0/bin/node`) counts as vulnerable, one that names a fixed version stays unknown. nvm, fnm, volta, asdf, mise, nodenv and n installs are listed the same way, for information.
27+
28+
Exit code: **1** if anything runs or will start a vulnerable Node, else **2** if anything it looked at could not be checked (a failed `pm2 jlist`, `crontab -l` or `docker ps`, an unreadable unit, a binary without an embedded version), else **0**. Unknown is never reported as patched. Distribution builds that link the system Brotli library (Debian's `nodejs`) are reported as unknown, because the fix for them comes from the distribution's packages, not from the version number.
2929

3030
```bash
3131
python3 node_runtime_audit.py --json # machine-readable
32-
python3 node_runtime_audit.py --redact # hide user names, home dirs and pm2 app names
33-
python3 node_runtime_audit.py --scan-path ~/bin # also search your helper scripts
32+
python3 node_runtime_audit.py --redact # hide user names, home dirs and app names
3433
python3 node_runtime_audit.py --fixed 22=22.23.4,24=24.22.1,26=26.12.1 # the default; change it for the next release
3534
```
3635

@@ -46,32 +45,34 @@ for h in deploy@web1 deploy@web2; do ssh "$h" 'python3 - --json' < node_runtime_
4645
- `lab/pm2-pin-lab.sh OLD_BIN NEW_BIN`: starts four pm2 apps (bare `node`, `npm start`, an absolute `--interpreter`, cluster mode) in a throwaway `PM2_HOME` on the old Node, switches `PATH` to the new one and records which binary each process really runs after `restart`, `restart --update-env`, delete and recreate, and `pm2 update`.
4746
- `scripts/record-run.sh`: records a redacted run under `examples/`.
4847
- `scripts/pgrep-vs-proc.sh`: compares `pgrep node` with the processes whose real binary is `node`.
49-
- `tests/`: `python3 -m unittest discover -s tests -v`. Fake hosts with the cases a version check gets wrong: a deleted binary still running, a container binary, a distribution build, a pm2 entry whose config is fixed but whose process was never restarted, `npm` pinned to the old install while its child gets the new one, cluster mode, end-of-life and pre-release versions, cron jobs on a removed version, an old binary in a directory named after a fixed version. And the safety cases: fake `node` scripts that leave a marker if anything runs them, FIFOs, hard links and symlinks where a file is expected, escape codes in command lines and container names.
48+
- `scripts/pm2-path-evidence.py`: prints which Node install the saved pm2 list and the daemon's child processes have first on `PATH` (evidence for the post; not part of the audit).
49+
- `tests/`: `python3 -m unittest discover -s tests -v`. Fake hosts with the cases a version check gets wrong: a deleted binary still running (also tested with a real process), a container binary, a distribution build, a pm2 entry whose config is fixed but whose process was never restarted, `npm` pinned to the old install while its child gets the new one, cluster mode, end-of-life and pre-release versions, a cron job on a removed version, an old binary in a directory named after a fixed version. And the safety cases: fake `node` scripts that leave a marker if anything runs them, a shell wrapper that mentions a fixed version, FIFOs, devices, hard links and symlinks where a file is expected, units owned by other users, escape codes in command lines and container names.
5050

5151
## Results
5252

5353
- `results/pm2-lab-20261022T063002Z.txt`: pm2 6.0.14 with Node v24.14.0 and v24.22.1.
54-
- `examples/run-20261022T065509Z.txt`: the script on our Raspberry Pi.
54+
- `examples/run-20261022T070555Z.txt`: the script on our Raspberry Pi.
55+
- `results/pm2-path-20261022T070504Z.txt`: the pm2 app's `PATH` on the Pi, saved and running.
5556
- `results/registry-20261022T062338Z.json`: 24 official tags, amd64 and arm64.
5657
- `results/pgrep-vs-proc-20261022T063334Z.txt`: `pgrep -c node` returns 0 on the Pi while five processes run a `node` binary.
5758

5859
Facts and their sources are in [SOURCES.md](SOURCES.md).
5960

6061
## Safety
6162

62-
Everything the script finds can come from someone else: other users' binaries and files, container names, process titles. So:
63+
Everything the script finds can come from someone else: binaries, unit files, container names, process titles. So:
6364

64-
- **It never runs a binary it finds on the host.** The only version source is the binary itself: the `node-vX.Y.Z-headers.tar.gz` URL that official builds compile in. A version that only a directory name gives is a hint and never counts as patched. Anything else is unknown.
65-
- **Files are pinned before they are read.** A binary or config file is opened with `O_PATH` (which never runs a device's open and never blocks on a FIFO), checked with `fstat` to be a regular file with exactly one link (a deleted binary seen through `/proc/<pid>/exe` has none), and only then reopened for reading through `/proc/self/fd/N` with `O_RDONLY | O_NONBLOCK`, so nothing can be swapped in between. A binary path from a config file is opened with `O_NOFOLLOW`: if its last component is a symlink, the result is unknown. Reads are capped, binaries are read in chunks rather than mapped, and a config file that is too large or unreadable makes the result unknown rather than clean.
66-
- **`--exec-version` is opt-in.** It runs `node --version` only when root (or you, when you are not root) owns the file and every directory from `/` down to it, none of them is writable by group or others, the file has one link and is the inode that was read, and it executes it through `/proc/self/fd/N`. As root it refuses any binary not owned by root. If the result disagrees with the embedded version, the answer is unknown.
67-
- **Containers.** By default it asks Docker or Podman to run `node --version` inside each running container. That runs the container's own binary inside the container, never on the host. `--no-container-exec` turns it off.
68-
- **pm2.** It runs `pm2 jlist` only for your own `PM2_HOME`, only when the pid file points at a live `PM2 v...` daemon (`pm2 jlist` starts a daemon otherwise), and only when the `pm2` CLI is owned by you or root and not writable by group or others.
69-
- **Output is sanitized.** Every control character (`0x00` to `0x1f`, `0x7f` and `0x80` to `0x9f`) from command lines, process names, file names and container names is printed as `\xNN`, in text and in JSON, so nothing can send escape sequences to your terminal.
65+
- **It never runs a binary it finds on the host.** The only version source for a running process is its binary, read through `/proc/<pid>/exe`: the `node-vX.Y.Z-headers.tar.gz` URL that official builds compile in, and only from an ELF file (a shell wrapper can mention any version). The read must be complete and the file unchanged while it is read. A version that only a directory name gives is a hint and never counts as patched.
66+
- **Files are pinned before they are read.** `/proc/<pid>/exe` and unit files are opened with `O_PATH` (which never runs a device's open and never blocks on a FIFO), checked with `fstat` to be a regular file with exactly one link (a deleted binary has none), and only then reopened for reading through `/proc/self/fd/N` with `O_RDONLY | O_NONBLOCK`, so nothing can be swapped in between. Unit files owned by anyone other than root or you are skipped, and nothing a unit refers to (`EnvironmentFile=`, scripts) is read.
67+
- **The commands it runs** are `pm2 jlist` (your own pm2, as you, only when a `PM2 v... God Daemon (<PM2_HOME>)` process of yours is running and the `pm2` CLI is owned by you or root and not writable by group or others), `crontab -l`, `systemctl show` for pm2 units, and `docker`/`podman` `ps`, `inspect` and `exec <id> node --version`. The last one runs the container's own binary inside the container, never on the host; `--no-container-exec` turns it off.
68+
- **`--exec-version` is opt-in.** It runs `node --version` on binaries named in pm2 entries, crontabs and units only when root (or you, when you are not root) owns the file and every directory from `/` down to it, none of them is writable by group or others, and the file has one link; it executes the pinned file through `/proc/self/fd/N`. As root it refuses any binary not owned by root.
69+
- **Output is sanitized.** Every control character (`0x00` to `0x1f`, `0x7f` and `0x80` to `0x9f`) from command lines, process names, file names and container names is printed as `\xNN`, in text and in JSON.
7070
- **Secrets stay out.** It prints only the first two arguments of a command line and drops any that contain `=`. From pm2 environments, crontabs and units it prints only the Node paths, never whole lines or other variables.
7171

7272
## Limits
7373

74-
- Linux only (`/proc`). It sees the processes, crontab and pm2 of the user running it; other users' processes are counted as not inspected. Run it as each user that owns Node processes.
74+
- Linux only (`/proc`). It sees the processes, crontab, pm2 and user units of the user running it; other users' processes are counted as not inspected.
75+
- System crontabs (`/etc/crontab`, `/etc/cron.d`, `/etc/cron.daily`) and the saved pm2 list (`dump.pm2`) are not read. Check them with `grep` (the post has the command).
7576
- A bare `node` in a cron job or unit is not resolved through that job's `PATH`; only absolute paths and `PATH=` lines are checked.
7677
- A Node binary renamed to something other than `node` or `nodejs` (single executable applications, Electron) is not detected as a process.
7778
- Kubernetes is not covered. Stopped containers and images that are not running are not covered. In a container, `node --version` reports the `node` on the container's `PATH`, not necessarily every Node in the image.

‎SOURCES.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,8 @@ From `doc/changelogs/CHANGELOG_V22.md`, `CHANGELOG_V24.md` and `CHANGELOG_V26.md
8383

8484
- `results/pgrep-vs-proc-20261022T063334Z.txt`: on the Pi, `pgrep -c node` and `pgrep -c -x node` both returned 0 while five processes ran a `node` binary, named `PM2 v6.0.14: Go`, `MainThread` and `next-server (v1` in `/proc/<pid>/comm`.
8585

86-
- `examples/run-20261022T065509Z.txt` and `.json`: `node_runtime_audit.py --redact` on the Raspberry Pi we build on, 2026-10-22T06:55:09Z, as the user that owns the Node processes. In that run the script refused to run the `pm2` CLI because the file is world-writable (mode 777), so the live pm2 list is skipped and reported as unknown.
86+
- `examples/run-20261022T070555Z.txt` and `.json`: `node_runtime_audit.py --redact` on the Raspberry Pi we build on, 2026-10-22T07:05:55Z, as the user that owns the Node processes. The script refused to run the `pm2` CLI because the file is world-writable (mode 777), so the live pm2 list is skipped and reported as unknown.
87+
- `results/pm2-path-20261022T070504Z.txt`: on the same Pi, the saved pm2 list (`dump.pm2`) and the process the pm2 daemon started (`/proc/<pid>/environ`) both have the v24.14.0 install first on `PATH`.
8788
- On the same Pi, the first line of the app's `node_modules/.bin/next` is `#!/usr/bin/env node` (checked with `head -1`), which is why `bun run dev` ends up starting Node processes.
8889
- The fnm `default` alias on the Pi was changed to v24.22.1 at 06:03 UTC on 22 October (the symlink's mtime); the pm2 daemon kept running v24.14.0.
8990

0 commit comments

Comments
 (0)