@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.
Files changed (24) hide show
  1. package/comate-engine/assets/skills/auto-commit/SKILL.md +2 -0
  2. package/comate-engine/assets/skills/auto-commit-sandbox-comate/SKILL.md +2 -2
  3. package/comate-engine/assets/skills/code-security/SKILL.md +110 -41
  4. package/comate-engine/assets/skills/code-security/references/credential_hosting.md +190 -28
  5. package/comate-engine/assets/skills/code-security/references/vul_analysis-go_sql_injection.md +149 -0
  6. package/comate-engine/assets/skills/code-security/references/vul_analysis-java_sql_injection.md +185 -0
  7. package/comate-engine/assets/skills/code-security/references/vul_analysis-php_sql_injection.md +147 -0
  8. package/comate-engine/assets/skills/code-security/references/vul_analysis-python_sql_injection.md +143 -0
  9. package/comate-engine/assets/skills/code-security/references/vul_repair-go_sql_injection.md +2 -2
  10. package/comate-engine/assets/skills/code-security/references/vul_repair-sca.md +225 -0
  11. package/comate-engine/assets/skills/code-security/scripts/credential_hosting.py +12 -10
  12. package/comate-engine/assets/skills/code-security/scripts/credential_open_page.py +125 -0
  13. package/comate-engine/assets/skills/code-security/scripts/credential_poll.py +12 -9
  14. package/comate-engine/assets/skills/code-security/scripts/credential_url.py +81 -0
  15. package/comate-engine/assets/skills/code-security/scripts/ducc/get_claude_session_id.sh +33 -0
  16. package/comate-engine/assets/skills/code-security/scripts/ducc/open_browser.py +191 -0
  17. package/comate-engine/assets/skills/code-security/scripts/parse_scan_result.py +99 -16
  18. package/comate-engine/assets/skills/code-security/scripts/repair_vulnerability.py +66 -13
  19. package/comate-engine/assets/skills/code-security/scripts/scan_vulnerability.py +44 -12
  20. package/comate-engine/assets/skills/create-automation/SKILL.md +3 -0
  21. package/comate-engine/assets/skills/create-subagent/SKILL.md +16 -4
  22. package/comate-engine/server.js +137 -77
  23. package/dist/bundle/index.js +3 -3
  24. 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 (误报) 和 aiAnalysisStatus=1 (分析中) 的漏洞会被单独归类,不进入修复流程。
88
+ aiAnalysisStatus=3(误报)和 aiAnalysisStatus=1(分析中)的漏洞会被单独归类。
89
+ 误报永远不进入修复流程;分析中漏洞需先经工作流的「本地兜底分析」复核,
90
+ 被判定为真实漏洞的会被合并回修复列表。
87
91
  """
88
- data = scan_result.get("data", {})
89
- runs = data.get("sarif", {}).get("runs", []) or data.get("runs", [])
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
- if analyzing_count > 0:
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
- if analyzing_count > 0:
316
- parts.append("另有 **{}** 个漏洞正在 AI 分析中,分析完成后可能会被判定为误报而排除,分析期间暂不处理。\n".format(analyzing_count))
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(format_vul_report(parsed["sensitive_vuls"], "硬编码漏洞报告", project_dir))
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 秒,约 10 分钟)
34
- MAX_REPAIR_POLLS = 200
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
- 确保每个 from_content 都是原文件中的连续子串,可以被 str.replace 精确匹配。
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
- # 使用 unified_diff 找出所有差异区域,n=0 不提供上下文行
53
- diff = list(difflib.unified_diff(old_lines, new_lines, n=0))
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
- print("修复中,等待结果... ({}/{})".format(poll_count, MAX_REPAIR_POLLS), file=sys.stderr)
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("错误: 修复超时,已轮询 {} 次仍未完成".format(MAX_REPAIR_POLLS), file=sys.stderr)
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(root_path, vulnerability_info, args.username, user_id, args.chat_id)
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 分析等待超时(已等待 {} 分钟),将使用当前结果继续".format(
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
- if analyzing_count == 0:
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
- elapsed_min = int(elapsed // 60)
276
- elapsed_sec = int(elapsed % 60)
277
- print("AI 分析中... 剩余 {} 个漏洞待分析(已等待 {}分{}秒)".format(
278
- analyzing_count, elapsed_min, elapsed_sec), file=sys.stderr)
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
- print("扫描中,等待结果... ({}/{})".format(poll_count, MAX_SCAN_POLLS), file=sys.stderr)
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("错误: 扫描超时,已轮询 {} 次仍未完成".format(MAX_SCAN_POLLS), file=sys.stderr)
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
- with open(output_file, "w", encoding="utf-8") as f:
423
- json.dump(result, f, ensure_ascii=False, indent=2)
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
  # 正常扫描模式
@@ -7,6 +7,9 @@ description: |
7
7
  - "每天/每周/每月自动帮我做..."
8
8
  - 配置 webhook 触发任务
9
9
  不适用于普通的一次性任务请求,仅适用于需要周期性或事件驱动执行的自动化配置。
10
+ metadata:
11
+ enableWhen:
12
+ - isComateIDE
10
13
  disable-model-invocation: true
11
14
  ---
12
15