DEV Community

Cover image for 156 Imports, Zero Elsewhere: What It Actually Takes to Prove a Go Program Has No Dependencies
Sadia Peerzada
Sadia Peerzada

Posted on

156 Imports, Zero Elsewhere: What It Actually Takes to Prove a Go Program Has No Dependencies

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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

Bareport-Pipeline

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
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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-phase self audit

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
}
Enter fullscreen mode Exit fullscreen mode

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, ...)
}
Enter fullscreen mode Exit fullscreen mode

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")
    }
}
Enter fullscreen mode Exit fullscreen mode

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.

benchmark chart

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
}
Enter fullscreen mode Exit fullscreen mode

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 securityconfig.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
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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

Dashboard Open Ports

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.

Light Report

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

Risk Overview

Bareport Image

Security Assessment Tool

Live Scan

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.

HTML Report Dark

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
Enter fullscreen mode Exit fullscreen mode

Repo: github.com/sadiapeerzada/BareportSTDLIB.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)