DEV Community

Cover image for Battle-testing my terminal scripts safety with Security Audit Skill
Gerardo León
Gerardo León

Posted on

Battle-testing my terminal scripts safety with Security Audit Skill

TL;DR: I pointed Cloudflare's security-audit skill at terminal-scripts, my 21 tiny PowerShell helpers (mostly git shortcuts). It found one real bug: a branch named +main turns my push helper into a force-push of main. It also rejected a finding that looked scary but wasn't, and it surfaced a list of ways the scripts could quietly lose work or mangle my PATH. Everything below comes from that one audit run.

Nobody security-audits their own shell aliases. Most of them are a few lines long, you wrote them, you use them a hundred times a day. That's exactly why I wanted to try it: these scripts sit on my PATH, and some of them run git push, git reset --hard and Stop-Process -Force. If any of those does something I didn't intend, I'll be the last to notice.

What terminal-scripts Is

terminal-scripts is a folder of short PowerShell scripts I use as handles for the things I type most in a terminal on Windows. You run .\install.ps1 once, it adds the folder to your user PATH, and from then on every script works by name from any terminal:

Command What it does
status, log, branch, diffstg git status, git log, git branch, git diff --staged
commit "msg" Commit staged files (defaults to the branch name as the message)
nubranch name Create and switch to a new branch
pusho Push the current branch to origin
fetchpullmaster Discard uncommitted changes, switch to master/main and pull
goroot, gorepos, goscripts Jump to the repo root, your repos folder, or the scripts folder
goremote Open the repo's origin in the browser
ports [port], killport port See what's listening on a port, and kill it
cdx 2, mkcd dir Go up N folders; create a folder and cd into it
glist List the main commands and what they do

Setup takes about 30 seconds:

git clone https://github.com/elparaquecosadeque/terminal-scripts
cd terminal-scripts
.\install.ps1
# open a new terminal, then:
glist
Enter fullscreen mode Exit fullscreen mode

The spirit of Terminal Scripts

How the Audit Works

I ran the security-audit skill in Claude Code. Its README says it's the single-repo starting point of the harness Cloudflare describes in Build your own vulnerability harness. What I liked is that it isn't "ask the AI if my code is secure". It's a pipeline with a hard rule: no finding without a concrete principal crossing a concrete boundary.

For my repo, using the skill's standard profile, the run went like this:

  1. Reconnaissance: 4 read-only agents mapped the scripts, who can influence their inputs, every dangerous call (Start-Process, Stop-Process, git reset --hard, registry writes), and what this machine could safely execute.
  2. Coverage-led hunting: the repo was split into 8 coverage units like "remote URL → Start-Process" or "branch name → git push arguments". 4 hunter agents took 2 units each and returned structured JSON instead of prose. Then 2 separate coverage critics checked for gaps. Both came back clean.
  3. Candidate validation: every candidate went to a fresh agent whose job was to disprove it.
  4. Structured output: the surviving records went into a findings.json checked against the skill's schema.
  5. Independent verification: a final agent re-checked the one confirmed record from scratch.
  6. Reporting: a REPORT.md was written only from the verified records.

That's 13 agents for 21 scripts, which sounds absurd until you see what got thrown away along the way.

The Finding: A Branch Named +main Is a Force-Push

This was pusho.ps1:

param([string]$Branch)

if ([string]::IsNullOrWhiteSpace($Branch)) {
    $Branch = git branch --show-current
    # ...exit if empty...
}

git push origin $Branch
Enter fullscreen mode Exit fullscreen mode

Looks harmless. The catch: git push origin <x> doesn't take a branch name, it takes a refspec, and in a refspec a leading + means force. Git also happily accepts +main as a branch name.

So if a teammate pushes a branch literally called +main, and you check it out to review it, then run pusho, the script runs:

git push origin +main   →  "force-push my local main over origin's main"
Enter fullscreen mode Exit fullscreen mode

That doesn't push the branch you're on. It force-pushes your local main, and if your main is behind, it rewinds the shared main and drops everyone else's commits from it. That's usually recoverable from someone else's clone or the server's reflog, if anyone notices.

The verifier reproduced it end to end with git 2.55.0 on dummy repos: another "contributor" pushed +main, the "user" ran git checkout +main (git creates the local branch from origin/+main without complaint), then ran the exact command pusho builds. Git printed:

+ 0112d90...28b20f8 main -> main (forced update)
Enter fullscreen mode Exit fullscreen mode

The other contributor's commit was gone from the remote main. In the control run, a normal git push origin main from the same stale branch was rejected, as it should be.

Severity: low. It needs an odd branch name, a stale local main, and no branch protection on the remote. It was verified on a plain git remote; I didn't test whether GitHub or GitLab accept a +main branch name. But it's a real bug in a script whose whole job is "push the thing I'm on".

Solution

Spell out the source and the target in full, so a leading + is just part of a name:

git push origin "refs/heads/${Branch}:refs/heads/${Branch}"
Enter fullscreen mode Exit fullscreen mode

The verifier ran this form too: Everything up-to-date, remote main untouched. Now pusho always pushes exactly the named (or current) branch to the same-named branch, never with force. A non-fast-forward push gets rejected like any normal push. The trade-off: you can no longer pass a full refspec like pusho local:remote. For that, use git push directly.

What Got Thrown Out, and Why That's the Best Part

Two scary-looking lines turned out not to be vulnerabilities.

goremote passes the repo's remote URL straight to Start-Process. Windows hands that string to whatever program opens it, so a remote like C:\something.exe would be run, not opened in a browser. Scary, right? The hunter worked through who can actually set that URL. git clone writes the URL you typed. Anyone who can edit .git/config in your repo can already make any git command run their code (core.hooksPath, core.fsmonitor, core.pager). So goremote gives an attacker nothing they didn't already have, and the hunter closed it without even raising a candidate. I still added an http(s)-only check that also strips user:token@ from the URL, because "not a vulnerability" isn't the same as "fine".

fetchpullmaster can be fooled by a file named master. This one did become a candidate, and it's the one finding the verifier rejected. The script ran git checkout master and trusted the exit code. In a main-only repo where someone committed a file called master, git treats the name as a path, restores that file, and exits 0. The script then runs git reset --hard on whatever branch you were on. The verifier confirmed the git behaviour, and then ran the control: without the planted file, the script already wipes your uncommitted work on its normal path. That's what it's for. A contributor can't make you lose anything the script wasn't going to throw away anyway. Rejected as a security finding, kept as a warning.

Lesson: This is the same do you assume or confirm? question I keep coming back to. Twice, a scary-looking line got checked instead of assumed: once by tracing who actually controls the input, once by running a control case. Half of the value of the run was the stuff it refused to call a vulnerability.

Not Vulnerabilities, But Ways to Lose Work

The audit also kept a separate list of "same-user" hazards: no attacker involved, just the scripts doing something you might not expect. A few of them surprised me:

  • install.ps1 could flatten your PATH. In Windows PowerShell 5.1, reading the user PATH with [Environment]::GetEnvironmentVariable('Path','User') returns it with %VAR% already expanded, and writing it back saves it as a plain string. The first time the installer actually added the folder, every %JAVA_HOME%\bin-style entry would be frozen to whatever value it had at that moment.
  • killport could kill your IDE. It matched any connection on the port, not just the listener. Pick a port in the ephemeral range and you could force-kill a browser or IDE that just happened to have an outgoing connection using it, with no prompt.
  • reloadpath drops your virtualenv. It rebuilds PATH from the registry, so anything activated only for the current session (venv, conda, a Visual Studio dev shell) disappears.
  • mkcd 'a[1]' could land you in a1. Set-Location -Path treats brackets as wildcards. Not a big deal, until the next thing you run is resethard.
  • Typing diff never ran diff.ps1. In PowerShell 5.1, diff is a built-in alias for Compare-Object, and aliases win over scripts on PATH.

What I Changed, and What I Kept on Purpose

File Change
pusho.ps1 Fully qualified refspec, so it can never force-push
install.ps1 Reads and writes the raw registry value, keeping %VAR% entries and the expandable type
killport.ps1 Only targets processes listening on the port
goremote.ps1 Only opens http(s) URLs and strips user:token@ from them
mkcd.ps1, goroot.ps1 Set-Location -LiteralPath, so brackets aren't wildcards
fetchpullmaster.ps1 git checkout → git switch, which only accepts branch names

The audit's first suggestion for fetchpullmaster was to refuse to run on a dirty tree. I said no. That script is meant to be "clean and pull": I run it when I want my local master to look exactly like origin and I don't care what's lying around. So it kept its reset --hard, and the README now says so in plain words instead of hiding it.

That's where the warnings ended up: a collapsible "⚠️ Warnings: things that can lose work or change your config" section at the bottom of the README. It lists which commands can wipe uncommitted work, which can affect other people, which can kill the wrong program, and which change your PATH. If you're going to put someone else's scripts on your PATH, that's the section you should read first.

Achievements 🎉🙌

  • Ran a 6-phase security audit on 21 PowerShell scripts: 8 coverage units, 13 agents, 2 clean coverage critics
  • Found and reproduced a real bug: a +main branch name turning pusho into a force-push of main
  • Watched 2 scary-looking issues get dismissed: one closed by the hunter before it became a candidate, one rejected by a verifier's control run
  • Fixed the bug plus 5 data-loss and config hazards, and documented the intentional ones in the README

If you live in a Windows terminal and type git status fifty times a day, give terminal-scripts a try: clone it, run .\install.ps1, type glist. And if you want to audit your own repo, the security-audit skill is free to use. Try it on something "too small to bother with". That's where I found mine.

FAQ

Is a branch named +main really allowed?

Yes, by git itself. Git's ref-name rules reject branch names that start with -, but a leading + is valid. You can create it with git branch +main, push it with a fully qualified refspec, and git checkout +main will create a local tracking branch from origin/+main. I verified that on a plain git remote, not on GitHub or GitLab. The problem only appears when that name is later passed to git push as a bare refspec.

Does this affect git push itself?

No. git push origin +main doing a force-push is documented refspec behaviour. The bug was in my script assuming that "current branch name" and "refspec" are the same thing. Any wrapper that does git push origin $(git branch --show-current) has the same issue, so check your own aliases.

Did the audit run my scripts?

No. Without an OS-level sandbox on Windows, the skill doesn't execute target code. The decisive behaviour was reproduced by running git itself on throwaway repos, with no network and no user or system git config. Everything else was source review.

Top comments (0)