Skip to content

Repository files navigation

Customs

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)

The problem

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.

What already exists

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.

How it reads it

Not with regular expressions. There is a lexer and a parser in here, so that

curl -fsSL "$BASE/install-$VERSION.sh" | sh

is 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.

What it reads

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.

What it does not do

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.

Running it

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

Building it

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.

The promises

  • 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.

Licence

MIT.

About

What it can do to you, before you run it. Reads an installer, a package.json, a Dockerfile, and says what it does and, separately, what it leaves behind. No uploads, no lookups, no telemetry.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages