What it can do to you, before you run it.
Every other tool in this series looks outward: what you give away when you hand something over. This one looks the other way. Somebody puts a line on a web page, you paste it into a terminal, and that is a decision made in about a second on the strength of the page it was written on.
[UNREAD ] the code that will run is not in this file
it downloads a script and runs it straight away
https://get.example.dev/install.sh (line 12)
nothing is written down, nothing is checked, and whatever is
at that address at the moment you run this is what runs
[REACHES] it goes for things it does not need
it reaches into a private ssh key ~/.ssh/id_rsa (line 61)
whoever holds it can log in wherever that key is trusted
[LEAVES ] it leaves something behind
it adds a line to your shell profile
~/.bashrc, or whichever of them it picks (lines 486, 493)
a line here runs in every shell you open from now on
[LEAVES ] it widens what this machine will accept
it writes a new signing key, as root
/etc/apt/keyrings/thing.asc (line 604)
packages signed with it are accepted as genuine from now on
[DOES ] it wants to be root
it asks to run as root sh -c apt-get install -y thing
(lines 601, 607)
Reading a script tells you what it does. It does not tell you what it leaves.
Those are two different questions and only one of them gets asked. A real installer is six hundred lines of shell, the three that matter are not marked, and the parts people skim for are the loud ones: the downloads, the sudo. The quiet half is what is still true next week, after you have uninstalled the thing and forgotten you ever ran this:
- the line appended to your shell profile, which runs in every shell you open from now on
- the service enabled to start at boot, as root, before anybody logs in
- the signing key added, which makes a stranger's packages genuine from now on, for good, because nobody ever takes one out
- the apt source, the cron entry, the launch agent, the certificate authority
So this sorts everything into those two piles and never mixes them, and it
puts a third above both: the code that will run is not in this file. A
download piped into a shell, an eval of something built at the time, a
payload that has to be decoded first. Once one of those turns up, nothing
underneath it can be checked by reading, which is what the file was handed
over for.
ShellCheck is the tool everybody knows and it answers a different question: it is a linter, it tells you the script is wrong. It has nothing to say about a script that is perfectly correct and reads your ssh folder.
Bashtion is the closest thing to this idea, and it works by sending your script to a language model for an opinion. That is a reasonable design and it means the script you did not trust enough to run is now somebody else's copy. This one does not send anything anywhere, and there is no model in it.
Shellyzer is a small set of grep rules in a Python script.
None of them separates what a script does from what it leaves.
Not with regular expressions. There is a lexer and a parser in here, so that
curl -fsSL "$BASE/install-$VERSION.sh" | shis understood as a pipeline, with the two sides told apart, and the address put back together out of the variables set forty lines up. Getting that far is the difference between saying "there is a download somewhere in this file" and saying what is downloaded and what happens to it.
The rest is what nine hundred lines of calibration against real installers turned up, and none of it appears in a hand written test:
Wrappers. A careful script says command curl, not curl, so that a
function of the same name cannot get in the way. nvm's installer says it a
hundred and sixty seven times. Reading the wrapper as the program made this
report that nvm's installer does nothing at all.
A command kept in a variable. Docker's installer sets sh_c='sh -c' at
the top, changes it to sudo -E sh -c if you are not root, and then every
line that matters is $sh_c "...". The name of the command is not in the
line and the command itself is inside a string.
What a function can hand back. nvm writes to $(nvm_detect_profile).
That cannot be known without running it, and what can be known is what the
function has to choose from, because the choices are written inside it as
literals. The report says the write lands on one of them rather than
pretending to know which.
Arguments. starship calls unpack "$archive" "$BIN_DIR" "$sudo" and
every path inside the helper is $1 and $2, so the arguments are bound at
the call site. The ensure() { "$@"; } wrapper is followed too.
Blocks with a redirect on the end. bun writes to three different shell
profiles with { ...; } >> "$config" and in no other way.
${ZDOTDIR:-$HOME}. The value a variable falls back to is the value on
almost every machine, so it is used rather than given up on.
Asking is not doing. command -v curl is a script checking whether curl
exists. Reporting that as a download is how a report gets ignored.
Shell, and the seven other places shell actually runs:
package.json |
the install hooks npm runs on its own, told apart from the scripts you have to type |
setup.py, install.js |
the commands they hand to a shell, and the calls that do the same thing without one |
Makefile |
every recipe line, with the target it belongs to |
Dockerfile |
the RUN lines and the ADD that fetches from a URL |
| systemd units | ExecStart and the rest, which run as root at boot |
.desktop files |
Exec, which is how a thing starts itself when you log in |
| Actions workflows | the run: blocks, and the actions pinned to a name somebody else can move |
Python and JavaScript are read for the shell in them and nothing else.
There is no Python parser in here and no JavaScript parser, because writing
either one properly is a project the size of this whole program and a half
good one would be worse than none. What there is instead is narrower and,
for this question, most of the value: the dangerous half of a setup.py or
an install.js is not the language, it is the command it hands over.
os.system, subprocess with shell=True, child_process.exec. Those
strings are lifted out and read by the same reader as everything else.
The calls that do the damage without a shell at all are turned into the
shell line that would have done the same thing, so urlretrieve reads as a
download and fs.appendFileSync into ${os.homedir()}/.zshrc reads as what
it is. The report says which line of which file each one came from, and it
says plainly that the rest of the file was not read.
It does not run anything, so anything the script decides at the time is past
where it can see. If a path is built inside a temporary directory that
mktemp invented, the report says a program is being run from a path the
script worked out, which is true and is as far as reading gets. rustup's
installer is the honest example: this can say it downloads something and
runs it, and it cannot say what.
It reads shell. A .ps1 and a .bat are named and skipped, and the report
says so rather than counting them as clean. A .py or a .js is read for
the commands it hands to a shell, which is not the same as reading it.
The workflow reader follows indentation rather than parsing YAML, because carrying a YAML parser would cost the promise that there are no dependencies. Anchors and flow style are not handled.
It cannot tell you whether something is malicious. It tells you what a thing can do and what it leaves, and an installer that does all of it may be exactly the installer you wanted. The judgement is still yours; this is so that you are making it with the file open.
customs install.sh one file
customs . everything in here that runs on its own
curl -sS URL | customs - read it on the way past
customs --source quote the line each finding came from
customs --version
--source prints the line of the script behind each finding, with any
credential on it taken out. It never prints a credential, and there is no
flag that changes that.
Exit codes, for a hook or a pipeline:
| Code | Meaning |
|---|---|
| 0 | nothing in here needs a decision |
| 1 | something in here is a decision rather than a fact |
| 2 | it could not read what it was given |
| 3 | it read some of it and says which part it could not |
go build
Go 1.26 or newer, nothing else. No dependencies, and a test that fails the build if one appears.
sh build.sh v1
builds the five packages the releases are made of.
- It never runs anything. No subprocess, no shell, no eval. This program is pointed at files somebody does not trust, and a test reads the source and fails the build if it ever learns how to run one.
- It only reads. Nothing is written, moved or deleted, here or anywhere else.
- No socket is opened. No version check, no telemetry, no model, nothing sent anywhere for an opinion.
- It never prints a credential, with or without
--source. They are covered when the file comes off the disk, so there is no later point where the original could reach the screen. - It says what it could not read, rather than counting silence as a pass.
MIT.