🧰 Analogy
A good field kit isn’t one giant gadget — it’s a frame with interchangeable parts: the same handle takes a scanner head, a fuzzer head, a recon head. A well-built Go security tool is the same: a reusable skeleton — bounded concurrency, rate limiting, structured logging, a scope guard — into which you drop the specific probe. Get the frame right once and every tool you write afterward is mostly the probe.
The release build
The headline feature is the build itself. One command produces a small, static, portable binary:
# Static (no libc), stripped (-s -w), reproducible (-trimpath), for Linux/amd64.
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
go build -ldflags='-s -w' -trimpath -o scanner ./cmd/scanner
# Same source, a Windows build from the same machine:
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 \
go build -ldflags='-s -w' -trimpath -o scanner.exe ./cmd/scanner
CGO_ENABLED=0— no C, so the binary is static and cross-compiles cleanly.-ldflags='-s -w'— drop the symbol table and DWARF debug info; a noticeably smaller binary.-trimpath— strip your local filesystem paths out of the binary (privacy and reproducibility).
The anatomy of a tool
Most scanners and recon tools share the same shape. Build it once and reuse it:
graph TD CLI["flags / config<br/>(targets, rate, scope)"] --> SCOPE["scope guard<br/>(authorized only)"] SCOPE --> JOBS["jobs channel"] JOBS --> POOL["worker pool<br/>(bounded N)"] POOL --> RL["rate limiter<br/>(req/sec)"] RL --> PROBE["the probe<br/>(scan / fetch / fuzz)"] PROBE --> RESULTS["results channel"] RESULTS --> LOG["structured log<br/>+ report (JSON)"]
The only part that changes between a port scanner, a subdomain enumerator, and a web fuzzer is the probe. Everything else — bounded concurrency, rate limiting, scope checks, logging — is shared infrastructure.
See it: the reusable scanner skeleton
A fixed worker pool drains a jobs channel, a rate limiter (a ticker here) caps the pace, and results come back deterministically sorted. Swap the body of probe and you have a different tool. This runs here — the “probe” is simulated so it works without a network:
package main
import (
"fmt"
"sort"
"sync"
"time"
)
type result struct {
target string
open bool
}
// probe is the ONLY part that changes per tool. Here it's simulated:
// pretend even-numbered targets are "open".
func probe(target int) result {
return result{target: fmt.Sprintf("10.0.0.%d", target), open: target%2 == 0}
}
func main() {
const workers = 8
jobs := make(chan int)
out := make(chan result)
// Rate limit: at most one probe per tick (kept short for the demo).
tick := time.NewTicker(time.Millisecond)
defer tick.Stop()
var wg sync.WaitGroup
for i := 0; i < workers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for t := range jobs {
<-tick.C // pace ourselves — never hammer a target
out <- probe(t)
}
}()
}
// Feed jobs, then close so workers finish.
go func() {
for t := 1; t <= 12; t++ {
jobs <- t
}
close(jobs)
}()
// Close results once all workers are done.
go func() { wg.Wait(); close(out) }()
var open []string
for r := range out {
if r.open {
open = append(open, r.target)
}
}
sort.Strings(open) // deterministic report
fmt.Printf("found %d open:\n", len(open))
for _, t := range open {
fmt.Println(" ", t)
}
}
That worker-pool-plus-rate-limiter is the backbone of every scanner in this track. The port scanning page fills in a real probe; worker pool and rate limiting cover the pattern in depth.
Embedding assets and logging actions
Two finishing touches make a tool field-ready:
import _ "embed"
//go:embed wordlists/subdomains.txt
var subdomains string // baked into the binary at compile time
go:embed keeps the drop-and-run promise: wordlists, payload templates, or a pinned CA certificate travel inside the binary. And for authorized work, log every action with slog in JSON so you have a timestamped, queryable record for the engagement report:
slog.Info("probe", "target", t, "port", p, "result", "open", "ts", time.Now())
🐹 Bound everything that touches the network
The number-one beginner mistake is unbounded fan-out: a bare go probe(t) inside a for … range targets loop over 50,000 targets opens 50,000 sockets at once, exhausts file descriptors, and may knock the target over. Always cap concurrency with a fixed worker pool and rate-limit requests/second. A polite tool is a professional tool — and one that won’t accidentally cause the very denial-of-service your rules of engagement forbid.
⚠️ Plugins, cgo, and the static-binary tax
Reaching for the plugin package to make a tool extensible has hidden costs: it requires cgo (so you lose the static binary), works only on Linux/macOS, and demands the plugin and host be built with the exact same Go toolchain version and dependency set — a mismatch panics at load time. For most tools an embedded scripting engine (Lua, Starlark) or a subprocess-based plugin protocol is more portable. Reach for plugin only when you control the whole build pipeline.
See also
- Port scanning — the skeleton above with a real probe.
- Worker pool & rate limiting — the concurrency backbone.
- Structured logging — slog for auditable action logs.
- Secrets management — keeping API keys out of the binary and the repo.
Next: the canonical first tool — port scanning.
Related topics
Why Go became the language of security tooling — static binaries that drop anywhere, cheap concurrency for scanners, and a crypto/net standard library that covers most of what a tool needs.
offensivePort ScanningHow a TCP connect scanner works and why Go is ideal for it — a bounded concurrent scanner, banner grabbing for service detection, and the defenses (rate limits, detection, least exposure) that stop it.
defenseSecrets ManagementKeeping API keys, passwords, and signing keys out of your code, repo, logs, and binary — config from the environment, secret managers, redaction, and rotation.
Check your understanding
Score: 0 / 51. How do you produce a small, static, cross-compiled release binary?
CGO_ENABLED=0 makes it static; GOOS/GOARCH pick the target; `-ldflags='-s -w'` strips the symbol table and DWARF debug info (smaller binary); `-trimpath` removes local filesystem paths from the binary. One command, any platform, no C toolchain.
2. What is the right concurrency shape for a scanner that probes thousands of targets?
Unbounded `go probe(t)` for 50k targets opens 50k sockets at once — you exhaust file descriptors, get rate-limited or blocked, and may DoS the target. A fixed worker pool (N goroutines pulling from a jobs channel) caps in-flight work; pair it with a rate limiter to control requests/second.
3. Why embed wordlists or templates with go:embed instead of reading them from disk?
go:embed bakes assets (wordlists, payload templates, a CA cert) into the binary at compile time. The drop-and-run superpower stays intact — one file, no 'where's the wordlist?' on the target. Use real files only when the data must be user-supplied or too large to embed.
4. Why does a security tool need structured logging of its own actions?
In authorized testing you must be able to prove what you did and didn't do. Structured logs (slog, JSON) give a timestamped, queryable trail of every action — essential for the client report, for de-conflicting with the blue team, and for protecting yourself if something breaks during the window.
5. What does Go's `plugin` package let a tool do, and what's the catch?
`plugin` loads .so files built with -buildmode=plugin, enabling modular tools. The catches are real: it needs cgo (no static binary), only works on Linux/macOS, and the plugin and host must be built with the exact same Go version and dependencies. Many tools prefer an embedded scripting engine (Lua, Starlark) or subprocess plugins instead.
Comments
Sign in with GitHub to join the discussion.