DEV Community

Anmol Maheshwari
Anmol Maheshwari

Posted on

My "reproducible" build kept returning the same hash after the code changed. That was the bug.

TL;DR: I built ZeroTrust — a supply-chain security scanner, zero third-party dependencies, go.mod with no require block — for the Zero Dependency Hackathon (Track E). It catches phantom packages, typosquats, lifecycle-hook backdoors, obfuscated payloads, and eval()-style dynamic execution. The scanner passed every test I wrote for it. Three times, my own verification process tried to hand me a result that looked correct and wasn't. This is what a tool built to catch deception looks like when it nearly deceives its own author.

The dependency that couldn't be the point

You already know why this hackathon exists if you've read the brief: chalk and debug, 2.6 billion combined weekly downloads, one phished maintainer account, a crypto-clipper hiding in memory. Shai-Hulud republishing itself into hundreds of packages days later. xz, two years of patient trust-building, one code review away from a backdoor on every Linux server on earth. And now the newer, quieter version of the same problem: AI coding assistants hallucinate package names 19.7% of the time, and attackers have started pre-registering the names the models reliably invent — a supply-chain attack where the victim never even makes a typo, because the machine makes it for them.

So I built the tool that's supposed to catch exactly that — and I built it with an empty manifest, because a security scanner that asks you to trust more strangers to protect you from strangers has already lost its own argument.

$ cat go.mod
module zerotrust
go 1.22

$ go list -m all
zerotrust
Enter fullscreen mode Exit fullscreen mode

That's the entire dependency tree. Everything else — a Bloom filter, a prefix Trie, a TOML tokenizer, a rate limiter, ANSI terminal styling, a SARIF 2.1.0 emitter — is hand-rolled against Go's standard library. Eleven substitutions, each with its own entry in STDLIB.md, because a claim like "zero dependencies" is worthless if it isn't also checkable in under five seconds.

That instinct — don't just claim something, make it checkable — is what turned this into a much more interesting three days than "write five detectors and call it done."

Five detectors, one honest question underneath all of them

ZeroTrust runs five checks, offline by default: phantom-package detection (Bloom filter + Trie against a curated, provenance-documented corpus), typosquat detection (hand-rolled Levenshtein distance), lifecycle-hook scanning (the exact mechanism Shai-Hulud used), Shannon-entropy scanning for obfuscated payloads, and lexical detection of dynamic-execution calls (eval, child_process.exec, os.system).

Every one of those detectors exists to answer one question: is this thing actually what it claims to be?

Which made it uncomfortable, in a useful way, when my own build pipeline started asking me the same question back.

Bug 1: the reproducible build that lied by staying the same

make verify-reproducible builds the binary twice and diffs the hashes. Simple, and it passed — the same hash, every single time I ran it, across multiple sessions:

A6DB0B58130997745CFF9798B0FDCF6BBD28DE03910F1F0E34BDB0AB6472E985

That should have felt like success. It felt wrong instead, for a reason that took me a while to name: real code had changed in between those checks. JSON output landed. SARIF emission landed. Severity-aware exit codes landed. A reproducible build is supposed to mean identical source → identical bytes — it is emphatically not supposed to mean the hash never moves no matter what I do to the source. One of those is determinism. The other is a build that isn't actually rebuilding what I think it's rebuilding, or is embedding something that has nothing to do with my code.

It was the second one. Since Go 1.18, go build stamps the current git commit hash and a timestamp into the binary by default — vcs.revision, vcs.time — unless you explicitly pass -buildvcs=false. My "stable" hash wasn't proof of a clean build. It was a coincidence of which git commit happened to be checked out at each of my testing sessions.

The proof that this was really it: I made a doc-only commit — zero code changes, just Markdown — and rebuilt.

14D9E8B23FF9786D54F7F82764CCC8B587F9E59E97743026DFBD74226FC6714D

Different hash. From a commit that changed nothing but a README. If a Markdown edit can move your "reproducible" hash, the hash was never measuring what the bonus claim says it measures.

Fixed with one flag, everywhere the binary gets built:

makefile
go build -trimpath -buildvcs=false -ldflags="-buildid= -s -w" -o ./bin/zerotrust .

And a final, actually-trustworthy pair:

Build 1 SHA256 (windows): D112B856931D9473E5DF7EC6175E4412FA8D0CE8F76FAF5AE7E6C0438ACB1E9E
Build 2 SHA256 (windows): D112B856931D9473E5DF7EC6175E4412FA8D0CE8F76FAF5AE7E6C0438ACB1E9E
REPRODUCIBLE BUILD VERIFICATION: PASS

Enter fullscreen mode Exit fullscreen mode

Still identical. But now I'd earned the right to say so, because I'd also watched it correctly fail to be identical when it should have — the doc-only-commit test is what turned "the hash matches" from a hope into an actual falsifiable check. A reproducible-build claim you haven't watched break is a build you haven't actually verified — you've just watched a coincidence hold twice.

Bug 2: the safety claim I couldn't check on my own machine

go test -race ./... is how I back up "no data races in the concurrent worker pool." On my own Windows dev machine, it didn't run at all:

cc1.exe: sorry, unimplemented: 64-bit mode not compiled in

The race detector needs CGO, CGO needs a working 64-bit C compiler, and my local MinGW toolchain was 32-bit-only. For a real stretch of this build, "the race detector passes" was a claim I genuinely could not verify on the machine I was developing on. Not a cosmetic annoyance — a security tool's own concurrency-safety claim, unverifiable, on the platform I was actually shipping from.

I didn't fix the toolchain. I changed which environment got to answer the question:

bash
docker run --rm -v ${PWD}:/app -w /app golang:1.22 go test ./... -v -race
Enter fullscreen mode Exit fullscreen mode

Clean pass, real Linux, real ELF binary, real 64-bit cc. Then a third, fully independent confirmation once the repo was pushed — GitHub Actions' own ubuntu-latest runners, green on every push since:

ok      zerotrust/tests 5.756s
Enter fullscreen mode Exit fullscreen mode

Same source, three separate environments, one of which genuinely couldn't tell me the answer at all. The lesson isn't "Windows is bad" — it's that a claim your own dev machine can't check is a claim you don't actually have yet, no matter how confident the surrounding code looks.

Bug 3: the validator output that was lying about what it validated

I claimed a real SARIF 2.1.0 subset, so I ran Microsoft's own sarif-multitool validate against it — the point being that "the JSON parses" and "a schema validator accepts this as real SARIF" are different claims, and only one of them is worth putting in a README.

The first attempt produced output I couldn't actually trust, and the reason was almost funny: the pasted validator output had ZeroTrust's own startup banner mixed into it — a stray line from the tool being validated, sitting inside what was supposed to be the validator's independent verdict. Contaminated evidence. I genuinely couldn't tell from that run whether sarif-multitool had validated anything or whether I was reading noise.

I caught it because the contaminating line was visibly out of place — it didn't belong to the tool that was supposed to be running. That's the same instinct the entropy scanner runs on every payload it looks at: something in this stream doesn't match what it claims to be.

Redo, deliberately isolated — generate the file to disk first, run the validator as a fully separate step, nothing interleaved:

bash
./bin/zerotrust --path . --format sarif > results.sarif
sarif-multitool validate results.sarif
# exit code: 0
Enter fullscreen mode Exit fullscreen mode

Clean. Trustworthy, this time, because I'd removed the thing that made the first result unreadable rather than just re-running it and hoping for a cleaner-looking paste.

What I'm not going to pretend happened

Three real bugs is a true story. Five would be padding, and this hackathon specifically rewards naming your limits rather than hiding them — so here's what didn't have drama, stated plainly instead of dressed up:

The entropy threshold (7.2 bits/byte) was correct on every run I checked. No tuning story, no wrong version I quietly fixed. If there's an origin story for that exact number, I don't have one to tell you.
The tokenizer's blind spot (globalThis'ev'+'al' defeating lexical pattern matching) is a real, honestly-documented limitation — but I reasoned my way to it, I didn't watch it fail in front of me. Different kind of claim, and I'd rather say so than imply I caught it live.
The Bloom filter's false-positive rate measured 0.0449 against a <0.05 target on the very first check and every check after. No wrong m/k calculation I had to walk back.
Skipping the Single File bonus was a clean, upfront decision — the project's actual shape (four manifest parsers, two hand-rolled data structures, five detectors, a rate limiter, three output renderers) was never going to collapse into one file without sacrificing the test isolation and idiomatic package boundaries a senior Go reviewer would expect. I didn't start down that path and bail. I never started down it.

A write-up where every single corner has a dragon in it stops being credible around the third dragon. Some corners were just fine. Saying so is worth more than inventing tension that wasn't there.

By the numbers

$ go test ./... -v -race
...
--- PASS: TestBloomFilterFalsePositiveBound (0.03s)
    bloom_test.go:52: Actual false positive rate: 0.0449 (target: 0.0500)
...
PASS
ok      zerotrust/tests 5.756s
0 third-party runtime dependencies (go list -m all prints one line: zerotrust)
2,489 lines across 28 Go files

Enter fullscreen mode Exit fullscreen mode

15/15 tests passing, race-detector clean, verified independently across Windows-hosted Docker Linux and GitHub Actions' ubuntu-latest
11 documented stdlib-for-package substitutions in STDLIB.md, each on the runtime path — none decorative
6 real attack-pattern findings caught in under a second against a seeded malicious fixture — phantom packages, typosquats, and a curl | sh postinstall hook
1 reproducible-build hash that was wrong for reasons that had nothing to do with the algorithm I thought I was testing
The thing worth taking from this

A scanner built to catch things pretending to be something they aren't spent its own build process doing exactly that to me, three separate times: a hash that looked stable because it wasn't actually being regenerated against new source, a safety guarantee my own machine was incapable of confirming, and a validation result contaminated by output that didn't belong to the tool producing it.

None of those were subtle attacks. They were the ordinary, boring failure mode of trusting the first result that looks clean. The entire pitch of this hackathon is that removing dependencies doesn't remove the need for engineering judgment — it just means every piece of that judgment is visibly yours, including the parts where you almost believed something that wasn't true.

The tool's job is to stop trusting things by default. Turns out that had to start with not trusting my own green checkmarks either.
GITHUB REPO
Built solo for the Zero Dependency Hackathon 2026, Track E (Security & Crypto Utilities). Repo, STDLIB.md, and deps-proof.txt are all public — the reproducible-build command is in the README if you want to try breaking it the way I did.

ZeroDependencyHack #Go #Security #SupplyChain #Testing #BuildInPublic

Top comments (0)