A devlog from building bareport — a network security scanner with zero third-party runtime dependencies — for Hackathon Raptors' Zero Dependency hackathon, Track C: Web & Network.
Most "zero dependencies" claims have slack in them. A README says it, go.mod backs it up, and that's where the checking stops — because for most projects, that is where the checking stops. Nobody re-derives your import graph by hand.
Except a scanner is exactly the kind of project where someone will. The entire pitch of a security tool that audits other people's dependency surfaces is that it doesn't have one of its own. If that claim only holds up to a glance at go.mod, it's not a claim, it's a hope. This is the write-up of what it took to turn "zero dependencies" from a sentence into something a stranger could disprove and fail to — the two-phase design that makes the binary check itself, the two bugs that only existed because nobody had actually killed the process and watched what happened, and the one mistake I made twice in the same repository, one file apart, having already fixed it the first time.
go.mod is one line long:
module bareport
go 1.22.2
No require lines. That's the whole pitch. Everything below is what it took to make that pitch survive someone trying to break it.
Repo: github.com/sadiapeerzada/Bareport
Demo: 5-minute walkthrough on Vimeo
Some real numbers, pulled directly from the repo rather than remembered:
| Metric | Value |
|---|---|
| Non-test Go source | 7,563 lines |
| Third-party runtime dependencies | 0 — go list -m all returns exactly one line |
| Documented stdlib substitutions | 19, each with its own rationale (STDLIB.md) |
| Unit test functions | 156 in tests/, 196 counting every package's own internal tests |
| Cross-package test coverage | 84.1% (go test ./tests/... -coverpkg=./... -cover) |
| Race detector | Clean on every concurrent path |
| Integration tests | 12/12 — real binary vs. real demo-target servers |
| Report formats | Terminal · JSON · CSV · SARIF 2.1.0 · self-contained HTML |
| Reproducible build | Byte-identical across two independent builds |
| Runtime self-audit |
imports walked: 156, outside stdlib: 0
|
What bareport actually does
It's a concurrent TCP/UDP scanner, a TLS/HTTP/DNS inspector, and a local web dashboard — discover hosts, scan ports, fingerprint services, run a deterministic security-findings engine, score risk, and report in five formats. All of it on net, net/http, and crypto/tls, nothing else, at runtime, ever:
That's the real per-host order scanner.Run executes: discovery, then a DNS lookup, then the TCP port scan, then banner/fingerprint/TLS/HTTP enrichment on whatever ports came back open, then the UDP probe pass if requested — repeated per host, then the findings engine and risk engine run once over the completed report.
$ ./bareport --targets 127.0.0.1 --ports 8081,8443,8444 --skip-discovery --no-color
HOST PORT PROTO STATE SERVICE SEVERITY NOTES
127.0.0.1 8444 tcp open http critical certificate expired 729 day(s) ago (+7 more)
127.0.0.1 8443 tcp open http warning missing security header: Strict-Transport-Security (+6 more)
127.0.0.1 8081 tcp open http warning missing security header: Strict-Transport-Security (+4 more)
Summary: 1 host(s) scanned, 1 alive, 3 port(s) open, 16 warning(s), 1 critical(s) — duration 4.5s
$ echo $?
3
That's a real run against demo-targets/, not a mockup — findings: 24, risk score 100/100, exit code 3 (CRITICAL). Every number in this write-up is like that one: re-run right before it went into the text, not remembered from an earlier commit.
The part that's actually hard: proving a negative
Anyone can not import a package for the length of a hackathon weekend. The hard part is proving it in a way that survives someone else checking, and the way bareport does that is the part of this project I'd point to first.
make deps-proof is the build-time half:
$ go list -m all
bareport
One line. But that only proves the claim on your machine, at the moment you ran it. The more interesting half is bareport --verify-zero-dep — the binary itself re-proving the claim, on anyone's machine, with no Go toolchain anywhere nearby to check with. That last constraint — no toolchain at runtime — forces a genuine two-phase architecture instead of a straight line:
At generate time (make selfaudit-manifest, a //go:build ignored file, never part of the shipped binary): a real go list -deps . and go list std run once, and the results — bareport's own full import graph and the actual Go standard library's package list — get snapshotted into selfaudit/manifest_generated.go, //go:embedded into the binary.
At run time, --verify-zero-dep does not re-read go.mod and does not re-walk the import graph. It reads the two baked-in tables and compares them:
func Verify() Result {
r := Result{
ModulePath: ModulePath,
GoDirective: GoDirective,
RequireLineCount: RequireLineCount,
}
stdlib := stdlibPackageSet()
ownPrefix := ModulePath + "/"
for _, imp := range ownImports {
r.ImportsWalked++
switch {
case stdlib[imp]:
continue
case imp == ModulePath || strings.HasPrefix(imp, ownPrefix):
continue
case strings.HasPrefix(imp, "vendor/"):
continue
default:
r.OutsideStdlib = append(r.OutsideStdlib, imp)
}
}
return r
}
Pure function. No os/exec, no I/O, nothing that touches the network or the filesystem. ownImports and the stdlib set are ordinary in-memory data by the time this runs — verified fresh, right now, for this exact write-up:
$ bareport --verify-zero-dep
bareport zero-dependency self-audit
go.mod: module bareport, go 1.22.2, 0 require lines
imports walked: 156
outside stdlib: 0
VERIFIED — zero third-party runtime dependencies
The distinction between those two phases sounds like a wording nitpick until you actually get it wrong in your own docs, which I did, more than once. An early draft described the runtime check as "reads go.mod, walks the import graph" — an accurate description of the generator, not of what the compiled binary does when someone runs it three months after you built it, with no Go installed. From the outside, watching --verify-zero-dep print a pass/fail report looks like live introspection. It isn't. It's a comparison against a snapshot taken earlier, and the fix wasn't a wording tweak — it was going back to the generator's own source comments, which already said the quiet part correctly, and making the README say the same thing.
Two bugs that only existed because nobody had killed the process yet
Everything above is the part of this project that's easy to be proud of. The two bugs below are the part that's more useful to write down, because neither one would have been caught by reading the code — only by running it and watching what actually happened when it was supposed to stop.
| Bug | Symptom | Only surfaced by | Fix |
|---|---|---|---|
| Unbounded DNS lookup | Scan could hang indefinitely against an unresponsive resolver | Handing the function a caller context with no deadline of its own, the exact shape used in production | Give InspectDNS its own timeout parameter, matching every sibling scanner function's existing convention |
go run grandchild process |
Ctrl+C during demo-targets/run-all.go could hang shutdown, leaving an orphaned listener |
Actually sending SIGTERM and checking whether the process — and its listening port — were still alive afterward |
Build each demo target to a temp binary once, run that binary directly instead of go running it |
Bug one: a scan that could hang forever, and the one function that didn't follow its own package's convention.
Every scanner function in this codebase takes its own timeout time.Duration and derives a bounded context.WithTimeout internally — GrabBanner, FingerprintOS, InspectTLS, InspectHTTP all do this. InspectDNS didn't. It took whatever context the caller handed it and trusted that context already carried a deadline:
// before
func InspectDNS(ctx context.Context, host string) (*DNSInfo, error) {
resolver := net.DefaultResolver
// ... LookupHost(ctx, ...), LookupMX(ctx, ...), LookupTXT(ctx, ...) — all on the raw ctx
}
In production, the caller is orchestrate.go, and its context is signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) — a context with no deadline at all, only a Ctrl+C/SIGTERM cancel. Against an unresponsive resolver, that meant InspectDNS, and the whole scan behind it, could hang indefinitely with no way out short of killing the process by hand. The existing test for this function didn't catch it, because the test wrapped its own bounded context around the call before passing it in — which meant the test was verifying the caller's discipline, not the function's. InspectDNS itself had no discipline of its own to verify.
The fix matches the convention every sibling function already follows:
// after
func InspectDNS(ctx context.Context, host string, timeout time.Duration) (*DNSInfo, error) {
dnsCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
resolver := net.DefaultResolver
// ... LookupHost(dnsCtx, ...), LookupMX(dnsCtx, ...), LookupTXT(dnsCtx, ...)
}
with a new config.DNSTimeout (3s default, kept separate from the connect timeout because DNS reconnaissance issues several sequential round-trips per host, not one connection attempt) and a --dns-timeout flag. The regression test is the actual proof, and it's the interesting part — it deliberately hands InspectDNS a context with no deadline of its own, the same shape orchestrate.go uses in production, and asserts the call still returns:
func TestDNS_InspectDNS_BoundsItselfWithoutCallerDeadline(t *testing.T) {
done := make(chan struct{})
go func() {
defer close(done)
_, _ = scanner.InspectDNS(context.Background(), "this-host-does-not-exist.invalid", 500*time.Millisecond)
}()
select {
case <-done:
// returned on its own — correct.
case <-time.After(5 * time.Second):
t.Fatal("InspectDNS did not return within a bounded time given a caller context with no deadline of its own")
}
}
Before the fix, that exact call shape would have blocked until the test binary itself was killed. No amount of reading InspectDNS top to bottom would have surfaced that — the bug wasn't in what the function computed, it was in what it was willing to wait for.
Bug two: the fix I'd already written once, one file away.
demo-targets/run-all.go starts all six local demo servers for one-command setup. It used to launch each one with exec.CommandContext(ctx, "go", "run", path, ...). That looks harmless. It isn't, and the reason is specific to how go run works: it forks the actual compiled binary as a child of the go run process itself. exec.CommandContext's cancel-on-context-done only kills the process it directly started — go run — not that grandchild. Because the demo's stdout is wrapped in a custom io.Writer (a prefixing writer that labels each server's output), Go's os/exec can't hand the child the file descriptor directly; it has to pipe through a background copier, and Wait() only returns once every process holding the write end of that pipe has closed it. The grandchild inherits and keeps that write end open even after go run itself is killed — so on Ctrl+C, shutdown could hang indefinitely, with a real server process still listening in the background, orphaned.
Here's the part that actually stings: integration/main.go, in the same repository, already hit this identical issue and already fixed it — build a temp binary once, run that binary directly, never shell out to go run for anything meant to be killed cleanly. The fix for run-all.go wasn't a new idea. It was copying a fix that already existed one directory over, because the second time a mistake shows up in your own codebase, it's not a coincidence, it's a pattern you hadn't generalized yet:
// buildTarget compiles a single demo-targets source file to a temp
// binary, mirroring integration/main.go's startDemoTargets.
func buildTarget(dir string, t target) (string, error) {
srcPath := filepath.Join(dir, t.file)
binPath := filepath.Join(os.TempDir(), "bareport-demo-"+t.name)
cmd := exec.Command("go", "build", "-o", binPath, srcPath)
if out, err := cmd.CombinedOutput(); err != nil {
return "", fmt.Errorf("go build: %w\n%s", err, out)
}
return binPath, nil
}
Verified live, not just built: started all six demo servers, sent SIGTERM, confirmed a clean exit within two seconds — no orphaned processes, no leftover listening ports. Neither of these two bugs would show up in a code review that only reads the diff. Both only show up when you actually run the thing to completion and check what's still alive afterward.
Documentation drift is the same category of bug
The other class of bug in this project wasn't in the scanner. It was in the README describing the scanner, and on a project whose whole pitch is verifiability, a wrong number in the docs is the same failure as a wrong number in the risk engine — a claim someone will check, found false.
Found each time by re-running the thing the README claimed, not by re-reading the previous draft:
| Claim | Was | Actually was |
|---|---|---|
| STDLIB substitutions badge | 15 | 19 — STDLIB.md had grown; the badge hadn't |
full scan profile's port count |
"all enabled checks" |
top-1000, identical to security — config.ApplyProfile's actual case for "full"
|
| Reproducible-build hashes pasted in the README | Fixed pair of SHA-256 values, presented as settled proof | Stale the moment a new commit lands — Go stamps the current VCS revision into the binary by default, so the hash changes with every commit even though byte-identical-across-two-builds-of-the-same-source still holds |
| Coverage number | Carried forward from an earlier commit message | Re-measured fresh: 84.1%, via go test ./tests/... -coverpkg=./... -cover, run just now for this line |
None of these are individually dramatic. What they share is the failure mode: two places in the same project disagreeing, and nobody had gone back to ground truth to find out which one was right. That reproducible-build one is worth sitting with for a second, because it's the subtlest: the property being claimed (two independent builds of the same source are byte-identical) is true and stays true. But the two specific hash values printed as evidence of it are a photograph, not a fact — they're only valid for the exact commit they were taken at, and every commit after that photograph makes it stale evidence for a claim that's still real. Publishing a static hash as if it were permanent proof, instead of documenting the always-reproducible command and letting the reader generate their own live pair, was the wrong call.
Six things built on a working scanner, and what stayed a pure function
Once the core scanner was stable, six features went in on top of it, and the rule was the same for all six: extend the story — discover, understand, fix, verify, monitor — without reopening the scanner, findings, or risk packages. Three of the six turned out to be read-only views over data the engine had already produced.
Explainable score breakdown. risk.Score is still one 0–100 number. risk.Breakdown groups the same findings by category, derived from each finding ID's own existing prefix, so TLS-EXPIRED-CERT becomes TLS with zero new metadata to keep in sync:
var categoryPrefixes = []struct{ prefix, category string }{
{"NET-", "Network"}, {"TLS-", "TLS"}, {"HTTP-", "HTTP"}, {"DNS-", "DNS"},
}
func categoryOf(id string) string {
for _, c := range categoryPrefixes {
if strings.HasPrefix(id, c.prefix) {
return c.category
}
}
return "Other" // an unmapped future prefix shows up here instead of vanishing silently
}
Summing every category's points reproduces the same total the base score already computes, under the same 100-point cap. Deliberately absent from --json/--save output — it's a display decomposition of an existing score, not a second scoring model.
Attack surface view. BuildAttackSurface groups the same finding list by host:port instead of by category, tagging each open port with its single worst severity — same source list, no re-derivation.
Zero-dependency showcase. The dashboard and HTML report both call selfaudit.Verify() fresh, on every render — not a hardcoded badge, the same pure function described above, called from a new place.
This is what those three look like live, in the local dashboard:
The other three needed real new code but stayed genuinely load-bearing rather than cosmetic:
A toggleable vulnerable target. Every other demo server is static — one fixed set of findings, useful for a single scan but not a before/after. vulnerable-app.go adds two HTTP endpoints that flip its own response headers between an insecure and a hardened configuration, deliberately isolated from the scanner/findings/risk packages — it's a target to be scanned, not a shortcut into the thing scanning it.
Fix → Rescan → Verify. With a toggleable target in place, this needed almost nothing new — bareport diff's drift-detection logic already existed. This is the actual, unedited output of make demo-fix-rescan, re-run just now:
=== BEFORE: scanning vulnerable-app (default state) ===
Risk: HIGH
Findings: 10
=== FIX: flipping vulnerable-app to its fixed state ===
vulnerable-app: switched to FIXED state
=== RESCAN: scanning vulnerable-app again ===
Risk: LOW
Findings: 3
=== VERIFY: bareport diff before -> after ===
SECURITY DRIFT DETECTED
────────────────────────────────
- RESOLVED MEDIUM finding: Missing Content-Security-Policy header (127.0.0.1:18095)
- RESOLVED MEDIUM finding: Missing HTTP Strict Transport Security header (127.0.0.1:18095)
- RESOLVED MEDIUM finding: Cookie set without recommended security flags (127.0.0.1:18095)
- RESOLVED LOW finding: Missing X-Content-Type-Options header (127.0.0.1:18095)
- RESOLVED LOW finding: Missing Referrer-Policy header (127.0.0.1:18095)
- RESOLVED LOW finding: Server header discloses software version (127.0.0.1:18095)
- RESOLVED LOW finding: Missing X-Frame-Options header (127.0.0.1:18095)
Changed security headers:
~ 127.0.0.1:18095 X-Content-Type-Options: "" -> "nosniff"
~ 127.0.0.1:18095 X-Frame-Options: "" -> "DENY"
~ 127.0.0.1:18095 Content-Security-Policy: "" -> "default-src 'self'"
~ 127.0.0.1:18095 Referrer-Policy: "" -> "strict-origin-when-cross-origin"
~ 127.0.0.1:18095 Strict-Transport-Security: "" -> "max-age=63072000; includeSubDomains"
Risk:
Baseline: 60 (HIGH)
Current: 10 (LOW)
Change: -50
Ten findings to three, seven resolved, risk 60 → 10. Every one of those seven is an HTTP- prefixed finding under the categorization scheme above — cross-checked against the breakdown logic, this fix shows up entirely inside the "HTTP" category and leaves TLS/Network/DNS untouched, exactly as it should for a change that only touched response headers. Nothing above is scripted — it's bareport diff reading two real saved assessments, the same path bareport diff baseline.json current.json runs for any two scans of anything.
SARIF, wired into an actual GitHub Actions workflow, not just described as possible. report/sarif.go already produced valid SARIF 2.1.0. What didn't exist was a workflow that ran it — build bareport, scan the safe local demo target, upload the SARIF to this repo's Security tab, on push, on PR (upload skipped for forked-repo PRs, which don't carry the security-events: write permission — a GitHub boundary, not a workflow choice), weekly on a schedule, and on manual dispatch. The weekly cron matters more than it looks: without it, this workflow only proves itself the moment someone pushes code, and could bit-rot silently until an unrelated commit surfaced the breakage.
What replacing a dependency actually looks like, four times
STDLIB.md documents all 19 substitutions with a rationale each. Four of the higher-stakes ones, condensed:
| Would normally reach for | Weekly importers (pkg.go.dev) | stdlib substitute | Why the substitute was actually sufficient |
|---|---|---|---|
spf13/cobra (CLI framework) |
195,884 | flag.FlagSet |
Every flag this tool needs is a plain flag; the one place subcommand sugar would've helped (diff) is a two-line special case on os.Args[0]
|
sirupsen/logrus (structured logging) |
239,958 |
log/slog (stdlib since Go 1.21) |
Shipped specifically to obsolete the logrus/zap/zerolog debate — leveled, structured, JSON-capable logging to any io.Writer is exactly what --verbose needs |
miekg/dns (DNS protocol library) |
16,234 |
net.Resolver's LookupMX/LookupTXT/LookupHost/LookupAddr
|
Earns its keep for raw packet construction or non-standard record types; this tool only needs the standard lookups the OS resolver already exposes |
charmbracelet/bubbletea (TUI framework) |
~11,700 |
report/live.go: a time.Ticker redrawing in place with raw ANSI cursor codes |
Bubbletea's model/update/view loop earns its keep for genuinely interactive TUIs; a four-line "hosts/ports/findings/elapsed" readout needs the redraw primitive directly, not the framework built on top of it |
The importer counts aren't there to shame the packages — cobra runs kubectl, logrus is genuinely excellent — they're there to make the size of the thing being left out concrete instead of abstract.
What's actually running under the hood
The local --serve dashboard mirrors the same in-place-redrawing scan view the terminal shows, with a HOME / LIVE / REPORT toggle and a light/dark theme switch — zero JS framework, plain DOM show/hide, the same SecurityAssessment every other report writer formats.
The HTML report is the same data again, offline-safe: inline CSS and vanilla JS only, no external stylesheet, font, CDN script, or network fetch of any resource — sections for executive summary, risk score, the category breakdown above, the attack-surface view, hosts, open ports, services, TLS/HTTP/DNS findings, evidence, recommendations, and a live zero-dependency self-audit status, computed fresh at render time by the exact function the CLI calls.
The performance table, told honestly
More workers should mean faster scans. Against a real network target, hiding I/O-wait latency behind extra in-flight connections, it does. Against 127.0.0.1, where every connection resolves in microseconds, there's no wait to hide behind — more goroutines just add scheduling overhead against a bottleneck that was never I/O in the first place:
| Workers | ms/op | allocs/op |
|---|---|---|
| 10 | 7.08 | 11,540 |
| 50 | 7.41 | 11,580 |
| 100 | 8.28 | 11,637 |
| 500 | 11.46 | 12,431 |
Throughput gets worse as the worker pool grows, on this specific benchmark, for a specific and disclosed reason. Publishing a benchmark that shows your own tuning knob apparently making things worse feels wrong the first time you write it into a README. It's also the honest number, measured against the exact interface the benchmark targets — the alternative, quietly picking a target that flatters the feature, is exactly the kind of unverifiable claim this whole project exists not to make. Against a real, latency-bearing network target the picture inverts — more workers means more in-flight connections hiding real round-trip latency, which is what --workers exists to tune in the first place.
Some Dashboard Snapshots
What I'd tell someone starting a zero-dependency project tomorrow
"Zero dependencies" needs a verifier, not just a go.mod. Anyone can avoid an import line for a weekend. The claim becomes trustworthy once there's a mechanism — ideally one the binary carries with it — that lets someone else check it without taking your word.
Runtime checks and generate-time checks are different claims, and conflating them in your own docs is an easy, specific mistake to make about your own code. If a check can't touch the network, filesystem, or a subprocess at runtime, something upstream had to produce the data it's checking against. Say exactly where that happens, or "what this does when you run it" quietly turns into "what happened when we built it" — which reads identically until someone asks how it works offline.
A bug that can't be caught by reading the diff is still your bug. Both real production bugs in this project — the unbounded DNS lookup, the go run grandchild process — were invisible to code review and only surfaced by actually running the thing to completion and checking what was still alive afterward. Neither would have shown up in a linter. Both showed up the first time someone killed the process and looked.
The second time a mistake appears in your own codebase, it's not a coincidence. integration/main.go had already solved the exact "don't shell out to go run for something you need to kill cleanly" problem. run-all.go, one file over, hit it again. The fix wasn't new engineering — it was noticing the pattern had already been named once and generalizing it, instead of re-discovering it from scratch.
A pure function called from three places is worth more than three features that each know how to score something. risk.Breakdown and BuildAttackSurface both read the same []findings.Finding the base score already reads, and neither re-derives anything. That's the only design that guarantees the breakdown, the attack-surface view, and the score can never quietly disagree — because there's only one place the underlying facts get computed.
A static hash is a photograph, not a fact. The property "two independent builds of the same source are byte-identical" stays true forever. The specific hash value printed as evidence of it is only true for one commit. Document the reproducible process, not a frozen snapshot of its output, or your own proof goes stale the moment you commit again.
Publish the number that makes your feature look worse, if that's the real number. The worker-pool benchmark under-performing on loopback isn't a flaw in this write-up. Cherry-picking around it would have been.
We didn't set out to write a scanner that finds zero bugs in itself — we set out to write one where every claim it makes about itself is checkable, and then we checked them, and two of them were wrong. That's not a worse outcome than shipping clean. It's the only version of "zero dependencies" worth publishing.
$ bareport --verify-zero-dep
bareport zero-dependency self-audit
go.mod: module bareport, go 1.22.2, 0 require lines
imports walked: 156
outside stdlib: 0
VERIFIED — zero third-party runtime dependencies
Repo: github.com/sadiapeerzada/Bareport — STDLIB.md has all 19 substitution rationales, ARCHITECTURE.md has the full pipeline and package breakdown, DEMO.md has the shot-by-shot walkthrough, and go.mod still has zero require lines.










Top comments (0)