agent-bypass 2.16.0 → 2.18.0

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.
@@ -1,19 +1,32 @@
1
1
  #!/usr/bin/env python3
2
2
  # -*- coding: utf-8 -*-
3
3
  """
4
- 并发端口扫描(无需 nmap)
4
+ 并发端口扫描(无需 nmap)——iSH / TUN / fake-IP 友好版
5
5
 
6
6
  提示: 手机环境(iSH/Termux)优先用 fastrecon.py scan —— selectors 并发实测快 5-45 倍
7
7
 
8
+ iSH + TUN(Clash/mihomo/sing-box)常见问题:系统 DNS 被劫持成 fake-IP(198.18.0.0/15),
9
+ 直接扫域名会得到不可路由假地址 → 全部超时/误判。本脚本默认自动检测并用 DoH 拿真实 IP。
10
+
8
11
  用法:
9
12
  python3 portscan.py <host> [--ports 1-65535] [--conc 500] [--timeout 2]
10
13
  python3 portscan.py 1.2.3.4 --ports top100
11
- python3 portscan.py 1.2.3.4 --ports 22,80,443,3306,6379
14
+ python3 portscan.py example.com --resolve doh # 强制 DoH 解析
15
+ python3 portscan.py example.com --resolve none # 只扫 IP,不解析
16
+ python3 portscan.py example.com --allow-fake # 允许扫 fake-IP(一般不要)
12
17
 
13
- 输出: jsonl(每行一个开放端口)
18
+ 输出: jsonl(每行一个开放端口,含 ip / host / port / service)
19
+ 退出码: 0 正常 / 2 参数错误 / 3 目标解析失败或只剩 fake-IP
14
20
  """
15
21
  import argsmini as argparse
16
- import asyncio, json, re, sys
22
+ import asyncio, json, os, re, sys, time
23
+
24
+ try: # 共享 DNS 工具(同目录 scripts/netdns.py)
25
+ import netdns
26
+ except Exception: # 单文件拷贝场景:退化为基础 socket 解析
27
+ netdns = None
28
+
29
+ ISH = os.path.isdir('/var/minis') or bool(os.environ.get('MINIS_ROOT'))
17
30
 
18
31
  # 100 端口 = 40 高价值服务 + 60 经典(对齐 nmap top100 覆盖面)
19
32
  TOP100 = [21,22,23,25,53,80,110,111,135,139,143,443,445,993,995,1433,1521,1723,
@@ -40,6 +53,11 @@ COMMON = {
40
53
  50000:"db2"
41
54
  }
42
55
 
56
+ FAKE_HINT = ("[fake-IP] 目标解析到 fake-IP(TUN 劫持 DNS),该地址不可信:\n"
57
+ " 1) 用 DoH 真 IP:python3 portscan.py <真实IP> 或 --resolve doh\n"
58
+ " 2) 关掉代理的 TUN 全局/DNS 劫持,或把目标加 DIRECT/fake-ip-filter\n"
59
+ " 3) 溯源同理:先 DoH 拿真 IP,再扫 IP(nmap -n -Pn <ip>)")
60
+
43
61
 
44
62
  def parse_ports(s):
45
63
  s = (s or "").strip().lower()
@@ -74,6 +92,53 @@ def parse_ports(s):
74
92
  return sorted(out)
75
93
 
76
94
 
95
+ def split_host(raw):
96
+ """'https://example.com:8443/x' / 'example.com' / '1.2.3.4' → ('example.com', 8443|None)"""
97
+ s = (raw or "").strip()
98
+ s = re.sub(r'^[a-zA-Z][a-zA-Z0-9+.-]*://', '', s)
99
+ s = s.split('/', 1)[0]
100
+ m = re.match(r'^(\[[^\]]+\]|[^:\s/]+)(?::(\d{1,5}))?$', s)
101
+ if not m:
102
+ return None, None
103
+ host = m.group(1).strip('[]')
104
+ port = int(m.group(2)) if m.group(2) else None
105
+ if port is not None and not (1 <= port <= 65535):
106
+ return None, None
107
+ return host, port
108
+
109
+
110
+ def is_ipv4_literal(s):
111
+ if netdns is not None:
112
+ return netdns.is_ipv4(s)
113
+ return bool(re.match(r'^\d{1,3}(?:\.\d{1,3}){3}$', str(s)))
114
+
115
+
116
+ def is_fake_ip(ip):
117
+ if netdns is not None:
118
+ return netdns.is_fake_ip(ip)
119
+ return str(ip).startswith(('198.18.', '198.19.', '240.', '198.51.100.', '203.0.113.'))
120
+
121
+
122
+ def resolve_target(host, mode="auto", timeout=6.0):
123
+ """返回 (ip, source, fake_list)。mode: auto/system/doh/none。"""
124
+ if is_ipv4_literal(host):
125
+ return host, "ip", []
126
+ if mode == "none":
127
+ return None, "none", []
128
+ if netdns is not None:
129
+ ips, source = netdns.resolve_real(host, prefer=("system" if mode == "system" else "doh" if mode == "doh" else "auto"),
130
+ timeout=timeout)
131
+ else:
132
+ try:
133
+ import socket as _s
134
+ ips, source = [_s.gethostbyname(host)], "system"
135
+ except Exception:
136
+ ips, source = [], "fail"
137
+ fake = [ip for ip in ips if is_fake_ip(ip)]
138
+ real = [ip for ip in ips if not is_fake_ip(ip)]
139
+ return (real[0] if real else (ips[0] if ips else None)), source, fake
140
+
141
+
77
142
  async def probe(host, port, timeout=2.0):
78
143
  try:
79
144
  r, w = await asyncio.wait_for(
@@ -88,31 +153,45 @@ async def probe(host, port, timeout=2.0):
88
153
  return None
89
154
 
90
155
 
91
- async def scan(host, ports, conc=500, timeout=2.0):
156
+ async def scan(host, ports, conc=500, timeout=2.0, deadline=None):
157
+ """deadline(time.monotonic 秒)用于总时限:到点停发新探测,返回已扫到的部分结果。
158
+ 返回 (open_ports, timed_out)。"""
92
159
  sem = asyncio.Semaphore(conc)
93
160
  open_ports = []
161
+ timed_out = False
94
162
 
95
163
  async def one(p):
164
+ nonlocal timed_out
165
+ if deadline is not None and time.monotonic() > deadline:
166
+ timed_out = True
167
+ return
96
168
  async with sem:
97
169
  r = await probe(host, p, timeout)
98
170
  if r:
99
171
  open_ports.append(r)
100
172
 
101
173
  await asyncio.gather(*[one(p) for p in ports])
102
- return sorted(open_ports)
174
+ return sorted(open_ports), timed_out
103
175
 
104
176
 
105
177
  def main():
106
- ap = argparse.ArgumentParser(description="并发端口扫描(无需 nmap)")
178
+ ap = argparse.ArgumentParser(description="并发端口扫描(无需 nmap;自动绕 fake-IP)")
107
179
  ap.add_argument("host")
108
180
  ap.add_argument("--ports", default="top20",
109
181
  help="1-65535 / top100 / top20 / 22,80,443")
110
182
  ap.add_argument("--conc", type=int, default=500, help="并发数")
111
183
  ap.add_argument("--timeout", type=float, default=2.0)
112
184
  ap.add_argument("--json", action="store_true", help="只输出 JSONL")
185
+ ap.add_argument("--resolve", choices=["auto", "system", "doh", "none"], default="auto",
186
+ help="DNS 解析方式: auto(系统→DoH) / system / doh / none(只接受 IP)")
187
+ ap.add_argument("--allow-fake", action="store_true",
188
+ help="允许扫描 fake-IP 地址(默认拒绝,防误判)")
189
+ ap.add_argument("--deadline", type=float, default=0,
190
+ help="总时限秒(0=自动:按端口数/并发估算,最长 600s;到点返回部分结果)")
113
191
  a = ap.parse_args()
114
192
 
115
- if not a.host or re.search(r"[\s/]", a.host):
193
+ name, inline_port = split_host(a.host)
194
+ if not name:
116
195
  print("[错误] host 只接受域名或 IP(可带 :端口)", file=sys.stderr)
117
196
  sys.exit(2)
118
197
  try:
@@ -124,20 +203,47 @@ def main():
124
203
  if conc != a.conc:
125
204
  print(f"[提示] 并发钳制为 {conc}(允许 1-2000)", file=sys.stderr)
126
205
  timeout = max(0.1, min(a.timeout, 30.0))
127
- if timeout != a.timeout:
128
- print(f"[提示] 超时钳制为 {timeout}s(允许 0.1-30)", file=sys.stderr)
206
+ if ISH and conc > 200:
207
+ conc = 200
208
+ print("[提示] iSH 环境并发降至 200(CPU 模拟 + 弱网更稳)", file=sys.stderr)
209
+ if ISH and timeout <= 2.0:
210
+ timeout = 3.0
211
+ print("[提示] iSH 环境超时上调至 3.0s", file=sys.stderr)
212
+ if inline_port and inline_port not in ports:
213
+ ports = sorted(set(ports) | {inline_port})
214
+
215
+ ip, source, fake = resolve_target(name, mode=a.resolve, timeout=max(timeout, 6.0))
216
+ if not ip:
217
+ print(f"[错误] 无法解析 {name}(--resolve {a.resolve})", file=sys.stderr)
218
+ print(FAKE_HINT, file=sys.stderr)
219
+ sys.exit(3)
220
+ if fake and not any(not is_fake_ip(x) for x in [ip]):
221
+ if not a.allow_fake:
222
+ print(f"[错误] {name} 只解析到 fake-IP:{','.join(fake)}", file=sys.stderr)
223
+ print(FAKE_HINT, file=sys.stderr)
224
+ sys.exit(3)
225
+ print(f"[警告] 正在扫描 fake-IP(--allow-fake):{ip}(结果不可信)", file=sys.stderr)
226
+ elif source == "doh":
227
+ print(f"[提示] 系统解析为 fake/IP 不可信,已用 DoH 真 IP: {ip}", file=sys.stderr)
228
+
129
229
  if not a.json:
130
- print(f"# 扫描 {a.host} 共 {len(ports)} 端口 (并发 {conc})",
230
+ print(f"# 扫描 {name} → {ip}({source})共 {len(ports)} 端口 (并发 {conc})",
131
231
  file=sys.stderr)
132
232
 
133
- open_ports = asyncio.run(scan(a.host, ports, conc, timeout))
233
+ overall = a.deadline if a.deadline > 0 else min(600.0, max(20.0, len(ports) * timeout / conc * 3 + 10))
234
+ deadline = time.monotonic() + max(5.0, min(overall, 3600.0))
235
+ open_ports, timed_out = asyncio.run(scan(ip, ports, conc, timeout, deadline))
236
+ if timed_out:
237
+ print(f"# 达到总时限 {overall:.0f}s:返回部分结果(可加大 --deadline 或降低端口数)", file=sys.stderr)
134
238
 
135
239
  for p in open_ports:
136
- rec = {"host": a.host, "port": p, "service": COMMON.get(p, "?")}
240
+ rec = {"host": name, "ip": ip, "port": p, "service": COMMON.get(p, "?")}
137
241
  print(json.dumps(rec, ensure_ascii=False))
138
242
 
139
243
  if not a.json:
140
244
  print(f"# 开放 {len(open_ports)} 个: {open_ports}", file=sys.stderr)
245
+ if ISH:
246
+ print("# iSH 提示:并发已限制、超时已上调;长扫描用 --deadline 控制,配合 nohup 后台跑", file=sys.stderr)
141
247
 
142
248
 
143
249
  if __name__ == "__main__":
@@ -250,5 +250,149 @@ class DoctorTests(unittest.TestCase):
250
250
  self.assertEqual(list(Path(home).rglob("*")), [], "doctor 不应写文件")
251
251
 
252
252
 
253
+ class NetDnsScanOriginTests(unittest.TestCase):
254
+ """fake-IP/TUN:netdns 判定 + portscan 解析守卫 + origin_trace 解析(全部离线)。"""
255
+
256
+ @classmethod
257
+ def setUpClass(cls):
258
+ sys.path.insert(0, str(SCRIPTS))
259
+ import netdns
260
+ import portscan
261
+ import origin_trace
262
+ cls.nd = netdns
263
+ cls.ps = portscan
264
+ cls.ot = origin_trace
265
+
266
+ def test_fake_ip_ranges(self):
267
+ for ip in ("198.18.0.5", "198.19.255.1", "240.1.2.3", "198.51.100.7", "203.0.113.9"):
268
+ self.assertTrue(self.nd.is_fake_ip(ip), ip)
269
+ for ip in ("1.1.1.1", "8.8.8.8", "192.168.1.1", "10.0.0.1"):
270
+ self.assertFalse(self.nd.is_fake_ip(ip), ip)
271
+
272
+ def test_parse_dns_json(self):
273
+ sample = ('{"Answer":[{"type":1,"data":"93.184.216.34"},'
274
+ '{"type":28,"data":"2606:2800::1"},{"type":1,"data":"198.18.0.1"}]}')
275
+ self.assertEqual(self.nd._parse_dns_json(sample, "A"), ["93.184.216.34", "198.18.0.1"])
276
+ self.assertEqual(self.nd._parse_dns_json(sample, "AAAA"), ["2606:2800::1"])
277
+ self.assertEqual(self.nd._parse_dns_json("not-json", "A"), [])
278
+
279
+ def test_portscan_split_host_fake_guard(self):
280
+ self.assertEqual(self.ps.split_host("https://example.com:8443/x"), ("example.com", 8443))
281
+ self.assertEqual(self.ps.split_host("1.2.3.4"), ("1.2.3.4", None))
282
+ self.assertIsNone(self.ps.split_host("bad host")[0])
283
+ self.assertTrue(self.ps.is_fake_ip("198.18.1.2"))
284
+ ip, src, fake = self.ps.resolve_target("198.18.1.2", mode="auto")
285
+ self.assertEqual((ip, src), ("198.18.1.2", "ip"))
286
+ self.assertEqual(fake, [])
287
+
288
+ def test_origin_trace_parsers(self):
289
+ ct = '[{"name_value":"a.example.com\\n*.example.com\\nother.test"}]'
290
+ self.assertEqual(self.ot.parse_crtsh(ct, "example.com"), ["a.example.com", "example.com"])
291
+ self.assertEqual(self.ot.extract_title("<title> Hi There </title>"), "Hi There")
292
+ self.assertEqual(self.ot.cdn_markers({"cf-ray": "x", "server": "nginx"}), ["cf-ray"])
293
+ base = {"title": "X", "server": "nginx", "status": "HTTP/1.1 200 OK", "cdn": ["cf-ray"]}
294
+ cand = {"title": "X", "server": "nginx", "status": "HTTP/1.1 200 OK", "cdn": [], "cert_domain_ok": True}
295
+ score, reasons = self.ot.score_candidate(base, cand)
296
+ self.assertGreaterEqual(score, 5)
297
+ self.assertIn("title", reasons)
298
+ self.assertLess(self.ot.score_candidate(base, {"cdn": ["cf-ray"]})[0], score)
299
+
300
+ def test_doh_shell_syntax(self):
301
+ sh = shutil.which("sh")
302
+ if not sh:
303
+ self.skipTest("no sh")
304
+ r = _tmp_run([sh, "-n", str(SCRIPTS / "doh_resolve.sh")])
305
+ self.assertEqual(r.returncode, 0, r.stderr)
306
+
307
+
308
+ class SkillCompactTests(unittest.TestCase):
309
+ """SKILL.md 体积/去重回归:防止再次膨胀。"""
310
+
311
+ def test_skill_structure_and_size(self):
312
+ skill = (ROOT / "SKILL.md").read_text(encoding="utf-8")
313
+ self.assertEqual(skill.count("<!-- module:"), 35)
314
+ self.assertNotIn("## 修正对照", skill, "重复的修正对照表已移除,勿再加回")
315
+ self.assertLess(len(skill.encode("utf-8")), 132000, "SKILL.md 体积超限(保持精简,长内容放 references/)")
316
+
317
+
318
+ class HangProtectionTests(unittest.TestCase):
319
+ """防卡死:DNS 硬超时、扫描总时限、溯源预算/同名 CDN 扣分(全部离线)。"""
320
+
321
+ @classmethod
322
+ def setUpClass(cls):
323
+ sys.path.insert(0, str(SCRIPTS))
324
+ import netdns, portscan, origin_trace
325
+ cls.nd = netdns
326
+ cls.ps = portscan
327
+ cls.ot = origin_trace
328
+
329
+ def test_system_resolve_hard_timeout(self):
330
+ import time as _t
331
+ orig = self.nd._system_resolve_impl
332
+ self.nd._system_resolve_impl = lambda name: (_t.sleep(3), ["1.2.3.4"])[1]
333
+ t0 = _t.monotonic()
334
+ try:
335
+ ips = self.nd.system_resolve("example.com", timeout=0.2)
336
+ finally:
337
+ self.nd._system_resolve_impl = orig
338
+ self.assertEqual(ips, [])
339
+ self.assertLess(_t.monotonic() - t0, 1.5, "system_resolve 必须有硬超时,不能挂住")
340
+
341
+ def test_portscan_total_deadline(self):
342
+ import asyncio, time as _t
343
+ found, timed_out = asyncio.run(
344
+ self.ps.scan("127.0.0.1", [9, 10], conc=1, timeout=0.05,
345
+ deadline=_t.monotonic() - 1))
346
+ self.assertEqual(found, [])
347
+ self.assertTrue(timed_out)
348
+
349
+ def test_origin_same_cdn_penalty(self):
350
+ base = {"title": "X", "server": "nginx", "status": "HTTP/1.1 200 OK", "cdn": ["cf-ray"]}
351
+ cand_cdn = {"title": "X", "server": "nginx", "status": "HTTP/1.1 200 OK", "cdn": ["cf-ray"]}
352
+ cand_origin = {"title": "X", "server": "nginx", "status": "HTTP/1.1 200 OK", "cdn": []}
353
+ s_cdn, r_cdn = self.ot.score_candidate(base, cand_cdn)
354
+ s_org, r_org = self.ot.score_candidate(base, cand_origin)
355
+ self.assertIn("same-cdn", r_cdn)
356
+ self.assertLess(s_cdn, s_org)
357
+
358
+ def test_passive_ct_parsers(self):
359
+ cs = '[{"dns_names":["www.example.com","*.example.com","x.test"]}]'
360
+ self.assertEqual(self.ot.parse_certspotter(cs, "example.com"), ["www.example.com", "example.com"])
361
+ otx = ('{"passive_dns":[{"hostname":"mail.example.com","address":"1.2.3.4"},'
362
+ '{"hostname":"x.test","address":"9.9.9.9"},'
363
+ '{"hostname":"cdn.example.com","address":"198.18.0.9"}]}')
364
+ hosts, ips = self.ot.parse_otx(otx, "example.com")
365
+ self.assertIn("mail.example.com", hosts)
366
+ self.assertEqual(ips, ["1.2.3.4"], "被动 DNS 的 fake-IP 应被过滤")
367
+ hosts, ips = self.ot.parse_hackertarget("vpn.example.com,5.6.7.8\nAPI count exceeded", "example.com")
368
+ self.assertEqual(hosts, ["vpn.example.com"])
369
+ self.assertEqual(ips, ["5.6.7.8"])
370
+ self.assertEqual(self.ot.parse_anubis('["a.example.com","b.test"]', "example.com"), ["a.example.com"])
371
+ self.assertEqual(self.ot.parse_rapiddns("<td>oa.example.com</td><td>x.test</td>", "example.com"),
372
+ ["oa.example.com"])
373
+ for src in ("crt.sh", "certspotter", "otx", "hackertarget", "anubis", "rapiddns"):
374
+ self.assertIn(src, self.ot.CT_SOURCES)
375
+
376
+ def test_ct_multi_source_fallback(self):
377
+ def fake(name, domain, timeout):
378
+ if name == "crt.sh":
379
+ raise RuntimeError("502 Bad Gateway")
380
+ return (["s1.example.com", "s2.example.com"], ["1.2.3.4"])
381
+ orig = self.ot._ct_fetch
382
+ self.ot._ct_fetch = fake
383
+ try:
384
+ hosts, ips, status = self.ot.fetch_passive_names(
385
+ "example.com", sources=["crt.sh", "otx"], budget=5)
386
+ finally:
387
+ self.ot._ct_fetch = orig
388
+ self.assertEqual(hosts, ["s1.example.com", "s2.example.com"])
389
+ self.assertEqual(ips, ["1.2.3.4"])
390
+ self.assertIn("失败", status["crt.sh"], "单源 502 应被记录并跳过")
391
+
392
+ def test_origin_bad_flag_rejected(self):
393
+ r = _tmp_run([sys.executable, str(SCRIPTS / "origin_trace.py"), "--bogus"])
394
+ self.assertEqual(r.returncode, 2)
395
+
396
+
253
397
  if __name__ == "__main__":
254
398
  unittest.main()