@zshuangmu/agenthub 0.4.14 → 0.4.16
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/LICENSE +21 -21
- package/README.md +268 -268
- package/package.json +41 -41
- package/src/api-server.js +518 -244
- package/src/cli.js +714 -671
- package/src/commands/api.js +9 -9
- package/src/commands/doctor.js +335 -335
- package/src/commands/info.js +15 -15
- package/src/commands/install.js +56 -56
- package/src/commands/list.js +78 -78
- package/src/commands/pack.js +249 -156
- package/src/commands/publish-remote.js +9 -9
- package/src/commands/publish.js +7 -7
- package/src/commands/rollback.js +59 -59
- package/src/commands/search.js +14 -14
- package/src/commands/serve.js +9 -9
- package/src/commands/stats.js +105 -105
- package/src/commands/uninstall.js +76 -76
- package/src/commands/update.js +54 -54
- package/src/commands/verify.js +133 -133
- package/src/commands/versions.js +75 -75
- package/src/commands/web.js +9 -9
- package/src/index.js +18 -18
- package/src/lib/auth.js +301 -0
- package/src/lib/bundle-transfer.js +58 -58
- package/src/lib/colors.js +60 -60
- package/src/lib/database.js +450 -244
- package/src/lib/debug.js +135 -135
- package/src/lib/fs-utils.js +107 -50
- package/src/lib/html.js +2163 -1824
- package/src/lib/http.js +168 -168
- package/src/lib/install.js +60 -60
- package/src/lib/manifest.js +124 -124
- package/src/lib/openclaw-config.js +40 -40
- package/src/lib/permissions.js +105 -0
- package/src/lib/privacy-engine.js +220 -0
- package/src/lib/registry.js +130 -130
- package/src/lib/remote.js +11 -11
- package/src/lib/security-scanner.js +233 -233
- package/src/lib/signing.js +158 -0
- package/src/lib/version-manager.js +77 -77
- package/src/server.js +176 -176
- package/src/web-server.js +135 -135
|
@@ -1,233 +1,233 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Security Scanner
|
|
3
|
-
* 安全扫描模块
|
|
4
|
-
*
|
|
5
|
-
* 根据 PRD v1.1 安全规范实现
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
import { readdir, readFile, stat } from "node:fs/promises";
|
|
9
|
-
import path from "node:path";
|
|
10
|
-
|
|
11
|
-
// 敏感信息模式
|
|
12
|
-
const SENSITIVE_PATTERNS = [
|
|
13
|
-
{ pattern: /sk-[a-zA-Z0-9]{20,}/g, name: "OpenAI API Key", severity: "high" },
|
|
14
|
-
{ pattern: /ghp_[a-zA-Z0-9]{36}/g, name: "GitHub Personal Token", severity: "high" },
|
|
15
|
-
{ pattern: /gho_[a-zA-Z0-9]{36}/g, name: "GitHub OAuth Token", severity: "high" },
|
|
16
|
-
{ pattern: /ghu_[a-zA-Z0-9]{36}/g, name: "GitHub User Token", severity: "high" },
|
|
17
|
-
{ pattern: /ghs_[a-zA-Z0-9]{36}/g, name: "GitHub Server Token", severity: "high" },
|
|
18
|
-
{ pattern: /ghr_[a-zA-Z0-9]{36}/g, name: "GitHub Refresh Token", severity: "high" },
|
|
19
|
-
{ pattern: /xox[baprs]-[a-zA-Z0-9-]+/g, name: "Slack Token", severity: "high" },
|
|
20
|
-
{ pattern: /eyJ[a-zA-Z0-9_-]*\.eyJ[a-zA-Z0-9_-]*\.[a-zA-Z0-9_-]*/g, name: "JWT Token", severity: "medium" },
|
|
21
|
-
{ pattern: /(password|passwd|pwd)\s*[=:]\s*['"][^'"]+['"]/gi, name: "Password", severity: "high" },
|
|
22
|
-
{ pattern: /(secret|api_key|apikey|access_key)\s*[=:]\s*['"][^'"]+['"]/gi, name: "API Key/Secret", severity: "high" },
|
|
23
|
-
{ pattern: /-----BEGIN (RSA |DSA |EC |OPENSSH )?PRIVATE KEY-----/g, name: "Private Key", severity: "critical" },
|
|
24
|
-
{ pattern: /(token|bearer)\s*[=:]\s*['"][^'"]+['"]/gi, name: "Token", severity: "medium" },
|
|
25
|
-
];
|
|
26
|
-
|
|
27
|
-
// 禁止的配置字段路径
|
|
28
|
-
const FORBIDDEN_CONFIG_PATHS = [
|
|
29
|
-
"channels",
|
|
30
|
-
"gateway",
|
|
31
|
-
"wizard",
|
|
32
|
-
"auth",
|
|
33
|
-
"credentials",
|
|
34
|
-
"secrets",
|
|
35
|
-
"tokens",
|
|
36
|
-
];
|
|
37
|
-
|
|
38
|
-
// 文件扩展名白名单(跳过二进制文件)
|
|
39
|
-
const TEXT_EXTENSIONS = new Set([
|
|
40
|
-
".md", ".txt", ".json", ".yaml", ".yml", ".xml", ".html", ".css", ".js", ".ts",
|
|
41
|
-
".py", ".sh", ".bash", ".zsh", ".env", ".conf", ".config", ".ini", ".toml",
|
|
42
|
-
]);
|
|
43
|
-
|
|
44
|
-
/**
|
|
45
|
-
* 检查文件是否为文本文件
|
|
46
|
-
*/
|
|
47
|
-
function isTextFile(filename) {
|
|
48
|
-
const ext = path.extname(filename).toLowerCase();
|
|
49
|
-
return TEXT_EXTENSIONS.has(ext) || !ext;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
/**
|
|
53
|
-
* 扫描文件内容中的敏感信息
|
|
54
|
-
*/
|
|
55
|
-
async function scanFileContent(filePath, content) {
|
|
56
|
-
const findings = [];
|
|
57
|
-
|
|
58
|
-
for (const { pattern, name, severity } of SENSITIVE_PATTERNS) {
|
|
59
|
-
const matches = content.match(pattern);
|
|
60
|
-
if (matches) {
|
|
61
|
-
findings.push({
|
|
62
|
-
file: filePath,
|
|
63
|
-
type: name,
|
|
64
|
-
severity,
|
|
65
|
-
count: matches.length,
|
|
66
|
-
// 不记录具体值,只记录发现
|
|
67
|
-
});
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
return findings;
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
/**
|
|
75
|
-
* 递归扫描目录
|
|
76
|
-
*/
|
|
77
|
-
async function scanDirectory(dirPath, basePath = "") {
|
|
78
|
-
const findings = [];
|
|
79
|
-
const entries = await readdir(dirPath, { withFileTypes: true });
|
|
80
|
-
|
|
81
|
-
for (const entry of entries) {
|
|
82
|
-
const relativePath = basePath ? `${basePath}/${entry.name}` : entry.name;
|
|
83
|
-
const fullPath = path.join(dirPath, entry.name);
|
|
84
|
-
|
|
85
|
-
if (entry.isDirectory()) {
|
|
86
|
-
// 跳过 node_modules 和隐藏目录
|
|
87
|
-
if (entry.name === "node_modules" || entry.name.startsWith(".")) {
|
|
88
|
-
continue;
|
|
89
|
-
}
|
|
90
|
-
const subFindings = await scanDirectory(fullPath, relativePath);
|
|
91
|
-
findings.push(...subFindings);
|
|
92
|
-
} else if (entry.isFile() && isTextFile(entry.name)) {
|
|
93
|
-
try {
|
|
94
|
-
const content = await readFile(fullPath, "utf8");
|
|
95
|
-
const fileFindings = await scanFileContent(relativePath, content);
|
|
96
|
-
findings.push(...fileFindings);
|
|
97
|
-
} catch {
|
|
98
|
-
// 跳过无法读取的文件
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
return findings;
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
/**
|
|
107
|
-
* 检查配置文件中的禁止字段
|
|
108
|
-
*/
|
|
109
|
-
function checkForbiddenFields(config, path = "") {
|
|
110
|
-
const violations = [];
|
|
111
|
-
|
|
112
|
-
for (const key of Object.keys(config)) {
|
|
113
|
-
const currentPath = path ? `${path}.${key}` : key;
|
|
114
|
-
|
|
115
|
-
// 检查是否是禁止的路径
|
|
116
|
-
for (const forbidden of FORBIDDEN_CONFIG_PATHS) {
|
|
117
|
-
if (currentPath.startsWith(forbidden) || key === forbidden) {
|
|
118
|
-
violations.push({
|
|
119
|
-
path: currentPath,
|
|
120
|
-
reason: `禁止包含 ${forbidden} 配置`,
|
|
121
|
-
});
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
// 递归检查嵌套对象
|
|
126
|
-
if (typeof config[key] === "object" && config[key] !== null) {
|
|
127
|
-
const nestedViolations = checkForbiddenFields(config[key], currentPath);
|
|
128
|
-
violations.push(...nestedViolations);
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
return violations;
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
/**
|
|
136
|
-
* 扫描 Bundle
|
|
137
|
-
*/
|
|
138
|
-
export async function scanBundle(bundleDir) {
|
|
139
|
-
const result = {
|
|
140
|
-
warnings: [],
|
|
141
|
-
errors: [],
|
|
142
|
-
findings: [],
|
|
143
|
-
canPublish: true,
|
|
144
|
-
};
|
|
145
|
-
|
|
146
|
-
// 1. 扫描敏感信息
|
|
147
|
-
const workspacePath = path.join(bundleDir, "WORKSPACE");
|
|
148
|
-
try {
|
|
149
|
-
const contentFindings = await scanDirectory(workspacePath, "WORKSPACE");
|
|
150
|
-
result.findings.push(...contentFindings);
|
|
151
|
-
|
|
152
|
-
for (const finding of contentFindings) {
|
|
153
|
-
const severityIcon = finding.severity === "critical" ? "🔴" : finding.severity === "high" ? "🟠" : "🟡";
|
|
154
|
-
result.warnings.push(`${severityIcon} ${finding.file}: 发现 ${finding.type} (${finding.count} 处)`);
|
|
155
|
-
|
|
156
|
-
if (finding.severity === "critical" || finding.severity === "high") {
|
|
157
|
-
result.canPublish = false;
|
|
158
|
-
}
|
|
159
|
-
}
|
|
160
|
-
} catch {
|
|
161
|
-
// WORKSPACE 目录不存在
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
// 2. 检查私有记忆
|
|
165
|
-
const manifestPath = path.join(bundleDir, "MANIFEST.json");
|
|
166
|
-
try {
|
|
167
|
-
const manifest = JSON.parse(await readFile(manifestPath, "utf8"));
|
|
168
|
-
|
|
169
|
-
if (manifest.includes?.memory?.private > 0) {
|
|
170
|
-
result.errors.push("检测到私有记忆 (memory/private),禁止公开发布");
|
|
171
|
-
result.canPublish = false;
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
// 3. 检查配置文件
|
|
175
|
-
const configPath = path.join(bundleDir, "OPENCLAW.template.json");
|
|
176
|
-
try {
|
|
177
|
-
const config = JSON.parse(await readFile(configPath, "utf8"));
|
|
178
|
-
const violations = checkForbiddenFields(config);
|
|
179
|
-
|
|
180
|
-
for (const violation of violations) {
|
|
181
|
-
result.warnings.push(`⚠️ OPENCLAW.template.json: ${violation.reason} (${violation.path})`);
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
if (violations.length > 0) {
|
|
185
|
-
result.canPublish = false;
|
|
186
|
-
}
|
|
187
|
-
} catch {
|
|
188
|
-
// 配置文件不存在
|
|
189
|
-
}
|
|
190
|
-
} catch {
|
|
191
|
-
// MANIFEST 不存在
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
return result;
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
/**
|
|
198
|
-
* 扫描工作区(打包前预扫描)
|
|
199
|
-
*/
|
|
200
|
-
export async function scanWorkspace(workspacePath) {
|
|
201
|
-
const result = {
|
|
202
|
-
warnings: [],
|
|
203
|
-
errors: [],
|
|
204
|
-
findings: [],
|
|
205
|
-
canPublish: true,
|
|
206
|
-
};
|
|
207
|
-
|
|
208
|
-
// 扫描敏感信息
|
|
209
|
-
const contentFindings = await scanDirectory(workspacePath);
|
|
210
|
-
result.findings.push(...contentFindings);
|
|
211
|
-
|
|
212
|
-
for (const finding of contentFindings) {
|
|
213
|
-
const severityIcon = finding.severity === "critical" ? "🔴" : finding.severity === "high" ? "🟠" : "🟡";
|
|
214
|
-
result.warnings.push(`${severityIcon} ${finding.file}: 发现 ${finding.type}`);
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
// 检查私有记忆
|
|
218
|
-
const privateMemoryPath = path.join(workspacePath, "memory", "private");
|
|
219
|
-
try {
|
|
220
|
-
const stat_ = await stat(privateMemoryPath);
|
|
221
|
-
if (stat_.isDirectory()) {
|
|
222
|
-
const files = await readdir(privateMemoryPath);
|
|
223
|
-
if (files.length > 0) {
|
|
224
|
-
result.warnings.push("🚫 检测到 memory/private/ 目录,包含私有记忆");
|
|
225
|
-
result.warnings.push(" 公开发布时将被阻止,建议将私有记忆移出工作区");
|
|
226
|
-
}
|
|
227
|
-
}
|
|
228
|
-
} catch {
|
|
229
|
-
// 目录不存在,正常
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
return result;
|
|
233
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Security Scanner
|
|
3
|
+
* 安全扫描模块
|
|
4
|
+
*
|
|
5
|
+
* 根据 PRD v1.1 安全规范实现
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { readdir, readFile, stat } from "node:fs/promises";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
|
|
11
|
+
// 敏感信息模式
|
|
12
|
+
const SENSITIVE_PATTERNS = [
|
|
13
|
+
{ pattern: /sk-[a-zA-Z0-9]{20,}/g, name: "OpenAI API Key", severity: "high" },
|
|
14
|
+
{ pattern: /ghp_[a-zA-Z0-9]{36}/g, name: "GitHub Personal Token", severity: "high" },
|
|
15
|
+
{ pattern: /gho_[a-zA-Z0-9]{36}/g, name: "GitHub OAuth Token", severity: "high" },
|
|
16
|
+
{ pattern: /ghu_[a-zA-Z0-9]{36}/g, name: "GitHub User Token", severity: "high" },
|
|
17
|
+
{ pattern: /ghs_[a-zA-Z0-9]{36}/g, name: "GitHub Server Token", severity: "high" },
|
|
18
|
+
{ pattern: /ghr_[a-zA-Z0-9]{36}/g, name: "GitHub Refresh Token", severity: "high" },
|
|
19
|
+
{ pattern: /xox[baprs]-[a-zA-Z0-9-]+/g, name: "Slack Token", severity: "high" },
|
|
20
|
+
{ pattern: /eyJ[a-zA-Z0-9_-]*\.eyJ[a-zA-Z0-9_-]*\.[a-zA-Z0-9_-]*/g, name: "JWT Token", severity: "medium" },
|
|
21
|
+
{ pattern: /(password|passwd|pwd)\s*[=:]\s*['"][^'"]+['"]/gi, name: "Password", severity: "high" },
|
|
22
|
+
{ pattern: /(secret|api_key|apikey|access_key)\s*[=:]\s*['"][^'"]+['"]/gi, name: "API Key/Secret", severity: "high" },
|
|
23
|
+
{ pattern: /-----BEGIN (RSA |DSA |EC |OPENSSH )?PRIVATE KEY-----/g, name: "Private Key", severity: "critical" },
|
|
24
|
+
{ pattern: /(token|bearer)\s*[=:]\s*['"][^'"]+['"]/gi, name: "Token", severity: "medium" },
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
// 禁止的配置字段路径
|
|
28
|
+
const FORBIDDEN_CONFIG_PATHS = [
|
|
29
|
+
"channels",
|
|
30
|
+
"gateway",
|
|
31
|
+
"wizard",
|
|
32
|
+
"auth",
|
|
33
|
+
"credentials",
|
|
34
|
+
"secrets",
|
|
35
|
+
"tokens",
|
|
36
|
+
];
|
|
37
|
+
|
|
38
|
+
// 文件扩展名白名单(跳过二进制文件)
|
|
39
|
+
const TEXT_EXTENSIONS = new Set([
|
|
40
|
+
".md", ".txt", ".json", ".yaml", ".yml", ".xml", ".html", ".css", ".js", ".ts",
|
|
41
|
+
".py", ".sh", ".bash", ".zsh", ".env", ".conf", ".config", ".ini", ".toml",
|
|
42
|
+
]);
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* 检查文件是否为文本文件
|
|
46
|
+
*/
|
|
47
|
+
function isTextFile(filename) {
|
|
48
|
+
const ext = path.extname(filename).toLowerCase();
|
|
49
|
+
return TEXT_EXTENSIONS.has(ext) || !ext;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* 扫描文件内容中的敏感信息
|
|
54
|
+
*/
|
|
55
|
+
async function scanFileContent(filePath, content) {
|
|
56
|
+
const findings = [];
|
|
57
|
+
|
|
58
|
+
for (const { pattern, name, severity } of SENSITIVE_PATTERNS) {
|
|
59
|
+
const matches = content.match(pattern);
|
|
60
|
+
if (matches) {
|
|
61
|
+
findings.push({
|
|
62
|
+
file: filePath,
|
|
63
|
+
type: name,
|
|
64
|
+
severity,
|
|
65
|
+
count: matches.length,
|
|
66
|
+
// 不记录具体值,只记录发现
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return findings;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* 递归扫描目录
|
|
76
|
+
*/
|
|
77
|
+
async function scanDirectory(dirPath, basePath = "") {
|
|
78
|
+
const findings = [];
|
|
79
|
+
const entries = await readdir(dirPath, { withFileTypes: true });
|
|
80
|
+
|
|
81
|
+
for (const entry of entries) {
|
|
82
|
+
const relativePath = basePath ? `${basePath}/${entry.name}` : entry.name;
|
|
83
|
+
const fullPath = path.join(dirPath, entry.name);
|
|
84
|
+
|
|
85
|
+
if (entry.isDirectory()) {
|
|
86
|
+
// 跳过 node_modules 和隐藏目录
|
|
87
|
+
if (entry.name === "node_modules" || entry.name.startsWith(".")) {
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
const subFindings = await scanDirectory(fullPath, relativePath);
|
|
91
|
+
findings.push(...subFindings);
|
|
92
|
+
} else if (entry.isFile() && isTextFile(entry.name)) {
|
|
93
|
+
try {
|
|
94
|
+
const content = await readFile(fullPath, "utf8");
|
|
95
|
+
const fileFindings = await scanFileContent(relativePath, content);
|
|
96
|
+
findings.push(...fileFindings);
|
|
97
|
+
} catch {
|
|
98
|
+
// 跳过无法读取的文件
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return findings;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* 检查配置文件中的禁止字段
|
|
108
|
+
*/
|
|
109
|
+
function checkForbiddenFields(config, path = "") {
|
|
110
|
+
const violations = [];
|
|
111
|
+
|
|
112
|
+
for (const key of Object.keys(config)) {
|
|
113
|
+
const currentPath = path ? `${path}.${key}` : key;
|
|
114
|
+
|
|
115
|
+
// 检查是否是禁止的路径
|
|
116
|
+
for (const forbidden of FORBIDDEN_CONFIG_PATHS) {
|
|
117
|
+
if (currentPath.startsWith(forbidden) || key === forbidden) {
|
|
118
|
+
violations.push({
|
|
119
|
+
path: currentPath,
|
|
120
|
+
reason: `禁止包含 ${forbidden} 配置`,
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// 递归检查嵌套对象
|
|
126
|
+
if (typeof config[key] === "object" && config[key] !== null) {
|
|
127
|
+
const nestedViolations = checkForbiddenFields(config[key], currentPath);
|
|
128
|
+
violations.push(...nestedViolations);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return violations;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* 扫描 Bundle
|
|
137
|
+
*/
|
|
138
|
+
export async function scanBundle(bundleDir) {
|
|
139
|
+
const result = {
|
|
140
|
+
warnings: [],
|
|
141
|
+
errors: [],
|
|
142
|
+
findings: [],
|
|
143
|
+
canPublish: true,
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
// 1. 扫描敏感信息
|
|
147
|
+
const workspacePath = path.join(bundleDir, "WORKSPACE");
|
|
148
|
+
try {
|
|
149
|
+
const contentFindings = await scanDirectory(workspacePath, "WORKSPACE");
|
|
150
|
+
result.findings.push(...contentFindings);
|
|
151
|
+
|
|
152
|
+
for (const finding of contentFindings) {
|
|
153
|
+
const severityIcon = finding.severity === "critical" ? "🔴" : finding.severity === "high" ? "🟠" : "🟡";
|
|
154
|
+
result.warnings.push(`${severityIcon} ${finding.file}: 发现 ${finding.type} (${finding.count} 处)`);
|
|
155
|
+
|
|
156
|
+
if (finding.severity === "critical" || finding.severity === "high") {
|
|
157
|
+
result.canPublish = false;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
} catch {
|
|
161
|
+
// WORKSPACE 目录不存在
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// 2. 检查私有记忆
|
|
165
|
+
const manifestPath = path.join(bundleDir, "MANIFEST.json");
|
|
166
|
+
try {
|
|
167
|
+
const manifest = JSON.parse(await readFile(manifestPath, "utf8"));
|
|
168
|
+
|
|
169
|
+
if (manifest.includes?.memory?.private > 0) {
|
|
170
|
+
result.errors.push("检测到私有记忆 (memory/private),禁止公开发布");
|
|
171
|
+
result.canPublish = false;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// 3. 检查配置文件
|
|
175
|
+
const configPath = path.join(bundleDir, "OPENCLAW.template.json");
|
|
176
|
+
try {
|
|
177
|
+
const config = JSON.parse(await readFile(configPath, "utf8"));
|
|
178
|
+
const violations = checkForbiddenFields(config);
|
|
179
|
+
|
|
180
|
+
for (const violation of violations) {
|
|
181
|
+
result.warnings.push(`⚠️ OPENCLAW.template.json: ${violation.reason} (${violation.path})`);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (violations.length > 0) {
|
|
185
|
+
result.canPublish = false;
|
|
186
|
+
}
|
|
187
|
+
} catch {
|
|
188
|
+
// 配置文件不存在
|
|
189
|
+
}
|
|
190
|
+
} catch {
|
|
191
|
+
// MANIFEST 不存在
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return result;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* 扫描工作区(打包前预扫描)
|
|
199
|
+
*/
|
|
200
|
+
export async function scanWorkspace(workspacePath) {
|
|
201
|
+
const result = {
|
|
202
|
+
warnings: [],
|
|
203
|
+
errors: [],
|
|
204
|
+
findings: [],
|
|
205
|
+
canPublish: true,
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
// 扫描敏感信息
|
|
209
|
+
const contentFindings = await scanDirectory(workspacePath);
|
|
210
|
+
result.findings.push(...contentFindings);
|
|
211
|
+
|
|
212
|
+
for (const finding of contentFindings) {
|
|
213
|
+
const severityIcon = finding.severity === "critical" ? "🔴" : finding.severity === "high" ? "🟠" : "🟡";
|
|
214
|
+
result.warnings.push(`${severityIcon} ${finding.file}: 发现 ${finding.type}`);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// 检查私有记忆
|
|
218
|
+
const privateMemoryPath = path.join(workspacePath, "memory", "private");
|
|
219
|
+
try {
|
|
220
|
+
const stat_ = await stat(privateMemoryPath);
|
|
221
|
+
if (stat_.isDirectory()) {
|
|
222
|
+
const files = await readdir(privateMemoryPath);
|
|
223
|
+
if (files.length > 0) {
|
|
224
|
+
result.warnings.push("🚫 检测到 memory/private/ 目录,包含私有记忆");
|
|
225
|
+
result.warnings.push(" 公开发布时将被阻止,建议将私有记忆移出工作区");
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
} catch {
|
|
229
|
+
// 目录不存在,正常
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
return result;
|
|
233
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Signing Module
|
|
3
|
+
* Bundle 签名与完整性校验 — AgentHub 信任链核心
|
|
4
|
+
*
|
|
5
|
+
* 职责:
|
|
6
|
+
* 1. 为 Bundle 生成 SHA-256 校验和
|
|
7
|
+
* 2. 使用 HMAC 签名
|
|
8
|
+
* 3. 安装前验证签名完整性
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { createHash, createHmac } from "node:crypto";
|
|
12
|
+
import { readFile, readdir, stat } from "node:fs/promises";
|
|
13
|
+
import path from "node:path";
|
|
14
|
+
import { pathExists, writeJson, readJson } from "./fs-utils.js";
|
|
15
|
+
|
|
16
|
+
const SIGNATURE_FILE = "SIGNATURE.json";
|
|
17
|
+
const HASH_ALGORITHM = "sha256";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* 计算文件的 SHA-256 哈希
|
|
21
|
+
*/
|
|
22
|
+
async function hashFile(filePath) {
|
|
23
|
+
const content = await readFile(filePath);
|
|
24
|
+
return createHash(HASH_ALGORITHM).update(content).digest("hex");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* 递归收集目录中所有文件的哈希
|
|
29
|
+
*/
|
|
30
|
+
async function collectChecksums(dirPath, basePath = "") {
|
|
31
|
+
const checksums = {};
|
|
32
|
+
const entries = await readdir(dirPath, { withFileTypes: true });
|
|
33
|
+
|
|
34
|
+
for (const entry of entries) {
|
|
35
|
+
const fullPath = path.join(dirPath, entry.name);
|
|
36
|
+
const relativePath = basePath ? `${basePath}/${entry.name}` : entry.name;
|
|
37
|
+
|
|
38
|
+
// 跳过签名文件本身
|
|
39
|
+
if (!basePath && entry.name === SIGNATURE_FILE) continue;
|
|
40
|
+
|
|
41
|
+
if (entry.isDirectory()) {
|
|
42
|
+
const subChecksums = await collectChecksums(fullPath, relativePath);
|
|
43
|
+
Object.assign(checksums, subChecksums);
|
|
44
|
+
} else if (entry.isFile()) {
|
|
45
|
+
checksums[relativePath] = `${HASH_ALGORITHM}:${await hashFile(fullPath)}`;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return checksums;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* 为 Bundle 生成签名
|
|
54
|
+
*
|
|
55
|
+
* @param {string} bundleDir - Bundle 目录路径
|
|
56
|
+
* @param {object} options
|
|
57
|
+
* @param {string} options.signer - 签名者标识(用户名或邮箱)
|
|
58
|
+
* @param {string} [options.secret] - HMAC 密钥
|
|
59
|
+
* @returns {Promise<object>} 签名信息
|
|
60
|
+
*/
|
|
61
|
+
export async function signBundle(bundleDir, options = {}) {
|
|
62
|
+
const { signer = "anonymous", secret = "agenthub-default-secret" } = options;
|
|
63
|
+
|
|
64
|
+
// 1. 收集所有文件的校验和
|
|
65
|
+
const checksums = await collectChecksums(bundleDir);
|
|
66
|
+
|
|
67
|
+
// 2. 将校验和排序后计算总哈希(确保顺序一致性)
|
|
68
|
+
const sortedKeys = Object.keys(checksums).sort();
|
|
69
|
+
const checksumString = sortedKeys.map((k) => `${k}:${checksums[k]}`).join("\n");
|
|
70
|
+
const contentHash = createHash(HASH_ALGORITHM).update(checksumString).digest("hex");
|
|
71
|
+
|
|
72
|
+
// 3. 使用 HMAC 签名
|
|
73
|
+
const signature = createHmac(HASH_ALGORITHM, secret).update(contentHash).digest("hex");
|
|
74
|
+
|
|
75
|
+
const signatureData = {
|
|
76
|
+
algorithm: HASH_ALGORITHM,
|
|
77
|
+
signer,
|
|
78
|
+
timestamp: new Date().toISOString(),
|
|
79
|
+
contentHash,
|
|
80
|
+
signature: `hmac-${HASH_ALGORITHM}:${signature}`,
|
|
81
|
+
checksums,
|
|
82
|
+
fileCount: sortedKeys.length,
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
// 4. 写入签名文件
|
|
86
|
+
await writeJson(path.join(bundleDir, SIGNATURE_FILE), signatureData);
|
|
87
|
+
|
|
88
|
+
return signatureData;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* 验证 Bundle 签名
|
|
93
|
+
*
|
|
94
|
+
* @param {string} bundleDir - Bundle 目录路径
|
|
95
|
+
* @param {object} [options]
|
|
96
|
+
* @param {string} [options.secret] - HMAC 密钥
|
|
97
|
+
* @returns {Promise<{valid: boolean, reason?: string, details?: object}>}
|
|
98
|
+
*/
|
|
99
|
+
export async function verifyBundleSignature(bundleDir, options = {}) {
|
|
100
|
+
const { secret = "agenthub-default-secret" } = options;
|
|
101
|
+
const sigPath = path.join(bundleDir, SIGNATURE_FILE);
|
|
102
|
+
|
|
103
|
+
// 1. 检查签名文件是否存在
|
|
104
|
+
if (!(await pathExists(sigPath))) {
|
|
105
|
+
return { valid: false, reason: "No signature file found", unsigned: true };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const signatureData = await readJson(sigPath);
|
|
109
|
+
|
|
110
|
+
// 2. 重新收集校验和
|
|
111
|
+
const currentChecksums = await collectChecksums(bundleDir);
|
|
112
|
+
|
|
113
|
+
// 3. 比对文件列表
|
|
114
|
+
const signedFiles = Object.keys(signatureData.checksums).sort();
|
|
115
|
+
const currentFiles = Object.keys(currentChecksums).sort();
|
|
116
|
+
|
|
117
|
+
const addedFiles = currentFiles.filter((f) => !signedFiles.includes(f));
|
|
118
|
+
const removedFiles = signedFiles.filter((f) => !currentFiles.includes(f));
|
|
119
|
+
const modifiedFiles = signedFiles.filter(
|
|
120
|
+
(f) => currentChecksums[f] && currentChecksums[f] !== signatureData.checksums[f]
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
if (addedFiles.length > 0 || removedFiles.length > 0 || modifiedFiles.length > 0) {
|
|
124
|
+
return {
|
|
125
|
+
valid: false,
|
|
126
|
+
reason: "Bundle content has been tampered with",
|
|
127
|
+
details: {
|
|
128
|
+
addedFiles,
|
|
129
|
+
removedFiles,
|
|
130
|
+
modifiedFiles,
|
|
131
|
+
},
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// 4. 验证 HMAC 签名
|
|
136
|
+
const sortedKeys = Object.keys(currentChecksums).sort();
|
|
137
|
+
const checksumString = sortedKeys.map((k) => `${k}:${currentChecksums[k]}`).join("\n");
|
|
138
|
+
const contentHash = createHash(HASH_ALGORITHM).update(checksumString).digest("hex");
|
|
139
|
+
const expectedSig = `hmac-${HASH_ALGORITHM}:${createHmac(HASH_ALGORITHM, secret).update(contentHash).digest("hex")}`;
|
|
140
|
+
|
|
141
|
+
if (expectedSig !== signatureData.signature) {
|
|
142
|
+
return { valid: false, reason: "Signature verification failed" };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return {
|
|
146
|
+
valid: true,
|
|
147
|
+
signer: signatureData.signer,
|
|
148
|
+
timestamp: signatureData.timestamp,
|
|
149
|
+
fileCount: signatureData.fileCount,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* 快速检查 Bundle 是否已签名
|
|
155
|
+
*/
|
|
156
|
+
export async function isSigned(bundleDir) {
|
|
157
|
+
return pathExists(path.join(bundleDir, SIGNATURE_FILE));
|
|
158
|
+
}
|