clawhatch 0.1.0

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 (115) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +348 -0
  3. package/dist/checks/cloud-sync.d.ts +10 -0
  4. package/dist/checks/cloud-sync.d.ts.map +1 -0
  5. package/dist/checks/cloud-sync.js +62 -0
  6. package/dist/checks/cloud-sync.js.map +1 -0
  7. package/dist/checks/data-protection.d.ts +9 -0
  8. package/dist/checks/data-protection.d.ts.map +1 -0
  9. package/dist/checks/data-protection.js +197 -0
  10. package/dist/checks/data-protection.js.map +1 -0
  11. package/dist/checks/identity.d.ts +14 -0
  12. package/dist/checks/identity.d.ts.map +1 -0
  13. package/dist/checks/identity.js +327 -0
  14. package/dist/checks/identity.js.map +1 -0
  15. package/dist/checks/model.d.ts +10 -0
  16. package/dist/checks/model.d.ts.map +1 -0
  17. package/dist/checks/model.js +337 -0
  18. package/dist/checks/model.js.map +1 -0
  19. package/dist/checks/network.d.ts +9 -0
  20. package/dist/checks/network.d.ts.map +1 -0
  21. package/dist/checks/network.js +177 -0
  22. package/dist/checks/network.js.map +1 -0
  23. package/dist/checks/operational.d.ts +9 -0
  24. package/dist/checks/operational.d.ts.map +1 -0
  25. package/dist/checks/operational.js +158 -0
  26. package/dist/checks/operational.js.map +1 -0
  27. package/dist/checks/sandbox.d.ts +9 -0
  28. package/dist/checks/sandbox.d.ts.map +1 -0
  29. package/dist/checks/sandbox.js +135 -0
  30. package/dist/checks/sandbox.js.map +1 -0
  31. package/dist/checks/secrets.d.ts +9 -0
  32. package/dist/checks/secrets.d.ts.map +1 -0
  33. package/dist/checks/secrets.js +816 -0
  34. package/dist/checks/secrets.js.map +1 -0
  35. package/dist/checks/skills.d.ts +9 -0
  36. package/dist/checks/skills.d.ts.map +1 -0
  37. package/dist/checks/skills.js +303 -0
  38. package/dist/checks/skills.js.map +1 -0
  39. package/dist/checks/tools.d.ts +9 -0
  40. package/dist/checks/tools.d.ts.map +1 -0
  41. package/dist/checks/tools.js +397 -0
  42. package/dist/checks/tools.js.map +1 -0
  43. package/dist/discover.d.ts +22 -0
  44. package/dist/discover.d.ts.map +1 -0
  45. package/dist/discover.js +281 -0
  46. package/dist/discover.js.map +1 -0
  47. package/dist/fixer.d.ts +16 -0
  48. package/dist/fixer.d.ts.map +1 -0
  49. package/dist/fixer.js +361 -0
  50. package/dist/fixer.js.map +1 -0
  51. package/dist/index.d.ts +16 -0
  52. package/dist/index.d.ts.map +1 -0
  53. package/dist/index.js +230 -0
  54. package/dist/index.js.map +1 -0
  55. package/dist/init.d.ts +14 -0
  56. package/dist/init.d.ts.map +1 -0
  57. package/dist/init.js +108 -0
  58. package/dist/init.js.map +1 -0
  59. package/dist/notify.d.ts +28 -0
  60. package/dist/notify.d.ts.map +1 -0
  61. package/dist/notify.js +217 -0
  62. package/dist/notify.js.map +1 -0
  63. package/dist/parsers/config.d.ts +16 -0
  64. package/dist/parsers/config.d.ts.map +1 -0
  65. package/dist/parsers/config.js +54 -0
  66. package/dist/parsers/config.js.map +1 -0
  67. package/dist/parsers/env.d.ts +6 -0
  68. package/dist/parsers/env.d.ts.map +1 -0
  69. package/dist/parsers/env.js +35 -0
  70. package/dist/parsers/env.js.map +1 -0
  71. package/dist/parsers/jsonl.d.ts +12 -0
  72. package/dist/parsers/jsonl.d.ts.map +1 -0
  73. package/dist/parsers/jsonl.js +61 -0
  74. package/dist/parsers/jsonl.js.map +1 -0
  75. package/dist/parsers/markdown.d.ts +17 -0
  76. package/dist/parsers/markdown.d.ts.map +1 -0
  77. package/dist/parsers/markdown.js +57 -0
  78. package/dist/parsers/markdown.js.map +1 -0
  79. package/dist/reporter-html.d.ts +9 -0
  80. package/dist/reporter-html.d.ts.map +1 -0
  81. package/dist/reporter-html.js +581 -0
  82. package/dist/reporter-html.js.map +1 -0
  83. package/dist/reporter.d.ts +10 -0
  84. package/dist/reporter.d.ts.map +1 -0
  85. package/dist/reporter.js +133 -0
  86. package/dist/reporter.js.map +1 -0
  87. package/dist/sanitize.d.ts +17 -0
  88. package/dist/sanitize.d.ts.map +1 -0
  89. package/dist/sanitize.js +83 -0
  90. package/dist/sanitize.js.map +1 -0
  91. package/dist/scanner.d.ts +18 -0
  92. package/dist/scanner.d.ts.map +1 -0
  93. package/dist/scanner.js +236 -0
  94. package/dist/scanner.js.map +1 -0
  95. package/dist/scoring.d.ts +17 -0
  96. package/dist/scoring.d.ts.map +1 -0
  97. package/dist/scoring.js +47 -0
  98. package/dist/scoring.js.map +1 -0
  99. package/dist/telemetry.d.ts +16 -0
  100. package/dist/telemetry.d.ts.map +1 -0
  101. package/dist/telemetry.js +52 -0
  102. package/dist/telemetry.js.map +1 -0
  103. package/dist/threat-feed.d.ts +14 -0
  104. package/dist/threat-feed.d.ts.map +1 -0
  105. package/dist/threat-feed.js +133 -0
  106. package/dist/threat-feed.js.map +1 -0
  107. package/dist/types.d.ts +221 -0
  108. package/dist/types.d.ts.map +1 -0
  109. package/dist/types.js +11 -0
  110. package/dist/types.js.map +1 -0
  111. package/dist/utils.d.ts +12 -0
  112. package/dist/utils.d.ts.map +1 -0
  113. package/dist/utils.js +34 -0
  114. package/dist/utils.js.map +1 -0
  115. package/package.json +71 -0
@@ -0,0 +1,816 @@
1
+ /**
2
+ * Secret Scanning checks (34-43).
3
+ *
4
+ * Checks for hardcoded API keys, .env handling, file permissions,
5
+ * secrets in markdown files, and session log leakage.
6
+ */
7
+ import { Severity } from "../types.js";
8
+ import { stat, access, constants, readFile } from "node:fs/promises";
9
+ import { platform } from "node:os";
10
+ import { join, basename } from "node:path";
11
+ import { scanMarkdown } from "../parsers/markdown.js";
12
+ import { parseJsonl } from "../parsers/jsonl.js";
13
+ import { readFileCapped } from "../utils.js";
14
+ /** Patterns that suggest an API key value (not a ${VAR} reference) */
15
+ const API_KEY_PATTERNS = [
16
+ /sk-[a-zA-Z0-9]{32,}/,
17
+ /sk-ant-[a-zA-Z0-9\-]{32,}/,
18
+ /AIza[a-zA-Z0-9_\-]{35}/,
19
+ /AKIA[A-Z0-9]{16}/,
20
+ /(?:ghp|gho|ghu|ghs|ghr)_[a-zA-Z0-9]{36,}/,
21
+ /(?:sk|pk)_(?:live|test)_[a-zA-Z0-9]{20,}/,
22
+ /xox[bpras]-[a-zA-Z0-9\-]{10,}/,
23
+ ];
24
+ export async function runSecretChecks(config, configRaw, files, deep) {
25
+ const findings = [];
26
+ // Check 34: No API keys in openclaw.json (use ${VAR} substitution)
27
+ if (configRaw) {
28
+ let totalKeyCount = 0;
29
+ for (const pattern of API_KEY_PATTERNS) {
30
+ // Use matchAll to count every occurrence, not just the first
31
+ const matches = [...configRaw.matchAll(new RegExp(pattern, "g"))];
32
+ totalKeyCount += matches.length;
33
+ }
34
+ if (totalKeyCount > 0) {
35
+ findings.push({
36
+ id: "SECRET-001",
37
+ severity: Severity.Critical,
38
+ confidence: "high",
39
+ category: "Secret Scanning",
40
+ title: "API key(s) found in openclaw.json",
41
+ description: `${totalKeyCount} hardcoded API key(s) detected — move all to .env`,
42
+ risk: "Keys will be exposed if config is shared, committed, or backed up",
43
+ remediation: "Move keys to .env file and use ${VAR_NAME} substitution in config",
44
+ autoFixable: false,
45
+ file: files.configPath ?? undefined,
46
+ });
47
+ }
48
+ }
49
+ // Check 35: .env files exist and not in git (.gitignore check)
50
+ if (files.envPath) {
51
+ // Check if .gitignore exists and includes .env
52
+ const gitignorePath = join(files.openclawDir, ".gitignore");
53
+ try {
54
+ const gitignore = await readFile(gitignorePath, "utf-8");
55
+ if (!gitignore.includes(".env")) {
56
+ findings.push({
57
+ id: "SECRET-002",
58
+ severity: Severity.High,
59
+ confidence: "high",
60
+ category: "Secret Scanning",
61
+ title: ".env not in .gitignore",
62
+ description: ".env file exists but is not listed in .gitignore",
63
+ risk: "Secrets in .env could be accidentally committed to git",
64
+ remediation: "Add .env to .gitignore",
65
+ autoFixable: true,
66
+ fixType: "safe",
67
+ file: gitignorePath,
68
+ });
69
+ }
70
+ }
71
+ catch {
72
+ // No .gitignore — flag it
73
+ findings.push({
74
+ id: "SECRET-002",
75
+ severity: Severity.High,
76
+ confidence: "high",
77
+ category: "Secret Scanning",
78
+ title: "No .gitignore found",
79
+ description: "No .gitignore file in OpenClaw directory — .env and credentials may be committed",
80
+ risk: "Secrets could be accidentally committed to git",
81
+ remediation: "Create a .gitignore with: .env, credentials/, *.key",
82
+ autoFixable: true,
83
+ fixType: "safe",
84
+ });
85
+ }
86
+ }
87
+ // Checks 36-39: File permissions (Unix: chmod, Windows: icacls)
88
+ if (platform() === "win32") {
89
+ // FIX: Actually check Windows ACLs using icacls
90
+ try {
91
+ const { execFile: ef } = await import("node:child_process");
92
+ const { promisify: p } = await import("node:util");
93
+ const execAsync = p(ef);
94
+ const { stdout } = await execAsync("icacls", [files.openclawDir], { timeout: 5000, windowsHide: true });
95
+ // Check for overly permissive ACLs (Everyone, Users, or BUILTIN\Users with access)
96
+ const dangerousGroups = /\b(Everyone|Users|BUILTIN\\Users|Authenticated Users)\s*:\s*\((?!N\))/i;
97
+ if (dangerousGroups.test(stdout)) {
98
+ findings.push({
99
+ id: "SECRET-003",
100
+ severity: Severity.High,
101
+ confidence: "high",
102
+ category: "Secret Scanning",
103
+ title: "OpenClaw directory has permissive Windows ACLs",
104
+ description: "~/.openclaw/ is accessible by other users on this system (Everyone/Users group has access)",
105
+ risk: "Other users on this system can read your OpenClaw configuration and secrets",
106
+ remediation: "Run: icacls \"%USERPROFILE%\\.openclaw\" /inheritance:r /grant:r \"%USERNAME%:F\"",
107
+ autoFixable: false,
108
+ file: files.openclawDir,
109
+ });
110
+ }
111
+ }
112
+ catch {
113
+ // icacls failed or not available — fall back to informational message
114
+ findings.push({
115
+ id: "SECRET-003",
116
+ severity: Severity.Low,
117
+ confidence: "medium",
118
+ category: "Secret Scanning",
119
+ title: "Windows ACL check inconclusive",
120
+ description: "Could not verify Windows file permissions (icacls unavailable or failed)",
121
+ risk: "Windows ACLs should be reviewed manually to restrict access to OpenClaw files",
122
+ remediation: "Verify that only your user account has access to ~/.openclaw/ via Windows Security settings",
123
+ autoFixable: false,
124
+ });
125
+ }
126
+ }
127
+ else {
128
+ // Check 36: ~/.openclaw/ directory permissions = 700
129
+ try {
130
+ const s = await stat(files.openclawDir);
131
+ const mode = s.mode & 0o777;
132
+ if (mode !== 0o700) {
133
+ findings.push({
134
+ id: "SECRET-003",
135
+ severity: Severity.High,
136
+ confidence: "high",
137
+ category: "Secret Scanning",
138
+ title: "OpenClaw directory has loose permissions",
139
+ description: `~/.openclaw/ has permissions ${mode.toString(8)} (should be 700)`,
140
+ risk: "Other users on this system can read your OpenClaw configuration and secrets",
141
+ remediation: 'Run: chmod 700 ~/.openclaw/',
142
+ autoFixable: true,
143
+ fixType: "safe",
144
+ file: files.openclawDir,
145
+ });
146
+ }
147
+ }
148
+ catch {
149
+ // Can't stat
150
+ }
151
+ // Check 37: openclaw.json permissions = 600
152
+ if (files.configPath) {
153
+ try {
154
+ const s = await stat(files.configPath);
155
+ const mode = s.mode & 0o777;
156
+ if (mode !== 0o600) {
157
+ findings.push({
158
+ id: "SECRET-004",
159
+ severity: Severity.High,
160
+ confidence: "high",
161
+ category: "Secret Scanning",
162
+ title: "Config file has loose permissions",
163
+ description: `openclaw.json has permissions ${mode.toString(8)} (should be 600)`,
164
+ risk: "Other users can read your agent configuration",
165
+ remediation: 'Run: chmod 600 ~/.openclaw/openclaw.json',
166
+ autoFixable: true,
167
+ fixType: "safe",
168
+ file: files.configPath,
169
+ });
170
+ }
171
+ }
172
+ catch {
173
+ // Can't stat
174
+ }
175
+ }
176
+ // Check 38: credentials/*.json permissions = 600
177
+ for (const credFile of files.credentialFiles) {
178
+ try {
179
+ const s = await stat(credFile);
180
+ const mode = s.mode & 0o777;
181
+ if (mode !== 0o600) {
182
+ findings.push({
183
+ id: "SECRET-005",
184
+ severity: Severity.High,
185
+ confidence: "high",
186
+ category: "Secret Scanning",
187
+ title: `Credential file has loose permissions`,
188
+ description: `${basename(credFile)} has permissions ${mode.toString(8)} (should be 600)`,
189
+ risk: "Other users can read your credentials",
190
+ remediation: `Run: chmod 600 "${credFile}"`,
191
+ autoFixable: true,
192
+ fixType: "safe",
193
+ file: credFile,
194
+ });
195
+ }
196
+ }
197
+ catch {
198
+ // Can't stat
199
+ }
200
+ }
201
+ // Check 39: auth-profiles.json permissions = 600
202
+ for (const authFile of files.authProfileFiles) {
203
+ try {
204
+ const s = await stat(authFile);
205
+ const mode = s.mode & 0o777;
206
+ if (mode !== 0o600) {
207
+ findings.push({
208
+ id: "SECRET-006",
209
+ severity: Severity.High,
210
+ confidence: "high",
211
+ category: "Secret Scanning",
212
+ title: "Auth profile has loose permissions",
213
+ description: `${basename(authFile)} has permissions ${mode.toString(8)} (should be 600)`,
214
+ risk: "Other users can read your API keys",
215
+ remediation: `Run: chmod 600 "${authFile}"`,
216
+ autoFixable: true,
217
+ fixType: "safe",
218
+ file: authFile,
219
+ });
220
+ }
221
+ }
222
+ catch {
223
+ // Can't stat
224
+ }
225
+ }
226
+ }
227
+ // Checks 40-42: Secrets in markdown files
228
+ const mdFilesToScan = [];
229
+ for (const mdFile of files.workspaceMarkdownFiles) {
230
+ const name = basename(mdFile).toUpperCase();
231
+ let checkId = "SECRET-010";
232
+ if (name === "SOUL.MD")
233
+ checkId = "SECRET-007";
234
+ else if (name === "AGENTS.MD")
235
+ checkId = "SECRET-008";
236
+ else if (name === "TOOLS.MD")
237
+ checkId = "SECRET-009";
238
+ mdFilesToScan.push({ path: mdFile, name, checkId });
239
+ }
240
+ for (const { path, name, checkId } of mdFilesToScan) {
241
+ try {
242
+ const result = await scanMarkdown(path);
243
+ if (result.secretMatches.length > 0) {
244
+ const firstMatch = result.secretMatches[0];
245
+ findings.push({
246
+ id: checkId,
247
+ severity: name === "TOOLS.MD" ? Severity.Critical : Severity.High,
248
+ confidence: "high",
249
+ category: "Secret Scanning",
250
+ title: `Secret found in ${name}`,
251
+ description: `${result.secretMatches.length} potential secret(s) detected — first: ${firstMatch.pattern} at line ${firstMatch.line}`,
252
+ risk: `Secrets in ${name} may be exposed via git, cloud sync, or agent output`,
253
+ remediation: `Move secrets from ${name} to .env file and use environment variable references`,
254
+ autoFixable: false,
255
+ file: path,
256
+ line: firstMatch.line,
257
+ });
258
+ }
259
+ }
260
+ catch {
261
+ // Can't read file
262
+ }
263
+ }
264
+ // Check 43: Session logs don't contain leaked keys (sample scan)
265
+ for (const logFile of files.sessionLogFiles.slice(0, 5)) {
266
+ // Only scan first 5 log files
267
+ try {
268
+ const result = await parseJsonl(logFile, deep);
269
+ if (result.truncated) {
270
+ const sizeMB = (result.totalSizeBytes / (1024 * 1024)).toFixed(1);
271
+ findings.push({
272
+ id: "SECRET-011",
273
+ severity: Severity.Low,
274
+ confidence: "medium",
275
+ category: "Secret Scanning",
276
+ title: `Large session log (${sizeMB}MB) — sampled`,
277
+ description: `${basename(logFile)} is ${sizeMB}MB — only first 1MB was scanned`,
278
+ risk: "Secrets in later portions of the log may be missed",
279
+ remediation: "Run with --deep for full session log scanning",
280
+ autoFixable: false,
281
+ file: logFile,
282
+ });
283
+ }
284
+ // Scan entries for API key patterns
285
+ for (const entry of result.entries) {
286
+ const content = entry.content || "";
287
+ for (const pattern of API_KEY_PATTERNS) {
288
+ if (pattern.test(content)) {
289
+ findings.push({
290
+ id: "SECRET-012",
291
+ severity: Severity.High,
292
+ confidence: "high",
293
+ category: "Secret Scanning",
294
+ title: "API key leaked in session log",
295
+ description: `Potential API key found in session log ${basename(logFile)}`,
296
+ risk: "Session logs with leaked keys may be backed up or synced to cloud",
297
+ remediation: "Rotate the exposed key immediately and clear session logs",
298
+ autoFixable: false,
299
+ file: logFile,
300
+ });
301
+ break; // One finding per file
302
+ }
303
+ }
304
+ }
305
+ }
306
+ catch {
307
+ // Can't read file
308
+ }
309
+ }
310
+ // SECRET-013: Private keys in workspace
311
+ if (files.privateKeyFiles.length > 0) {
312
+ findings.push({
313
+ id: "SECRET-013",
314
+ severity: Severity.High,
315
+ confidence: "high",
316
+ category: "Secret Scanning",
317
+ title: "Private key files in workspace",
318
+ description: `${files.privateKeyFiles.length} private key file(s) found: ${files.privateKeyFiles.slice(0, 3).map((f) => basename(f)).join(", ")}`,
319
+ risk: "Private keys in the workspace can be read by agents, committed to git, or synced to cloud",
320
+ remediation: "Move private keys to a secure location outside the workspace (e.g., ~/.ssh/)",
321
+ autoFixable: false,
322
+ file: files.privateKeyFiles[0],
323
+ });
324
+ }
325
+ // SECRET-014: Certificates in workspace
326
+ if (files.workspaceDir) {
327
+ const certExtensions = [".cer", ".crt"];
328
+ const certFiles = files.privateKeyFiles.filter((f) => certExtensions.some((ext) => f.toLowerCase().endsWith(ext)));
329
+ // Also check for .cer/.crt alongside .pem/.key files
330
+ if (files.privateKeyFiles.length > 0 || certFiles.length > 0) {
331
+ // Only flag if there's a .git directory (suggesting they could be committed)
332
+ const gitDir = join(files.workspaceDir, ".git");
333
+ try {
334
+ await access(gitDir, constants.R_OK);
335
+ findings.push({
336
+ id: "SECRET-014",
337
+ severity: Severity.Medium,
338
+ confidence: "medium",
339
+ category: "Secret Scanning",
340
+ title: "Certificate/key files may be committed to git",
341
+ description: "Private key or certificate files exist in a git-tracked workspace",
342
+ risk: "Certificates and private keys committed to git are exposed in repository history",
343
+ remediation: "Add *.pem, *.key, *.p12, *.cer, *.crt to .gitignore",
344
+ autoFixable: false,
345
+ });
346
+ }
347
+ catch {
348
+ // No .git
349
+ }
350
+ }
351
+ }
352
+ // SECRET-015: Database URLs in config
353
+ if (configRaw) {
354
+ const dbUrlPatterns = [
355
+ /postgres(?:ql)?:\/\/[^\s"']+/i,
356
+ /mysql:\/\/[^\s"']+/i,
357
+ /mongodb(\+srv)?:\/\/[^\s"']+/i,
358
+ /redis:\/\/[^\s"']+/i,
359
+ ];
360
+ for (const pattern of dbUrlPatterns) {
361
+ if (pattern.test(configRaw)) {
362
+ findings.push({
363
+ id: "SECRET-015",
364
+ severity: Severity.High,
365
+ confidence: "high",
366
+ category: "Secret Scanning",
367
+ title: "Database connection string in config",
368
+ description: "Database URL found in openclaw.json — may contain credentials",
369
+ risk: "Database URLs often include username/password in the connection string",
370
+ remediation: "Move database URLs to .env and use ${VAR} substitution",
371
+ autoFixable: false,
372
+ file: files.configPath ?? undefined,
373
+ });
374
+ break;
375
+ }
376
+ }
377
+ }
378
+ // SECRET-016: OAuth tokens in session logs
379
+ const oauthPatterns = [
380
+ /Bearer\s+[a-zA-Z0-9\-_.~+/]+=*/,
381
+ /access_token[=:]\s*[a-zA-Z0-9\-_.~+/]{20,}/,
382
+ ];
383
+ for (const logFile of files.sessionLogFiles.slice(0, 5)) {
384
+ try {
385
+ // FIX: Read only the first 512KB via stream instead of reading entire file then slicing
386
+ const content = await readFileCapped(logFile, 512 * 1024);
387
+ const hasOAuth = oauthPatterns.some((p) => p.test(content));
388
+ if (hasOAuth) {
389
+ findings.push({
390
+ id: "SECRET-016",
391
+ severity: Severity.High,
392
+ confidence: "medium",
393
+ category: "Secret Scanning",
394
+ title: "OAuth/access token in session log",
395
+ description: `${basename(logFile)} contains Bearer token or access_token values`,
396
+ risk: "OAuth tokens in logs can be used to impersonate users or access protected resources",
397
+ remediation: "Enable session log scrubbing to redact Bearer tokens and access tokens",
398
+ autoFixable: false,
399
+ file: logFile,
400
+ });
401
+ break;
402
+ }
403
+ }
404
+ catch {
405
+ // Can't read
406
+ }
407
+ }
408
+ // SECRET-017: Webhook secrets in plaintext config
409
+ if (configRaw) {
410
+ const webhookPatterns = [
411
+ /whsec_[a-zA-Z0-9]{20,}/, // Stripe webhook secret
412
+ /webhook[_-]?secret\s*[=:]\s*"[^$]/i, // Generic webhook secret not using env ref
413
+ ];
414
+ for (const pattern of webhookPatterns) {
415
+ if (pattern.test(configRaw)) {
416
+ findings.push({
417
+ id: "SECRET-017",
418
+ severity: Severity.High,
419
+ confidence: "high",
420
+ category: "Secret Scanning",
421
+ title: "Webhook secret in plaintext config",
422
+ description: "Webhook signing secret found in openclaw.json instead of .env",
423
+ risk: "Exposed webhook secrets allow forging webhook payloads",
424
+ remediation: "Move webhook secrets to .env and use ${VAR} substitution",
425
+ autoFixable: false,
426
+ file: files.configPath ?? undefined,
427
+ });
428
+ break;
429
+ }
430
+ }
431
+ }
432
+ // SECRET-018: SSH keys in workspace
433
+ if (files.sshKeyFiles.length > 0) {
434
+ findings.push({
435
+ id: "SECRET-018",
436
+ severity: Severity.High,
437
+ confidence: "high",
438
+ category: "Secret Scanning",
439
+ title: "SSH keys in workspace",
440
+ description: `SSH key file(s) found: ${files.sshKeyFiles.map((f) => basename(f)).join(", ")}`,
441
+ risk: "SSH keys in the workspace can be read by agents or committed to git",
442
+ remediation: "Move SSH keys to ~/.ssh/ and add id_rsa, id_ed25519 to .gitignore",
443
+ autoFixable: false,
444
+ file: files.sshKeyFiles[0],
445
+ });
446
+ }
447
+ // SECRET-019: AWS credentials in config (not env ref)
448
+ if (configRaw) {
449
+ const awsKeyPattern = /AWS_ACCESS_KEY_ID\s*[=:]\s*["']?(?!\$\{)[A-Z0-9]{16,}/;
450
+ const awsSecretPattern = /AWS_SECRET_ACCESS_KEY\s*[=:]\s*["']?(?!\$\{)[a-zA-Z0-9/+=]{30,}/;
451
+ if (awsKeyPattern.test(configRaw) || awsSecretPattern.test(configRaw)) {
452
+ findings.push({
453
+ id: "SECRET-019",
454
+ severity: Severity.Critical,
455
+ confidence: "high",
456
+ category: "Secret Scanning",
457
+ title: "AWS credentials in config",
458
+ description: "AWS access key or secret key found hardcoded in openclaw.json",
459
+ risk: "AWS credentials grant access to cloud resources — billing, data, infrastructure",
460
+ remediation: "Move AWS credentials to .env or use IAM roles/instance profiles",
461
+ autoFixable: false,
462
+ file: files.configPath ?? undefined,
463
+ });
464
+ }
465
+ }
466
+ // SECRET-020: JWT secrets weak
467
+ if (configRaw) {
468
+ const jwtMatch = configRaw.match(/jwt[_-]?secret\s*[=:]\s*["']([^"'$][^"']{0,30})["']/i);
469
+ if (jwtMatch && jwtMatch[1] && jwtMatch[1].length < 32) {
470
+ findings.push({
471
+ id: "SECRET-020",
472
+ severity: Severity.Medium,
473
+ confidence: "medium",
474
+ category: "Secret Scanning",
475
+ title: "JWT secret is weak",
476
+ description: `JWT signing secret is only ${jwtMatch[1].length} characters (minimum 32 recommended)`,
477
+ risk: "Short JWT secrets can be brute-forced, allowing token forgery",
478
+ remediation: "Use a JWT secret of at least 32 random characters or use asymmetric keys",
479
+ autoFixable: false,
480
+ });
481
+ }
482
+ }
483
+ // SECRET-021: API keys not documented
484
+ if (files.envPath) {
485
+ const envExamplePath = join(files.openclawDir, ".env.example");
486
+ try {
487
+ await access(envExamplePath, constants.R_OK);
488
+ }
489
+ catch {
490
+ findings.push({
491
+ id: "SECRET-021",
492
+ severity: Severity.Low,
493
+ confidence: "low",
494
+ category: "Secret Scanning",
495
+ title: "No .env.example file",
496
+ description: ".env exists but no .env.example to document required variables",
497
+ risk: "Team members may not know which environment variables are needed",
498
+ remediation: "Create a .env.example with variable names (no values) for documentation",
499
+ autoFixable: false,
500
+ });
501
+ }
502
+ }
503
+ // SECRET-022: Hardcoded IPs/domains in config
504
+ if (configRaw) {
505
+ const internalPatterns = [
506
+ /\b10\.\d{1,3}\.\d{1,3}\.\d{1,3}\b/,
507
+ /\b172\.(1[6-9]|2\d|3[01])\.\d{1,3}\.\d{1,3}\b/,
508
+ /\b192\.168\.\d{1,3}\.\d{1,3}\b/,
509
+ /staging\./i,
510
+ /\.internal\b/i,
511
+ /\.local\b/i,
512
+ ];
513
+ const hasInternal = internalPatterns.some((p) => p.test(configRaw));
514
+ if (hasInternal) {
515
+ findings.push({
516
+ id: "SECRET-022",
517
+ severity: Severity.Low,
518
+ confidence: "low",
519
+ category: "Secret Scanning",
520
+ title: "Internal IPs or staging domains in config",
521
+ description: "Config contains internal IP addresses or staging/internal domain names",
522
+ risk: "Exposes internal network topology if config is shared or committed",
523
+ remediation: "Use ${VAR} substitution for environment-specific URLs and IPs",
524
+ autoFixable: false,
525
+ });
526
+ }
527
+ }
528
+ // SECRET-023: No credential rotation policy
529
+ // Only fire if we actually found credentials to rotate (env file with keys, or API keys in config)
530
+ const hasCredentials = files.envPath ||
531
+ (configRaw && API_KEY_PATTERNS.some((p) => p.test(configRaw)));
532
+ if (hasCredentials) {
533
+ // Check for rotation evidence in .env files
534
+ let hasRotationEvidence = false;
535
+ if (files.envPath) {
536
+ try {
537
+ const envContent = await readFile(files.envPath, "utf-8");
538
+ // Look for rotation-related patterns
539
+ if (/(?:_OLD|_BACKUP|_PREVIOUS|ROTATED|EXPIRED)/i.test(envContent) ||
540
+ /(?:rotation|rotate_at|expires|valid_until)/i.test(envContent)) {
541
+ hasRotationEvidence = true;
542
+ }
543
+ // Also check file modification date - if modified recently, might indicate rotation
544
+ const envStat = await stat(files.envPath);
545
+ const daysSinceModified = (Date.now() - envStat.mtime.getTime()) / (1000 * 60 * 60 * 24);
546
+ if (daysSinceModified < 30) {
547
+ // Modified in last 30 days - possible rotation activity
548
+ hasRotationEvidence = true;
549
+ }
550
+ }
551
+ catch {
552
+ // Can't read/stat
553
+ }
554
+ }
555
+ if (!hasRotationEvidence) {
556
+ findings.push({
557
+ id: "SECRET-023",
558
+ severity: Severity.Low,
559
+ confidence: "low",
560
+ category: "Secret Scanning",
561
+ title: "No credential rotation evidence",
562
+ description: "No evidence of key rotation policy (no expiry dates, rotation scripts, or recent credential updates)",
563
+ risk: "Long-lived credentials increase exposure window if compromised",
564
+ remediation: "Implement a key rotation schedule and document the rotation procedure",
565
+ autoFixable: false,
566
+ });
567
+ }
568
+ }
569
+ // SECRET-024: Shared credentials across envs
570
+ if (files.envPath && files.workspaceDir) {
571
+ // Check for multiple .env files with potentially shared values
572
+ const envFiles = [files.envPath];
573
+ const additionalEnvs = [".env.production", ".env.staging", ".env.development"];
574
+ for (const envName of additionalEnvs) {
575
+ const candidate = join(files.openclawDir, envName);
576
+ try {
577
+ await access(candidate, constants.R_OK);
578
+ envFiles.push(candidate);
579
+ }
580
+ catch {
581
+ // doesn't exist
582
+ }
583
+ }
584
+ if (envFiles.length > 1) {
585
+ // Read all env files and check for identical values
586
+ const envContents = [];
587
+ for (const ef of envFiles) {
588
+ try {
589
+ envContents.push(await readFile(ef, "utf-8"));
590
+ }
591
+ catch {
592
+ // Can't read
593
+ }
594
+ }
595
+ if (envContents.length > 1) {
596
+ // FIX: Use exact line matching to avoid partial-match false positives
597
+ // (e.g., "KEY=val" should not match "MY_KEY=value")
598
+ const lines0 = envContents[0].split("\n").map((l) => l.trim()).filter((l) => l.includes("=") && !l.startsWith("#"));
599
+ const otherLineSets = envContents.slice(1).map((content) => new Set(content.split("\n").map((l) => l.trim())));
600
+ const shared = lines0.filter((line) => otherLineSets.some((lineSet) => lineSet.has(line)));
601
+ if (shared.length > 0) {
602
+ findings.push({
603
+ id: "SECRET-024",
604
+ severity: Severity.Medium,
605
+ confidence: "medium",
606
+ category: "Secret Scanning",
607
+ title: "Shared credentials across environments",
608
+ description: `${shared.length} credential(s) appear identical across multiple .env files`,
609
+ risk: "Shared credentials mean a breach in one environment compromises all environments",
610
+ remediation: "Use unique credentials for each environment (production, staging, development)",
611
+ autoFixable: false,
612
+ });
613
+ }
614
+ }
615
+ }
616
+ }
617
+ // SECRET-025: Credentials in error messages (session logs)
618
+ for (const logFile of files.sessionLogFiles.slice(0, 3)) {
619
+ try {
620
+ // FIX: Use capped read to avoid OOM on large session logs
621
+ const content = await readFileCapped(logFile, 512 * 1024);
622
+ const hasStackLeak = /(?:Error|Exception|Traceback)[\s\S]{0,200}(?:password|token|secret|key)\s*[=:]/i.test(content);
623
+ if (hasStackLeak) {
624
+ findings.push({
625
+ id: "SECRET-025",
626
+ severity: Severity.Medium,
627
+ confidence: "medium",
628
+ category: "Secret Scanning",
629
+ title: "Credentials in error messages",
630
+ description: `${basename(logFile)} contains stack traces that may leak secret values`,
631
+ risk: "Error messages in logs can expose credentials to anyone with log access",
632
+ remediation: "Sanitize error output to strip credential values before logging",
633
+ autoFixable: false,
634
+ file: logFile,
635
+ });
636
+ break;
637
+ }
638
+ }
639
+ catch {
640
+ // Can't read
641
+ }
642
+ }
643
+ // SECRET-026: No secrets scanning in CI
644
+ if (files.workspaceDir) {
645
+ // FIX: .github/workflows is a directory — need to glob for YAML files inside it.
646
+ // Check individual CI files first, then scan workflow directory separately.
647
+ const ciFiles = [
648
+ join(files.workspaceDir, ".gitlab-ci.yml"),
649
+ join(files.workspaceDir, "Jenkinsfile"),
650
+ ];
651
+ const workflowDir = join(files.workspaceDir, ".github", "workflows");
652
+ let hasCI = false;
653
+ let hasSecretScan = false;
654
+ // Check if .github/workflows/ directory exists
655
+ try {
656
+ const wfStat = await stat(workflowDir);
657
+ if (wfStat.isDirectory()) {
658
+ hasCI = true;
659
+ // Read all YAML files in the workflows dir
660
+ const { readdir } = await import("node:fs/promises");
661
+ const wfFiles = await readdir(workflowDir);
662
+ for (const wf of wfFiles.filter((f) => f.endsWith(".yml") || f.endsWith(".yaml"))) {
663
+ const content = await readFile(join(workflowDir, wf), "utf-8").catch(() => "");
664
+ if (/trufflehog|gitguardian|gitleaks|detect-secrets|secret.*scan/i.test(content)) {
665
+ hasSecretScan = true;
666
+ }
667
+ }
668
+ }
669
+ }
670
+ catch {
671
+ // No workflows dir
672
+ }
673
+ for (const ciPath of ciFiles) {
674
+ try {
675
+ await access(ciPath, constants.R_OK);
676
+ hasCI = true;
677
+ const content = await readFile(ciPath, "utf-8").catch(() => "");
678
+ if (/trufflehog|gitguardian|gitleaks|detect-secrets|secret.*scan/i.test(content)) {
679
+ hasSecretScan = true;
680
+ }
681
+ }
682
+ catch {
683
+ // doesn't exist
684
+ }
685
+ }
686
+ if (hasCI && !hasSecretScan) {
687
+ findings.push({
688
+ id: "SECRET-026",
689
+ severity: Severity.Low,
690
+ confidence: "low",
691
+ category: "Secret Scanning",
692
+ title: "No secrets scanning in CI",
693
+ description: "CI pipeline exists but no secret scanning tool detected",
694
+ risk: "Secrets committed accidentally won't be caught by CI",
695
+ remediation: "Add TruffleHog, GitGuardian, or gitleaks to your CI pipeline",
696
+ autoFixable: false,
697
+ });
698
+ }
699
+ }
700
+ // SECRET-027: Password/token in git commit messages
701
+ if (files.workspaceDir) {
702
+ try {
703
+ const { execFile: ef } = await import("node:child_process");
704
+ const { promisify: p } = await import("node:util");
705
+ const execAsync = p(ef);
706
+ const startTime = Date.now();
707
+ const { stdout } = await execAsync("git", ["log", "--oneline", "-50", "--format=%s"], { cwd: files.workspaceDir, timeout: 5000 });
708
+ const duration = Date.now() - startTime;
709
+ // FIX: Log warning if git command takes >2s (may indicate large/slow repo)
710
+ if (duration > 2000) {
711
+ console.error(` Warning: git log took ${duration}ms — consider optimizing git history`);
712
+ }
713
+ const sensitiveCommit = /\b(?:password|token|secret|api[_-]?key)\s*[=:]/i.test(stdout);
714
+ if (sensitiveCommit) {
715
+ findings.push({
716
+ id: "SECRET-027",
717
+ severity: Severity.Medium,
718
+ confidence: "medium",
719
+ category: "Secret Scanning",
720
+ title: "Sensitive terms in git commit messages",
721
+ description: "Git commit messages contain references to passwords, tokens, or API keys",
722
+ risk: "Commit messages are visible to anyone with repository access",
723
+ remediation: "Avoid including credential values in commit messages",
724
+ autoFixable: false,
725
+ });
726
+ }
727
+ }
728
+ catch {
729
+ // git not available or not a repo
730
+ }
731
+ }
732
+ // SECRET-028: Service account keys exposed
733
+ if (files.workspaceDir) {
734
+ for (const mdFile of files.workspaceMarkdownFiles.slice(0, 10)) {
735
+ try {
736
+ const content = await readFile(mdFile, "utf-8");
737
+ if (/service[_-]?account|client_email.*iam\.gserviceaccount/i.test(content)) {
738
+ findings.push({
739
+ id: "SECRET-028",
740
+ severity: Severity.High,
741
+ confidence: "medium",
742
+ category: "Secret Scanning",
743
+ title: "Service account key referenced in workspace",
744
+ description: `${basename(mdFile)} references a GCP/cloud service account`,
745
+ risk: "Service account keys grant programmatic access to cloud infrastructure",
746
+ remediation: "Remove service account references from workspace files; use workload identity instead",
747
+ autoFixable: false,
748
+ file: mdFile,
749
+ });
750
+ break;
751
+ }
752
+ }
753
+ catch {
754
+ // Can't read
755
+ }
756
+ }
757
+ }
758
+ // SECRET-029: API keys with billing risk (Stripe live keys, etc.)
759
+ if (configRaw) {
760
+ const billingPatterns = [
761
+ /sk_live_[a-zA-Z0-9]{20,}/, // Stripe live secret key
762
+ /rk_live_[a-zA-Z0-9]{20,}/, // Stripe restricted key
763
+ ];
764
+ for (const pattern of billingPatterns) {
765
+ if (pattern.test(configRaw)) {
766
+ findings.push({
767
+ id: "SECRET-029",
768
+ // FIX: Elevated from MEDIUM to CRITICAL — live Stripe secret keys can create charges
769
+ severity: Severity.Critical,
770
+ confidence: "high",
771
+ category: "Secret Scanning",
772
+ title: "Live billing API key in config",
773
+ description: "Stripe live secret key found in config — has billing access",
774
+ risk: "Live billing keys can create charges, refunds, and access financial data",
775
+ remediation: "Move billing keys to .env; use restricted keys with minimum required permissions",
776
+ autoFixable: false,
777
+ file: files.configPath ?? undefined,
778
+ });
779
+ break;
780
+ }
781
+ }
782
+ }
783
+ // SECRET-030: Shared API keys (same key in config and markdown)
784
+ if (configRaw) {
785
+ for (const mdFile of files.workspaceMarkdownFiles.slice(0, 5)) {
786
+ try {
787
+ const mdContent = await readFile(mdFile, "utf-8");
788
+ for (const pattern of API_KEY_PATTERNS) {
789
+ const configMatches = [...configRaw.matchAll(new RegExp(pattern, "g"))].map((m) => m[0]);
790
+ for (const key of configMatches) {
791
+ if (mdContent.includes(key)) {
792
+ findings.push({
793
+ id: "SECRET-030",
794
+ severity: Severity.Low,
795
+ confidence: "medium",
796
+ category: "Secret Scanning",
797
+ title: "API key duplicated across files",
798
+ description: `Same API key appears in both config and ${basename(mdFile)}`,
799
+ risk: "Duplicated keys increase the surface area for accidental exposure",
800
+ remediation: "Use ${VAR} references everywhere — define keys only in .env",
801
+ autoFixable: false,
802
+ file: mdFile,
803
+ });
804
+ break;
805
+ }
806
+ }
807
+ }
808
+ }
809
+ catch {
810
+ // Can't read
811
+ }
812
+ }
813
+ }
814
+ return findings;
815
+ }
816
+ //# sourceMappingURL=secrets.js.map