You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit 679e307
Browse filesBrowse the repository at this point in the historyBrowse files
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.
python3 node_runtime_audit.py # as the user that owns the Node processes; root is not needed
6
6
```
7
7
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).
9
9
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.
11
11
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.
13
13
14
14
Written for the DevOps Daily post: https://devops-daily.com/posts/node-october-2026-security-release-find-every-node
15
15
16
16
## What it checks
17
17
18
-
|Where|How|
18
+
|Source|What it reports|
19
19
| --- | --- |
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. |
27
25
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.
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
34
33
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
35
34
```
36
35
@@ -46,32 +45,34 @@ for h in deploy@web1 deploy@web2; do ssh "$h" 'python3 - --json' < node_runtime_
46
45
-`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`.
47
46
-`scripts/record-run.sh`: records a redacted run under `examples/`.
48
47
-`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.
50
50
51
51
## Results
52
52
53
53
-`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.
55
56
-`results/registry-20261022T062338Z.json`: 24 official tags, amd64 and arm64.
56
57
-`results/pgrep-vs-proc-20261022T063334Z.txt`: `pgrep -c node` returns 0 on the Pi while five processes run a `node` binary.
57
58
58
59
Facts and their sources are in [SOURCES.md](SOURCES.md).
59
60
60
61
## Safety
61
62
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:
63
64
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.
70
70
-**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.
71
71
72
72
## Limits
73
73
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).
75
76
- 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.
76
77
- A Node binary renamed to something other than `node` or `nodejs` (single executable applications, Electron) is not detected as a process.
77
78
- 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.
Copy file name to clipboardExpand all lines: SOURCES.md
+2-1Lines changed: 2 additions & 1 deletion
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -83,7 +83,8 @@ From `doc/changelogs/CHANGELOG_V22.md`, `CHANGELOG_V24.md` and `CHANGELOG_V26.md
83
83
84
84
-`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`.
85
85
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`.
87
88
- 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.
88
89
- 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.
0 commit comments