Skip to content

Commit 470e7da

Browse files
committed
feat: add Tor exit node and DNSBL (spamlist) resolvers
1 parent 14a6af7 commit 470e7da

15 files changed

Lines changed: 912 additions & 101 deletions
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
---
2+
description: LLM must verify file state before writing to avoid overwriting changes
3+
alwaysApply: true
4+
---
5+
6+
# Read Before Write
7+
8+
**You MUST check whether a file has been modified before writing to it.**
9+
10+
## Rule
11+
12+
1. **Before any write or search_replace** on an existing file, **read the file** from disk (using the Read tool) to get the current contents.
13+
2. Do not rely on earlier context, open buffers, or previous tool output for the file’s current state—it may have changed.
14+
3. Use the freshly read content to construct accurate edits (e.g. correct field names, indentation, and structure).
15+
16+
## Why
17+
18+
- Files can be changed by the user or other tools between your reads and writes.
19+
- Using stale or assumed content leads to wrong renames, duplicate blocks, or reverted changes.
20+
- Reading immediately before editing ensures edits match the actual file on disk.
21+
22+
## Applies To
23+
24+
- All edits (StrReplace, Write over existing files).
25+
- Especially when changing names, types, or structure that might have been updated elsewhere.

DEVELOPMENT.md

Lines changed: 100 additions & 88 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
# Development
22

3+
## Using udig as a library
4+
35
### Basic usage
46

57
Import the package:
@@ -10,7 +12,7 @@ go get github.com/netrixone/udig
1012

1113
```go
1214
dig := udig.NewUdig()
13-
for res := range dig.Resolve("example.com") {
15+
for res := range dig.Resolve(context.Background(), "example.com") {
1416
fmt.Println(res.Type(), res.Query())
1517
}
1618
```
@@ -31,42 +33,62 @@ dig := udig.NewUdig(
3133
)
3234
```
3335

34-
35-
| Option | Effect |
36-
| -------------------- | --------------------------------------------------------- |
37-
| `WithDebugLogging()` | Enable verbose debug output |
38-
| `WithStrictMode()` | Only follow domains that share the same TLD |
39-
| `WithTimeout(d)` | Set connection timeout for all resolvers |
40-
| `WithCTSince(t)` | Only collect CT logs issued after this date |
41-
| `WithCTExpired()` | Include expired CT log entries |
36+
| Option | Effect |
37+
| -------------------- | --------------------------------------------------------------- |
38+
| `WithDebugLogging()` | Enable verbose debug output |
39+
| `WithStrictMode()` | Only follow domains that share the same TLD |
40+
| `WithTimeout(d)` | Set connection timeout for all resolvers |
41+
| `WithCTSince(t)` | Only collect CT logs issued after this date |
42+
| `WithCTExpired()` | Include expired CT log entries |
4243
| `WithMaxDepth(n)` | Limit recursive discovery to n hops from seed (-1 = unlimited) |
4344

44-
4545
### Working with resolutions
4646

47-
Each resolver produces a typed resolution. Use a type switch to handle them:
47+
Each resolver produces typed resolutions. Every resolution carries exactly one result (one type, one query, one record). Use a type switch to handle them:
4848

4949
```go
50-
for res := range dig.Resolve("example.com") {
50+
for res := range dig.Resolve(context.Background(), "example.com") {
5151
switch res.Type() {
5252
case udig.TypeDNS:
53-
for _, rr := range res.(*udig.DNSResolution).Records {
54-
fmt.Printf("DNS %s %s -> %s\n", dns.TypeToString[rr.QueryType], res.Query(), rr.Record)
55-
}
53+
record := res.(*udig.DNSResolution).Record
54+
fmt.Printf("DNS %s %s -> %s\n", dns.TypeToString[record.QueryType], res.Query(), record)
55+
5656
case udig.TypeTLS:
57-
for _, cert := range res.(*udig.TLSResolution).Certificates {
58-
fmt.Printf("TLS %s -> %s\n", res.Query(), cert.Subject.CommonName)
59-
}
57+
record := &res.(*udig.TLSResolution).Record
58+
fmt.Printf("TLS %s -> %s\n", res.Query(), record.Subject.CommonName)
59+
6060
case udig.TypeBGP:
61-
for _, as := range res.(*udig.BGPResolution).Records {
62-
fmt.Printf("BGP %s -> AS%d %s\n", res.Query(), as.Number, as.Name)
63-
}
64-
// ... TypeWHOIS, TypeHTTP, TypeCT, TypeGEO
61+
record := &res.(*udig.BGPResolution).Record
62+
fmt.Printf("BGP %s -> AS%d %s\n", res.Query(), record.ASN, record.Name)
63+
64+
case udig.TypeGEO:
65+
record := res.(*udig.GeoResolution).Record
66+
fmt.Printf("GEO %s -> %s\n", res.Query(), record.CountryCode)
67+
68+
case udig.TypePTR:
69+
record := res.(*udig.PTRResolution).Record
70+
fmt.Printf("PTR %s -> %s\n", res.Query(), record.Hostname)
71+
72+
case udig.TypeRDAP:
73+
record := &res.(*udig.RDAPResolution).Record
74+
fmt.Printf("RDAP %s -> %s (%s)\n", res.Query(), record.Name, record.Handle)
75+
76+
case udig.TypeDNSBL:
77+
// Emitted only when the IP is listed; record.Zone + record.Meaning describe the listing.
78+
record := &res.(*udig.DNSBLResolution).Record
79+
fmt.Printf("DNSBL %s -> %s\n", res.Query(), record)
80+
81+
case udig.TypeTor:
82+
// Emitted when the IP is any known Tor relay (exit, guard, middle, …).
83+
record := res.(*udig.TorResolution).Record
84+
fmt.Printf("TOR %s -> %s (exit=%v)\n", res.Query(), record.Nickname, record.IsExitNode())
85+
86+
// ... TypeWHOIS, TypeHTTP, TypeCT, TypeDMARC
6587
}
6688
}
6789
```
6890

69-
Resolution types: `TypeDNS`, `TypeTLS`, `TypeWHOIS`, `TypeHTTP`, `TypeCT`, `TypeBGP`, `TypeGEO`.
91+
Resolution types: `TypeDNS`, `TypeDMARC`, `TypeTLS`, `TypeWHOIS`, `TypeHTTP`, `TypeCT`, `TypePTR`, `TypeBGP`, `TypeGEO`, `TypeRDAP`, `TypeDNSBL`, `TypeTor`.
7092

7193
## Architecture
7294

@@ -79,67 +101,61 @@ flowchart TB
79101
subgraph domain["Domain resolvers"]
80102
DR[DomainResolver]
81103
DNS[DNSResolver]
104+
DMARC[DMARCResolver]
82105
TLS[TLSResolver]
83106
HTTP[HTTPResolver]
84107
WHOIS[WhoisResolver]
85108
CT[CTResolver]
86109
end
87110
88-
subgraph domain_out["Domain resolution types"]
89-
DNSRecord[DNSRecord]
90-
TLSCert[TLSCertificate]
91-
Header[HTTPHeader]
92-
Contact[WhoisContact]
93-
CTLog[CTLog]
94-
end
95-
96111
subgraph ip["IP resolvers"]
97112
IR[IPResolver]
113+
PTR[PTRResolver]
98114
BGP[BGPResolver]
99115
Geo[GeoResolver]
116+
RDAP[RDAPResolver]
117+
DNSBL[DNSBLResolver]
118+
Tor[TorResolver]
100119
end
101120
102-
subgraph ip_out["IP resolution types"]
103-
ASRecord[ASRecord]
104-
GeoRecord[GeoipRecord]
105-
end
106-
107-
Udig --> DR
108-
Udig --> IR
109-
DR --> DNS & TLS & HTTP & WHOIS & CT
110-
DNS --> DNSRecord
111-
TLS --> TLSCert
112-
HTTP --> Header
113-
WHOIS --> Contact
114-
CT --> CTLog
115-
IR --> BGP & Geo
116-
BGP --> ASRecord
117-
Geo --> GeoRecord
121+
Udig --> DR & IR
122+
DR --> DNS & DMARC & TLS & HTTP & WHOIS & CT
123+
IR --> PTR & BGP & Geo & RDAP & DNSBL & Tor
118124
```
119125

120-
121-
122126
### Resolution flow
123127

124128
1. A domain enters the processing queue.
125129
2. All `DomainResolver` instances run **concurrently** (goroutines + `sync.WaitGroup`).
126130
3. Discovered IPs are enqueued for `IPResolver` processing.
127131
4. Discovered domains are checked for relatedness and recursively enqueued.
128-
5. Deduplication ensures each domain and IP is only resolved once.
132+
5. Deduplication ensures each domain and IP is resolved only once.
129133

130134
### Resolver overview
131135

132-
133-
| File | Resolver | Resolves | Data source |
134-
| ---------- | --------------- | -------------------------------------------- | ------------------------ |
135-
| `dns.go` | `DNSResolver` | DNS records (A, AAAA, NS, MX, TXT, SOA, ...) | Local/custom nameservers |
136-
| `tls.go` | `TLSResolver` | TLS certificate chains | Direct TLS handshake |
137-
| `whois.go` | `WhoisResolver` | WHOIS contacts | WHOIS protocol |
138-
| `http.go` | `HTTPResolver` | Security HTTP headers (CSP, CORS, Alt-Svc) | HTTP/HTTPS requests |
139-
| `ct.go` | `CTResolver` | Certificate Transparency logs | crt.sh JSON API |
140-
| `bgp.go` | `BGPResolver` | BGP AS records | Team Cymru DNS |
141-
| `geo.go` | `GeoResolver` | GeoIP country codes | IP2Location LITE DB |
142-
136+
| Resolver file | Resolver | Resolves | Data source |
137+
| --------------------- | ----------------- | --------------------------------------------- | -------------------------------- |
138+
| `dns_resolver.go` | `DNSResolver` | DNS records (A, AAAA, NS, MX, TXT, SOA, ...) | Local/custom nameservers |
139+
| `dmarc_resolver.go` | `DMARCResolver` | DMARC policy and reporting addresses | DNS TXT `_dmarc.{domain}` |
140+
| `tls_resolver.go` | `TLSResolver` | TLS certificate chains | Direct TLS handshake |
141+
| `whois_resolver.go` | `WhoisResolver` | WHOIS contacts | WHOIS protocol |
142+
| `http_resolver.go` | `HTTPResolver` | Security HTTP headers, security.txt, robots | HTTP/HTTPS requests |
143+
| `ct_resolver.go` | `CTResolver` | Certificate Transparency logs | crt.sh JSON API |
144+
| `ptr_resolver.go` | `PTRResolver` | Reverse DNS hostnames | DNS PTR records |
145+
| `bgp_resolver.go` | `BGPResolver` | BGP AS records | Team Cymru DNS |
146+
| `geo_resolver.go` | `GeoResolver` | GeoIP country codes | IP2Location LITE DB |
147+
| `rdap_resolver.go` | `RDAPResolver` | IP registration metadata | RIR RDAP via IANA bootstrap |
148+
| `dnsbl_resolver.go` | `DNSBLResolver` | DNS blocklist checks (listed zones + meaning) | Barracuda, UCEProtect, DroneBL |
149+
| `tor_resolver.go` | `TorResolver` | Tor node detection (exit/guard/relay) | Tor Onionoo HTTPS API |
150+
151+
### Adding a new resolver
152+
153+
1. Create `{name}.go` — define `{Name}Resolution` embedding `*ResolutionBase` with a single `Record {Name}Record` field, and `{Name}Record` with a `String()` method.
154+
2. Create `{name}_resolver.go` — implement `ResolveIP(ip string) []Resolution` or `ResolveDomain(domain string) []Resolution`, plus `Type() ResolutionType`.
155+
3. Add a `Type{Name}` constant to `api.go`.
156+
4. Register it in `NewUdig()` in `udig.go`.
157+
5. Handle the new type in the `switch` in `cmd/udig/main.go` and `cmd/udig/graph/graph.go`.
158+
6. Add tests in `{name}_test.go`.
143159

144160
## Building
145161

@@ -152,23 +168,21 @@ flowchart TB
152168

153169
### Make targets
154170

155-
156-
| Target | Description |
157-
| --------------- | ----------------------------------------------------- |
158-
| `make` | Build and run tests (default) |
159-
| `make build` | Compile binary |
160-
| `make test` | Run tests |
161-
| `make test-race`| Run tests with race detector |
162-
| `make install` | Run tests, install binary, copy GeoIP DB if present |
163-
| `make release` | Stripped + UPX release binary |
164-
| `make clean` | Remove binaries, GeoIP DB, test cache |
165-
| `make fmt` | Format code |
166-
| `make vet` | Run `go vet` |
167-
| `make lint` | Run golangci-lint |
168-
| `make mod-tidy` | Tidy go.mod / go.sum |
169-
| `make geoip` | Download GeoIP database if missing |
170-
| `make help` | List targets |
171-
171+
| Target | Description |
172+
| ---------------- | ----------------------------------------------------- |
173+
| `make` | Build and run tests (default) |
174+
| `make build` | Compile binary |
175+
| `make test` | Run tests |
176+
| `make test-race` | Run tests with race detector |
177+
| `make install` | Run tests, install binary, copy GeoIP DB if present |
178+
| `make release` | Stripped + UPX release binary |
179+
| `make clean` | Remove binaries, GeoIP DB, test cache |
180+
| `make fmt` | Format code |
181+
| `make vet` | Run `go vet` |
182+
| `make lint` | Run golangci-lint |
183+
| `make mod-tidy` | Tidy go.mod / go.sum |
184+
| `make geoip` | Download GeoIP database if missing |
185+
| `make help` | List targets |
172186

173187
### Running tests
174188

@@ -180,13 +194,11 @@ go test -v ./...
180194

181195
## Key files
182196

183-
184-
| File | Purpose |
185-
| ------------------ | --------------------------------------------------------- |
186-
| `api.go` | Interfaces, types, resolution structs, functional options |
187-
| `udig.go` | Facade implementation, queue processing, domain crawling |
188-
| `utils.go` | Domain/IP regex extraction, domain relation heuristics |
189-
| `log.go` | Colorized logging with log levels |
190-
| `cmd/udig/main.go` | CLI entry point, argument parsing, output formatting |
191-
192-
197+
| File | Purpose |
198+
| --------------------- | --------------------------------------------------------- |
199+
| `api.go` | Interfaces, resolution types, `ResolutionBase`, constants |
200+
| `udig.go` | Facade, queue processing, domain crawling, `NewUdig()` |
201+
| `utils.go` | Domain/IP regex extraction, domain relation heuristics |
202+
| `log.go` | Colorized logging with log levels |
203+
| `cmd/udig/main.go` | CLI entry point, argument parsing, output formatting |
204+
| `cmd/udig/graph/` | Graph output: DOT, JSON, terminal tree |

README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77

88
**Fast, non-intrusive domain reconnaissance tool written in Go.**
99

10-
Udig provides a quick overview of a target domain's infrastructure by combining multiple active scanning techniques — DNS enumeration (including CAA, DNSSEC, and DMARC), TLS certificate scraping, WHOIS lookups, HTTP analysis (headers, security.txt and robots.txt), Certificate Transparency log search, BGP ASN mapping, GeoIP resolution, and RDAP (RIR registration data for discovered IPs). Discovered domains are automatically followed and resolved recursively.
10+
Udig provides a quick overview of a target domain's infrastructure by combining multiple active scanning techniques — DNS enumeration (including CAA, DNSSEC, and DMARC), TLS certificate scraping, WHOIS lookups, HTTP analysis (headers, security.txt and robots.txt), Certificate Transparency log search, BGP ASN mapping, GeoIP resolution, RDAP (RIR registration data for discovered IPs), DNSBL blocklist checks and Tor exit-node detection Discovered domains are automatically followed and resolved recursively.
1111

1212
This is not a full-blown DNS enumerator. There is no brute-forcing, no port scanning, no search engine scraping. udig is designed to be unobtrusive and fast, suitable for long-term experiments with many targets.
1313

@@ -24,6 +24,8 @@ This is not a full-blown DNS enumerator. There is no brute-forcing, no port scan
2424
- **BGP** — maps discovered IPs to autonomous systems via Team Cymru
2525
- **GeoIP** — resolves country codes for discovered IPs via IP2Location
2626
- **RDAP** — looks up IP registration metadata (network name, handle, range, abuse contact) via RIR RDAP servers using the IANA bootstrap (no API key)
27+
- **DNSBL** — checks discovered IPs against DNS blocklists (Barracuda, UCEProtect, DroneBL) and decodes return codes
28+
- **Tor** — detects Tor nodes (exit, guard, relay) via the Onionoo API; reports nickname, fingerprint, and flags
2729
- **Recursive crawling** — domains found in any resolution are automatically followed
2830
- **Output** — colorized human-readable CLI output, JSON or graph as DOT (Graphviz), JSON, or terminal tree (`--graph=dot|json|term`)
2931

api.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,12 @@ const (
4747

4848
// TypeRDAP is a type of all RDAP (Registration Data Access Protocol) resolutions.
4949
TypeRDAP ResolutionType = "RDAP"
50+
51+
// TypeDNSBL is a type of all DNSBL (DNS Blocklist) resolutions.
52+
TypeDNSBL ResolutionType = "DNSBL"
53+
54+
// TypeTor is a type of all Tor exit-node resolutions.
55+
TypeTor ResolutionType = "TOR"
5056
)
5157

5258
// Udig is a high-level facade for domain resolution which:

cmd/udig/graph/graph.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ const (
1717
nodeTypeCountry nodeType = "country"
1818
nodeTypeWhois nodeType = "whois"
1919
nodeTypeRDAP nodeType = "rdap"
20+
nodeTypeDNSBL nodeType = "dnsbl"
21+
nodeTypeTor nodeType = "tor"
2022
)
2123

2224
func (t nodeType) String() string {
@@ -167,6 +169,24 @@ func (g *Graph) Collect(domain string, options []udig.Option) {
167169
if nodeID != "" && nodeID != "RDAP/" {
168170
g.addEdge(query, nodeID, "RDAP", nodeTypeRDAP)
169171
}
172+
173+
case udig.TypeDNSBL:
174+
g.addNode(query, nodeTypeIP)
175+
record := res.(*udig.DNSBLResolution).Record
176+
zoneNode := fmt.Sprintf("%s (%s)", record.Zone, record.Meaning)
177+
g.addEdge(query, zoneNode, "DNSBL", nodeTypeDNSBL)
178+
179+
case udig.TypeTor:
180+
g.addNode(query, nodeTypeIP)
181+
record := res.(*udig.TorResolution).Record
182+
label := "Tor Relay"
183+
if record.IsExitNode() {
184+
label = "Tor Exit Node"
185+
}
186+
if record.Nickname != "" {
187+
label = fmt.Sprintf("%s (%s)", label, record.Nickname)
188+
}
189+
g.addEdge(query, label, "TOR", nodeTypeTor)
170190
}
171191
}
172192
}

cmd/udig/graph/graph_dot.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,12 @@ func dotEdgeStyle(label string) (color, fontcolor string) {
3737
case label == "RDAP":
3838
return "#89B4FA", "#89B4FA"
3939

40+
case label == "DNSBL":
41+
return "#F38BA8", "#F38BA8"
42+
43+
case label == "TOR":
44+
return "#F5C2E7", "#F5C2E7"
45+
4046
default:
4147
return "#6C7086", "#9399B2"
4248
}
@@ -94,6 +100,12 @@ func (g *Graph) EmitDOT() error {
94100
case nodeTypeRDAP:
95101
attrs = `fillcolor="#89B4FA", fontcolor="#1B1B1C"`
96102

103+
case nodeTypeDNSBL:
104+
attrs = `fillcolor="#F38BA8", fontcolor="#1B1B1C"`
105+
106+
case nodeTypeTor:
107+
attrs = `fillcolor="#F5C2E7", fontcolor="#1B1B1C"`
108+
97109
default:
98110
attrs = `fillcolor="#313244", fontcolor="#CDD6F4"`
99111
}

cmd/udig/graph/graph_term.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ const (
1111
termCyan = "\033[1;36m"
1212
termGreen = "\033[1;32m"
1313
termMagenta = "\033[1;35m"
14+
termRed = "\033[1;31m"
1415
)
1516

1617
// isSharedLeaf returns true for node types that are shared singletons (country codes,
@@ -27,6 +28,10 @@ func (t nodeType) termColor() string {
2728
return termGreen
2829
case nodeTypeCountry:
2930
return termMagenta
31+
case nodeTypeDNSBL:
32+
return termRed
33+
case nodeTypeTor:
34+
return termMagenta
3035
default:
3136
return termReset
3237
}

0 commit comments

Comments
 (0)