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 +3 -0
- reconflow/__main__.py +6 -0
- reconflow/cli.py +118 -0
- reconflow/diff.py +206 -0
- reconflow/history.py +46 -0
- reconflow/scan.py +448 -0
- reconflow-0.2.2.dist-info/METADATA +184 -0
- reconflow-0.2.2.dist-info/RECORD +12 -0
- reconflow-0.2.2.dist-info/WHEEL +5 -0
- reconflow-0.2.2.dist-info/entry_points.txt +2 -0
- reconflow-0.2.2.dist-info/licenses/LICENSE +21 -0
- reconflow-0.2.2.dist-info/top_level.txt +1 -0
reconflow/__init__.py
ADDED
reconflow/__main__.py
ADDED
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,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
|