Six Security Automation Tools in Babashka: A Zero-Dependency Monorepo
A few weeks ago I asked myself the kind of question that usually ends in an abandoned side project: what can I build that is genuinely guaranteed to work?
Not “works on my machine.” Not “mostly works.” Actually deterministic — the same input, the same output, every time, provably. Most weekend projects rot because they depend on something flaky: a third-party API, a GUI, a half-working library. I wanted the opposite. I wanted boring — data in, data out, pure logic in between.
The result is a six-tool security automation monorepo written in Babashka (Clojure for the shell), with a self-contained CSV parser, zero external dependencies, and 175 assertions of golden tests. This post is the full story: the design rules, the six tools, and the bugs I actually caught along the way.
1. The Design Rule That Makes “Zero Bugs” Possible
Every security automation idea has the same failure mode: it reaches for the network, the LLM, or fuzzy heuristics, and suddenly “correctness” is a moving target.
So I set four hard constraints from the start:
- Pure functions only — I/O happens exclusively in
-mainentry points; everything else is data in, data out. - Deterministic output — sorted maps, stable sorts, no hash-order leaks. Same CSV in, byte-identical CSV out.
- Zero external dependencies — including the CSV
handling. No
clojure.data.csv, no HTTP calls, no LLM. - Tests assert the spec, not the implementation — golden fixtures over realistic sample data plus a boundary case for every rule.
The fourth one is the interesting one. When a scoring formula changes, the golden tests fail loudly and you have to decide: was the code wrong, or was the spec wrong? Half the bugs I hit during this project were spec bugs — my expectations, not the code’s.
(defn read-csv-rows
"Parses CSV text into a seq of rows. RFC 4180 quoting: quoted fields,
escaped quotes, commas and newlines inside quotes. Lenient extras:
blank lines skipped, CRLF accepted, unclosed quotes kept literally."
[csv-text]
...) ; a 60-line state machine, fully covered by tests
2. Why Babashka, and Why a Monorepo
Babashka is a single static binary that starts in ~10ms. That matters more than it sounds: a CLI that feels instant gets used; a CLI that costs a JVM boot doesn’t. For this kind of tooling — CSV in, CSV out, run it in a cron job or a CI pipeline — there is nothing better.
And a monorepo? Because the tools are not six islands. They share the
CSV layer, the scoring utilities, the test fixtures, and the
bb.edn task runner. One repository, one
bb test command, one lint pass, one place to document the
shared rules. Each tool is a separate namespace under
src/security/, and each one stays small enough to reason
about completely.
{:paths ["src" "test"]
:tasks
{test {:task (shell "bb -m security.test-runner")}
prioritize {:task (apply shell "bb -m security.prioritizer" *command-line-args*)}
triage {:task (apply shell "bb -m security.triage" *command-line-args*)}
...}}
One gotcha worth sharing: task keys must be symbols, not
keywords. I wrote :prioritize and
bb tasks silently showed nothing — keywords are ignored,
and the only symptom was “No tasks found.” A whole afternoon of
debugging distilled to a single typo-class mistake in an EDN file.
3. The Six Tools
| Tool | Input | Output | Decision |
|---|---|---|---|
| prioritizer | CVEs + CVSS + exposure | sorted by 0-10 score | priority band, due date, rationale |
| summarizer | access review rows | EDN report + flagged rows | duplicates, orphans, unused >90d |
| triage | security findings | action per finding | escalate / assign / snooze / close |
| classifier | privileged-access requests | approve / escalate / deny | risk keywords + justification |
| tickets | policy rows | ticket CSV with acceptance criteria | priority + owner + due date |
| matcher | roles + job titles | best role + alternatives | token-overlap confidence |
Vulnerability Remediation Prioritizer
The one I’d argue for first in any security team:
cve, cvss, severity, exploit_poc, asset_criticality, exposure
in, and a weighted 0-10 score out. CVSS contributes half, severity a
quarter, asset criticality and exploit status and exposure the rest.
Scores map to priority bands — ≥8 critical, ≥6.5 high, ≥4 medium, else
low — with a due date and a rationale string on every row.
(def weights {:cvss 0.50 :severity 0.25 :criticality 0.15
:exploit 0.05 :exposure 0.05})
The whole thing is arithmetic over a lookup table. That’s what makes it provably correct: there is no hidden state, no environment, no randomness.
Access Review Summarizer
Access reviews produce spreadsheets, and spreadsheets hide problems. This tool takes review rows and answers the three questions every IAM auditor actually asks: who has no owner? (orphaned access), who hasn’t touched their grant in 90+ days? (unused access), and which user+system+permission combos appear twice? (duplicate grants). Output is an EDN report plus a CSV of exactly the flagged rows, ready to paste into a review ticket.
Security Findings Triage
Triage is a rule table, and rule tables are where most security tools accumulate mystery. I made the order explicit and the reason always attached: resolved → auto-close; critical severity or CVSS ≥9 → escalate in 24h; high on a critical asset → escalate in 48h; medium older than 60 days → reassign. Every row comes back with an action, a priority, a due time, and a human-readable reason. Deterministic decisions are auditable decisions.
Privileged-Access Request Classifier
This one is the most fun because it’s adversarial. Request rows carry
role, privilege, justification, duration, and an emergency flag. Risk
keywords like root, admin, all,
prod escalate — unless the justification contains an
incident ticket and it’s flagged emergency, which approves with a
high-risk marker. Durations over 90 days get denied outright.
Justifications shorter than 15 characters get escalated as insufficient.
Rule order is the contract, and tests lock each branch.
Policy-to-Ticket Generator
Policies are obligations that need to become work items. Give it
policy_id, title, control, description, owner, risk_level
and it emits a ticket with a title, a multi-line description (including
acceptance criteria), a priority derived from risk, a due date, and the
owner. The multiline descriptions exercise the CSV writer’s quoting —
commas and newlines inside quoted fields — which is exactly where naive
CSV code dies.
IAM/PAM Job-Matching Assistant
Match job titles to roles with token-overlap scoring: keyword overlap weighted 0.7, job-title-to-role-title overlap 0.3, ties broken alphabetically, top-3 alternatives listed. It’s the only “fuzzy” tool in the set, and even it is deterministic. The honest lesson: matching quality is never provably perfect, so the implementation is — every score is a pure function of the two strings.
4. The Bugs We Actually Caught
A project like this generates a nice narrative if you let it. The truth is better: I wrote tests, the tests caught real bugs, and every bug was a deterministic, reproducible, fixable bug. Four of them are worth sharing:
- Lowercase-after-regex.
(str/lower-case (str/replace s #"[^a-z0-9]" " "))— the replace runs on the original string, so"Linux Server Admin"became" inux erver dmin". The first letter of every word vanished. Order matters: lowercase first, then strip. - Blank-line detection ate empty fields. My CSV
parser skipped lines where
fieldwas blank androwwas empty — which also describes an explicit""field. Atouchedflag distinguishing “nothing was written” from “a quoted empty was written” fixed it. - Sets don’t preserve order. Filtering keyword
matches through a set produced
"server admin linux"instead of"linux server admin"— deterministic per run, but not per insertion order. Iterate the keyword vector, test membership in the set. - Keyword vs symbol task keys. Already told above.
bb taskssays “No tasks found” and gives you nothing to work with.
None of these were exotic. All of them would have shipped silently in a codebase without golden tests.
5. How Verification Actually Worked
The final gate is three commands, and all three must be clean:
bb run test # 50 tests, 175 assertions, 0 failures
clj-kondo --lint src test # 0 errors, 0 warnings
clojure-lsp diagnostics # No diagnostics found
The linters earned their keep too: unused bindings from
destructuring, an unresolvable clojure.set reference, a
required-but-unused namespace. Small things, but a clean lint pass is
what lets a reviewer (or an AI agent) trust the code instead of auditing
it.
The most valuable part isn’t the tool count. It’s the template: a weekend project with a closed input space, deterministic output, and tests that pin the spec — that combination is rare and worth repeating.
The Takeaway
Ask “what can I build that works 100%?” and the answer is the same every time: something small, pure, and fully pinned down by tests. Six tools later, the hardest part wasn’t the Clojure — it was deciding what “correct” means and writing it down as test assertions first.
The repo is public:
github.com/nurazhardotcom/security-tools.
bb run test, then point any of the six tools at a CSV and
watch it do the boring work deterministically.