Compare commits

...

4 Commits

Author SHA1 Message Date
公明 bb9e3f9477 Update version to v1.4.13 in config.yaml 2026-04-09 21:45:39 +08:00
公明 a57720fb29 Add files via upload 2026-04-09 21:40:43 +08:00
公明 9e34b480e7 Add files via upload 2026-04-09 21:34:26 +08:00
公明 cd30953a84 Add files via upload 2026-04-09 20:44:17 +08:00
10 changed files with 578 additions and 103 deletions
+1 -1
View File
@@ -10,7 +10,7 @@
# ============================================
# 前端显示的版本号(可选,不填则显示默认版本)
version: "v1.4.12"
version: "v1.4.13"
# 服务器配置
server:
host: 0.0.0.0 # 监听地址,0.0.0.0 表示监听所有网络接口
+18
View File
@@ -990,6 +990,24 @@ func (h *AgentHandler) createProgressCallback(conversationID, assistantMessageID
return
}
// 当 Agent 同时发送 thinking_stream_* 和 thinking(带同一 streamId)时,
// thinking_stream_* 已经会在 flushThinkingStreams() 聚合落库;
// 这里跳过同 streamId 的 thinking,避免 processDetails 双份展示。
if eventType == "thinking" {
if dataMap, ok := data.(map[string]interface{}); ok {
if sid, ok2 := dataMap["streamId"].(string); ok2 && sid != "" {
if tb, exists := thinkingStreams[sid]; exists && tb != nil {
if strings.TrimSpace(tb.b.String()) != "" {
return
}
}
if flushedThinking[sid] {
return
}
}
}
}
// 保存过程详情到数据库(排除 response/doneresponse 正文已在 messages 表)
// response_start/response_delta 已聚合为 planning,不落逐条。
if assistantMessageID != "" &&
@@ -1,6 +1,7 @@
package burp;
import javax.swing.*;
import java.awt.*;
import java.util.ArrayList;
import java.util.List;
@@ -10,6 +11,7 @@ public class BurpExtender implements IBurpExtender, IContextMenuFactory {
private CyberStrikeAITab tab;
private final CyberStrikeAIClient client = new CyberStrikeAIClient();
private String lastInstruction = HttpMessageFormatter.defaultInstruction();
@Override
public void registerExtenderCallbacks(IBurpExtenderCallbacks callbacks) {
@@ -36,111 +38,149 @@ public class BurpExtender implements IBurpExtender, IContextMenuFactory {
if (selected == null || selected.length == 0) {
return;
}
CyberStrikeAIClient.Config cfg = tab.currentConfig();
String token = tab.getToken();
if (token == null || token.trim().isEmpty()) {
JOptionPane.showMessageDialog(tab.getUiComponent(),
"Please click Validate first to obtain a token.",
"CyberStrikeAI", JOptionPane.WARNING_MESSAGE);
return;
}
String prompt = HttpMessageFormatter.toPrompt(helpers, selected[0]);
String title = HttpMessageFormatter.getRequestTitle(helpers, selected[0]);
String agentModeStr = (cfg.agentMode == CyberStrikeAIClient.AgentMode.MULTI) ? "Multi Agent" : "Single Agent";
String runId = tab.startNewRun(title, agentModeStr, selected[0]);
tab.appendProgressToRun(runId, "\n[server] " + cfg.baseUrl + "\n\n");
client.streamTest(cfg, token, prompt, new CyberStrikeAIClient.StreamListener() {
@Override
public void onEvent(String type, String message, String rawJson) {
if (type == null) type = "";
switch (type) {
case "response_delta":
case "eino_agent_reply_stream_delta":
// delta chunk (content only)
tab.appendFinalToRun(runId, message);
break;
case "response":
// final response (full)
tab.appendFinalToRun(runId, "\n\n--- Final Response ---\n");
tab.appendFinalToRun(runId, message);
tab.setFinalResponse(runId, message);
break;
case "progress":
tab.appendProgressToRun(runId, "\n[progress] " + message + "\n");
tab.setRunStatus(runId, "running");
break;
case "cancelled":
tab.appendProgressToRun(runId, "\n[cancelled] " + message + "\n");
tab.setRunStatus(runId, "cancelled");
break;
case "error":
tab.appendProgressToRun(runId, "\n[error] " + message + "\n");
tab.setRunStatus(runId, "error");
break;
case "thinking_stream_start":
if (tab.isShowDebugEvents()) {
tab.resetThinkingStream(runId);
}
break;
case "thinking_stream_delta":
case "tool_call":
case "tool_result":
case "tool_result_delta":
// debug; hide by default
if (tab.isShowDebugEvents() && message != null && !message.isEmpty()) {
if ("thinking_stream_delta".equals(type)) {
tab.appendThinkingDelta(runId, message);
} else {
tab.appendProgressToRun(runId, "\n[" + type + "] " + message + "\n");
}
}
break;
case "conversation":
// Capture conversationId for stop/cancel.
if (rawJson != null) {
String convId = SimpleJson.extractStringField(rawJson, "conversationId");
if (convId != null && !convId.trim().isEmpty()) {
tab.setRunConversationId(runId, convId);
}
}
if (tab.isShowDebugEvents() && message != null && !message.isEmpty()) {
tab.appendProgressToRun(runId, "\n[" + type + "] " + message + "\n");
}
break;
case "done":
// handled in onDone too
break;
default:
if (tab.isShowDebugEvents() && message != null && !message.isEmpty()) {
tab.appendProgressToRun(runId, "\n[" + type + "] " + message + "\n");
}
break;
}
}
@Override
public void onError(String message, Exception e) {
tab.appendProgressToRun(runId, "\n[error] " + message + "\n");
tab.setRunStatus(runId, "error");
callbacks.printError("CyberStrikeAI stream error: " + message);
if (e != null) {
callbacks.printError(e.toString());
}
}
@Override
public void onDone() {
tab.appendProgressToRun(runId, "\n\n[done]\n");
tab.setRunStatus(runId, "done");
}
});
sendMessage(selected[0]);
});
items.add(sendItem);
return items;
}
private void sendMessage(IHttpRequestResponse msg) {
if (msg == null) return;
CyberStrikeAIClient.Config cfg = tab.currentConfig();
String token = tab.getToken();
if (token == null || token.trim().isEmpty()) {
JOptionPane.showMessageDialog(tab.getUiComponent(),
"Please click Validate first to obtain a token.",
"CyberStrikeAI", JOptionPane.WARNING_MESSAGE);
return;
}
String instruction = showInstructionEditor(tab.getUiComponent(), lastInstruction);
if (instruction == null) {
return;
}
lastInstruction = instruction;
String prompt = HttpMessageFormatter.toPrompt(helpers, msg, instruction);
String title = HttpMessageFormatter.getRequestTitle(helpers, msg);
String agentModeStr = (cfg.agentMode == CyberStrikeAIClient.AgentMode.MULTI) ? "Multi Agent" : "Single Agent";
String runId = tab.startNewRun(title, agentModeStr, msg);
tab.appendProgressToRun(runId, "\n[server] " + cfg.baseUrl + "\n\n");
client.streamTest(cfg, token, prompt, new CyberStrikeAIClient.StreamListener() {
@Override
public void onEvent(String type, String message, String rawJson) {
if (type == null) type = "";
switch (type) {
case "response_delta":
case "eino_agent_reply_stream_delta":
tab.appendFinalToRun(runId, message);
break;
case "response":
tab.appendFinalToRun(runId, "\n\n--- Final Response ---\n");
tab.appendFinalToRun(runId, message);
tab.setFinalResponse(runId, message);
break;
case "progress":
tab.appendProgressToRun(runId, "\n[progress] " + message + "\n");
tab.setRunStatus(runId, "running");
break;
case "cancelled":
tab.appendProgressToRun(runId, "\n[cancelled] " + message + "\n");
tab.setRunStatus(runId, "cancelled");
break;
case "error":
tab.appendProgressToRun(runId, "\n[error] " + message + "\n");
tab.setRunStatus(runId, "error");
break;
case "thinking_stream_start":
if (tab.isShowDebugEvents()) {
tab.resetThinkingStream(runId);
}
break;
case "thinking_stream_delta":
case "tool_call":
case "tool_result":
case "tool_result_delta":
if (tab.isShowDebugEvents() && message != null && !message.isEmpty()) {
if ("thinking_stream_delta".equals(type)) {
tab.appendThinkingDelta(runId, message);
} else {
tab.appendProgressToRun(runId, "\n[" + type + "] " + message + "\n");
}
}
break;
case "conversation":
if (rawJson != null) {
String convId = SimpleJson.extractStringField(rawJson, "conversationId");
if (convId != null && !convId.trim().isEmpty()) {
tab.setRunConversationId(runId, convId);
}
}
if (tab.isShowDebugEvents() && message != null && !message.isEmpty()) {
tab.appendProgressToRun(runId, "\n[" + type + "] " + message + "\n");
}
break;
case "done":
break;
default:
if (tab.isShowDebugEvents() && message != null && !message.isEmpty()) {
tab.appendProgressToRun(runId, "\n[" + type + "] " + message + "\n");
}
break;
}
}
@Override
public void onError(String message, Exception e) {
tab.appendProgressToRun(runId, "\n[error] " + message + "\n");
tab.setRunStatus(runId, "error");
callbacks.printError("CyberStrikeAI stream error: " + message);
if (e != null) {
callbacks.printError(e.toString());
}
}
@Override
public void onDone() {
tab.appendProgressToRun(runId, "\n\n[done]\n");
tab.setRunStatus(runId, "done");
}
});
}
private static String showInstructionEditor(Component parent, String initialValue) {
JTextArea editor = new JTextArea(
initialValue == null || initialValue.trim().isEmpty()
? HttpMessageFormatter.defaultInstruction()
: initialValue,
6,
70
);
editor.setLineWrap(true);
editor.setWrapStyleWord(true);
editor.setFont(new Font(Font.SANS_SERIF, Font.PLAIN, 13));
JPanel panel = new JPanel(new BorderLayout(0, 8));
panel.add(new JLabel("Edit instruction before sending:"), BorderLayout.NORTH);
panel.add(new JScrollPane(editor), BorderLayout.CENTER);
int result = JOptionPane.showConfirmDialog(
parent,
panel,
"Customize Prompt Instruction",
JOptionPane.OK_CANCEL_OPTION,
JOptionPane.PLAIN_MESSAGE
);
if (result != JOptionPane.OK_OPTION) {
return null;
}
String value = editor.getText();
if (value == null || value.trim().isEmpty()) {
return HttpMessageFormatter.defaultInstruction();
}
return value.trim();
}
}
@@ -5,6 +5,8 @@ import java.util.List;
final class HttpMessageFormatter {
private HttpMessageFormatter() {}
private static final String DEFAULT_INSTRUCTION =
"针对该流量做web渗透测试,并输出测试结果,要求:只针对该接口流量做测试,切勿拓展其他接口";
static String getRequestTitle(IExtensionHelpers helpers, IHttpRequestResponse msg) {
IRequestInfo reqInfo = helpers.analyzeRequest(msg);
@@ -22,7 +24,15 @@ final class HttpMessageFormatter {
return method + " " + host + shortPath + q;
}
static String defaultInstruction() {
return DEFAULT_INSTRUCTION;
}
static String toPrompt(IExtensionHelpers helpers, IHttpRequestResponse msg) {
return toPrompt(helpers, msg, DEFAULT_INSTRUCTION);
}
static String toPrompt(IExtensionHelpers helpers, IHttpRequestResponse msg, String instruction) {
IRequestInfo reqInfo = helpers.analyzeRequest(msg);
String method = reqInfo.getMethod();
String url = reqInfo.getUrl() != null ? reqInfo.getUrl().toString() : "(unknown)";
@@ -53,8 +63,12 @@ final class HttpMessageFormatter {
+ respBody;
}
String prefix = (instruction == null || instruction.trim().isEmpty())
? DEFAULT_INSTRUCTION
: instruction.trim();
return ""
+ "针对该流量做web渗透测试,并输出测试结果,要求:只针对该接口流量做测试,切勿拓展其他接口\n\n"
+ prefix + "\n\n"
+ "[Target]\n"
+ method + " " + url + "\n\n"
+ "[Request]\n"
+403
View File
@@ -0,0 +1,403 @@
name: "shodan_search"
command: "python3"
args:
- "-c"
- |
import sys
import json
import requests
import os
import math
# ==================== Shodan配置 ====================
# 请在此处配置您的Shodan API Key
# 您也可以在环境变量中设置:SHODAN_API_KEY
# enable 默认为 false,需开启才能调用该MCP
SHODAN_API_KEY = "" # 请替换为您自己的Shodan API Key
# ==================================================
# Shodan API基础URL
base_url = "https://api.shodan.io"
# 解析参数(从JSON字符串或命令行参数)
def parse_args():
# 尝试从第一个参数读取JSON配置
if len(sys.argv) > 1:
try:
arg1 = str(sys.argv[1])
config = json.loads(arg1)
if isinstance(config, dict):
return config
except (json.JSONDecodeError, TypeError, ValueError):
pass
# 传统位置参数方式(向后兼容)
# 兼容两种序列:
# 1) query,page,facets,minify,fields,count_only,size
# 2) query,page,minify,fields,count_only,size (facets省略时执行器会压缩参数)
config = {}
if len(sys.argv) > 1:
config["query"] = str(sys.argv[1])
if len(sys.argv) > 2:
try:
config["page"] = int(sys.argv[2])
except (ValueError, TypeError):
pass
def is_bool_like(val):
if isinstance(val, bool):
return True
if not isinstance(val, str):
return False
return val.strip().lower() in ("true", "false", "1", "0", "yes", "no")
remaining = [str(x) for x in sys.argv[3:]]
if remaining:
# facets 省略时,第一个剩余参数通常是 minify(布尔)
first_is_bool = is_bool_like(remaining[0])
idx = 0
if not first_is_bool:
config["facets"] = remaining[idx]
idx += 1
if idx < len(remaining):
val = remaining[idx]
config["minify"] = val.lower() in ("true", "1", "yes")
idx += 1
if idx < len(remaining):
config["fields"] = remaining[idx]
idx += 1
if idx < len(remaining):
val = remaining[idx]
config["count_only"] = val.lower() in ("true", "1", "yes")
idx += 1
if idx < len(remaining):
try:
config["size"] = int(remaining[idx])
except (ValueError, TypeError):
pass
return config
def normalize_bool(value, default_value):
if value is None:
return default_value
if isinstance(value, bool):
return value
if isinstance(value, str):
return value.lower() in ("true", "1", "yes")
if isinstance(value, (int, float)):
return value != 0
return default_value
try:
config = parse_args()
if not isinstance(config, dict):
error_result = {
"status": "error",
"message": f"参数解析错误: 期望字典类型,但得到 {type(config).__name__}",
"type": "TypeError"
}
print(json.dumps(error_result, ensure_ascii=False, indent=2))
sys.exit(1)
api_key = os.getenv("SHODAN_API_KEY", SHODAN_API_KEY).strip()
query = str(config.get("query", "")).strip()
if not api_key:
error_result = {
"status": "error",
"message": "缺少Shodan配置: api_keyShodan API密钥)",
"required_config": ["api_key"],
"note": "请在YAML文件的SHODAN_API_KEY配置项中填写您的API密钥,或在环境变量SHODAN_API_KEY中设置。API密钥可在Shodan账户页面查看: https://account.shodan.io/"
}
print(json.dumps(error_result, ensure_ascii=False, indent=2))
sys.exit(1)
if not query:
error_result = {
"status": "error",
"message": "缺少必需参数: query(搜索查询语句)",
"required_params": ["query"],
"examples": [
"product:nginx",
"apache country:DE",
"port:22",
"ssl.cert.subject.cn:example.com",
"org:\"Amazon\" port:443"
]
}
print(json.dumps(error_result, ensure_ascii=False, indent=2))
sys.exit(1)
count_only = normalize_bool(config.get("count_only"), False)
minify = normalize_bool(config.get("minify"), True)
requested_size = config.get("size", None)
if requested_size is not None:
try:
requested_size = int(requested_size)
if requested_size <= 0:
requested_size = None
else:
# 防止单次请求过大导致额度和响应时间问题
requested_size = min(requested_size, 1000)
except (ValueError, TypeError):
requested_size = None
# 根据 count_only 选择搜索端点
endpoint = "/shodan/host/count" if count_only else "/shodan/host/search"
url = f"{base_url}{endpoint}"
params = {
"key": api_key,
"query": query
}
# 可选参数 facetssearch 和 count 都支持)
if "facets" in config and config["facets"]:
facets_value = str(config["facets"]).strip()
if facets_value:
params["facets"] = facets_value
# search 接口的可选参数
if not count_only:
if "page" in config and config["page"] is not None:
try:
page = int(config["page"])
if page > 0:
params["page"] = page
except (ValueError, TypeError):
pass
minify_effective = minify
if "fields" in config and config["fields"]:
fields_value = str(config["fields"]).strip()
if fields_value:
params["fields"] = fields_value
# Shodan API约束:fields 与 minify=true 互斥
minify_effective = False
params["minify"] = "true" if minify_effective else "false"
try:
if count_only:
response = requests.get(url, params=params, timeout=30)
response.raise_for_status()
result_data = response.json()
if isinstance(result_data, dict) and result_data.get("error"):
error_result = {
"status": "error",
"message": f"Shodan API错误: {result_data.get('error', '未知错误')}",
"suggestion": "请检查API密钥、查询语法和账户查询额度"
}
print(json.dumps(error_result, ensure_ascii=False, indent=2))
sys.exit(1)
output = {
"status": "success",
"mode": "count",
"query": query,
"total": result_data.get("total", 0),
"facets": result_data.get("facets", {}),
"size": requested_size,
"note": "count模式仅返回统计,不返回明细结果",
"message": "统计查询完成(未返回资产明细)"
}
else:
start_page = int(params.get("page", 1))
# Shodan search 每页固定最多100条
# 如果未指定 size,则保持原始行为(单页)
target_size = requested_size if requested_size else 100
pages_needed = 1 if not requested_size else max(1, int(math.ceil(target_size / 100.0)))
all_matches = []
last_result_data = {}
current_page = start_page
pages_fetched = 0
for _ in range(pages_needed):
page_params = dict(params)
page_params["page"] = current_page
response = requests.get(url, params=page_params, timeout=30)
response.raise_for_status()
result_data = response.json()
last_result_data = result_data if isinstance(result_data, dict) else {}
pages_fetched += 1
if isinstance(last_result_data, dict) and last_result_data.get("error"):
error_result = {
"status": "error",
"message": f"Shodan API错误: {last_result_data.get('error', '未知错误')}",
"suggestion": "请检查API密钥、查询语法和账户查询额度"
}
print(json.dumps(error_result, ensure_ascii=False, indent=2))
sys.exit(1)
page_matches = last_result_data.get("matches", []) if isinstance(last_result_data, dict) else []
if not page_matches:
break
all_matches.extend(page_matches)
if len(all_matches) >= target_size:
break
current_page += 1
matches = all_matches[:target_size]
output = {
"status": "success",
"mode": "search",
"query": query,
"page": start_page,
"size": target_size,
"pages_fetched": pages_fetched,
"total": last_result_data.get("total", 0),
"results_count": len(matches),
"facets": last_result_data.get("facets", {}),
"results": matches,
"message": f"成功获取 {len(matches)} 条结果"
}
print(json.dumps(output, ensure_ascii=False, indent=2))
except requests.exceptions.RequestException as e:
response_body = ""
status_code = None
if hasattr(e, "response") and e.response is not None:
status_code = e.response.status_code
try:
response_body = e.response.text[:500]
except Exception:
response_body = ""
error_result = {
"status": "error",
"message": f"请求失败: {str(e)}",
"status_code": status_code,
"response": response_body,
"suggestion": "请检查网络连接、Shodan API状态、API密钥与查询额度"
}
print(json.dumps(error_result, ensure_ascii=False, indent=2))
sys.exit(1)
except Exception as e:
error_result = {
"status": "error",
"message": f"执行出错: {str(e)}",
"type": type(e).__name__
}
print(json.dumps(error_result, ensure_ascii=False, indent=2))
sys.exit(1)
enabled: false
short_description: "Shodan网络空间搜索,支持search与count模式"
description: |
Shodan 资产搜索工具,基于官方 Developer API 实现,支持快速检索和统计分析。
**主要功能:**
- 使用 `/shodan/host/search` 进行资产搜索
- 使用 `/shodan/host/count` 进行无明细统计(节省查询信用)
- 支持按 `size` 控制返回条数(自动翻页聚合)
- 支持分页(page
- 支持分面统计(facets
- 支持结果字段裁剪(fields
- 支持 `minify` 控制返回数据体积
**鉴权方式:**
- Query 参数使用 `key`
- 可在本文件中填写 `SHODAN_API_KEY`,或通过环境变量 `SHODAN_API_KEY` 注入
**查询语法示例:**
- `product:nginx`
- `apache country:DE`
- `port:22`
- `org:"Amazon" port:443`
- `ssl.cert.subject.cn:example.com`
**注意事项:**
- 带过滤器的查询通常会消耗 query credits
- 翻页(超过第1页)会额外消耗额度
- `size` 大于 100 时会自动请求更多页(每页最多 100)
- `size` 最大限制为 1000(防止过量请求)
- `count_only=true` 使用统计接口,不返回 matches 明细
parameters:
- name: "query"
type: "string"
description: |
Shodan 搜索语句(必需)。
支持 Shodan filter 语法(`filter:value`)与关键字组合。
示例:
- `product:nginx`
- `apache country:DE`
- `port:22`
- `org:"Amazon" port:443`
required: true
position: 1
format: "positional"
- name: "page"
type: "int"
description: |
页码(可选,仅 search 模式生效),从 1 开始,默认 1。
required: false
position: 2
format: "positional"
default: 1
- name: "facets"
type: "string"
description: |
分面统计字段(可选)。
多个字段用英文逗号分隔,也可指定数量:
- `org,os`
- `country:20,org:10`
required: false
position: 3
format: "positional"
- name: "minify"
type: "bool"
description: |
是否精简返回字段(可选,仅 search 模式生效)。
默认 `true`。
required: false
position: 4
format: "positional"
default: true
- name: "fields"
type: "string"
description: |
指定返回字段(可选,仅 search 模式生效)。
多个字段用英文逗号分隔,例如:
- `ip_str,port,org,hostnames,http.title`
- `tags,http.title,http.favicon.hash`
required: false
position: 5
format: "positional"
- name: "count_only"
type: "bool"
description: |
是否仅统计总数(可选)。
- `false`(默认):调用 `/shodan/host/search` 返回明细
- `true`:调用 `/shodan/host/count` 仅返回 total 和 facets
required: false
position: 6
format: "positional"
default: false
- name: "size"
type: "int"
description: |
返回结果数量(可选,仅 search 模式生效)。
- 支持 `10 / 20 / 100 / n`
- Shodan 单页最多 100,超过 100 时会自动翻页拼接
- 为避免额度和时延问题,最大值限制为 1000
- 未传时默认返回单页结果(最多 100 条)
required: false
position: 7
format: "positional"