Installed-code integrity
Every run checks that the runnable's installed code is still what was installed.
If someone edited a file inside the venv (a quick fix on the box that never made
it back to the repo), the run still goes ahead, but it is recorded as
modified: a warning on stderr, a modified badge on the run in the console's
History, and an audit event that also goes to the OS log.
Nothing to set up. It is on for every runnable from runspec 0.81.0, and for Node runnables from runspec-node 0.47.0 (below).
What gets checked
When a package is installed, pip writes a RECORD file listing every file it
installed with its SHA-256 hash. On each run, runspec.parse() hashes those
files again and compares:
- the package that ships the runnable's
runspec.toml, and - each package it depends on directly, which for a typical runnable is its
-corepackage and runspec itself.
That covers the Python code, the runspec.toml (so an edited autonomy or a
removed password type is caught too) and the console scripts. It adds about
10 ms to a run: runspec-linux and its dependencies are about 200 files.
It doesn't check:
- Byte-code (
__pycache__/*.pyc). pip records it without a hash. - New files dropped into a package folder. An edited file that imports one is still caught.
- Packages further down the dependency tree (
httpx,requests, …).runspec verify --allchecks those on demand.
Development installs are left alone
How a package was installed is recorded next to it (direct_url.json, PEP 610),
and the check uses that:
| Installed with | Checked | Shown in History |
|---|---|---|
pip install mypkg from PyPI or a private index |
yes | nothing, unless modified |
pip install -e . (editable) |
no | nothing |
pip install . from a folder |
yes | local build |
pip install ./mypkg-1.0-py3-none-any.whl |
yes | local build |
pip install git+https://… |
yes | local build |
An editable install points at your working tree, so you can edit and run as
much as you like. A spec that isn't part of an installed package at all (a
script next to its runspec.toml, a project checkout) isn't checked either.
local build isn't a warning, just a record. It says the code didn't come from a
package index, so it may never have been committed or published. That's the
other half of the "edited on the box" problem: code built from someone's
uncommitted checkout.
When a file has changed
The run carries on. On stderr:
⚠ which: 2 installed files differ from the package (runspec-linux 0.20.0, runspec-linux-core 0.14.1): runspec_linux/files.py, runspec_linux_core/__init__.py. This run is recorded as modified. Reinstall the package to restore it; "runspec verify" lists the changes.
And it is recorded:
- The run record (
run_summary) and the runs index carry"integrity": "modified"and the changed files, so History shows a redmodifiedbadge on that run, with the files in its tooltip. - An audit event,
runnable.modified(outcomedetected), naming the package, its version and the changed files. It goes to the venv's hash-chainedlogs/audit.jsonland to syslog / the Windows event log, so the person who edited the file can't quietly remove the record once your log shipping has picked it up. It is written once per distinct change, so a runnable that runs every minute doesn't repeat it; a further edit is a new event.
To put it right, reinstall the package:
pip install --force-reinstall --no-deps runspec-linux==0.20.0
runspec verify
Check on demand, for example from a monitoring job or before a change window:
runspec verify # every runnable package in this venv + what it depends on directly
runspec verify runspec-linux # one package (or the package that ships a runnable: runspec verify which)
runspec verify --all # every package in the venv
runspec verify --json # for scripts
runspec 0.80.0 index ok 90 files
runspec-linux 0.20.0 index modified 1 of 73 files changed
changed runspec_linux/files.py
runspec-linux-core 0.14.1 index ok 24 files
✗ 1 distribution(s) modified since install. Reinstall to restore: pip install --force-reinstall --no-deps <name>==<version>
It exits 1 when anything is modified and records nothing.
What it can and can't promise
This is tamper-evident, not tamper-proof. Anyone who can edit a file in the
venv can also edit pip's RECORD, edit runspec itself, or change the runnable so
it never calls parse(). Module code also runs when it's imported, before
parse() is reached. The check catches the habit of fixing things in place and
forgetting to commit them. It won't stop someone who sets out to hide a change.
If the real worry is a person reading secrets by editing a runnable, the fix is who owns the code, not a checksum:
- Install the venv as an account the operators can't become, and run tools as
another account that can read but not write it (
run_as). Therunspec-linux-roottools already work this way: install them into a root-owned venv so only root can change code that root runs. - Keep secrets out of files on the box: pass them through the console's credentials, which reach the runnable at run time and are never written to disk or a command line.
Switching it off
RUNSPEC_INTEGRITY=off skips the check, for test suites or a host where a known
local change is accepted for now. There is no runspec.toml setting: a package
can't switch off its own check.
Node runnables
The Node pack (runspec-node 0.47.0) runs the same check, with one difference:
npm doesn't record a hash for each installed file, so runspec has to get the
known-good hashes from somewhere else.
What's checked: the project folder (the one with runspec.toml and
package.json, as deployed) and each of its direct
dependencies in node_modules, runspec-node included.
Where the hashes come from, in order:
-
runspec-files.jsonin the package. A publisher writes it withrunspec manifest, which hashes every filenpm packwould publish. Add it as the last step ofprepack, after the build:"scripts": { "build": "tsc", "prepack": "npm run build && runspec manifest" }If
package.jsonhas afileslist, addrunspec-files.jsonto it.runspec-nodeships its own list from 0.47.0. -
A snapshot from the first run. A package without a list is hashed the first time a runnable runs after it's installed, and the hashes are kept in
logs/.integrity-baseline.json. Later runs compare against that. An edit made before that first run becomes part of the snapshot and is never reported, so publish with a list where you can. After reinstalling a package without a list,runspec verify --snapshottakes the snapshot again.
For the project folder the snapshot leaves out logs/, bin/ (the shims
runspec bin writes), package-lock.json, dot-files such as .runspec_env,
and node_modules.
Development is left alone. Nothing is checked when the project folder is in
a git checkout (it has a .git folder, or a folder above it does). A dependency
installed as a symlink (npm link, npm install ../my-lib) is skipped. One
installed from a .tgz file or from git is recorded as local build, the same
as in Python.
When a file has changed, the run goes ahead with the same stderr warning,
and the run record carries integrity: "modified" and the changed files when
[config.logging] is on, so the console's History shows the badge. From
runspec-node 0.48.0 it also records a runnable.modified audit event
once per distinct change, remembered in logs/.integrity-state.json.
npx runspec verify # the project + its direct dependencies
npx runspec verify runspec-node # one package (a runnable name means the project)
npx runspec verify --all # every package in node_modules
npx runspec verify --snapshot # take the snapshot again for packages without a list
mytools 1.0.0 index list modified 1 of 3 files changed
changed greet.js
runspec-node 0.47.0 index list ok 146 files
✗ 1 package(s) modified since install. Reinstall to restore them (npm install, or unpack the package again).
The third column says where the hashes came from: list, snapshot, or
none (no list, and no run has taken a snapshot yet).