-
Notifications
You must be signed in to change notification settings - Fork 10
Expand file tree
/
Copy pathmain.py
More file actions
2837 lines (2575 loc) · 130 KB
/
Copy pathmain.py
File metadata and controls
2837 lines (2575 loc) · 130 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/usr/bin/env python3
"""tchkiller — 基于 claude-agent-sdk 的智能渗透 Agent"""
VERSION = "1.0.2"
import argparse
import asyncio
import json
import re
import sys
import os
from datetime import datetime
from pathlib import Path
from time import time
from claude_agent_sdk import (
query,
ClaudeAgentOptions,
ResultMessage,
AssistantMessage,
UserMessage,
SystemMessage,
StreamEvent,
RateLimitEvent,
TextBlock,
ToolUseBlock,
ToolResultBlock,
CLINotFoundError,
CLIConnectionError,
CLIJSONDecodeError,
ProcessError,
)
from claude_agent_sdk.types import AgentDefinition
from prompt.system import build_system_prompt, build_target_prompt, VULN_LABELS
from prompt.judge import build_judge_prompt, JUDGE_OUTPUT_FORMAT
from providers import ProviderManager
from hooks import build_safety_hooks, reset_bypass_counter
# 统一 flag 匹配正则 — 所有检测点共用,避免不一致
FLAG_PATTERN = re.compile(r'flag\{[^}]+\}', re.IGNORECASE)
# MCP 调试日志(写入 /tmp/ 确保可见)
_DEBUG_LOG = Path("/tmp/tchkiller_mcp_debug.log")
def _mcp_log(msg: str):
try:
ts = datetime.now().strftime("%H:%M:%S.%f")[:-3]
with open(_DEBUG_LOG, "a", encoding="utf-8") as f:
f.write(f"[{ts}] [main] {msg}\n")
except Exception:
pass
def _timeline_append(run_dir: Path, entry: dict):
"""追加一条记录到 timeline.jsonl"""
try:
entry.setdefault("ts", time())
with open(run_dir / "timeline.jsonl", "a", encoding="utf-8") as f:
f.write(json.dumps(entry, ensure_ascii=False) + "\n")
except Exception:
pass
def _extract_tool_result_text(block: ToolResultBlock) -> tuple[str, bool]:
"""从 ToolResultBlock 提取文本和错误状态,返回 (text, is_error)"""
content = block.content
is_error = getattr(block, "is_error", False)
if isinstance(content, str):
text = content[:250]
elif isinstance(content, list) and content:
text = content[0].get("text", "")[:250] if isinstance(content[0], dict) else str(content[0])[:250]
else:
text = "(empty)"
return text, is_error
def _ensure_system_path(env: dict) -> None:
"""确保 PATH 包含标准系统目录 + 用户本地目录,防止 subagent 找不到工具"""
home = Path.home()
std_dirs = [
str(home / ".local" / "bin"), # pip install --user
"/usr/local/sbin", "/usr/local/bin", "/usr/sbin", "/usr/bin", "/sbin", "/bin",
]
# pipx venvs: 当前 venv 的 bin 路径(如 claude-agent-sdk venv 里的 tccli, ldeep 等)
pipx_venv_bin = str(home / ".local" / "share" / "pipx" / "venvs" / "claude-agent-sdk" / "bin")
if Path(pipx_venv_bin).is_dir():
std_dirs.insert(0, pipx_venv_bin)
current = env.get("PATH", "")
existing = set(current.split(":")) if current else set()
missing = [d for d in std_dirs if d not in existing]
if missing:
env["PATH"] = current + (":" if current else "") + ":".join(missing)
def _format_duration(seconds: float) -> str:
"""格式化秒数为可读时间字符串"""
minutes, secs = divmod(int(seconds), 60)
hours, minutes = divmod(minutes, 60)
return f"{hours}h{minutes:02d}m{secs:02d}s" if hours else f"{minutes}m{secs:02d}s"
def _init_provider_manager(args) -> ProviderManager:
"""初始化 API provider 管理器"""
config_path = ROOT / args.providers_config
pm = ProviderManager(config_path)
if pm.available:
if args.provider:
if not pm.select(args.provider):
print(f" ⚠️ Provider '{args.provider}' 未找到,可用: {', '.join(p.name for p in pm._providers)}")
sys.exit(1)
print(f" 🔌 API Providers ({pm.count} 个):\n{pm.list_providers()}")
return pm
def _apply_provider_env(env: dict, pm: ProviderManager) -> dict:
"""将当前 provider 的配置注入到环境变量中(覆盖 settings.json 的值)"""
if pm.available:
env.update(pm.get_env())
return env
def _build_report(
args, run_dir: Path, result_msg: ResultMessage | None,
tool_calls_count: int = 0, tool_errors_count: int = 0,
rounds: list | None = None,
) -> dict:
"""构建结构化运行报告"""
report = {
"version": VERSION,
"target": args.target,
"mode": args.mode,
"team": getattr(args, "team", False),
"model": args.model,
"orchestrator": getattr(args, "orchestrator", False),
"start_time": datetime.now().isoformat(),
"duration_s": 0,
"duration_api_s": 0,
"total_cost_usd": 0,
"usage": {"input_tokens": 0, "output_tokens": 0},
"num_turns": 0,
"stop_reason": None,
"is_error": False,
"vulns_count": 0,
"evidence_count": 0,
"flag_found": False,
"flag_value": None,
"tool_calls_count": tool_calls_count,
"tool_errors_count": tool_errors_count,
}
if result_msg:
report["duration_s"] = (result_msg.duration_ms or 0) / 1000
report["duration_api_s"] = (result_msg.duration_api_ms or 0) / 1000
report["total_cost_usd"] = result_msg.total_cost_usd or 0
report["num_turns"] = result_msg.num_turns
report["stop_reason"] = result_msg.stop_reason
report["is_error"] = result_msg.is_error
if result_msg.usage:
report["usage"] = {
"input_tokens": result_msg.usage.get("input_tokens", 0),
"output_tokens": result_msg.usage.get("output_tokens", 0),
}
# 漏洞和证据统计
vulns_json = run_dir / "vulns.json"
if vulns_json.exists():
try:
vulns = json.loads(vulns_json.read_text(encoding="utf-8"))
report["vulns_count"] = len(vulns)
except Exception:
pass
evidence_dir = run_dir / "evidence"
if evidence_dir.exists():
report["evidence_count"] = len([f for f in evidence_dir.glob("*.md") if f.name != "index.md"])
# Flag 检测
if evidence_dir.exists():
for f in evidence_dir.glob("*.md"):
try:
content = f.read_text(encoding="utf-8", errors="replace")
m = FLAG_PATTERN.findall(content)
if m:
report["flag_found"] = True
report["flag_value"] = m[-1]
break
except Exception:
pass
if rounds:
report["rounds"] = rounds
return report
# 项目根目录
ROOT = Path(__file__).parent.resolve()
def parse_args():
p = argparse.ArgumentParser(description="tchkiller — AI 渗透测试 Agent")
p.add_argument("target", nargs="?", default=None, help="目标地址 (e.g. testfire.net, 192.168.1.0/24)")
p.add_argument(
"-m", "--mode",
choices=["src_hunt", "cve_cloud", "network", "domain", "auto"],
default="auto",
help="赛区模式 (default: auto)",
)
p.add_argument("--model", default="sonnet", help="主模型 (default: sonnet)")
p.add_argument("--max-turns", type=int, default=150, help="最大轮次 (default: 150)")
p.add_argument("--thinking-tokens", type=int, default=10000, help="Thinking token 预算")
p.add_argument("--config", default="config.yaml", help="配置文件路径")
p.add_argument("--scope", nargs="*", help="额外授权范围 (域名/IP)")
p.add_argument("--team", action="store_true", help="启用 Agent Teams 模式(CC 原生多 agent 协作)")
p.add_argument("--prompt", type=str, default=None, help="附加提示词(追加到场景 prompt 之后,不替换)")
p.add_argument("--hint", type=str, default=None, help="官方提示(高优先级独立注入,不混入 extra_prompt)")
p.add_argument("--orchestrator", action="store_true", help="启用决策 agent 架构(多轮评估)")
p.add_argument("--max-rounds", type=int, default=3, help="[orchestrator] 最大渗透轮数 (default: 3)")
p.add_argument("--round-timeout", type=int, default=30, help="[orchestrator] 单轮超时分钟数 (default: 30)")
p.add_argument("--total-timeout", type=int, default=None, help="[orchestrator] 总超时分钟数 (default: 自动计算)")
p.add_argument("--judge-model", default="MiniMax-M2.7-highspeed", help="[orchestrator] 决策 agent 模型 (default: MiniMax-M2.7-highspeed)")
p.add_argument("--provider", type=str, default=None, help="指定 API provider 名称 (见 providers.yaml)")
p.add_argument("--providers-config", type=str, default="providers.json", help="providers 配置文件路径")
p.add_argument("--profile", type=str, default=None, help="漏洞排除 profile (见 profiles/*.yaml,如 real-pentest)")
p.add_argument("--exclude", type=str, default=None, help="排除漏洞类型,逗号分隔 (如 xss,open_redirect,tls_ssl)")
p.add_argument("--browser", action="store_true", help="启用 Playwright 浏览器 MCP(需已安装 @playwright/mcp)")
p.add_argument("--check", action="store_true", help="检查运行环境依赖是否安装完整")
p.add_argument("--deep", action="store_true", help="深度检查:实际运行每个工具验证可用性 (配合 --check 使用)")
p.add_argument("--check-providers", action="store_true", help="测试所有 API provider 可用性(模型连通性 + 余额)")
p.add_argument("-v", "--verbose", action="store_true", help="详细输出")
return p.parse_args()
def check_environment(deep: bool = False):
"""检查运行环境依赖是否安装完整"""
import shutil
checks = []
# 1. Python version
py_ver = f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}"
checks.append(("Python >= 3.10", sys.version_info >= (3, 10), py_ver))
# 2. Claude Code CLI
claude_bin = shutil.which("claude")
checks.append(("Claude Code CLI", claude_bin is not None, claude_bin or "未安装"))
# 3. claude-agent-sdk
try:
import claude_agent_sdk
sdk_ver = getattr(claude_agent_sdk, "__version__", "installed")
checks.append(("claude-agent-sdk", True, sdk_ver))
except ImportError:
checks.append(("claude-agent-sdk", False, "pip install claude-agent-sdk"))
# 4. PyYAML
try:
import yaml
checks.append(("PyYAML", True, yaml.__version__))
except ImportError:
checks.append(("PyYAML", False, "pip install pyyaml"))
# 5. vulndb index
vulndb_path = ROOT / "vulndb.sqlite"
vulndb_ok = vulndb_path.exists() and vulndb_path.stat().st_size > 0
if vulndb_ok:
import sqlite3
try:
conn = sqlite3.connect(str(vulndb_path))
count = conn.execute("SELECT COUNT(*) FROM vulns").fetchone()[0]
conn.close()
checks.append(("vulndb 索引", True, f"{count} 条漏洞"))
except Exception:
checks.append(("vulndb 索引", False, "运行 python3 vulndb_index.py 重建"))
else:
checks.append(("vulndb 索引", False, "运行 python3 vulndb_index.py 构建"))
# 6. Playwright MCP (optional)
npx_bin = shutil.which("npx")
if npx_bin:
import subprocess
try:
result = subprocess.run(
["npm", "list", "-g", "@playwright/mcp"],
capture_output=True, timeout=15,
)
pw_ok = result.returncode == 0
except Exception:
pw_ok = False
checks.append(("@playwright/mcp (可选)", pw_ok,
"已安装" if pw_ok else "npm install -g @playwright/mcp"))
else:
checks.append(("@playwright/mcp (可选)", False, "需先安装 Node.js + npm"))
# 7. Common tools
for tool, pkg in [("curl", "curl"), ("ffuf", "ffuf")]:
found = shutil.which(tool)
checks.append((tool, found is not None, found or f"未安装 ({pkg})"))
# 7b. Runtime / Language checks
import subprocess as _sp_rt
# Docker
docker_bin = shutil.which("docker")
if docker_bin:
try:
_r = _sp_rt.run([docker_bin, "--version"], capture_output=True, text=True, timeout=5)
_dv = _r.stdout.strip().split(",")[0] if _r.returncode == 0 else docker_bin
checks.append(("Docker", True, _dv))
except Exception:
checks.append(("Docker", True, docker_bin))
else:
checks.append(("Docker", False, "未安装 → https://docs.docker.com/engine/install/"))
# Go
go_bin = shutil.which("go")
if go_bin:
try:
_r = _sp_rt.run([go_bin, "version"], capture_output=True, text=True, timeout=5)
_gv = _r.stdout.strip() if _r.returncode == 0 else go_bin
checks.append(("Go", True, _gv))
except Exception:
checks.append(("Go", True, go_bin))
else:
checks.append(("Go", False, "未安装 → apt install golang-go 或 https://go.dev/dl/"))
# Python3
py3_bin = shutil.which("python3")
if py3_bin:
try:
_r = _sp_rt.run([py3_bin, "--version"], capture_output=True, text=True, timeout=5)
_pv = _r.stdout.strip() if _r.returncode == 0 else py3_bin
checks.append(("Python3", True, _pv))
except Exception:
checks.append(("Python3", True, py3_bin))
else:
checks.append(("Python3", False, "未安装 → apt install python3"))
# Ruby
ruby_bin = shutil.which("ruby")
if ruby_bin:
try:
_r = _sp_rt.run([ruby_bin, "--version"], capture_output=True, text=True, timeout=5)
_rv = _r.stdout.strip() if _r.returncode == 0 else ruby_bin
checks.append(("Ruby", True, _rv))
except Exception:
checks.append(("Ruby", True, ruby_bin))
else:
checks.append(("Ruby", False, "未安装 → apt install ruby-full"))
# 8. nc/ncat (any variant)
nc_bin = shutil.which("ncat") or shutil.which("nc") or shutil.which("netcat")
checks.append(("nc/ncat", nc_bin is not None, nc_bin or "未安装 (apt install ncat)"))
# 9. Pentest tools (skills/tool/* 对应的工具)
pentest_tools = [
("nuclei", "go install -v github.com/projectdiscovery/nuclei/v3/cmd/nuclei@latest"),
("naabu", "go install -v github.com/projectdiscovery/naabu/v2/cmd/naabu@latest"),
("katana", "go install -v github.com/projectdiscovery/katana/cmd/katana@latest"),
("subfinder", "go install -v github.com/projectdiscovery/subfinder/v2/cmd/subfinder@latest"),
("spray", "https://github.com/chainreactors/spray/releases"),
("gogo", "https://github.com/chainreactors/gogo/releases"),
("zombie", "https://github.com/chainreactors/zombie/releases"),
("xray", "https://github.com/chaitin/xray/releases"),
("msfconsole", "apt install metasploit-framework"),
("nmap", "apt install nmap"),
("sqlmap", "apt install sqlmap"),
("hashcat", "apt install hashcat"),
("iox", "https://github.com/EddieIvan01/iox/releases"),
("cdk", "https://github.com/cdk-team/CDK/releases"),
("k8spider", "https://github.com/Esonhugh/k8spider/releases"),
("ingressnightmare", "https://github.com/Esonhugh/ingressNightmare-CVE-2025-1974-exps/releases"),
("kubectl", "https://dl.k8s.io/release/stable.txt"),
("kubeletctl", "https://github.com/cyberark/kubeletctl/releases"),
("peirates", "https://github.com/inguardians/peirates/releases"),
("etcdctl", "https://github.com/etcd-io/etcd/releases"),
("tccli", "pip install tccli"),
("ligolo-proxy", "https://github.com/nicocha30/ligolo-ng/releases"),
("linpeas", "https://github.com/peass-ng/PEASS-ng/releases"),
("lsassy", "pip install lsassy"),
("donpapi", "pip install donpapi"),
("pywhisker", "f8x -ad 或 git clone https://github.com/ShutdownRepo/pywhisker"),
("dfscoerce", "f8x -ad 或 git clone https://github.com/Wh04m1001/DFSCoerce"),
("shadowcoerce", "f8x -ad 或 git clone https://github.com/ShutdownRepo/ShadowCoerce"),
("gpowned", "f8x -ad 或 git clone https://github.com/X-C3LL/GPOwned"),
("gpp-decrypt", "pip install gpp-decrypt"),
("ccupp", "git clone https://github.com/WangYihang/ccupp && cd ccupp && pipx install ."),
("githacker", "pip install GitHacker"),
]
for tool, install_hint in pentest_tools:
found = shutil.which(tool)
checks.append((f"{tool} (渗透)", found is not None, found or f"未安装 → {install_hint}"))
# 9b. httpx — 必须是 ProjectDiscovery 版本,不是 Python httpx
import subprocess as _sp
httpx_bin = shutil.which("httpx")
httpx_ok = False
httpx_detail = "未安装 → go install -v github.com/projectdiscovery/httpx/cmd/httpx@latest"
if httpx_bin:
try:
r = _sp.run([httpx_bin, "-version"], capture_output=True, text=True, timeout=5)
ver_out = (r.stdout + r.stderr).lower()
if "projectdiscovery" in ver_out or "httpx.runner" in ver_out:
httpx_ok = True
httpx_detail = httpx_bin
else:
httpx_detail = f"发现 {httpx_bin} 但不是 ProjectDiscovery 版本 → go install -v github.com/projectdiscovery/httpx/cmd/httpx@latest"
except Exception:
httpx_detail = f"发现 {httpx_bin} 但无法验证版本 → go install -v github.com/projectdiscovery/httpx/cmd/httpx@latest"
checks.append(("httpx (渗透)", httpx_ok, httpx_detail))
# 9c. 多二进制名工具(有多个可能的命令名)
multi_bin_tools = [
("CrackMapExec", ["crackmapexec", "cme"], "https://github.com/Porchetta-Industries/CrackMapExec"),
("NetExec", ["nxc", "netexec"], "https://github.com/Pennyw0rth/NetExec"),
("impacket", ["impacket-secretsdump", "secretsdump.py", "impacket-smbclient"],
"pip install impacket / https://github.com/fortra/impacket"),
("certipy", ["certipy", "certipy-ad"], "pip install certipy-ad"),
("Adinfo", ["Adinfo"], "https://github.com/lzzbb/Adinfo/releases"),
("Responder", ["responder", "Responder.py", "responder.py"],
"https://github.com/lgandx/Responder"),
("bloodhound-python", ["bloodhound-python", "bloodhound"],
"pip install bloodhound"),
("kerbrute", ["kerbrute"], "https://github.com/ropnop/kerbrute/releases"),
("Coercer", ["coercer"], "pip install coercer"),
("ldeep", ["ldeep"], "pip install ldeep"),
("enum4linux-ng", ["enum4linux-ng"], "pip install enum4linux-ng"),
("john", ["john"], "apt install john"),
("evil-winrm", ["evil-winrm"], "gem install evil-winrm"),
("bloodyAD", ["bloodyAD", "bloodyadpy"], "pip install bloodyAD"),
("mitm6", ["mitm6"], "pip install mitm6"),
("ldapsearch", ["ldapsearch"], "apt install ldap-utils"),
("xfreerdp", ["xfreerdp", "xfreerdp3"], "apt install freerdp2-x11 / freerdp3-x11"),
("proxychains", ["proxychains4", "proxychains"], "apt install proxychains4"),
("sliver-client", ["sliver", "sliver-client"], "https://github.com/BishopFox/sliver/wiki/Getting-Started"),
("sliver-server", ["sliver-server"], "https://github.com/BishopFox/sliver/wiki/Getting-Started"),
("gMSADumper", ["gMSADumper.py", "gMSADumper"], "https://github.com/micahvandeusen/gMSADumper"),
("pypykatz", ["pypykatz"], "pip install pypykatz"),
("nps/npc", ["npc", "nps"], "https://github.com/ehang-io/nps/releases"),
("Exegol", ["exegol"], "pip install exegol"),
("docker", ["docker"], "https://docs.docker.com/engine/install/"),
]
for label, bins, install_hint in multi_bin_tools:
found_bin = None
for b in bins:
found_bin = shutil.which(b)
if found_bin:
break
# sliver-server 常安装在 /root/sliver-server,不在 PATH 中
if not found_bin and label == "sliver-server":
_ss = Path.home() / "sliver-server"
if _ss.is_file():
found_bin = str(_ss)
checks.append((f"{label} (渗透)", found_bin is not None,
found_bin or f"未安装 → {install_hint}"))
# 9d. Python 库检查(无 CLI 命令,仅 import 检测)
try:
import minikerberos as _mk
checks.append(("minikerberos (渗透)", True, f"已安装 ({_mk.__version__})" if hasattr(_mk, '__version__') else "已安装"))
except ImportError:
checks.append(("minikerberos (渗透)", False, "未安装 → pip install minikerberos"))
# 9f. /pentest/ 目录工具检查(git clone 安装,无 PATH 二进制)
_pentest_dir_tools = [
("PetitPotam", "PetitPotam/PetitPotam.py", "f8x -ad"),
("noPac", "noPac/noPac.py", "f8x -ad"),
("zerologon", "CVE-2020-1472/cve-2020-1472-exploit.py", "f8x -ad"),
("pyGPOAbuse", "pyGPOAbuse/pygpoabuse.py", "f8x -ad"),
("targetedKerberoast", "targetedKerberoast/targetedKerberoast.py", "f8x -ad"),
("PassTheCert", "PassTheCert/Python/passthecert.py", "f8x -ad"),
("krbrelayx", "krbrelayx/krbrelayx.py", "f8x -ad"),
]
_pentest_base = Path("/pentest")
for _label, _rel, _hint in _pentest_dir_tools:
_p = _pentest_base / _rel
checks.append((f"{_label} (渗透)", _p.is_file(), str(_p) if _p.is_file() else f"未安装 → {_hint}"))
# 9e. docker compose 检查(子命令,无法用 which 检测)
import subprocess as _sp2
_dc_bin = shutil.which("docker-compose")
if _dc_bin:
checks.append(("docker-compose (渗透)", True, _dc_bin))
else:
try:
_r = _sp2.run(["docker", "compose", "version"], capture_output=True, text=True, timeout=5)
if _r.returncode == 0:
_ver = _r.stdout.strip().split()[-1] if _r.stdout.strip() else ""
checks.append(("docker-compose (渗透)", True, f"docker compose {_ver}"))
else:
checks.append(("docker-compose (渗透)", False, "未安装 → apt install docker-compose-plugin"))
except Exception:
checks.append(("docker-compose (渗透)", False, "未安装 → apt install docker-compose-plugin"))
# 10. 资源库检查:nuclei-templates + AboutSecurity 字典库
nuclei_tpl_dir = Path.home() / "nuclei-templates"
if nuclei_tpl_dir.is_dir():
tpl_count = sum(1 for _ in nuclei_tpl_dir.rglob("*.yaml"))
checks.append(("nuclei-templates (资源)", True, f"{nuclei_tpl_dir} ({tpl_count} 模板)"))
else:
checks.append(("nuclei-templates (资源)", False,
f"未找到 → nuclei -update-templates 或 git clone https://github.com/projectdiscovery/nuclei-templates ~/nuclei-templates"))
about_sec_dir = Path("/pentest/AboutSecurity/Dic")
if not about_sec_dir.is_dir():
# 开发环境 fallback
about_sec_dir = ROOT / "../../public/AboutSecurity/Dic" if (ROOT / "../../public/AboutSecurity/Dic").is_dir() else None
if about_sec_dir and about_sec_dir.is_dir():
dic_count = sum(1 for _ in about_sec_dir.rglob("*.txt"))
checks.append(("AboutSecurity 字典库 (资源)", True, f"{about_sec_dir.resolve()} ({dic_count} 字典)"))
else:
checks.append(("AboutSecurity 字典库 (资源)", False,
"未找到 /pentest/AboutSecurity/Dic → git clone AboutSecurity 到 /pentest/ 下"))
# 11. 投递物仓库检查 (arsenal)
_ARSENAL_TOOLS = [
("fscan", "https://github.com/shadow1ng/fscan/releases", None),
("gogo", "https://github.com/chainreactors/gogo/releases", None),
("zombie", "https://github.com/chainreactors/zombie/releases", None),
("spray", "https://github.com/chainreactors/spray/releases", None),
("cdk", "https://github.com/cdk-team/CDK/releases", ["linux_amd64", "linux_arm64"]),
("k8spider", "https://github.com/Esonhugh/k8spider/releases", None),
("ingressnightmare", "https://github.com/Esonhugh/ingressNightmare-CVE-2025-1974-exps/releases", None),
("kubeletctl", "https://github.com/cyberark/kubeletctl/releases", None),
("peirates", "https://github.com/inguardians/peirates/releases", ["linux_amd64", "linux_arm64"]),
("etcdctl", "https://github.com/etcd-io/etcd/releases", None),
("kubectl", "https://dl.k8s.io", None),
("iox", "https://github.com/EddieIvan01/iox/releases", None),
("frpc", "https://github.com/fatedier/frp/releases", None),
("chisel", "https://github.com/jpillora/chisel/releases", None),
("ligolo-agent", "https://github.com/nicocha30/ligolo-ng/releases", None),
("linpeas", "https://github.com/peass-ng/PEASS-ng/releases", ["linux_amd64", "linux_arm64"]),
("winpeas", "https://github.com/peass-ng/PEASS-ng/releases", ["_any_"]),
("GodPotato", "https://github.com/BeichenDream/GodPotato/releases", ["_any_"]),
("mimikatz", "https://github.com/gentilkiwi/mimikatz/releases", ["_any_"]),
("SharpHound", "https://github.com/BloodHoundAD/SharpHound/releases", ["_any_"]),
("PrintSpoofer", "https://github.com/itm4n/PrintSpoofer/releases", ["_any_"]),
("SharpWMI", "https://github.com/QAX-A-Team/sharpwmi/releases", ["_any_"]),
("SharPersist", "https://github.com/mandiant/SharPersist/releases", ["_any_"]),
("StandIn", "https://github.com/FuzzySecurity/StandIn/releases", ["_any_"]),
]
_ARSENAL_PLATFORMS = ["linux_amd64", "linux_arm64", "windows_amd64"]
# 读取 config 中的 arsenal_dir,默认 /pentest/arsenal
arsenal_dir = Path("/pentest/arsenal")
try:
import yaml as _yaml
cfg_path = ROOT / "config.yaml"
if cfg_path.exists():
with open(cfg_path) as _f:
_cfg = _yaml.safe_load(_f) or {}
if _cfg.get("arsenal_dir"):
arsenal_dir = Path(_cfg["arsenal_dir"])
except Exception:
pass
if arsenal_dir.is_dir():
arsenal_summary = []
for tool_name, dl_url, tool_plats in _ARSENAL_TOOLS:
tool_dir = arsenal_dir / tool_name
plats = tool_plats or _ARSENAL_PLATFORMS
if not tool_dir.is_dir():
arsenal_summary.append(f"{tool_name}: 缺失")
continue
found_plats = []
for plat in plats:
if plat == "_any_":
# Windows-only tools: just check directory has files
if any(tool_dir.iterdir()):
found_plats.append(plat)
else:
# 匹配 fscan_linux_amd64, fscan_linux_amd64.exe, cdk_linux_amd64 等
matches = list(tool_dir.glob(f"*{plat}*"))
if matches:
found_plats.append(plat)
if len(found_plats) == len(plats):
arsenal_summary.append(f"{tool_name}: ✓")
else:
missing = [p for p in plats if p not in found_plats]
arsenal_summary.append(f"{tool_name}: 缺 {','.join(missing)}")
all_complete = all("✓" in s for s in arsenal_summary)
summary_str = " | ".join(arsenal_summary)
checks.append(("投递物仓库 (arsenal)", all_complete, summary_str))
else:
checks.append(("投递物仓库 (arsenal)", False,
f"未找到 {arsenal_dir} → mkdir -p {arsenal_dir}/{{fscan,gogo,zombie,spray,cdk,k8spider,ingressnightmare,kubeletctl,peirates,etcdctl,kubectl,iox,frpc,chisel,ligolo-agent,linpeas,winpeas,GodPotato,mimikatz,SharpHound,PrintSpoofer,SharpWMI,SharPersist,StandIn}} 并下载各平台二进制"))
# ── 深度检查 (--deep): 实际运行每个工具,检测架构不兼容 / Python 依赖缺失 / 动态库缺失 ──
if deep:
import concurrent.futures
import subprocess as _sp_deep
_STARTUP_FAIL_PATTERNS = [
"exec format error",
"cannot execute binary file",
"cannot execute",
"modulenotfounderror",
"importerror",
"cannot open shared object file",
"error while loading shared libraries",
]
# 跳过:非工具类检查 / 已在基础检查中运行过 --version
_DEEP_SKIP_KEYWORDS = (
"(资源)", "(arsenal)", "minikerberos", "Python >=", "Python3",
"playwright", "anthropic", "PyYAML", "vulndb", "Docker",
"docker-compose", "Go", "Ruby",
)
_deep_targets = [] # (idx, label, binary, is_pyscript)
for _i, (_nm, _ok, _dt) in enumerate(checks):
if not _ok:
continue
if any(kw in _nm for kw in _DEEP_SKIP_KEYWORDS):
continue
_bin = _dt.strip()
if not _bin.startswith("/"):
continue
if not Path(_bin).is_file():
continue
_deep_targets.append((_i, _nm, _bin, _bin.endswith(".py")))
def _deep_verify(item):
idx, label, binary, is_py = item
try:
cmd = ["python3", binary, "--help"] if is_py else [binary, "--version"]
r = _sp_deep.run(cmd, capture_output=True, text=True, timeout=10)
# exit 126/127 = 无法执行
if r.returncode in (126, 127):
combined = ((r.stderr or "") + " " + (r.stdout or "")).lower()
if "exec format error" in combined:
return (idx, False, "架构不兼容 (Exec format error)")
return (idx, False, f"无法执行 (exit {r.returncode})")
# exit 0 = 正常
if r.returncode == 0:
return (idx, True, None)
# exit != 0: 检查是否为启动失败(依赖/架构问题)
combined = ((r.stderr or "") + " " + (r.stdout or "")).lower()
# 输出量大(>300字符)= 工具成功启动并打印了帮助/用法,非零退出仅因缺少必选参数
if len(combined) > 300:
return (idx, True, None)
for pat in _STARTUP_FAIL_PATTERNS:
if pat in combined:
for line in (r.stderr or "").split("\n"):
if pat in line.lower():
return (idx, False, line.strip()[:100])
return (idx, False, pat)
# 非零退出但无启动失败模式 → 工具可启动,只是不支持 --version
return (idx, True, None)
except OSError as e:
if getattr(e, "errno", 0) == 8: # ENOEXEC
return (idx, False, "架构不兼容 (Exec format error)")
return (idx, False, f"OS 错误: {e}")
except _sp_deep.TimeoutExpired:
return (idx, True, None) # 超时 = 至少能启动
except Exception as e:
return (idx, False, f"异常: {str(e)[:80]}")
if _deep_targets:
print(f"\n🔬 深度检查:验证 {len(_deep_targets)} 个工具实际可运行...")
_deep_fails = []
with concurrent.futures.ThreadPoolExecutor(max_workers=10) as _pool:
_deep_results = list(_pool.map(_deep_verify, _deep_targets))
for _idx, _dok, _reason in _deep_results:
if not _dok:
_n, _, _d = checks[_idx]
checks[_idx] = (_n, False, f"{_d} ⛔ {_reason}")
_deep_fails.append((_n, _reason))
if _deep_fails:
print(f"\n ⚠️ {len(_deep_fails)} 个工具不可用:")
for _n, _r in _deep_fails:
print(f" ❌ {_n}: {_r}")
else:
print(f" ✅ 全部 {len(_deep_targets)} 个工具验证通过")
print()
# Print results
print(f"\n🔍 tchkiller v{VERSION} 环境检查\n")
all_ok = True
for name, ok, detail in checks:
if ok:
icon = "✅"
elif "(可选)" in name or "(渗透)" in name or "(资源)" in name or "(arsenal)" in name:
icon = "⚠️"
else:
icon = "❌"
all_ok = False
print(f" {icon} {name:<30s} {detail}")
print()
if all_ok:
print("✅ 所有必要依赖已就绪!")
else:
print("⚠️ 部分必要依赖缺失,请按提示安装")
print()
return all_ok
def check_providers(args) -> bool:
"""测试所有 API provider 的可用性:发送最小 chat completion 请求"""
import concurrent.futures
import json
import time
try:
from urllib.request import Request, urlopen
from urllib.error import HTTPError, URLError
except ImportError:
print("❌ urllib 不可用")
return False
config_path = ROOT / args.providers_config
if not config_path.exists():
print(f"❌ 未找到 providers 配置: {config_path}")
return False
with open(config_path, encoding="utf-8") as f:
data = json.load(f)
providers = [p for p in data.get("providers", []) if p.get("enabled", True)]
if not providers:
print("⚠️ providers.json 中无可用 provider")
return False
print(f"\n🔌 测试 {len(providers)} 个 API Provider 可用性...\n")
def _test_provider(p):
name = p["name"]
base_url = p["base_url"].rstrip("/")
api_key = p["api_key"]
# 取第一个可用模型
models = p.get("models", {})
model = models.get("haiku") or models.get("sonnet") or models.get("opus") or "test"
# 优先尝试 Anthropic Messages API,失败再 fallback OpenAI Chat Completions
attempts = [
("Anthropic", f"{base_url}/v1/messages",
json.dumps({"model": model, "messages": [{"role": "user", "content": "hi"}], "max_tokens": 5}).encode(),
{"Content-Type": "application/json", "x-api-key": api_key, "anthropic-version": "2023-06-01"}),
("OpenAI", f"{base_url}/v1/chat/completions",
json.dumps({"model": model, "messages": [{"role": "user", "content": "hi"}], "max_tokens": 5, "stream": False}).encode(),
{"Content-Type": "application/json", "Authorization": f"Bearer {api_key}"}),
]
working_fmt = None
for api_fmt, url, body, headers in attempts:
req = Request(url, data=body, headers=headers)
t0 = time.time()
try:
resp = urlopen(req, timeout=30)
latency = time.time() - t0
resp_body = resp.read().decode()
try:
rj = json.loads(resp_body)
usage = rj.get("usage", {})
tok_info = ""
if usage:
tok_total = usage.get("total_tokens") or usage.get("input_tokens", 0) + usage.get("output_tokens", 0)
tok_info = f" tokens={tok_total}"
has_content = rj.get("content") or rj.get("choices")
if has_content:
working_fmt = (api_fmt, latency, tok_info, headers)
break
return (name, True, f"⚠️ {model} | {latency:.1f}s | 响应无内容: {resp_body[:100]}")
except json.JSONDecodeError:
return (name, False, f"❌ 非 JSON 响应: {resp_body[:100]}")
except HTTPError as e:
latency = time.time() - t0
err_body = ""
try:
err_body = e.read().decode()[:200]
except Exception:
pass
code = e.code
if code == 404:
continue # 该格式不支持,尝试下一种
if code == 429:
return (name, False, f"❌ 限流 (429) | {latency:.1f}s | {err_body}")
elif code == 402:
return (name, False, f"❌ 余额不足 (402) | {err_body}")
elif code == 401 or code == 403:
return (name, False, f"❌ 认证失败 ({code}) | {err_body}")
else:
return (name, False, f"❌ HTTP {code} | {latency:.1f}s | {err_body}")
except URLError as e:
return (name, False, f"❌ 连接失败: {e.reason}")
except TimeoutError:
return (name, False, f"❌ 超时 (>30s)")
except Exception as e:
return (name, False, f"❌ {type(e).__name__}: {str(e)[:80]}")
if not working_fmt:
return (name, False, f"❌ Anthropic/OpenAI 两种格式均返回 404")
api_fmt, latency, tok_info, base_headers = working_fmt
# Phase 2: streaming + tool_use 多轮兼容性测试(模拟 claude-agent-sdk 实际调用方式)
tool_ok, tool_detail = _test_tool_use_streaming(name, base_url, api_key, model, api_fmt, base_headers)
if tool_ok:
return (name, True, f"✅ {model} ({api_fmt}) | {latency:.1f}s{tok_info} | streaming+tool ✅")
else:
return (name, False,
f"⚠️ {model} ({api_fmt}) | {latency:.1f}s{tok_info} | "
f"❌ {tool_detail}")
def _test_tool_use_streaming(name, base_url, api_key, model, api_fmt, base_headers):
"""模拟 claude-agent-sdk 的实际调用: streaming + tool_use + multi-turn tool_result"""
if api_fmt != "Anthropic":
# OpenAI 格式暂不做深度测试
return True, ""
url = f"{base_url}/v1/messages"
tool_def = {
"name": "get_time",
"description": "Get current time in ISO format",
"input_schema": {"type": "object", "properties": {}, "required": []}
}
# ── Step 1: streaming + tool_use 请求 ──
body1 = json.dumps({
"model": model, "max_tokens": 200, "stream": True,
"tools": [tool_def],
"messages": [{"role": "user", "content": "What time is it? You must call the get_time tool."}],
}).encode()
headers = {**base_headers}
req1 = Request(url, data=body1, headers=headers)
try:
resp1 = urlopen(req1, timeout=30)
except HTTPError as e:
err = ""
try:
err = e.read().decode()[:200]
except Exception:
pass
return False, f"streaming 请求失败: HTTP {e.code} {err}"
except Exception as e:
return False, f"streaming 连接失败: {e}"
# 解析 SSE 流,提取完整响应
tool_use_id = None
try:
raw = resp1.read().decode("utf-8", errors="replace")
# 从 SSE data 行中提取 JSON events
for line in raw.split("\n"):
line = line.strip()
if not line.startswith("data: "):
continue
data = line[6:]
if data == "[DONE]":
break
try:
evt = json.loads(data)
except json.JSONDecodeError:
return False, f"streaming SSE JSON 解析失败: {data[:100]}"
# 查找 tool_use content_block
evt_type = evt.get("type", "")
if evt_type == "content_block_start":
cb = evt.get("content_block", {})
if cb.get("type") == "tool_use":
tool_use_id = cb.get("id")
# message_delta 包含 stop_reason
if evt_type == "message_delta":
delta = evt.get("delta", {})
if delta.get("stop_reason") == "tool_use" and not tool_use_id:
# 某些代理可能不按标准发送 content_block_start
pass
except Exception as e:
return False, f"streaming 响应读取失败: {type(e).__name__}: {str(e)[:80]}"
if not tool_use_id:
# 模型可能直接文本回复而非调用工具 — streaming 本身是正常的
return True, ""
# ── Step 2: 多轮 tool_result 请求(这是 MiniMax 容易崩的地方)──
body2 = json.dumps({
"model": model, "max_tokens": 200, "stream": True,
"tools": [tool_def],
"messages": [
{"role": "user", "content": "What time is it? Call get_time."},
{"role": "assistant", "content": [
{"type": "tool_use", "id": tool_use_id, "name": "get_time", "input": {}}
]},
{"role": "user", "content": [
{"type": "tool_result", "tool_use_id": tool_use_id, "content": "2025-01-01T12:00:00Z"}
]},
],
}).encode()
req2 = Request(url, data=body2, headers=headers)
try:
resp2 = urlopen(req2, timeout=30)
except HTTPError as e:
err = ""
try:
err = e.read().decode()[:200]
except Exception:
pass
return False, f"多轮 tool_result 请求失败: HTTP {e.code} {err}"
except Exception as e:
return False, f"多轮 tool_result 连接失败: {e}"
try:
raw2 = resp2.read().decode("utf-8", errors="replace")
got_text = False
for line in raw2.split("\n"):
line = line.strip()
if not line.startswith("data: "):
continue
data = line[6:]
if data == "[DONE]":
break
try:
evt = json.loads(data)
got_text = True
except json.JSONDecodeError:
return False, f"多轮 streaming JSON 解析失败 (实际运行会 Failed to parse JSON): {data[:100]}"
if not got_text:
return False, f"多轮 streaming 无有效数据"
return True, ""
except Exception as e:
return False, f"多轮 streaming 读取失败: {type(e).__name__}: {str(e)[:80]}"
with concurrent.futures.ThreadPoolExecutor(max_workers=len(providers)) as pool:
results = list(pool.map(_test_provider, providers))
all_ok = True
for p, (name, ok, detail) in zip(providers, results):
icon = "✅" if ok else "❌"
url_short = p["base_url"].rstrip("/")
print(f" {icon} {name:<25s} {url_short}")
print(f" {detail}")
if not ok:
all_ok = False
print()
if all_ok:
print(f"✅ 全部 {len(providers)} 个 provider 可用!")
else:
failed = sum(1 for _, ok, _ in results if not ok)
passed = len(results) - failed
print(f"⚠️ {failed}/{len(providers)} 个 provider 不可用,{passed} 个正常")
print()
return all_ok
def load_config(config_path: str) -> dict:
"""加载 YAML 配置文件"""
path = ROOT / config_path
if not path.exists():
return {}
try:
import yaml
with open(path) as f:
return yaml.safe_load(f) or {}
except ImportError:
# 无 pyyaml 时返回空配置
return {}
# 快捷别名 → 展开为具体漏洞类型
_EXCLUSION_ALIASES = {
"xss": ["xss_reflected", "xss_stored", "xss_dom", "xss"],
"tls": ["tls_ssl"],
"tls_ssl": ["tls_ssl"],
"header": ["missing_header"],
"cookie": ["cookie_security"],
"redirect": ["open_redirect"],
"info": ["info_disclosure"],
"session": ["session_mgmt"],
}
def resolve_exclusions(args) -> list[str]:
"""解析 --profile 和 --exclude 参数,返回排除的漏洞类型列表"""
excluded = set()
# 1) 从 profile YAML 加载
if args.profile:
profile_path = ROOT / "profiles" / f"{args.profile}.yaml"
if not profile_path.exists():
profile_path = ROOT / "profiles" / f"{args.profile}.yml"
if profile_path.exists():
try:
import yaml
with open(profile_path) as f:
prof = yaml.safe_load(f) or {}
for v in prof.get("exclude_vulns", []):
for expanded in _EXCLUSION_ALIASES.get(v, [v]):
excluded.add(expanded)
except ImportError:
# pyyaml 未安装,用简单解析器处理
with open(profile_path, encoding="utf-8") as f:
for line in f:
line = line.strip()
if line.startswith("- "):
v = line[2:].strip()
if v:
for expanded in _EXCLUSION_ALIASES.get(v, [v]):
excluded.add(expanded)
except Exception as e:
print(f"⚠️ 读取 profile {args.profile} 失败: {e}")
else:
print(f"⚠️ Profile 不存在: {profile_path}")