reconflow 0.2.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
reconflow/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """reconflow package."""
2
+
3
+ __version__ = "0.2.2"
reconflow/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """reconflow - Command line execution module."""
2
+
3
+ from .cli import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
reconflow/cli.py ADDED
@@ -0,0 +1,118 @@
1
+ """reconflow.cli - Command-line interface and subcommand routing."""
2
+
3
+ import argparse
4
+ import sys
5
+ from . import __version__, scan
6
+
7
+
8
+ def build_parser():
9
+ """Construct the main argument parser with subcommands."""
10
+ parser = argparse.ArgumentParser(
11
+ prog="reconflow",
12
+ description="Recon pipeline for bug bounty hunters",
13
+ )
14
+ parser.add_argument(
15
+ "-v",
16
+ "--version",
17
+ action="version",
18
+ version=f"%(prog)s {__version__}",
19
+ )
20
+
21
+ subparsers = parser.add_subparsers(
22
+ dest="command",
23
+ title="subcommands",
24
+ description="available operations",
25
+ )
26
+
27
+ # scan subcommand
28
+ p_scan = subparsers.add_parser(
29
+ "scan",
30
+ help="Run recon on a domain",
31
+ description="Discover subdomains, filter wildcard DNS, and probe live web services.",
32
+ )
33
+ p_scan.add_argument(
34
+ "domain",
35
+ help="Target domain (must be in scope!)",
36
+ )
37
+ p_scan.add_argument(
38
+ "-o",
39
+ "--output",
40
+ default="output",
41
+ help="Output folder (default: output)",
42
+ )
43
+ p_scan.add_argument(
44
+ "--threads",
45
+ type=int,
46
+ default=10,
47
+ help="Number of threads for probing (default: 10)",
48
+ )
49
+ p_scan.add_argument(
50
+ "--timeout",
51
+ type=int,
52
+ default=5,
53
+ help="Timeout in seconds for probing (default: 5)",
54
+ )
55
+ p_scan.add_argument(
56
+ "--keep-wildcard",
57
+ action="store_true",
58
+ help="Warn about wildcard DNS but don't filter wildcard results",
59
+ )
60
+ p_scan.add_argument(
61
+ "--json",
62
+ action="store_true",
63
+ dest="json_report",
64
+ help="Also write structured results to report.json",
65
+ )
66
+ p_scan.add_argument(
67
+ "--screenshots",
68
+ action="store_true",
69
+ help="Capture screenshots of live hosts using gowitness",
70
+ )
71
+
72
+ # diff subcommand
73
+ p_diff = subparsers.add_parser(
74
+ "diff",
75
+ help="Compare the latest two scans for a domain",
76
+ description=(
77
+ "Compare the latest two scan snapshots to detect "
78
+ "newly discovered subdomains and host changes."
79
+ ),
80
+ )
81
+ p_diff.add_argument(
82
+ "domain",
83
+ help="Target domain to compare runs for",
84
+ )
85
+ p_diff.add_argument(
86
+ "-o",
87
+ "--output",
88
+ default="output",
89
+ help="Output folder (default: output)",
90
+ )
91
+ p_diff.add_argument(
92
+ "--json",
93
+ action="store_true",
94
+ dest="json_report",
95
+ help="Output diff comparison as JSON",
96
+ )
97
+
98
+ return parser
99
+
100
+
101
+ def main(args=None):
102
+ """Main entrypoint for CLI command dispatch."""
103
+ parser = build_parser()
104
+ parsed_args = parser.parse_args(args)
105
+
106
+ if parsed_args.command == "scan":
107
+ scan.run(parsed_args)
108
+ elif parsed_args.command == "diff":
109
+ from . import diff
110
+
111
+ diff.run(parsed_args)
112
+ else:
113
+ parser.print_help()
114
+ sys.exit(0)
115
+
116
+
117
+ if __name__ == "__main__":
118
+ main()
reconflow/diff.py ADDED
@@ -0,0 +1,206 @@
1
+ """reconflow.diff - Differential change detection between scan runs."""
2
+
3
+ import json
4
+ import sys
5
+ from pathlib import Path
6
+ from urllib.parse import urlparse
7
+ from . import history
8
+
9
+
10
+ # Tags that flip between runs because of CDN/transport behavior, not app changes
11
+ IGNORED_TECH = {"HTTP/2", "HTTP/3", "HSTS"}
12
+
13
+
14
+ def _hostname(url):
15
+ return (urlparse(url).hostname or url).lower()
16
+
17
+
18
+ def _by_host(hosts):
19
+ """Map hostname -> host record. If a host answers on both schemes, prefer https."""
20
+ result = {}
21
+ for h in hosts or []:
22
+ if not isinstance(h, dict) or "url" not in h:
23
+ continue
24
+ name = _hostname(h["url"])
25
+ current = result.get(name)
26
+ if current is None or (
27
+ h["url"].startswith("https://") and not current["url"].startswith("https://")
28
+ ):
29
+ result[name] = h
30
+ return result
31
+
32
+
33
+ def _meaningful_tech(tech):
34
+ return {t for t in (tech or []) if t not in IGNORED_TECH}
35
+
36
+
37
+ def compare(old, new):
38
+ """Compare two scan snapshots and return differences.
39
+
40
+ Hosts are matched by hostname, so a scheme flip (http <-> https) is not
41
+ reported as one host disappearing and another appearing.
42
+ """
43
+ old_hosts = _by_host(old.get("hosts"))
44
+ new_hosts = _by_host(new.get("hosts"))
45
+
46
+ changed = {}
47
+ for name in old_hosts.keys() & new_hosts.keys():
48
+ o, n = old_hosts[name], new_hosts[name]
49
+ diffs = {}
50
+ if o.get("status_code") != n.get("status_code"):
51
+ diffs["status_code"] = (o.get("status_code"), n.get("status_code"))
52
+ if o.get("title") != n.get("title"):
53
+ diffs["title"] = (o.get("title"), n.get("title"))
54
+ if _meaningful_tech(o.get("technologies")) != _meaningful_tech(n.get("technologies")):
55
+ diffs["technologies"] = (o.get("technologies") or [], n.get("technologies") or [])
56
+ if diffs:
57
+ changed[n["url"]] = diffs
58
+
59
+ old_subs = set(old.get("subdomains") or [])
60
+ new_subs = set(new.get("subdomains") or [])
61
+ return {
62
+ "new_subdomains": sorted(new_subs - old_subs),
63
+ "removed_subdomains": sorted(old_subs - new_subs),
64
+ "new_live": sorted(new_hosts[h]["url"] for h in new_hosts.keys() - old_hosts.keys()),
65
+ "gone_live": sorted(old_hosts[h]["url"] for h in old_hosts.keys() - new_hosts.keys()),
66
+ "changed": changed,
67
+ }
68
+
69
+
70
+ def format_diff(diff_result, old_id, new_id, new_data=None):
71
+ """Render a human-readable comparison summary between two runs."""
72
+ lines = [
73
+ f"[*] Recon diff: {old_id} -> {new_id}",
74
+ "=" * 60,
75
+ ]
76
+
77
+ new_subs = diff_result.get("new_subdomains", [])
78
+ new_live = diff_result.get("new_live", [])
79
+ changed = diff_result.get("changed", {})
80
+ removed_subs = diff_result.get("removed_subdomains", [])
81
+ gone_live = diff_result.get("gone_live", [])
82
+
83
+ has_changes = any([new_subs, new_live, changed, removed_subs, gone_live])
84
+ if not has_changes:
85
+ lines.append("[+] No changes detected between scans.")
86
+ return "\n".join(lines)
87
+
88
+ if new_subs:
89
+ lines.append(f"\n[+] NEW SUBDOMAINS ({len(new_subs)}):")
90
+ for s in new_subs:
91
+ lines.append(f" + {s}")
92
+
93
+ if new_live:
94
+ lines.append(f"\n[+] NEWLY LIVE HOSTS ({len(new_live)}):")
95
+ hosts_map = {}
96
+ if new_data and isinstance(new_data, dict):
97
+ hosts_map = {
98
+ h["url"]: h
99
+ for h in (new_data.get("hosts") or [])
100
+ if isinstance(h, dict) and "url" in h
101
+ }
102
+ for h in new_live:
103
+ meta = hosts_map.get(h)
104
+ if meta:
105
+ code = meta.get("status_code")
106
+ title = meta.get("title")
107
+ tech = meta.get("technologies") or []
108
+ parts = []
109
+ if code:
110
+ parts.append(f"[{code}]")
111
+ if title:
112
+ parts.append(f"[{title}]")
113
+ if tech:
114
+ tech_str = ", ".join(tech) if isinstance(tech, list) else str(tech)
115
+ parts.append(f"[{tech_str}]")
116
+ detail = (" " + " ".join(parts)) if parts else ""
117
+ lines.append(f" + {h}{detail}")
118
+ else:
119
+ lines.append(f" + {h}")
120
+
121
+ if changed:
122
+ lines.append(f"\n[*] CHANGED HOSTS ({len(changed)}):")
123
+ for url, diffs in changed.items():
124
+ lines.append(f" ~ {url}")
125
+ if "status_code" in diffs:
126
+ o, n = diffs["status_code"]
127
+ lines.append(f" status: {o} -> {n}")
128
+ if "title" in diffs:
129
+ o, n = diffs["title"]
130
+ lines.append(f" title: '{o}' -> '{n}'")
131
+ if "technologies" in diffs:
132
+ o, n = diffs["technologies"]
133
+ lines.append(f" tech: {o} -> {n}")
134
+
135
+ if removed_subs:
136
+ lines.append(f"\n[-] Removed subdomains ({len(removed_subs)}) [informational]:")
137
+ for s in removed_subs:
138
+ lines.append(f" - {s}")
139
+
140
+ if gone_live:
141
+ lines.append(f"\n[-] Inactive hosts ({len(gone_live)}) [informational]:")
142
+ for h in gone_live:
143
+ lines.append(f" - {h}")
144
+
145
+ lines.append("\n" + "=" * 60)
146
+ return "\n".join(lines)
147
+
148
+
149
+ def run(args):
150
+ """Execute diff comparison for a domain between its latest two scan runs."""
151
+ domain = getattr(args, "domain", None)
152
+ if not domain:
153
+ sys.exit("Error: Target domain is required.")
154
+
155
+ output_dir = getattr(args, "output", "output") or "output"
156
+ json_output = bool(getattr(args, "json_report", False) or getattr(args, "json", False))
157
+ out_dir = Path(output_dir) / domain
158
+
159
+ runs = history.list_runs(out_dir)
160
+ if len(runs) < 2:
161
+ if not runs:
162
+ msg = f"[!] No scan history found for {domain} in {out_dir}. Run a scan first."
163
+ else:
164
+ msg = (
165
+ f"[!] Only 1 scan run found for {domain} ({runs[0].name}). "
166
+ "Need at least 2 runs to compare."
167
+ )
168
+ if json_output:
169
+ print(json.dumps({"domain": domain, "error": msg, "runs_count": len(runs)}))
170
+ else:
171
+ print(msg)
172
+ sys.exit(0)
173
+
174
+ old_run = runs[-2]
175
+ new_run = runs[-1]
176
+ old_data = history.load_snapshot(old_run)
177
+ new_data = history.load_snapshot(new_run)
178
+
179
+ diff_result = compare(old_data, new_data)
180
+ has_changes = any([
181
+ diff_result["new_subdomains"],
182
+ diff_result["new_live"],
183
+ diff_result["changed"],
184
+ diff_result["removed_subdomains"],
185
+ diff_result["gone_live"],
186
+ ])
187
+
188
+ if json_output:
189
+ payload = {
190
+ "domain": domain,
191
+ "old_run": old_data.get("run_id", old_run.name),
192
+ "new_run": new_data.get("run_id", new_run.name),
193
+ "has_changes": has_changes,
194
+ **diff_result,
195
+ }
196
+ print(json.dumps(payload, indent=2))
197
+ else:
198
+ output_text = format_diff(
199
+ diff_result,
200
+ old_data.get("run_id", old_run.name),
201
+ new_data.get("run_id", new_run.name),
202
+ new_data=new_data,
203
+ )
204
+ print(output_text)
205
+
206
+ sys.exit(1 if has_changes else 0)
reconflow/history.py ADDED
@@ -0,0 +1,46 @@
1
+ """reconflow.history - Snapshot tracking and scan history management."""
2
+
3
+ import json
4
+ from datetime import datetime
5
+ from pathlib import Path
6
+
7
+
8
+ def save_snapshot(out_dir: Path, subs, hosts):
9
+ """Save scan results as a timestamped snapshot under out_dir/runs/<run_id>/."""
10
+ base_id = datetime.now().strftime("%Y-%m-%d_%H%M%S")
11
+ run_id = base_id
12
+ runs_dir = out_dir / "runs"
13
+ runs_dir.mkdir(parents=True, exist_ok=True)
14
+
15
+ counter = 1
16
+ while (runs_dir / run_id).exists():
17
+ run_id = f"{base_id}_{counter}"
18
+ counter += 1
19
+
20
+ run_dir = runs_dir / run_id
21
+ run_dir.mkdir(parents=True, exist_ok=True)
22
+ data = {
23
+ "run_id": run_id,
24
+ "timestamp": datetime.now().isoformat(),
25
+ "subdomains": sorted(subs),
26
+ "hosts": hosts or [],
27
+ }
28
+ (run_dir / "snapshot.json").write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
29
+ return run_id
30
+
31
+
32
+ def list_runs(out_dir: Path):
33
+ """Return a sorted list of run directories containing snapshot.json."""
34
+ runs_dir = out_dir / "runs"
35
+ if not runs_dir.exists() or not runs_dir.is_dir():
36
+ return []
37
+ valid_runs = [p for p in runs_dir.iterdir() if p.is_dir() and (p / "snapshot.json").exists()]
38
+ return sorted(valid_runs, key=lambda p: p.name)
39
+
40
+
41
+ def load_snapshot(run_dir: Path):
42
+ """Load and parse snapshot.json from the specified run directory."""
43
+ snapshot_file = run_dir / "snapshot.json"
44
+ if not snapshot_file.exists():
45
+ raise FileNotFoundError(f"Snapshot file not found: {snapshot_file}")
46
+ return json.loads(snapshot_file.read_text(encoding="utf-8"))
reconflow/scan.py ADDED
@@ -0,0 +1,448 @@
1
+ """reconflow.scan - subdomain discovery and live host probing pipeline."""
2
+
3
+ import argparse
4
+ import json
5
+ import random
6
+ import shutil
7
+ import socket
8
+ import string
9
+ import subprocess
10
+ import sys
11
+ from concurrent.futures import ThreadPoolExecutor
12
+ from datetime import datetime
13
+ from pathlib import Path
14
+
15
+ RESOLVE_THREADS = 50
16
+
17
+
18
+ TOOL_DOWNLOAD_URLS = {
19
+ "subfinder": "https://github.com/projectdiscovery/subfinder/releases",
20
+ "httpx": "https://github.com/projectdiscovery/httpx/releases (ProjectDiscovery Go binary)",
21
+ "gowitness": "https://github.com/sensepost/gowitness/releases",
22
+ }
23
+
24
+
25
+ def run_cmd(cmd):
26
+ """Run an external command and return the completed process."""
27
+ return subprocess.run(
28
+ cmd,
29
+ stdin=subprocess.DEVNULL,
30
+ capture_output=True,
31
+ text=True,
32
+ encoding="utf-8",
33
+ errors="replace",
34
+ )
35
+
36
+
37
+ # Backwards compatibility alias
38
+ run_subprocess = run_cmd
39
+
40
+
41
+ def resolve_httpx_binary():
42
+ """Find and verify ProjectDiscovery httpx binary (checking httpx-toolkit then httpx)."""
43
+ for candidate in ["httpx-toolkit", "httpx"]:
44
+ if shutil.which(candidate) is None:
45
+ continue
46
+ try:
47
+ res = run_cmd([candidate, "-version"])
48
+ out = (res.stdout + " " + res.stderr).lower()
49
+ if "projectdiscovery" in out:
50
+ return candidate
51
+ if candidate == "httpx-toolkit":
52
+ return candidate
53
+ except Exception:
54
+ pass
55
+
56
+ if shutil.which("httpx"):
57
+ try:
58
+ res = run_cmd(["httpx", "-h"])
59
+ out = (res.stdout + " " + res.stderr).lower()
60
+ if "projectdiscovery" in out or "-status-code" in out:
61
+ return "httpx"
62
+ except Exception:
63
+ pass
64
+ return "httpx"
65
+ return None
66
+
67
+
68
+ def check_tools(tools):
69
+ """Check that required external binaries are available in PATH."""
70
+ missing = []
71
+ for t in tools:
72
+ if t == "httpx":
73
+ if not resolve_httpx_binary():
74
+ missing.append("httpx")
75
+ elif shutil.which(t) is None:
76
+ missing.append(t)
77
+
78
+ if missing:
79
+ lines = [
80
+ f"[!] Missing required tools: {', '.join(missing)}",
81
+ "",
82
+ "Installation links:",
83
+ ]
84
+ for t in missing:
85
+ url = TOOL_DOWNLOAD_URLS.get(t, "See README for details")
86
+ lines.append(f" - {t}: {url}")
87
+ lines.append("")
88
+ lines.append(
89
+ "Note: 'httpx' must be ProjectDiscovery's binary "
90
+ "(https://github.com/projectdiscovery/httpx),"
91
+ )
92
+ lines.append(" not the Python 'httpx' library from PyPI.")
93
+ sys.exit("\n".join(lines))
94
+
95
+
96
+ def resolve(host):
97
+ """Return the set of IP addresses a hostname resolves to (empty set if none)."""
98
+ try:
99
+ return {info[4][0] for info in socket.getaddrinfo(host, None)}
100
+ except (socket.gaierror, UnicodeError, OSError):
101
+ return set()
102
+
103
+
104
+ def detect_wildcard(domain, min_samples=5, max_samples=20, stable_rounds=3, resolve_fn=None):
105
+ """Resolve random nonexistent names to detect wildcard DNS and rotating IP pools.
106
+
107
+ Returns the set of wildcard IPs (empty set means no wildcard detected).
108
+ If a domain uses wildcard DNS with round-robin or rotating pools, continues
109
+ sampling until the IP set stabilizes or max_samples is reached.
110
+ """
111
+ _resolve = resolve_fn or resolve
112
+ first_label = "".join(random.choices(string.ascii_lowercase + string.digits, k=14))
113
+ first_ips = _resolve(f"{first_label}.{domain}")
114
+ if not first_ips:
115
+ return set()
116
+
117
+ ips = set(first_ips)
118
+ consecutive_stable = 0
119
+
120
+ for i in range(1, max_samples):
121
+ label = "".join(random.choices(string.ascii_lowercase + string.digits, k=14))
122
+ resolved_ips = _resolve(f"{label}.{domain}")
123
+ new_ips = resolved_ips - ips
124
+ if new_ips:
125
+ ips |= new_ips
126
+ consecutive_stable = 0
127
+ else:
128
+ consecutive_stable += 1
129
+ if i >= min_samples and consecutive_stable >= stable_rounds:
130
+ break
131
+
132
+ return ips
133
+
134
+
135
+ def filter_wildcard(subs, wildcard_ips, resolve_fn=None):
136
+ """Drop subdomains whose IPs all fall inside the wildcard IP set."""
137
+ _resolve = resolve_fn or resolve
138
+ with ThreadPoolExecutor(max_workers=RESOLVE_THREADS) as pool:
139
+ resolved = list(pool.map(_resolve, subs))
140
+ kept = [s for s, ips in zip(subs, resolved) if ips and not ips <= wildcard_ips]
141
+ return kept
142
+
143
+
144
+ def find_subdomains(domain, out_file):
145
+ """Discover subdomains using subfinder and write to out_file."""
146
+ print(f"[*] Finding subdomains for {domain}...")
147
+ result = run_cmd(["subfinder", "-d", domain, "-silent"])
148
+ subs = set(result.stdout.split())
149
+ subs.add(domain.strip().lower())
150
+ subs_sorted = sorted(subs)
151
+ out_file.write_text("\n".join(subs_sorted) + "\n", encoding="utf-8")
152
+ print(f"[+] Found {len(subs_sorted)} subdomains")
153
+ return subs_sorted
154
+
155
+
156
+ def probe_hosts(
157
+ subs_file,
158
+ out_file,
159
+ threads=None,
160
+ timeout=None,
161
+ json_report=False,
162
+ httpx_bin=None,
163
+ ):
164
+ """Probe subdomains for live HTTP/HTTPS services using httpx."""
165
+ bin_name = httpx_bin or resolve_httpx_binary() or "httpx"
166
+ print("[*] Probing for live hosts (this can take a while on big targets)...")
167
+ cmd = [
168
+ bin_name,
169
+ "-l",
170
+ str(subs_file),
171
+ "-silent",
172
+ "-status-code",
173
+ "-title",
174
+ "-tech-detect",
175
+ ]
176
+ if json_report:
177
+ cmd.append("-json")
178
+ if threads is not None:
179
+ cmd.extend(["-threads", str(threads)])
180
+ if timeout is not None:
181
+ cmd.extend(["-timeout", str(int(timeout))])
182
+
183
+ lines = []
184
+ hosts_data = []
185
+ error_lines = []
186
+ with subprocess.Popen(
187
+ cmd,
188
+ stdin=subprocess.DEVNULL,
189
+ stdout=subprocess.PIPE,
190
+ stderr=subprocess.DEVNULL,
191
+ text=True,
192
+ encoding="utf-8",
193
+ errors="replace",
194
+ ) as proc:
195
+ for line in proc.stdout:
196
+ stripped = line.strip()
197
+ if not stripped:
198
+ continue
199
+ if json_report:
200
+ try:
201
+ data = json.loads(stripped)
202
+ url = data.get("url") or ""
203
+ code = data.get("status_code", data.get("status-code", 0))
204
+ title = data.get("title", "")
205
+ tech = data.get("tech") or data.get("technologies") or []
206
+ hosts_data.append({
207
+ "url": url,
208
+ "status_code": code,
209
+ "title": title,
210
+ "technologies": tech,
211
+ })
212
+ display = f"{url} [{code}]"
213
+ if title:
214
+ display += f" [{title}]"
215
+ if tech:
216
+ tech_str = ", ".join(tech) if isinstance(tech, list) else tech
217
+ display += f" [{tech_str}]"
218
+ lines.append(display)
219
+ print(f"[*] [{len(lines)} alive] {display}")
220
+ except json.JSONDecodeError:
221
+ error_lines.append(stripped)
222
+ else:
223
+ if stripped.startswith("http://") or stripped.startswith("https://"):
224
+ lines.append(stripped)
225
+ print(f"[*] [{len(lines)} alive] {stripped}")
226
+ else:
227
+ error_lines.append(stripped)
228
+ proc.wait()
229
+
230
+ if proc.returncode != 0:
231
+ if error_lines:
232
+ err_msg = "\n".join(error_lines)
233
+ print(f"[!] httpx probing error (exit code {proc.returncode}):\n{err_msg}")
234
+ elif not lines:
235
+ print(f"[!] httpx probing failed (exit code {proc.returncode})")
236
+
237
+ out_file.write_text("\n".join(lines) + ("\n" if lines else ""), encoding="utf-8")
238
+ print(f"[+] Probing complete: {len(lines)} hosts are alive")
239
+ return lines, (hosts_data if json_report else None)
240
+
241
+
242
+ def capture_screenshots(live_hosts, screenshots_dir):
243
+ """Capture screenshots of live hosts using gowitness."""
244
+ screenshots_dir.mkdir(parents=True, exist_ok=True)
245
+ urls = [line.split()[0] for line in live_hosts if line.strip()]
246
+ if not urls:
247
+ return []
248
+ print("[*] Capturing screenshots of live hosts with gowitness...")
249
+ urls_file = screenshots_dir / ".targets.txt"
250
+ urls_file.write_text("\n".join(urls) + "\n", encoding="utf-8")
251
+ cmd = [
252
+ "gowitness",
253
+ "scan",
254
+ "file",
255
+ "-f",
256
+ str(urls_file),
257
+ "--screenshot-path",
258
+ str(screenshots_dir),
259
+ ]
260
+ run_cmd(cmd)
261
+ try:
262
+ urls_file.unlink(missing_ok=True)
263
+ except OSError:
264
+ pass
265
+ images = sorted(p.name for p in screenshots_dir.glob("*.png"))
266
+ print(f"[+] Captured {len(images)} screenshots in {screenshots_dir}")
267
+ return images
268
+
269
+
270
+ def write_report(
271
+ domain,
272
+ subs,
273
+ live,
274
+ out_dir,
275
+ wildcard_ips=None,
276
+ dropped=0,
277
+ hosts_data=None,
278
+ screenshots=None,
279
+ ):
280
+ """Write markdown and optional JSON reports to out_dir."""
281
+ report = out_dir / "report.md"
282
+ warning = ""
283
+ if wildcard_ips:
284
+ warning = (
285
+ f"> **Wildcard DNS detected** (resolves to {', '.join(sorted(wildcard_ips))}). "
286
+ f"{dropped} subdomains that only pointed there were filtered out.\n\n"
287
+ )
288
+ screenshot_section = ""
289
+ if screenshots:
290
+ screenshot_section = (
291
+ f"## Screenshots\n\nCaptured {len(screenshots)} screenshot(s) in `screenshots/`.\n\n"
292
+ )
293
+ report.write_text(
294
+ f"# reconflow report: {domain}\n\n"
295
+ f"Generated: {datetime.now():%Y-%m-%d %H:%M}\n\n"
296
+ f"{warning}"
297
+ f"- Subdomains found: {len(subs)}\n"
298
+ f"- Live hosts: {len(live)}\n\n"
299
+ f"## Live hosts\n\n```\n" + "\n".join(live) + "\n```\n\n"
300
+ f"{screenshot_section}",
301
+ encoding="utf-8",
302
+ )
303
+ print(f"[+] Report saved to {report}")
304
+
305
+ if hosts_data is not None:
306
+ json_report_path = out_dir / "report.json"
307
+ report_dict = {
308
+ "domain": domain,
309
+ "generated": datetime.now().isoformat(),
310
+ "subdomains_count": len(subs),
311
+ "live_count": len(live),
312
+ "wildcard_detected": bool(wildcard_ips),
313
+ "wildcard_ips": sorted(wildcard_ips) if wildcard_ips else [],
314
+ "wildcard_filtered_count": dropped,
315
+ "screenshots_count": len(screenshots) if screenshots else 0,
316
+ "live_hosts": hosts_data,
317
+ }
318
+ json_report_path.write_text(json.dumps(report_dict, indent=2) + "\n", encoding="utf-8")
319
+ print(f"[+] JSON report saved to {json_report_path}")
320
+
321
+
322
+ def run(args):
323
+ """Run the core recon pipeline using provided arguments namespace."""
324
+ domain = getattr(args, "domain", None)
325
+ if not domain:
326
+ sys.exit("Error: Target domain is required.")
327
+
328
+ output_dir = getattr(args, "output", "output") or "output"
329
+ threads = getattr(args, "threads", None)
330
+ timeout = getattr(args, "timeout", None)
331
+ keep_wildcard = getattr(args, "keep_wildcard", False)
332
+ json_report = bool(getattr(args, "json_report", False) or getattr(args, "json", False))
333
+ screenshots_enabled = getattr(args, "screenshots", False)
334
+
335
+ required_tools = ["subfinder", "httpx"]
336
+ if screenshots_enabled:
337
+ required_tools.append("gowitness")
338
+ check_tools(required_tools)
339
+
340
+ out_dir = Path(output_dir) / domain
341
+ out_dir.mkdir(parents=True, exist_ok=True)
342
+
343
+ try:
344
+ subs = find_subdomains(domain, out_dir / "subdomains.txt")
345
+
346
+ wildcard_ips = detect_wildcard(domain)
347
+ dropped = 0
348
+ if wildcard_ips:
349
+ ips_str = ", ".join(sorted(wildcard_ips))
350
+ print(f"[!] Wildcard DNS detected: random names resolve to {ips_str}")
351
+ if keep_wildcard:
352
+ print("[!] --keep-wildcard set, results will not be filtered")
353
+ else:
354
+ kept = filter_wildcard(subs, wildcard_ips)
355
+ dropped = len(subs) - len(kept)
356
+ print(f"[*] Filtered out {dropped} wildcard-only subdomains, {len(kept)} remain")
357
+ subs = kept
358
+ (out_dir / "subdomains.txt").write_text(
359
+ "\n".join(subs) + ("\n" if subs else ""), encoding="utf-8"
360
+ )
361
+
362
+ screenshots = []
363
+ if not subs:
364
+ print("[!] No subdomains left to probe.")
365
+ live = []
366
+ hosts_data = []
367
+ else:
368
+ httpx_bin = resolve_httpx_binary() or "httpx"
369
+ live, hosts_data = probe_hosts(
370
+ out_dir / "subdomains.txt",
371
+ out_dir / "live.txt",
372
+ threads=threads,
373
+ timeout=timeout,
374
+ json_report=True,
375
+ httpx_bin=httpx_bin,
376
+ )
377
+ if screenshots_enabled and live:
378
+ screenshots = capture_screenshots(live, out_dir / "screenshots")
379
+
380
+ write_report(
381
+ domain,
382
+ subs,
383
+ live,
384
+ out_dir,
385
+ wildcard_ips=wildcard_ips,
386
+ dropped=dropped,
387
+ hosts_data=(hosts_data if json_report else None),
388
+ screenshots=screenshots,
389
+ )
390
+
391
+ from . import history
392
+
393
+ run_id = history.save_snapshot(out_dir, subs, hosts_data or [])
394
+ print(f"[+] Saved scan snapshot ({run_id})")
395
+
396
+ return {
397
+ "domain": domain,
398
+ "run_id": run_id,
399
+ "subdomains": subs,
400
+ "live": live,
401
+ "hosts": hosts_data or [],
402
+ "output_dir": str(out_dir),
403
+ }
404
+ except KeyboardInterrupt:
405
+ sys.exit(f"\n[!] Interrupted. Partial results (if any) are in {out_dir}")
406
+
407
+
408
+ def main():
409
+ """Standalone entry point for scan module."""
410
+ parser = argparse.ArgumentParser(
411
+ description="reconflow: subdomain discovery + live host probing in one command"
412
+ )
413
+ parser.add_argument("-d", "--domain", required=True, help="target domain (must be in scope!)")
414
+ parser.add_argument("-o", "--output", default="output", help="output folder (default: output)")
415
+ parser.add_argument(
416
+ "--keep-wildcard",
417
+ action="store_true",
418
+ help="warn about wildcard DNS but don't filter wildcard results",
419
+ )
420
+ parser.add_argument(
421
+ "--threads",
422
+ type=int,
423
+ default=None,
424
+ help="number of threads for httpx probing (default: httpx default)",
425
+ )
426
+ parser.add_argument(
427
+ "--timeout",
428
+ type=int,
429
+ default=5,
430
+ help="timeout in seconds for httpx probing (default: 5)",
431
+ )
432
+ parser.add_argument(
433
+ "--json",
434
+ action="store_true",
435
+ dest="json_report",
436
+ help="also write results to report.json",
437
+ )
438
+ parser.add_argument(
439
+ "--screenshots",
440
+ action="store_true",
441
+ help="capture screenshots of live hosts using gowitness",
442
+ )
443
+ args = parser.parse_args()
444
+ run(args)
445
+
446
+
447
+ if __name__ == "__main__":
448
+ main()
@@ -0,0 +1,184 @@
1
+ Metadata-Version: 2.4
2
+ Name: reconflow
3
+ Version: 0.2.2
4
+ Summary: Recon pipeline for bug bounty hunters with differential change tracking
5
+ Author: Nithishvelan M
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/ErenYeager1836/reconflow
8
+ Project-URL: Repository, https://github.com/ErenYeager1836/reconflow
9
+ Project-URL: Issues, https://github.com/ErenYeager1836/reconflow/issues
10
+ Keywords: bugbounty,recon,reconnaissance,subdomain,subfinder,httpx,differential,security,infosec
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3.8
13
+ Classifier: Programming Language :: Python :: 3.9
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Operating System :: OS Independent
18
+ Classifier: Topic :: Security
19
+ Classifier: Intended Audience :: Information Technology
20
+ Requires-Python: >=3.8
21
+ Description-Content-Type: text/markdown
22
+ License-File: LICENSE
23
+ Dynamic: license-file
24
+
25
+ # reconflow
26
+
27
+ Recon for bug bounty hunters that tells you **what changed** since your last scan.
28
+
29
+ `reconflow` chains [subfinder](https://github.com/projectdiscovery/subfinder) and [httpx](https://github.com/projectdiscovery/httpx) into one command, filters out wildcard DNS noise, saves a snapshot of every run, and lets you `diff` any two runs to see new subdomains, newly live hosts, and changed responses.
30
+
31
+ > **Use responsibly.** Only scan domains you own or that are explicitly in scope for a bug bounty / pentest program you are authorized to test.
32
+
33
+ ## Features
34
+
35
+ - **`scan`**: finds subdomains, drops wildcard DNS false positives (including rotating IP pools), probes live hosts for status code, title and technologies, and writes a Markdown report
36
+ - **`diff`**: compares your two most recent scans of a domain and highlights what's new
37
+ - **Scan history**: every scan saves a structured snapshot, so nothing is overwritten
38
+ - **Cron-friendly**: `diff` exits `1` when something changed and `0` when nothing did
39
+ - No Python dependencies beyond the standard library
40
+
41
+ ## Requirements
42
+
43
+ - Python 3.8+
44
+ - [`subfinder`](https://github.com/projectdiscovery/subfinder/releases) and [`httpx`](https://github.com/projectdiscovery/httpx/releases) (ProjectDiscovery's Go versions) on your `PATH`
45
+ - [`gowitness`](https://github.com/sensepost/gowitness/releases) (optional, only for `--screenshots`)
46
+
47
+ > **Note:** `httpx` must be ProjectDiscovery's tool, not the Python `httpx` library. On Kali/Debian/Ubuntu it is often packaged as `httpx-toolkit`. reconflow checks for both names and verifies the binary.
48
+
49
+ Check your tools:
50
+
51
+ ```
52
+ subfinder -version
53
+ httpx -version
54
+ ```
55
+
56
+ ## Install
57
+
58
+ From PyPI:
59
+
60
+ ```
61
+ pip install reconflow
62
+ ```
63
+
64
+ From source:
65
+
66
+ ```
67
+ git clone https://github.com/ErenYeager1836/reconflow.git
68
+ cd reconflow
69
+ pip install -e .
70
+ ```
71
+
72
+ ## Usage
73
+
74
+ ```
75
+ reconflow scan example.com
76
+ reconflow diff example.com
77
+ ```
78
+
79
+ You can also run it as a module: `python -m reconflow scan example.com`
80
+
81
+ ### `scan`
82
+
83
+ | Option | Description |
84
+ | --- | --- |
85
+ | `domain` | Target domain (required, must be in scope) |
86
+ | `-o`, `--output` | Output folder (default: `output`) |
87
+ | `--threads` | Number of threads for probing (default: 10) |
88
+ | `--timeout` | Timeout in seconds for probing (default: 5) |
89
+ | `--keep-wildcard` | Warn about wildcard DNS but don't filter wildcard results |
90
+ | `--json` | Also write structured results to `report.json` |
91
+ | `--screenshots` | Capture screenshots of live hosts (requires gowitness) |
92
+
93
+ ### `diff`
94
+
95
+ | Option | Description |
96
+ | --- | --- |
97
+ | `domain` | Target domain to compare runs for (required) |
98
+ | `-o`, `--output` | Output folder (default: `output`) |
99
+ | `--json` | Print the comparison as JSON |
100
+
101
+ `diff` needs at least two saved scans of the same domain. Run `scan` twice first.
102
+
103
+ ## Track changes with `diff`
104
+
105
+ Most recon tools tell you what exists. reconflow also tells you what changed. New subdomains and newly live hosts are often the least-tested parts of a target.
106
+
107
+ Example output (illustrative):
108
+
109
+ ```
110
+ [*] Recon diff: 2026-10-18_185218 -> 2026-10-18_190412
111
+ ============================================================
112
+ [+] NEW SUBDOMAINS (2):
113
+ + beta.example.com
114
+ + staging-api.example.com
115
+ [+] NEWLY LIVE HOSTS (1):
116
+ + https://beta.example.com [200] [Beta Portal] [Nginx, React]
117
+ [*] CHANGED HOSTS (1):
118
+ ~ https://admin.example.com
119
+ status: 403 -> 200
120
+ title: 'Forbidden' -> 'Admin Login'
121
+ tech: ['Apache'] -> ['Apache', 'PHP']
122
+ [-] Removed subdomains (1) [informational]:
123
+ - old.example.com
124
+ ============================================================
125
+ ```
126
+
127
+ Removed subdomains are informational only, because subfinder's sources can return slightly different results between runs.
128
+
129
+ ### Exit codes
130
+
131
+ | Code | Meaning |
132
+ | --- | --- |
133
+ | `0` | No changes found (or not enough history to compare) |
134
+ | `1` | Changes were detected |
135
+
136
+ ### Run it on a schedule
137
+
138
+ ```
139
+ # Every 6 hours: rescan and log changes
140
+ 0 */6 * * * reconflow scan example.com && reconflow diff example.com >> ~/recon-changes.log
141
+ ```
142
+
143
+ ## Output
144
+
145
+ Files are saved under `output/<domain>/`:
146
+
147
+ ```
148
+ subdomains.txt all discovered subdomains
149
+ live.txt live hosts with status code, title, tech
150
+ report.md summary report
151
+ report.json structured report (with --json)
152
+ screenshots/ screenshots of live hosts (with --screenshots)
153
+ runs/<run_id>/ snapshot of each scan, used by diff
154
+ ```
155
+
156
+ ## Known limitations
157
+
158
+ - **Wildcard DNS domains** return many fake "live" hosts that all point to the same server. reconflow detects them and filters those hosts (use `--keep-wildcard` to disable).
159
+ - Discovery depends on subfinder's sources, so results can vary between runs.
160
+
161
+ ## Roadmap
162
+
163
+ - [x] Subdomain discovery and live host probing
164
+ - [x] Wildcard DNS detection (including rotating IP pools)
165
+ - [x] `--threads` and `--timeout` options
166
+ - [x] JSON report output
167
+ - [x] Screenshots of live hosts
168
+ - [x] Scan history and `reconflow diff`
169
+ - [ ] `reconflow scope`: check targets against program scope
170
+ - [ ] `reconflow report`: regenerate reports from saved scans
171
+ - [ ] `--notify`: Discord/Slack webhook alerts for diff results
172
+ - [ ] Save results as they come in
173
+
174
+ ## Contributing
175
+
176
+ Contributions are welcome. Look for issues labelled `good first issue`. Run the tests with:
177
+
178
+ ```
179
+ python -m unittest discover tests -v
180
+ ```
181
+
182
+ ## License
183
+
184
+ MIT
@@ -0,0 +1,12 @@
1
+ reconflow/__init__.py,sha256=gehFXwC1xM7i8O2qYBEV_RqKlKFvwnT7X9hsmlSW2Zc,51
2
+ reconflow/__main__.py,sha256=wpBqS9iZ2nMOe2-p-l8XuWuH8_PV48KXFqZ6YJyHGhY,111
3
+ reconflow/cli.py,sha256=8MPmd2qonuenPy2Lo-dEyr0miklQKhHqJVBlk_sHzPM,2968
4
+ reconflow/diff.py,sha256=3Cth3uMJfDr-s5Mii1xcXOiDY4cwXtjgkW9F5_5BkdA,7359
5
+ reconflow/history.py,sha256=1i7YjXMLmEkiDCRbyYqg1N1DWrYudGDU_At2SXF9-Uk,1599
6
+ reconflow/scan.py,sha256=VDKMcHzwb-c8J6sj5K8CFPy1ktiyhnczcQHx08fjc08,15391
7
+ reconflow-0.2.2.dist-info/licenses/LICENSE,sha256=UFvdfYlWb-XfYbEF6dpNGc3ZrEm4tI2udofy9B2JGAw,1092
8
+ reconflow-0.2.2.dist-info/METADATA,sha256=7be1EjOhnKBqr8npc7rt13h_Zq3hVASME7Po8iEd-0g,6547
9
+ reconflow-0.2.2.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
10
+ reconflow-0.2.2.dist-info/entry_points.txt,sha256=aSmnkq5yjZuuKH50rZRYdl_H3qSzRa6ifLBMN534AEI,49
11
+ reconflow-0.2.2.dist-info/top_level.txt,sha256=oraasyX3DZYbNo2cUI3a0calvKNBOohjcb3gQ5b5O1M,10
12
+ reconflow-0.2.2.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ reconflow = reconflow.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Nithishvelan M
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ reconflow