Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/ISSUE_TEMPLATE/bug_report.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ If applicable, add screenshots or copy the terminal output to help explain your

**Environment (please complete the following information):**
- OS: [e.g. Ubuntu 22.04, Windows 11, macOS 13.0]
- Go Version: [e.g. 1.22.0]
- Go Version: [e.g. 1.24.2]
- subenum Version: [e.g. 0.3.0]

**Additional context**
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/go.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ name: Go
on:
push:
branches: [ "main" ]
tags: [ "v*" ]
pull_request:
branches: [ "main" ]

Expand Down
4 changes: 3 additions & 1 deletion .golangci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,10 @@ linters:
settings:
gosec:
excludes:
# G304: File path provided as taint input expected for a wordlist tool
# G304: File path provided as taint input, expected for a wordlist tool
- G304
# G404: Weak RNG is fine; math/rand/v2 only drives simulation output, which is not security-sensitive
- G404
errcheck:
# These write to stdout/files in hot paths; errors are intentionally ignored
exclude-functions:
Expand Down
21 changes: 20 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,26 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]
## [0.5.1] - 2026-06-03

### Fixed
- Data race in simulation mode: migrated `internal/dns/simulate.go` to `math/rand/v2`, whose top-level functions are goroutine-safe and auto-seeded. `SimulateResolution` is now safe to call concurrently (previously a shared `*math/rand.Rand` was used from every worker).
- Send-on-closed-channel race in `scan.Run`: the progress ticker goroutine now signals its own exit (`tickerStopped`) and guards its send with a select, and `Run` waits for that exit before emitting `EventDone`, so the deferred `close(events)` can no longer race an in-flight ticker send.
- TUI now renders the "Aborted" status when a scan is cancelled with `ctrl+c` (the scan view is marked aborted so the subsequent `EventDone` shows partial counts).
- TUI form no longer blocks a live-mode scan when the Hit Rate field is empty or out of range; hit rate is validated only when Simulate is on.
- `-version` now reports the correct version (`subenum v0.5.1`).
- Docker build: the builder now copies `go.sum` and runs `go mod download` before building, the base image satisfies the module Go version, and `main_test.go` is no longer copied into the build image.

### Changed
- Go minimum version reconciled to 1.24.2 across `go.mod`, the Dockerfile base image, README, and docs (the charmbracelet TUI dependencies require it); direct vs indirect dependency classification corrected via `go mod tidy`.

### Added
- Tests: concurrent `SimulateResolution` test, `internal/scan` runner tests (concurrent simulate run and mid-scan context cancellation), and `internal/tui` form validation tests.

### Docs
- README facelift: plain-text description under the badges, a copy-paste quick-start block, the TUI screenshot promoted to a hero position, PRs-Welcome and platform badges, and removal of em dashes for a clean human-authored look.
- ARCHITECTURE: corrected the progress ticker interval (1 second) and the argument-parsing section (`flag.*Var` into a `cliFlags` struct).
- Removed the unused, duplicate `docs/assets/title.svg`.

## [0.5.0] - 2026-03-14

Expand Down
8 changes: 4 additions & 4 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
FROM golang:1.22-alpine AS builder
FROM golang:1.24.2-alpine AS builder

WORKDIR /app

# Copy go.mod and go.sum first to leverage Docker cache
COPY go.mod ./
# Copy module files first and download deps to leverage Docker layer caching
COPY go.mod go.sum ./
RUN go mod download

# Copy source code
COPY main.go ./
COPY main_test.go ./
COPY internal/ ./internal/

# Build the binary with optimizations
Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

[![Build](https://img.shields.io/github/actions/workflow/status/TMHSDigital/subenum/go.yml?branch=main&style=for-the-badge&label=build)](https://github.com/TMHSDigital/subenum/actions)
[![Release](https://img.shields.io/github/v/release/TMHSDigital/subenum?style=for-the-badge)](https://github.com/TMHSDigital/subenum/releases)
[![Go](https://img.shields.io/badge/Go-1.22+-00ADD8?style=for-the-badge&logo=go&logoColor=white)](https://go.dev)
[![Go](https://img.shields.io/badge/Go-1.24.2+-00ADD8?style=for-the-badge&logo=go&logoColor=white)](https://go.dev)
[![License: GPL v3](https://img.shields.io/badge/License-GPLv3-blue.svg?style=for-the-badge)](LICENSE)
[![CodeQL](https://img.shields.io/github/actions/workflow/status/TMHSDigital/subenum/codeql.yml?label=CodeQL&style=for-the-badge)](https://github.com/TMHSDigital/subenum/actions/workflows/codeql.yml)
[![Go Report Card](https://goreportcard.com/badge/github.com/TMHSDigital/subenum?style=for-the-badge&v=0.5.0)](https://goreportcard.com/report/github.com/TMHSDigital/subenum)
Expand Down Expand Up @@ -87,7 +87,7 @@ flowchart LR

## Installation

**Prerequisites:** Go 1.22+ · Git · Make _(optional)_ · Docker _(optional)_
**Prerequisites:** Go 1.24.2+ · Git · Make _(optional)_ · Docker _(optional)_

<details>
<summary><strong>Build from source</strong></summary>
Expand Down Expand Up @@ -272,7 +272,7 @@ No flags required. Fill in the form and press `ctrl+r` to start scanning. Last-u

| Layer | Components |
| :--- | :--- |
| Core Engine | Go 1.22 &middot; `net.Resolver` &middot; `context` &middot; `sync/atomic` |
| Core Engine | Go 1.24.2 &middot; `net.Resolver` &middot; `context` &middot; `sync/atomic` |
| Concurrency | goroutines &middot; channels &middot; `sync.WaitGroup` &middot; `sync.Mutex` |
| TUI | Bubble Tea &middot; Bubbles (textinput, viewport, progress) &middot; Lip Gloss |
| Infrastructure | Docker &middot; Alpine &middot; Make &middot; docker-compose |
Expand Down
30 changes: 15 additions & 15 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,19 +40,19 @@ internal/tui/config.go — Session persistence (load/save ~/.config/sube
### 2.1. Argument Parsing

* **Purpose**: This component is responsible for processing the command-line arguments provided by the user when `subenum` is executed. It extracts the target domain, the path to the wordlist file, the desired number of concurrent workers, and the DNS lookup timeout.
* **Implementation**: Utilizes Go's standard `flag` package.
* `flag.String("w", "", "Path to the wordlist file")`: Defines the wordlist file flag.
* `flag.Int("t", 100, "Number of concurrent workers")`: Defines the concurrency level flag.
* `flag.Int("timeout", 1000, "DNS lookup timeout in milliseconds")`: Defines the DNS timeout flag.
* `flag.String("dns-server", DefaultDNSServer, "DNS server to use")`: Defines the custom DNS server flag.
* `flag.Bool("v", false, "Enable verbose output")`: Defines the verbose flag.
* `flag.Bool("progress", true, "Show progress during scanning")`: Defines the progress reporting flag.
* `flag.Bool("version", false, "Show version information")`: Defines the version flag.
* `flag.String("o", "", "Write results to file")`: Defines the output file flag.
* `flag.Int("attempts", 0, "Total DNS resolution attempts per subdomain")`: Defines the attempt count flag.
* `flag.Int("retries", 0, "Deprecated: alias for -attempts")`: Deprecated retry flag.
* `flag.Bool("force", false, "Continue scanning on wildcard DNS")`: Defines the force flag.
* `flag.Parse()`: Parses the provided arguments.
* **Implementation**: Utilizes Go's standard `flag` package. `parseFlags()` binds every flag into a `cliFlags` struct using the `flag.*Var` forms, then calls `flag.Parse()`.
* `flag.StringVar(&f.wordlistFile, "w", "", ...)`: Binds the wordlist file flag.
* `flag.IntVar(&f.concurrency, "t", 100, ...)`: Binds the concurrency level flag.
* `flag.IntVar(&f.timeoutMs, "timeout", 1000, ...)`: Binds the DNS timeout flag.
* `flag.StringVar(&f.dnsServer, "dns-server", DefaultDNSServer, ...)`: Binds the custom DNS server flag.
* `flag.BoolVar(&f.verbose, "v", false, ...)`: Binds the verbose flag.
* `flag.BoolVar(&f.showProgress, "progress", true, ...)`: Binds the progress reporting flag.
* `flag.BoolVar(&f.showVersion, "version", false, ...)`: Binds the version flag.
* `flag.StringVar(&f.outputFile, "o", "", ...)`: Binds the output file flag.
* `flag.IntVar(&f.attempts, "attempts", 0, ...)`: Binds the attempt count flag.
* `flag.IntVar(&f.retries, "retries", 0, ...)`: Binds the deprecated retry flag.
* `flag.BoolVar(&f.force, "force", false, ...)`: Binds the force flag.
* `flag.Parse()`: Parses the provided arguments into the `cliFlags` struct.
* `flag.Arg(0)`: Retrieves the positional argument (the target domain).
* **Interactions**: The parsed values are used to configure the subsequent components, such as the Wordlist Processing and DNS Resolution Engine. Input validation is performed to ensure valid values for critical parameters like concurrency, timeout, DNS server format (validated via `validateDNSServer`), and domain syntax (validated via `validateDomain`).

Expand Down Expand Up @@ -105,7 +105,7 @@ internal/tui/config.go — Session persistence (load/save ~/.config/sube
* **Verbose Output** (when `-v` flag is enabled):
* Configuration summary, per-query DNS resolution info, and final scan statistics — all via `Info` to stderr.
* **Progress Reporting** (when `-progress` flag is enabled):
* A dedicated goroutine using a 2-second ticker calls `Progress` on stderr.
* A dedicated goroutine using a 1-second ticker calls `Progress` on stderr.
* **Interactions**: All components route output through the `Writer`. Since results are the only thing on stdout, piping (`| cut -d' ' -f2`) works without `-progress=false`.

### 2.6. Progress Monitoring
Expand All @@ -117,7 +117,7 @@ internal/tui/config.go — Session persistence (load/save ~/.config/sube
* `processedWords`: An atomic counter that's incremented each time a subdomain is checked.
* `foundSubdomains`: An atomic counter that's incremented each time a valid subdomain is found.
* **Progress Display** (on stderr):
* A dedicated goroutine using a ticker (running every 2 seconds) calls `Writer.Progress`
* A dedicated goroutine using a ticker (running every 1 second) calls `Writer.Progress`
* Uses `\r` carriage return to update the same line repeatedly
* Shows percentage completion, processed count, and found count
* **Interactions**: The Progress Monitoring component works alongside the worker goroutines, using atomic operations to safely track counts across multiple goroutines. Writing to stderr keeps stdout pipe-clean.
Expand Down
2 changes: 1 addition & 1 deletion docs/CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ See the [Code of Conduct](CODE_OF_CONDUCT.html).

### Prerequisites

- Go 1.22 or later
- Go 1.24.2 or later
- Git
- Make (optional but recommended)
- Docker (optional, for containerized development)
Expand Down
2 changes: 1 addition & 1 deletion docs/DEVELOPER_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ This guide provides information for developers looking to contribute to or build

To work with `subenum`, you'll need:

* **Go Programming Language**: [Go 1.22+](https://golang.org/dl/) is required.
* **Go Programming Language**: [Go 1.24.2+](https://golang.org/dl/) is required.
* **Git**: For version control.
* **Text Editor or IDE**: VS Code, GoLand, or any editor with Go support is recommended.

Expand Down
16 changes: 0 additions & 16 deletions docs/assets/title.svg

This file was deleted.

2 changes: 1 addition & 1 deletion examples/advanced_usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,7 @@ For CI/CD environments, you can use the version flag to ensure the correct versi

```bash
./subenum -version
# Output: subenum v0.4.0
# Output: subenum v0.5.1
```

Use simulation mode in CI pipelines to test the tool's behaviour without network access:
Expand Down
9 changes: 6 additions & 3 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,17 @@ module github.com/TMHSDigital/subenum

go 1.24.2

require (
github.com/charmbracelet/bubbles v1.0.0
github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/lipgloss v1.1.0
)

require (
github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/charmbracelet/bubbles v1.0.0 // indirect
github.com/charmbracelet/bubbletea v1.3.10 // indirect
github.com/charmbracelet/colorprofile v0.4.1 // indirect
github.com/charmbracelet/harmonica v0.2.0 // indirect
github.com/charmbracelet/lipgloss v1.1.0 // indirect
github.com/charmbracelet/x/ansi v0.11.6 // indirect
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
Expand Down
18 changes: 7 additions & 11 deletions internal/dns/simulate.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,12 @@ package dns

import (
"fmt"
"math/rand"
"math/rand/v2"
"os"
"strings"
"time"
)

// seededRand is a package-level random source seeded at startup.
// math/rand is appropriate here — simulation output is not security-sensitive.
var seededRand = rand.New(rand.NewSource(time.Now().UnixNano())) //nolint:gosec

// SimulateResolution returns a synthetic DNS result without performing any
// network I/O. Common subdomain prefixes resolve ~90% of the time; everything
// else uses the supplied hitRate (0-100).
Expand All @@ -25,20 +21,20 @@ func SimulateResolution(domain string, hitRate int, verbose bool) bool {
for _, sub := range commonSubdomains {
if strings.HasPrefix(domain, sub+".") {
if verbose {
fakeTiming := time.Duration(50+seededRand.Intn(200)) * time.Millisecond
fakeIP := fmt.Sprintf("192.168.%d.%d", seededRand.Intn(255), 1+seededRand.Intn(254))
fakeTiming := time.Duration(50+rand.IntN(200)) * time.Millisecond
fakeIP := fmt.Sprintf("192.168.%d.%d", rand.IntN(255), 1+rand.IntN(254))
fmt.Fprintf(os.Stderr, "Resolved (SIMULATED): %s (IP: %s) in %s\n", domain, fakeIP, fakeTiming)
}
return seededRand.Intn(100) < 90
return rand.IntN(100) < 90
}
}

result := seededRand.Intn(100) < hitRate
result := rand.IntN(100) < hitRate

if verbose {
fakeTiming := time.Duration(100+seededRand.Intn(500)) * time.Millisecond
fakeTiming := time.Duration(100+rand.IntN(500)) * time.Millisecond
if result {
fakeIP := fmt.Sprintf("10.%d.%d.%d", seededRand.Intn(255), seededRand.Intn(255), 1+seededRand.Intn(254))
fakeIP := fmt.Sprintf("10.%d.%d.%d", rand.IntN(255), rand.IntN(255), 1+rand.IntN(254))
fmt.Fprintf(os.Stderr, "Resolved (SIMULATED): %s (IP: %s) in %s\n", domain, fakeIP, fakeTiming)
} else {
fmt.Fprintf(os.Stderr, "Failed to resolve (SIMULATED): %s (Error: no such host) in %s\n", domain, fakeTiming)
Expand Down
22 changes: 22 additions & 0 deletions internal/dns/simulate_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package dns

import (
"sync"
"testing"
)

Expand All @@ -27,3 +28,24 @@ func TestSimulateResolution(t *testing.T) {
t.Errorf("Expected 0%% hit rate to never resolve, got %d/%d", resolved, runs)
}
}

// TestSimulateResolutionConcurrent calls SimulateResolution from many goroutines
// at once. With math/rand/v2 top-level functions this is race-free; the test
// exists to be caught by `go test -race`.
func TestSimulateResolutionConcurrent(t *testing.T) {
const goroutines = 64
const perGoroutine = 200

var wg sync.WaitGroup
for g := 0; g < goroutines; g++ {
wg.Add(1)
go func() {
defer wg.Done()
for i := 0; i < perGoroutine; i++ {
SimulateResolution("api.example.com", 50, false)
SimulateResolution("zzz-random.example.com", 25, true)
}
}()
}
wg.Wait()
}
21 changes: 16 additions & 5 deletions internal/scan/runner.go
Original file line number Diff line number Diff line change
Expand Up @@ -73,18 +73,27 @@ func Run(ctx context.Context, cfg Config, events chan<- Event) {
subdomains := make(chan string)
var wg sync.WaitGroup

// Progress ticker — fires every second.
// tickerDone is closed to stop the goroutine before we close events.
// Progress ticker - fires every second.
// tickerDone signals the goroutine to stop; tickerStopped confirms it has
// fully exited so we never close events while a send is pending.
tickerDone := make(chan struct{})
tickerStopped := make(chan struct{})
ticker := time.NewTicker(time.Second)
go func() {
defer close(tickerStopped)
defer ticker.Stop()
for {
select {
case <-ticker.C:
p := atomic.LoadInt64(&processed)
f := atomic.LoadInt64(&found)
events <- Event{Kind: EventProgress, Processed: p, Total: total, Found: f}
select {
case events <- Event{Kind: EventProgress, Processed: p, Total: total, Found: f}:
case <-tickerDone:
return
case <-ctx.Done():
return
}
case <-tickerDone:
return
case <-ctx.Done():
Expand Down Expand Up @@ -131,9 +140,11 @@ func Run(ctx context.Context, cfg Config, events chan<- Event) {
drain:
close(subdomains)
wg.Wait()
// Stop the ticker goroutine before we close events, preventing a send on
// a closed channel if the ticker fires between wg.Wait() and defer close.
// Stop the ticker goroutine and wait for it to fully exit before emitting
// EventDone, so the deferred close(events) can never race an in-flight
// ticker send.
close(tickerDone)
<-tickerStopped

events <- Event{
Kind: EventDone,
Expand Down
Loading