@comate/zulu 1.4.0-beta.5 → 1.4.0-beta.6
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.
- package/comate-engine/assets/skills/auto-commit/SKILL.md +2 -0
- package/comate-engine/assets/skills/auto-commit-sandbox-comate/SKILL.md +2 -2
- package/comate-engine/assets/skills/code-security/SKILL.md +110 -41
- package/comate-engine/assets/skills/code-security/references/credential_hosting.md +190 -28
- package/comate-engine/assets/skills/code-security/references/vul_analysis-go_sql_injection.md +149 -0
- package/comate-engine/assets/skills/code-security/references/vul_analysis-java_sql_injection.md +185 -0
- package/comate-engine/assets/skills/code-security/references/vul_analysis-php_sql_injection.md +147 -0
- package/comate-engine/assets/skills/code-security/references/vul_analysis-python_sql_injection.md +143 -0
- package/comate-engine/assets/skills/code-security/references/vul_repair-go_sql_injection.md +2 -2
- package/comate-engine/assets/skills/code-security/references/vul_repair-sca.md +225 -0
- package/comate-engine/assets/skills/code-security/scripts/credential_hosting.py +12 -10
- package/comate-engine/assets/skills/code-security/scripts/credential_open_page.py +125 -0
- package/comate-engine/assets/skills/code-security/scripts/credential_poll.py +12 -9
- package/comate-engine/assets/skills/code-security/scripts/credential_url.py +81 -0
- package/comate-engine/assets/skills/code-security/scripts/ducc/get_claude_session_id.sh +33 -0
- package/comate-engine/assets/skills/code-security/scripts/ducc/open_browser.py +191 -0
- package/comate-engine/assets/skills/code-security/scripts/parse_scan_result.py +99 -16
- package/comate-engine/assets/skills/code-security/scripts/repair_vulnerability.py +66 -13
- package/comate-engine/assets/skills/code-security/scripts/scan_vulnerability.py +44 -12
- package/comate-engine/assets/skills/create-automation/SKILL.md +3 -0
- package/comate-engine/assets/skills/create-subagent/SKILL.md +16 -4
- package/comate-engine/server.js +137 -77
- package/dist/bundle/index.js +3 -3
- package/package.json +1 -1
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
在 DUCC 内嵌浏览器中打开硬编码风险治理网页。
|
|
4
|
+
|
|
5
|
+
用法(凭证参数模式,与 credential_open_page.py 参数一致):
|
|
6
|
+
python3 open_browser.py --chat-id <chatID> --username <用户名> \
|
|
7
|
+
[--ide-name <ideType>] [--repo <repo>] [--project-dir <目录>]
|
|
8
|
+
|
|
9
|
+
用法(直接指定 URL 模式):
|
|
10
|
+
python3 open_browser.py --url <url> [--title <标题>] [--pid <kernel_pid>]
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import argparse
|
|
14
|
+
import glob
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
import subprocess
|
|
18
|
+
import socket
|
|
19
|
+
import sys
|
|
20
|
+
|
|
21
|
+
# 导入公共 URL 构建模块
|
|
22
|
+
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
|
|
23
|
+
from credential_url import build_url_from_args
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def get_parent_pid(pid: int) -> int | None:
|
|
27
|
+
"""获取进程的父进程 PID"""
|
|
28
|
+
try:
|
|
29
|
+
result = subprocess.run(
|
|
30
|
+
["ps", "-p", str(pid), "-o", "ppid="],
|
|
31
|
+
capture_output=True, text=True, timeout=5
|
|
32
|
+
)
|
|
33
|
+
val = result.stdout.strip()
|
|
34
|
+
return int(val) if val else None
|
|
35
|
+
except Exception:
|
|
36
|
+
return None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def find_kernel_socket() -> str | None:
|
|
40
|
+
"""沿进程树向上追溯,找到有对应 comate-kernel socket 文件的祖先进程。
|
|
41
|
+
|
|
42
|
+
适用于 DUCC/Comate 两种环境:无论脚本由哪个父进程调用,
|
|
43
|
+
都能找到当前 VSCode 窗口对应的 kernel socket。
|
|
44
|
+
"""
|
|
45
|
+
tmpdir = os.environ.get("TMPDIR", "/tmp").rstrip("/")
|
|
46
|
+
pid = os.getpid()
|
|
47
|
+
for _ in range(20): # 最多向上追溯 20 层
|
|
48
|
+
pid = get_parent_pid(pid)
|
|
49
|
+
if not pid or pid == 1:
|
|
50
|
+
break
|
|
51
|
+
for sock_dir in [tmpdir, "/tmp"]:
|
|
52
|
+
sock_path = f"{sock_dir}/comate-kernel-{pid}.sock"
|
|
53
|
+
if os.path.exists(sock_path):
|
|
54
|
+
return sock_path
|
|
55
|
+
return None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def open_url(url: str, pid: str = None, title: str = "网页") -> bool:
|
|
59
|
+
"""在当前 DUCC 聊天框所在 VSCode 窗口的内嵌浏览器中打开 URL"""
|
|
60
|
+
tmpdir = os.environ.get("TMPDIR", "/tmp").rstrip("/")
|
|
61
|
+
|
|
62
|
+
body = json.dumps({
|
|
63
|
+
"action": "executeVirtualEditor",
|
|
64
|
+
"method": "openUrlInEditorWebview",
|
|
65
|
+
"payload": {"url": url, "title": title, "reuseExisting": True},
|
|
66
|
+
})
|
|
67
|
+
req = (
|
|
68
|
+
"POST /editor/command/ HTTP/1.1\r\n"
|
|
69
|
+
"Host: localhost\r\n"
|
|
70
|
+
"Content-Type: application/json\r\n"
|
|
71
|
+
f"Content-Length: {len(body.encode())}\r\n"
|
|
72
|
+
"Connection: close\r\n\r\n"
|
|
73
|
+
+ body
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
def send_request(sock_path: str) -> bool:
|
|
77
|
+
try:
|
|
78
|
+
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
79
|
+
s.settimeout(3)
|
|
80
|
+
s.connect(sock_path)
|
|
81
|
+
s.send(req.encode())
|
|
82
|
+
resp = b""
|
|
83
|
+
while True:
|
|
84
|
+
chunk = s.recv(4096)
|
|
85
|
+
if not chunk:
|
|
86
|
+
break
|
|
87
|
+
resp += chunk
|
|
88
|
+
s.close()
|
|
89
|
+
return b"ok" in resp
|
|
90
|
+
except Exception:
|
|
91
|
+
return False
|
|
92
|
+
|
|
93
|
+
# 如果指定了 PID,直接使用
|
|
94
|
+
if pid:
|
|
95
|
+
for sock_dir in [tmpdir, "/tmp"]:
|
|
96
|
+
sock_path = f"{sock_dir}/comate-kernel-{pid}.sock"
|
|
97
|
+
if os.path.exists(sock_path) and send_request(sock_path):
|
|
98
|
+
print(f"✅ 已在内嵌浏览器中打开 (PID: {pid})")
|
|
99
|
+
return True
|
|
100
|
+
print(f"❌ 连接失败 (PID: {pid})")
|
|
101
|
+
return False
|
|
102
|
+
|
|
103
|
+
# 优先使用 init_env.sh 在 shell 层检测到的 kernel PID
|
|
104
|
+
env_kernel_pid = os.environ.get("_KERNEL_PID", "").strip()
|
|
105
|
+
if env_kernel_pid:
|
|
106
|
+
for sock_dir in [tmpdir, "/tmp"]:
|
|
107
|
+
sock_path = f"{sock_dir}/comate-kernel-{env_kernel_pid}.sock"
|
|
108
|
+
if os.path.exists(sock_path) and send_request(sock_path):
|
|
109
|
+
print(f"✅ 已在内嵌浏览器中打开 (当前窗口 PID: {env_kernel_pid})")
|
|
110
|
+
return True
|
|
111
|
+
|
|
112
|
+
# 次选:Python 进程树向上查找
|
|
113
|
+
kernel_sock = find_kernel_socket()
|
|
114
|
+
if kernel_sock and send_request(kernel_sock):
|
|
115
|
+
k_pid = kernel_sock.rsplit("-", 1)[-1].replace(".sock", "")
|
|
116
|
+
print(f"✅ 已在内嵌浏览器中打开 (当前窗口 PID: {k_pid})")
|
|
117
|
+
return True
|
|
118
|
+
|
|
119
|
+
# 降级:用最新的活跃 socket(只考虑进程仍存在的)
|
|
120
|
+
def is_process_alive(pid_str: str) -> bool:
|
|
121
|
+
try:
|
|
122
|
+
subprocess.run(["ps", "-p", pid_str], capture_output=True, timeout=2, check=True)
|
|
123
|
+
return True
|
|
124
|
+
except Exception:
|
|
125
|
+
return False
|
|
126
|
+
|
|
127
|
+
socket_candidates = set(glob.glob(f"{tmpdir}/comate-kernel-*.sock") +
|
|
128
|
+
glob.glob("/tmp/comate-kernel-*.sock"))
|
|
129
|
+
|
|
130
|
+
active_sockets = []
|
|
131
|
+
for sock_path in socket_candidates:
|
|
132
|
+
k_pid = sock_path.rsplit("-", 1)[-1].replace(".sock", "")
|
|
133
|
+
if is_process_alive(k_pid):
|
|
134
|
+
active_sockets.append((sock_path, k_pid))
|
|
135
|
+
|
|
136
|
+
# 先按访问时间排序尝试,再按修改时间排序
|
|
137
|
+
tried_pids = set()
|
|
138
|
+
|
|
139
|
+
for sort_key in [os.path.getatime, os.path.getmtime]:
|
|
140
|
+
sorted_sockets = sorted(active_sockets, key=lambda x: sort_key(x[0]), reverse=True)
|
|
141
|
+
for sock_path, k_pid in sorted_sockets:
|
|
142
|
+
if k_pid in tried_pids:
|
|
143
|
+
continue
|
|
144
|
+
if send_request(sock_path):
|
|
145
|
+
mode = "修改时间" if sort_key == os.path.getmtime else "访问时间"
|
|
146
|
+
print(f"✅ 已在内嵌浏览器中打开 (PID: {k_pid}, 降级模式 - 按{mode})")
|
|
147
|
+
return True
|
|
148
|
+
tried_pids.add(k_pid)
|
|
149
|
+
|
|
150
|
+
print("❌ 无法打开")
|
|
151
|
+
return False
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
"""
|
|
155
|
+
入口函数
|
|
156
|
+
python3 open_browser.py --chat-id <chatID> --username <用户名> \
|
|
157
|
+
[--ide-name <ideType>] [--repo <repo>] [--project-dir <目录>]
|
|
158
|
+
ducc打开硬编码风险治理页面。
|
|
159
|
+
"""
|
|
160
|
+
def main():
|
|
161
|
+
"""入口函数:解析参数并在 DUCC 内嵌浏览器中打开硬编码风险治理网页。"""
|
|
162
|
+
parser = argparse.ArgumentParser(description="在 DUCC 内嵌浏览器中打开硬编码风险治理网页")
|
|
163
|
+
|
|
164
|
+
# 凭证参数模式(与 credential_open_page.py 一致)
|
|
165
|
+
parser.add_argument("--chat-id", help="会话 ID (_CHAT_ID)")
|
|
166
|
+
parser.add_argument("--username", help="用户名 (_USERNAME)")
|
|
167
|
+
parser.add_argument("--ide-name", default="ducc", help="IDE 类型")
|
|
168
|
+
parser.add_argument("--repo", default="", help="仓库标识,留空则自动从 git remote 获取")
|
|
169
|
+
parser.add_argument("--project-dir", default=".", help="项目目录,用于自动获取 repo")
|
|
170
|
+
|
|
171
|
+
# 直接指定 URL 模式
|
|
172
|
+
parser.add_argument("--url", help="直接指定要打开的 URL(与凭证参数模式二选一)")
|
|
173
|
+
parser.add_argument("--title", default="硬编码风险治理", help="页面标题")
|
|
174
|
+
parser.add_argument("--pid", help="指定 comate-kernel 进程 PID")
|
|
175
|
+
|
|
176
|
+
args = parser.parse_args()
|
|
177
|
+
|
|
178
|
+
if args.url:
|
|
179
|
+
url = args.url
|
|
180
|
+
elif args.chat_id and args.username:
|
|
181
|
+
url = build_url_from_args(args.chat_id, args.username, args.ide_name,
|
|
182
|
+
args.project_dir, args.repo)
|
|
183
|
+
else:
|
|
184
|
+
parser.error("必须提供 --url 或同时提供 --chat-id 和 --username")
|
|
185
|
+
|
|
186
|
+
success = open_url(url, args.pid, args.title)
|
|
187
|
+
sys.exit(0 if success else 1)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
if __name__ == "__main__":
|
|
191
|
+
main()
|
|
@@ -35,6 +35,7 @@ parsed_result.json 格式:
|
|
|
35
35
|
"startLine": 12,
|
|
36
36
|
"endLine": 12,
|
|
37
37
|
"hash": "abc123...",
|
|
38
|
+
"importPath": "com.example:foo:1.2.3",
|
|
38
39
|
"is_sensitive": false,
|
|
39
40
|
"aiAnalysisStatus": 0,
|
|
40
41
|
"aiAnalysisStatusText": "无需分析",
|
|
@@ -43,9 +44,10 @@ parsed_result.json 格式:
|
|
|
43
44
|
|
|
44
45
|
aiAnalysisStatus 说明:
|
|
45
46
|
0 - 无需分析
|
|
46
|
-
1 -
|
|
47
|
+
1 - 分析中(脚本会单独归类到 analyzing_vuls;后续工作流会通过本地兜底分析判定,
|
|
48
|
+
被判定为真实漏洞的会重新合入修复列表)
|
|
47
49
|
2 - 真实漏洞
|
|
48
|
-
3 -
|
|
50
|
+
3 - 误报(始终不进入修复流程)
|
|
49
51
|
"""
|
|
50
52
|
|
|
51
53
|
import argparse
|
|
@@ -83,10 +85,13 @@ def parse_scan_result(scan_result):
|
|
|
83
85
|
# type: (dict) -> dict
|
|
84
86
|
"""解析扫描结果,返回结构化漏洞数据。
|
|
85
87
|
|
|
86
|
-
aiAnalysisStatus=3
|
|
88
|
+
aiAnalysisStatus=3(误报)和 aiAnalysisStatus=1(分析中)的漏洞会被单独归类。
|
|
89
|
+
误报永远不进入修复流程;分析中漏洞需先经工作流的「本地兜底分析」复核,
|
|
90
|
+
被判定为真实漏洞的会被合并回修复列表。
|
|
87
91
|
"""
|
|
88
|
-
data = scan_result.get("data", {})
|
|
89
|
-
|
|
92
|
+
data = scan_result.get("data", {}) or {}
|
|
93
|
+
sarif = data.get("sarif") or {}
|
|
94
|
+
runs = sarif.get("runs") or data.get("runs") or []
|
|
90
95
|
|
|
91
96
|
# 提取 bundleHash(由 scan_vulnerability.py 写入顶层)
|
|
92
97
|
bundle_hash = scan_result.get("bundleHash", "")
|
|
@@ -122,6 +127,8 @@ def parse_scan_result(scan_result):
|
|
|
122
127
|
level = level_config.get("level", "NONE") if level_config else "NONE"
|
|
123
128
|
level_cn = LEVEL_MAP.get(level, "低危")
|
|
124
129
|
vul_hash = result.get("properties", {}).get("hash", "")
|
|
130
|
+
# SCA 类漏洞会带有非空 importPath(依赖引入路径),用于后续判定是否走 SCA 本地修复
|
|
131
|
+
import_path = result.get("properties", {}).get("importPath", "") or ""
|
|
125
132
|
|
|
126
133
|
# AI 分析状态
|
|
127
134
|
ai_status = result.get("properties", {}).get("aiAnalysisStatus", 0)
|
|
@@ -172,6 +179,7 @@ def parse_scan_result(scan_result):
|
|
|
172
179
|
"startLine": start_line,
|
|
173
180
|
"endLine": end_line,
|
|
174
181
|
"hash": vul_hash,
|
|
182
|
+
"importPath": import_path,
|
|
175
183
|
"is_sensitive": is_sensitive,
|
|
176
184
|
"aiAnalysisStatus": ai_status,
|
|
177
185
|
"aiAnalysisStatusText": ai_status_text,
|
|
@@ -197,6 +205,23 @@ def parse_scan_result(scan_result):
|
|
|
197
205
|
false_positive_vuls.sort(key=sort_key)
|
|
198
206
|
analyzing_vuls.sort(key=sort_key)
|
|
199
207
|
|
|
208
|
+
# 去重:仅当 hash 相同(即扫描端认定为同一漏洞)时才合并;
|
|
209
|
+
# 不能仅按 (file, startLine, endLine, is_sensitive) 去重——多条来源不同的漏洞
|
|
210
|
+
# 可能共享同一个 sink 点(例如多个数据流汇入同一行),它们的 hash 各不相同,
|
|
211
|
+
# 必须各自保留以便后续修复完整覆盖。
|
|
212
|
+
def deduplicate(vuls):
|
|
213
|
+
seen = set()
|
|
214
|
+
result = []
|
|
215
|
+
for v in vuls:
|
|
216
|
+
key = v.get("hash") or (v["file"], v["startLine"], v["endLine"], v["is_sensitive"])
|
|
217
|
+
if key not in seen:
|
|
218
|
+
seen.add(key)
|
|
219
|
+
result.append(v)
|
|
220
|
+
return result
|
|
221
|
+
|
|
222
|
+
common_vuls = deduplicate(common_vuls)
|
|
223
|
+
sensitive_vuls = deduplicate(sensitive_vuls)
|
|
224
|
+
|
|
200
225
|
return {
|
|
201
226
|
"total": len(common_vuls) + len(sensitive_vuls) + len(false_positive_vuls) + len(analyzing_vuls),
|
|
202
227
|
"common_count": len(common_vuls),
|
|
@@ -263,6 +288,11 @@ def format_vul_report(vuls, title="漏洞报告", project_dir=""):
|
|
|
263
288
|
loc_link = _make_file_link(vul["file"], vul["startLine"], project_dir)
|
|
264
289
|
lines.append(" - {}".format(loc_link))
|
|
265
290
|
|
|
291
|
+
# SCA 漏洞展示依赖引入路径(importPath),便于定位需要升级的依赖链
|
|
292
|
+
import_path = vul.get("importPath", "")
|
|
293
|
+
if import_path:
|
|
294
|
+
lines.append(" - **依赖引入路径**:`{}`".format(import_path))
|
|
295
|
+
|
|
266
296
|
# 数据流折叠展示
|
|
267
297
|
if vul["codeFlows"]:
|
|
268
298
|
lines.append(" <details><summary>数据流</summary>\n")
|
|
@@ -303,30 +333,32 @@ def format_full_report(parsed, project_dir=""):
|
|
|
303
333
|
if false_positive_count > 0:
|
|
304
334
|
summary_parts.append("**{}** 个误报(已由 AI 分析确认,无需修复)".format(false_positive_count))
|
|
305
335
|
|
|
336
|
+
if analyzing_count > 0:
|
|
337
|
+
summary_parts.append("**{}** 个漏洞正在 AI 分析中(暂未判定)".format(analyzing_count))
|
|
338
|
+
|
|
306
339
|
if summary_parts:
|
|
307
340
|
parts.append("扫描发现 {}。\n".format(",".join(summary_parts)))
|
|
308
341
|
else:
|
|
309
|
-
|
|
310
|
-
parts.append("扫描完成,**{}** 个漏洞正在 AI 分析中,暂未发现已确认的漏洞。\n".format(analyzing_count))
|
|
311
|
-
else:
|
|
312
|
-
parts.append("扫描完成,未发现漏洞。\n")
|
|
342
|
+
parts.append("扫描完成,未发现漏洞。\n")
|
|
313
343
|
return "\n".join(parts)
|
|
314
344
|
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
# 只有误报没有真实漏洞的情况
|
|
319
|
-
if real_vul_count == 0 and false_positive_count > 0:
|
|
345
|
+
# 只有误报没有真实漏洞、且没有分析中的情况(才能断言“全部为误报”)
|
|
346
|
+
if real_vul_count == 0 and false_positive_count > 0 and analyzing_count == 0:
|
|
320
347
|
parts.append("所有检测到的漏洞均已被 AI 分析确认为误报,无需进行修复。\n")
|
|
321
348
|
if parsed.get("false_positive_vuls"):
|
|
322
349
|
parts.append(format_vul_report(parsed["false_positive_vuls"], "误报漏洞列表(仅供参考)", project_dir))
|
|
323
350
|
return "\n".join(parts)
|
|
324
351
|
|
|
352
|
+
# 仅剩分析中的情况(无真实漏洞、无误报)
|
|
353
|
+
if real_vul_count == 0 and false_positive_count == 0 and analyzing_count > 0:
|
|
354
|
+
parts.append("当前暂无已确认的漏洞,需等待 AI 分析完成后再评估。\n")
|
|
355
|
+
return "\n".join(parts)
|
|
356
|
+
|
|
325
357
|
if parsed["common_vuls"]:
|
|
326
358
|
parts.append(format_vul_report(parsed["common_vuls"], "普通漏洞报告", project_dir))
|
|
327
359
|
|
|
328
360
|
if parsed["sensitive_vuls"]:
|
|
329
|
-
parts.append(
|
|
361
|
+
parts.append(format_sensitive_table(parsed["sensitive_vuls"], 10, project_dir))
|
|
330
362
|
|
|
331
363
|
# 误报漏洞折叠展示
|
|
332
364
|
if false_positive_count > 0:
|
|
@@ -346,6 +378,42 @@ def format_full_report(parsed, project_dir=""):
|
|
|
346
378
|
|
|
347
379
|
return "\n".join(parts)
|
|
348
380
|
|
|
381
|
+
def format_sensitive_table(vuls, collapse_threshold=5, project_dir=""):
|
|
382
|
+
# type: (list, int, str) -> str
|
|
383
|
+
"""将硬编码漏洞列表格式化为 Markdown 表格。
|
|
384
|
+
|
|
385
|
+
当漏洞数量超过 collapse_threshold 时,使用 <details> 标签折叠展示,
|
|
386
|
+
点击可展开或收起;否则直接展示完整表格。
|
|
387
|
+
|
|
388
|
+
Args:
|
|
389
|
+
vuls: 漏洞列表
|
|
390
|
+
collapse_threshold: 超过该行数时触发折叠(默认 5)
|
|
391
|
+
project_dir: 项目根目录,用于生成可点击的绝对路径链接。若为空,则相对路径链接。
|
|
392
|
+
|
|
393
|
+
Returns:
|
|
394
|
+
Markdown
|
|
395
|
+
"""
|
|
396
|
+
if not vuls:
|
|
397
|
+
return "未发现硬编码漏洞。\n"
|
|
398
|
+
|
|
399
|
+
total = len(vuls)
|
|
400
|
+
header = "| 序号 | 漏洞名称 | 漏洞位置 | 漏洞等级 |\n| --- | --- | --- | --- |"
|
|
401
|
+
rows = []
|
|
402
|
+
for idx, vul in enumerate(vuls, 1):
|
|
403
|
+
loc_link = _make_file_link(vul["file"], vul["startLine"], project_dir)
|
|
404
|
+
rows.append("| {} | {} | {} | {} |".format(idx, vul["name"], loc_link, vul["level_cn"]))
|
|
405
|
+
|
|
406
|
+
if total <= collapse_threshold:
|
|
407
|
+
return "{}\n{}\n".format(header, "\n".join(rows))
|
|
408
|
+
|
|
409
|
+
# 前 collapse_threshold 条直接展示,超出部分用 <details> 折叠
|
|
410
|
+
visible = "{}\n{}".format(header, "\n".join(rows[:collapse_threshold]))
|
|
411
|
+
hidden_rows = rows[collapse_threshold:]
|
|
412
|
+
hidden_table = "{}\n{}".format(header, "\n".join(hidden_rows))
|
|
413
|
+
collapsed = "<details>\n<summary>展开查看剩余 {} 条漏洞</summary>\n\n{}\n\n</details>".format(
|
|
414
|
+
len(hidden_rows), hidden_table
|
|
415
|
+
)
|
|
416
|
+
return "{}\n\n{}\n".format(visible, collapsed)
|
|
349
417
|
|
|
350
418
|
def main():
|
|
351
419
|
"""
|
|
@@ -374,6 +442,21 @@ def main():
|
|
|
374
442
|
# 解析
|
|
375
443
|
parsed = parse_scan_result(scan_result)
|
|
376
444
|
|
|
445
|
+
# 紧凑统计行(必须优先打印,保证即使后续长报告被截断,计数仍可被模型读取)
|
|
446
|
+
stats_line = (
|
|
447
|
+
"STATS: total={total} common={common} sensitive={sensitive}"
|
|
448
|
+
" false_positive={fp} analyzing={analyzing}"
|
|
449
|
+
).format(
|
|
450
|
+
total=parsed["total"],
|
|
451
|
+
common=parsed["common_count"],
|
|
452
|
+
sensitive=parsed["sensitive_count"],
|
|
453
|
+
fp=parsed["false_positive_count"],
|
|
454
|
+
analyzing=parsed["analyzing_count"],
|
|
455
|
+
)
|
|
456
|
+
print(stats_line)
|
|
457
|
+
print(stats_line, file=sys.stderr)
|
|
458
|
+
print("")
|
|
459
|
+
|
|
377
460
|
# 输出 Markdown 报告到标准输出
|
|
378
461
|
report = format_full_report(parsed, args.project_dir)
|
|
379
462
|
print(report)
|
|
@@ -389,4 +472,4 @@ def main():
|
|
|
389
472
|
|
|
390
473
|
|
|
391
474
|
if __name__ == "__main__":
|
|
392
|
-
main()
|
|
475
|
+
main()
|
|
@@ -30,8 +30,8 @@ import utils
|
|
|
30
30
|
|
|
31
31
|
logger = logging.getLogger("repair")
|
|
32
32
|
|
|
33
|
-
# 修复轮询最大次数(每次间隔 3 秒,约
|
|
34
|
-
MAX_REPAIR_POLLS =
|
|
33
|
+
# 修复轮询最大次数(每次间隔 3 秒,约 5 分钟)
|
|
34
|
+
MAX_REPAIR_POLLS = 100
|
|
35
35
|
REPAIR_POLL_INTERVAL = 3
|
|
36
36
|
MAX_UPLOAD_RETRIES = 10
|
|
37
37
|
|
|
@@ -40,8 +40,10 @@ def diff_file_content(file_path, new_content):
|
|
|
40
40
|
# type: (str, str) -> str
|
|
41
41
|
"""比较原文件与修复后内容,使用 difflib 生成精确 diff,返回 diff 列表 JSON。
|
|
42
42
|
|
|
43
|
-
每个不连续的变更区域(hunk)生成独立的 {from_content, to_content}
|
|
44
|
-
|
|
43
|
+
每个不连续的变更区域(hunk)生成独立的 {from_content, to_content} 对。
|
|
44
|
+
携带 n=3 上下文行,确保:
|
|
45
|
+
1. from_content 包含足够上下文在文件中唯一,str.replace 不会误匹配
|
|
46
|
+
2. 纯插入 hunk(只有 + 行)也有上下文锚点,from_content 不会为空字符串
|
|
45
47
|
"""
|
|
46
48
|
with open(file_path, "r", encoding="utf-8") as f:
|
|
47
49
|
old_content = f.read()
|
|
@@ -49,8 +51,8 @@ def diff_file_content(file_path, new_content):
|
|
|
49
51
|
old_lines = old_content.splitlines(keepends=True)
|
|
50
52
|
new_lines = new_content.splitlines(keepends=True)
|
|
51
53
|
|
|
52
|
-
#
|
|
53
|
-
diff = list(difflib.unified_diff(old_lines, new_lines, n=
|
|
54
|
+
# n=3 携带上下文行,避免 from_content 为空或匹配不唯一
|
|
55
|
+
diff = list(difflib.unified_diff(old_lines, new_lines, n=3))
|
|
54
56
|
|
|
55
57
|
diffs = [] # type: list
|
|
56
58
|
current_from = [] # type: list
|
|
@@ -68,6 +70,16 @@ def diff_file_content(file_path, new_content):
|
|
|
68
70
|
for line in diff:
|
|
69
71
|
if line.startswith("---") or line.startswith("+++"):
|
|
70
72
|
continue
|
|
73
|
+
elif line.startswith("\\"):
|
|
74
|
+
# 防御性处理:兼容 git/POSIX 风格 diff 的 "" 标记。
|
|
75
|
+
# Python 标准库 difflib 当前版本不会输出该标记,但 splitlines(keepends=True)
|
|
76
|
+
# 已经天然保留了原文件是否以换行结尾的状态——若出现该标记,说明上一行实际无尾随换行,
|
|
77
|
+
# 此处主动剥掉可能被误加入的 "\n",确保 from_content 能在原文件中精确匹配。
|
|
78
|
+
if current_from and current_from[-1].endswith("\n"):
|
|
79
|
+
current_from[-1] = current_from[-1][:-1]
|
|
80
|
+
if current_to and current_to[-1].endswith("\n"):
|
|
81
|
+
current_to[-1] = current_to[-1][:-1]
|
|
82
|
+
continue
|
|
71
83
|
elif line.startswith("@@"):
|
|
72
84
|
# 新 hunk 开始,先保存上一组
|
|
73
85
|
_flush()
|
|
@@ -75,6 +87,11 @@ def diff_file_content(file_path, new_content):
|
|
|
75
87
|
current_from.append(line[1:])
|
|
76
88
|
elif line.startswith("+"):
|
|
77
89
|
current_to.append(line[1:])
|
|
90
|
+
else:
|
|
91
|
+
# 上下文行(空格前缀),同时加入 from 和 to 作为锚点
|
|
92
|
+
context_line = line[1:]
|
|
93
|
+
current_from.append(context_line)
|
|
94
|
+
current_to.append(context_line)
|
|
78
95
|
|
|
79
96
|
# 保存最后一组
|
|
80
97
|
_flush()
|
|
@@ -133,7 +150,7 @@ def repair_vulnerability(root_path, vulnerability_info, username, user_id, chat_
|
|
|
133
150
|
if upload_retry_count > MAX_UPLOAD_RETRIES:
|
|
134
151
|
print("错误: 修复上传重试 {} 次后仍有缺失文件".format(MAX_UPLOAD_RETRIES), file=sys.stderr)
|
|
135
152
|
return {"status": -1, "message": "修复上传重试次数超限"}
|
|
136
|
-
print("上传缺失文件: {} 个 (重试 {}/{})".format(len(missing),
|
|
153
|
+
print("上传缺失文件: {} 个 (重试 {}/{})".format(len(missing),
|
|
137
154
|
upload_retry_count, MAX_UPLOAD_RETRIES), file=sys.stderr)
|
|
138
155
|
upload_files_for_repair(root_path, missing, username, user_id, chat_id)
|
|
139
156
|
time.sleep(REPAIR_POLL_INTERVAL)
|
|
@@ -155,21 +172,39 @@ def repair_vulnerability(root_path, vulnerability_info, username, user_id, chat_
|
|
|
155
172
|
return result
|
|
156
173
|
|
|
157
174
|
poll_count += 1
|
|
158
|
-
|
|
175
|
+
progress = min(99, int(poll_count * 100 / MAX_REPAIR_POLLS))
|
|
176
|
+
print("修复中... {}%".format(progress), file=sys.stderr)
|
|
159
177
|
time.sleep(REPAIR_POLL_INTERVAL)
|
|
160
178
|
|
|
161
|
-
print("错误:
|
|
179
|
+
print("错误: 修复超时,已等待超过预期时间", file=sys.stderr)
|
|
162
180
|
return {"status": -1, "message": "修复超时"}
|
|
163
181
|
|
|
164
182
|
|
|
183
|
+
def is_sca_vuln(vul):
|
|
184
|
+
# type: (dict) -> bool
|
|
185
|
+
"""判定是否为 SCA 类漏洞。
|
|
186
|
+
|
|
187
|
+
SCA 漏洞的修复方式是升级依赖版本,与代码改写无关,后端修复接口对其无能为力,
|
|
188
|
+
因此直接在本地参考修复手册(references/vul_repair-sca.md)处理,不发起后端请求。
|
|
189
|
+
|
|
190
|
+
判定规则:漏洞条目带有非空 `importPath` 字段即为 SCA 漏洞。
|
|
191
|
+
"""
|
|
192
|
+
return bool(vul.get("importPath"))
|
|
193
|
+
|
|
194
|
+
|
|
165
195
|
def build_vulnerability_info(parsed):
|
|
166
196
|
# type: (dict) -> dict
|
|
167
|
-
"""从 parsed_result.json 构建修复接口所需的 vulnerability-info。
|
|
197
|
+
"""从 parsed_result.json 构建修复接口所需的 vulnerability-info。
|
|
198
|
+
|
|
199
|
+
SCA 类漏洞会被过滤掉,不进入后端修复请求,由 agent 在本地参考手册自行修复。
|
|
200
|
+
"""
|
|
168
201
|
bundle_hash = parsed.get("bundle_hash", "")
|
|
169
202
|
common_vuls = parsed.get("common_vuls", [])
|
|
170
203
|
|
|
171
204
|
file_map = {} # type: dict
|
|
172
205
|
for vul in common_vuls:
|
|
206
|
+
if is_sca_vuln(vul):
|
|
207
|
+
continue
|
|
173
208
|
fname = vul.get("file", "")
|
|
174
209
|
if not fname:
|
|
175
210
|
continue
|
|
@@ -207,7 +242,7 @@ def main():
|
|
|
207
242
|
|
|
208
243
|
user_id = utils.make_user_id(args.username)
|
|
209
244
|
|
|
210
|
-
logger.info("repair_vulnerability start: username=%s, chat_id=%s, root_path=%s", args.username,
|
|
245
|
+
logger.info("repair_vulnerability start: username=%s, chat_id=%s, root_path=%s", args.username,
|
|
211
246
|
args.chat_id, args.root_path)
|
|
212
247
|
|
|
213
248
|
root_path = os.path.realpath(args.root_path)
|
|
@@ -228,6 +263,23 @@ def main():
|
|
|
228
263
|
print("无普通漏洞需要修复", file=sys.stderr)
|
|
229
264
|
sys.exit(0)
|
|
230
265
|
vulnerability_info = build_vulnerability_info(parsed)
|
|
266
|
+
# 全部为 SCA 漏洞时,后端 files 为空,直接走本地兜底,由 agent 参考
|
|
267
|
+
# references/vul_repair-sca.md 修复
|
|
268
|
+
if not vulnerability_info.get("files"):
|
|
269
|
+
sca_count = sum(1 for v in parsed.get("common_vuls", []) if is_sca_vuln(v))
|
|
270
|
+
result = {
|
|
271
|
+
"status": -3,
|
|
272
|
+
"message": "all_vulns_are_sca_use_local_fallback",
|
|
273
|
+
"fallback": True,
|
|
274
|
+
"sca_only": True,
|
|
275
|
+
"sca_count": sca_count,
|
|
276
|
+
"data": {"files": []},
|
|
277
|
+
}
|
|
278
|
+
output_file = os.path.join(output_dir, "repair_result.json")
|
|
279
|
+
with open(output_file, "w", encoding="utf-8") as f:
|
|
280
|
+
json.dump(result, f, ensure_ascii=False, indent=2)
|
|
281
|
+
print(output_file)
|
|
282
|
+
return
|
|
231
283
|
else:
|
|
232
284
|
try:
|
|
233
285
|
vulnerability_info = json.loads(args.vulnerability_info)
|
|
@@ -235,7 +287,8 @@ def main():
|
|
|
235
287
|
print("错误: 漏洞信息 JSON 解析失败: {}".format(e), file=sys.stderr)
|
|
236
288
|
sys.exit(1)
|
|
237
289
|
|
|
238
|
-
result = repair_vulnerability(
|
|
290
|
+
result = repair_vulnerability(
|
|
291
|
+
root_path, vulnerability_info, args.username, user_id, args.chat_id)
|
|
239
292
|
|
|
240
293
|
output_file = os.path.join(output_dir, "repair_result.json")
|
|
241
294
|
with open(output_file, "w", encoding="utf-8") as f:
|
|
@@ -244,4 +297,4 @@ def main():
|
|
|
244
297
|
|
|
245
298
|
|
|
246
299
|
if __name__ == "__main__":
|
|
247
|
-
main()
|
|
300
|
+
main()
|
|
@@ -244,16 +244,31 @@ def get_analyzing_count(result):
|
|
|
244
244
|
return count
|
|
245
245
|
|
|
246
246
|
|
|
247
|
+
def _has_valid_runs(result):
|
|
248
|
+
# type: (dict) -> bool
|
|
249
|
+
"""检查结果中是否包含有效的 runs 数据(非空列表)。"""
|
|
250
|
+
data = result.get("data") or {}
|
|
251
|
+
sarif = data.get("sarif") or {}
|
|
252
|
+
runs = sarif.get("runs") or data.get("runs")
|
|
253
|
+
return bool(runs)
|
|
254
|
+
|
|
255
|
+
|
|
247
256
|
def _poll_ai_analysis(scan_info, chat_id, username, user_id, ai_analysis_timeout):
|
|
248
257
|
# type: (dict, str, str, str, int) -> dict
|
|
249
|
-
"""轮询等待 AI 分析完成,返回最新结果。
|
|
258
|
+
"""轮询等待 AI 分析完成,返回最新结果。
|
|
259
|
+
|
|
260
|
+
维护 last_valid_result:仅当响应 status != 1 且 runs 非空时更新。
|
|
261
|
+
超时或循环结束时返回 last_valid_result(而非最后一次可能为空的响应),
|
|
262
|
+
避免服务端中间态导致漏洞数据丢失。
|
|
263
|
+
"""
|
|
250
264
|
start_time = time.time()
|
|
251
265
|
poll_interval = 10
|
|
266
|
+
last_valid_result = None # type: dict | None
|
|
252
267
|
|
|
253
268
|
while True:
|
|
254
269
|
elapsed = time.time() - start_time
|
|
255
270
|
if elapsed >= ai_analysis_timeout:
|
|
256
|
-
print("\nAI 分析等待超时(已等待 {}
|
|
271
|
+
print("\nAI 分析等待超时(已等待 {} 分钟),将使用最近有效结果继续".format(
|
|
257
272
|
int(elapsed // 60)), file=sys.stderr)
|
|
258
273
|
logger.warning("AI analysis timeout after %d seconds", int(elapsed))
|
|
259
274
|
break
|
|
@@ -266,17 +281,29 @@ def _poll_ai_analysis(scan_info, chat_id, username, user_id, ai_analysis_timeout
|
|
|
266
281
|
json_body=scan_info,
|
|
267
282
|
)
|
|
268
283
|
|
|
284
|
+
# 记录有效结果:status != 1 且 runs 非空
|
|
285
|
+
if result.get("status") != 1 and _has_valid_runs(result):
|
|
286
|
+
last_valid_result = result
|
|
287
|
+
|
|
269
288
|
analyzing_count = get_analyzing_count(result)
|
|
270
|
-
|
|
289
|
+
# status=1 表示服务端扫描/分析仍在进行中,此时 runs 通常为 null,
|
|
290
|
+
# 不能仅凭 analyzing_count == 0 就判定分析完成,需要 status != 1 才算真正完成
|
|
291
|
+
if result.get("status") != 1 and analyzing_count == 0:
|
|
271
292
|
print("\nAI 分析完成!", file=sys.stderr)
|
|
272
293
|
logger.info("AI analysis completed after %d seconds", int(time.time() - start_time))
|
|
273
294
|
return result
|
|
274
295
|
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
296
|
+
elapsed_progress = min(99, int(elapsed * 100 / ai_analysis_timeout)) if ai_analysis_timeout > 0 else 0
|
|
297
|
+
if result.get("status") == 1:
|
|
298
|
+
print("AI 分析中... {}%(服务端仍在处理)".format(elapsed_progress), file=sys.stderr)
|
|
299
|
+
else:
|
|
300
|
+
print("AI 分析中... {}%(剩余 {} 个漏洞待分析)".format(
|
|
301
|
+
elapsed_progress, analyzing_count), file=sys.stderr)
|
|
279
302
|
|
|
303
|
+
# 超时:优先返回最近一次有效结果,避免返回 runs 为空的中间态
|
|
304
|
+
if last_valid_result is not None:
|
|
305
|
+
return last_valid_result
|
|
306
|
+
# 兜底:如果从未拿到过有效结果,返回最后一次响应(调用方会做写入前校验)
|
|
280
307
|
return result
|
|
281
308
|
|
|
282
309
|
|
|
@@ -325,11 +352,12 @@ def scan_vulnerability(root_path, chat_id="", username="", user_id="", wait_ai=T
|
|
|
325
352
|
if result.get("status") != 1:
|
|
326
353
|
break
|
|
327
354
|
poll_count += 1
|
|
328
|
-
|
|
355
|
+
progress = min(99, int(poll_count * 100 / MAX_SCAN_POLLS))
|
|
356
|
+
print("扫描中... {}%".format(progress), file=sys.stderr)
|
|
329
357
|
time.sleep(SCAN_POLL_INTERVAL)
|
|
330
358
|
|
|
331
359
|
if result.get("status") == 1:
|
|
332
|
-
print("错误:
|
|
360
|
+
print("错误: 扫描超时,已等待超过预期时间", file=sys.stderr)
|
|
333
361
|
return {"status": -1, "message": "扫描超时"}, bundle_hash
|
|
334
362
|
|
|
335
363
|
# 第二阶段:等待 AI 分析完成(可选)
|
|
@@ -417,10 +445,14 @@ def main():
|
|
|
417
445
|
root_path, args.scan_result, chat_id=args.chat_id,
|
|
418
446
|
username=args.username, user_id=user_id,
|
|
419
447
|
)
|
|
420
|
-
# 原地更新 scan_result.json
|
|
448
|
+
# 原地更新 scan_result.json(仅当结果包含有效 runs 时才覆盖,防止超时中间态清空漏洞)
|
|
421
449
|
output_file = os.path.realpath(args.scan_result)
|
|
422
|
-
|
|
423
|
-
|
|
450
|
+
if _has_valid_runs(result):
|
|
451
|
+
with open(output_file, "w", encoding="utf-8") as f:
|
|
452
|
+
json.dump(result, f, ensure_ascii=False, indent=2)
|
|
453
|
+
else:
|
|
454
|
+
print("警告: AI 分析结果中 runs 为空,保留原 scan_result.json 不覆盖", file=sys.stderr)
|
|
455
|
+
logger.warning("AI analysis result has empty runs, skip overwriting %s", output_file)
|
|
424
456
|
print(output_file)
|
|
425
457
|
else:
|
|
426
458
|
# 正常扫描模式
|