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.
- package/LICENSE +21 -0
- package/README.md +348 -0
- package/dist/checks/cloud-sync.d.ts +10 -0
- package/dist/checks/cloud-sync.d.ts.map +1 -0
- package/dist/checks/cloud-sync.js +62 -0
- package/dist/checks/cloud-sync.js.map +1 -0
- package/dist/checks/data-protection.d.ts +9 -0
- package/dist/checks/data-protection.d.ts.map +1 -0
- package/dist/checks/data-protection.js +197 -0
- package/dist/checks/data-protection.js.map +1 -0
- package/dist/checks/identity.d.ts +14 -0
- package/dist/checks/identity.d.ts.map +1 -0
- package/dist/checks/identity.js +327 -0
- package/dist/checks/identity.js.map +1 -0
- package/dist/checks/model.d.ts +10 -0
- package/dist/checks/model.d.ts.map +1 -0
- package/dist/checks/model.js +337 -0
- package/dist/checks/model.js.map +1 -0
- package/dist/checks/network.d.ts +9 -0
- package/dist/checks/network.d.ts.map +1 -0
- package/dist/checks/network.js +177 -0
- package/dist/checks/network.js.map +1 -0
- package/dist/checks/operational.d.ts +9 -0
- package/dist/checks/operational.d.ts.map +1 -0
- package/dist/checks/operational.js +158 -0
- package/dist/checks/operational.js.map +1 -0
- package/dist/checks/sandbox.d.ts +9 -0
- package/dist/checks/sandbox.d.ts.map +1 -0
- package/dist/checks/sandbox.js +135 -0
- package/dist/checks/sandbox.js.map +1 -0
- package/dist/checks/secrets.d.ts +9 -0
- package/dist/checks/secrets.d.ts.map +1 -0
- package/dist/checks/secrets.js +816 -0
- package/dist/checks/secrets.js.map +1 -0
- package/dist/checks/skills.d.ts +9 -0
- package/dist/checks/skills.d.ts.map +1 -0
- package/dist/checks/skills.js +303 -0
- package/dist/checks/skills.js.map +1 -0
- package/dist/checks/tools.d.ts +9 -0
- package/dist/checks/tools.d.ts.map +1 -0
- package/dist/checks/tools.js +397 -0
- package/dist/checks/tools.js.map +1 -0
- package/dist/discover.d.ts +22 -0
- package/dist/discover.d.ts.map +1 -0
- package/dist/discover.js +281 -0
- package/dist/discover.js.map +1 -0
- package/dist/fixer.d.ts +16 -0
- package/dist/fixer.d.ts.map +1 -0
- package/dist/fixer.js +361 -0
- package/dist/fixer.js.map +1 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +230 -0
- package/dist/index.js.map +1 -0
- package/dist/init.d.ts +14 -0
- package/dist/init.d.ts.map +1 -0
- package/dist/init.js +108 -0
- package/dist/init.js.map +1 -0
- package/dist/notify.d.ts +28 -0
- package/dist/notify.d.ts.map +1 -0
- package/dist/notify.js +217 -0
- package/dist/notify.js.map +1 -0
- package/dist/parsers/config.d.ts +16 -0
- package/dist/parsers/config.d.ts.map +1 -0
- package/dist/parsers/config.js +54 -0
- package/dist/parsers/config.js.map +1 -0
- package/dist/parsers/env.d.ts +6 -0
- package/dist/parsers/env.d.ts.map +1 -0
- package/dist/parsers/env.js +35 -0
- package/dist/parsers/env.js.map +1 -0
- package/dist/parsers/jsonl.d.ts +12 -0
- package/dist/parsers/jsonl.d.ts.map +1 -0
- package/dist/parsers/jsonl.js +61 -0
- package/dist/parsers/jsonl.js.map +1 -0
- package/dist/parsers/markdown.d.ts +17 -0
- package/dist/parsers/markdown.d.ts.map +1 -0
- package/dist/parsers/markdown.js +57 -0
- package/dist/parsers/markdown.js.map +1 -0
- package/dist/reporter-html.d.ts +9 -0
- package/dist/reporter-html.d.ts.map +1 -0
- package/dist/reporter-html.js +581 -0
- package/dist/reporter-html.js.map +1 -0
- package/dist/reporter.d.ts +10 -0
- package/dist/reporter.d.ts.map +1 -0
- package/dist/reporter.js +133 -0
- package/dist/reporter.js.map +1 -0
- package/dist/sanitize.d.ts +17 -0
- package/dist/sanitize.d.ts.map +1 -0
- package/dist/sanitize.js +83 -0
- package/dist/sanitize.js.map +1 -0
- package/dist/scanner.d.ts +18 -0
- package/dist/scanner.d.ts.map +1 -0
- package/dist/scanner.js +236 -0
- package/dist/scanner.js.map +1 -0
- package/dist/scoring.d.ts +17 -0
- package/dist/scoring.d.ts.map +1 -0
- package/dist/scoring.js +47 -0
- package/dist/scoring.js.map +1 -0
- package/dist/telemetry.d.ts +16 -0
- package/dist/telemetry.d.ts.map +1 -0
- package/dist/telemetry.js +52 -0
- package/dist/telemetry.js.map +1 -0
- package/dist/threat-feed.d.ts +14 -0
- package/dist/threat-feed.d.ts.map +1 -0
- package/dist/threat-feed.js +133 -0
- package/dist/threat-feed.js.map +1 -0
- package/dist/types.d.ts +221 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +11 -0
- package/dist/types.js.map +1 -0
- package/dist/utils.d.ts +12 -0
- package/dist/utils.d.ts.map +1 -0
- package/dist/utils.js +34 -0
- package/dist/utils.js.map +1 -0
- 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
|