clawarmor 1.2.0 → 2.0.0-alpha.3
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/cli.js +66 -2
- package/lib/audit-log.js +38 -0
- package/lib/audit.js +37 -2
- package/lib/checks/credential-files.js +156 -0
- package/lib/checks/exec-approval.js +64 -0
- package/lib/checks/git-credential-leak.js +135 -0
- package/lib/checks/skill-pinning.js +159 -0
- package/lib/checks/token-age.js +180 -0
- package/lib/digest.js +157 -0
- package/lib/harden.js +312 -0
- package/lib/log-viewer.js +140 -0
- package/lib/output/progress.js +2 -1
- package/lib/prescan.js +167 -0
- package/lib/protect.js +352 -0
- package/lib/scan.js +14 -0
- package/lib/status.js +250 -0
- package/lib/watch.js +235 -0
- package/package.json +1 -1
package/cli.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// ClawArmor
|
|
2
|
+
// ClawArmor v2.0.0-alpha.2 — Security armor for OpenClaw agents
|
|
3
3
|
|
|
4
4
|
import { paint } from './lib/output/colors.js';
|
|
5
5
|
|
|
6
|
-
const VERSION = '
|
|
6
|
+
const VERSION = '2.0.0-alpha.3';
|
|
7
7
|
const GATEWAY_PORT_DEFAULT = 18789;
|
|
8
8
|
|
|
9
9
|
function isLocalhost(host) {
|
|
@@ -45,6 +45,13 @@ function usage() {
|
|
|
45
45
|
console.log(` ${paint.cyan('trend')} Show score over last N audits (ASCII chart)`);
|
|
46
46
|
console.log(` ${paint.cyan('compare')} Compare coverage vs openclaw security audit`);
|
|
47
47
|
console.log(` ${paint.cyan('fix')} Auto-apply safe fixes (--dry-run to preview, --apply to run)`);
|
|
48
|
+
console.log(` ${paint.cyan('harden')} Interactive hardening wizard (--dry-run, --auto)`);
|
|
49
|
+
console.log(` ${paint.cyan('status')} One-screen security posture dashboard`);
|
|
50
|
+
console.log(` ${paint.cyan('watch')} Monitor config and skill changes in real time`);
|
|
51
|
+
console.log(` ${paint.cyan('protect')} Install/uninstall/status the full guard system`);
|
|
52
|
+
console.log(` ${paint.cyan('prescan')} Pre-scan a skill before installing it`);
|
|
53
|
+
console.log(` ${paint.cyan('log')} View the audit event log`);
|
|
54
|
+
console.log(` ${paint.cyan('digest')} Show weekly security digest`);
|
|
48
55
|
console.log('');
|
|
49
56
|
console.log(` ${paint.dim('Flags:')}`);
|
|
50
57
|
console.log(` ${paint.dim('--url <host:port>')} Probe a specific host:port instead of 127.0.0.1`);
|
|
@@ -153,5 +160,62 @@ if (cmd === 'fix') {
|
|
|
153
160
|
process.exit(await runFix(fixFlags));
|
|
154
161
|
}
|
|
155
162
|
|
|
163
|
+
if (cmd === 'watch') {
|
|
164
|
+
const { runWatch } = await import('./lib/watch.js');
|
|
165
|
+
const watchFlags = { daemon: args.includes('--daemon') };
|
|
166
|
+
process.exit(await runWatch(watchFlags));
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (cmd === 'protect') {
|
|
170
|
+
const { runProtect } = await import('./lib/protect.js');
|
|
171
|
+
const protectFlags = {
|
|
172
|
+
install: args.includes('--install'),
|
|
173
|
+
uninstall: args.includes('--uninstall'),
|
|
174
|
+
status: args.includes('--status'),
|
|
175
|
+
};
|
|
176
|
+
process.exit(await runProtect(protectFlags));
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (cmd === 'prescan') {
|
|
180
|
+
const skillArg = args[1];
|
|
181
|
+
if (!skillArg) {
|
|
182
|
+
console.log(` Usage: clawarmor prescan <skill-name>`);
|
|
183
|
+
process.exit(1);
|
|
184
|
+
}
|
|
185
|
+
const { runPrescan } = await import('./lib/prescan.js');
|
|
186
|
+
process.exit(await runPrescan(skillArg));
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (cmd === 'log') {
|
|
190
|
+
const sinceIdx = args.indexOf('--since');
|
|
191
|
+
const sinceArg = sinceIdx !== -1 ? args[sinceIdx + 1] : null;
|
|
192
|
+
const logFlags = {
|
|
193
|
+
json: args.includes('--json'),
|
|
194
|
+
tokens: args.includes('--tokens'),
|
|
195
|
+
since: sinceArg || null,
|
|
196
|
+
};
|
|
197
|
+
const { runLog } = await import('./lib/log-viewer.js');
|
|
198
|
+
process.exit(await runLog(logFlags));
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (cmd === 'harden') {
|
|
202
|
+
const hardenFlags = {
|
|
203
|
+
dryRun: args.includes('--dry-run'),
|
|
204
|
+
auto: args.includes('--auto'),
|
|
205
|
+
};
|
|
206
|
+
const { runHarden } = await import('./lib/harden.js');
|
|
207
|
+
process.exit(await runHarden(hardenFlags));
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (cmd === 'status') {
|
|
211
|
+
const { runStatus } = await import('./lib/status.js');
|
|
212
|
+
process.exit(await runStatus());
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (cmd === 'digest') {
|
|
216
|
+
const { runDigest } = await import('./lib/digest.js');
|
|
217
|
+
process.exit(await runDigest());
|
|
218
|
+
}
|
|
219
|
+
|
|
156
220
|
console.log(` ${paint.red('✗')} Unknown command: ${paint.bold(cmd)}`);
|
|
157
221
|
usage(); process.exit(1);
|
package/lib/audit-log.js
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// ClawArmor v2.0 — Security Audit Log
|
|
2
|
+
// Appends one JSONL line per event to ~/.clawarmor/audit.log
|
|
3
|
+
// Schema: { ts, cmd, trigger, score, delta, findings, blocked, skill }
|
|
4
|
+
|
|
5
|
+
import { mkdirSync, appendFileSync } from 'fs';
|
|
6
|
+
import { join } from 'path';
|
|
7
|
+
import { homedir } from 'os';
|
|
8
|
+
|
|
9
|
+
const LOG_DIR = join(homedir(), '.clawarmor');
|
|
10
|
+
const LOG_FILE = join(LOG_DIR, 'audit.log');
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Append one audit event to ~/.clawarmor/audit.log (JSONL format).
|
|
14
|
+
* @param {object} entry
|
|
15
|
+
* @param {string} entry.cmd - 'audit' | 'scan' | 'prescan' | 'watch'
|
|
16
|
+
* @param {string} entry.trigger - 'manual' | 'gateway:startup' | 'watch' | 'prescan'
|
|
17
|
+
* @param {number|null} entry.score - numeric score (audit only)
|
|
18
|
+
* @param {number|null} entry.delta - score change from previous run
|
|
19
|
+
* @param {Array} entry.findings - [{ id, severity }]
|
|
20
|
+
* @param {boolean|null} entry.blocked - prescan blocked install
|
|
21
|
+
* @param {string|null} entry.skill - skill name (prescan / scan per-skill)
|
|
22
|
+
*/
|
|
23
|
+
export function append(entry) {
|
|
24
|
+
try {
|
|
25
|
+
mkdirSync(LOG_DIR, { recursive: true });
|
|
26
|
+
const line = JSON.stringify({
|
|
27
|
+
ts: new Date().toISOString(),
|
|
28
|
+
cmd: entry.cmd ?? null,
|
|
29
|
+
trigger: entry.trigger ?? 'manual',
|
|
30
|
+
score: entry.score ?? null,
|
|
31
|
+
delta: entry.delta ?? null,
|
|
32
|
+
findings: Array.isArray(entry.findings) ? entry.findings : [],
|
|
33
|
+
blocked: entry.blocked ?? null,
|
|
34
|
+
skill: entry.skill ?? null,
|
|
35
|
+
}) + '\n';
|
|
36
|
+
appendFileSync(LOG_FILE, line, 'utf8');
|
|
37
|
+
} catch { /* non-fatal — never crash the main command */ }
|
|
38
|
+
}
|
package/lib/audit.js
CHANGED
|
@@ -11,15 +11,21 @@ import toolChecks from './checks/tools.js';
|
|
|
11
11
|
import versionChecks from './checks/version.js';
|
|
12
12
|
import hooksChecks from './checks/hooks.js';
|
|
13
13
|
import allowFromChecks from './checks/allowfrom.js';
|
|
14
|
+
import tokenAgeChecks from './checks/token-age.js';
|
|
15
|
+
import execApprovalChecks from './checks/exec-approval.js';
|
|
16
|
+
import skillPinningChecks from './checks/skill-pinning.js';
|
|
17
|
+
import gitCredentialLeakChecks from './checks/git-credential-leak.js';
|
|
14
18
|
import { writeFileSync, mkdirSync, existsSync, readFileSync, renameSync } from 'fs';
|
|
15
19
|
import { join } from 'path';
|
|
16
20
|
import { homedir } from 'os';
|
|
17
21
|
import { checkIntegrity, updateBaseline } from './integrity.js';
|
|
22
|
+
import { append as auditLogAppend } from './audit-log.js';
|
|
23
|
+
import credentialFilesChecks from './checks/credential-files.js';
|
|
18
24
|
|
|
19
25
|
const W = { CRITICAL: 25, HIGH: 15, MEDIUM: 8, LOW: 3, INFO: 0 };
|
|
20
26
|
const SEP = paint.dim('─'.repeat(52));
|
|
21
27
|
const W52 = 52;
|
|
22
|
-
const VERSION = '
|
|
28
|
+
const VERSION = '2.0.0-alpha.1';
|
|
23
29
|
|
|
24
30
|
const HISTORY_DIR = join(homedir(), '.clawarmor');
|
|
25
31
|
const HISTORY_FILE = join(HISTORY_DIR, 'history.json');
|
|
@@ -156,6 +162,9 @@ export async function runAudit(flags = {}) {
|
|
|
156
162
|
...gatewayChecks, ...filesystemChecks, ...channelChecks,
|
|
157
163
|
...authChecks, ...toolChecks, ...versionChecks, ...hooksChecks,
|
|
158
164
|
...allowFromChecks,
|
|
165
|
+
...tokenAgeChecks, ...execApprovalChecks, ...skillPinningChecks,
|
|
166
|
+
...gitCredentialLeakChecks,
|
|
167
|
+
...credentialFilesChecks,
|
|
159
168
|
];
|
|
160
169
|
const staticResults = [];
|
|
161
170
|
for (const check of allChecks) {
|
|
@@ -207,6 +216,18 @@ export async function runAudit(flags = {}) {
|
|
|
207
216
|
|
|
208
217
|
if (flags.json) {
|
|
209
218
|
console.log(JSON.stringify({score,grade,failed,passed},null,2));
|
|
219
|
+
const histJ = loadHistory();
|
|
220
|
+
const prevScoreJ = histJ.length ? histJ[histJ.length - 1].score : null;
|
|
221
|
+
const deltaJ = prevScoreJ != null ? score - prevScoreJ : null;
|
|
222
|
+
auditLogAppend({
|
|
223
|
+
cmd: 'audit',
|
|
224
|
+
trigger: 'manual',
|
|
225
|
+
score,
|
|
226
|
+
delta: deltaJ,
|
|
227
|
+
findings: failed.map(f => ({ id: f.id, severity: f.severity })),
|
|
228
|
+
blocked: null,
|
|
229
|
+
skill: null,
|
|
230
|
+
});
|
|
210
231
|
appendHistory({ timestamp: new Date().toISOString(), score, grade,
|
|
211
232
|
findings: failed.length, criticals, version: VERSION,
|
|
212
233
|
failedIds: failed.map(f => f.id) });
|
|
@@ -261,7 +282,21 @@ export async function runAudit(flags = {}) {
|
|
|
261
282
|
console.log(` ${paint.green('✓')} Config baseline updated.`);
|
|
262
283
|
}
|
|
263
284
|
|
|
264
|
-
//
|
|
285
|
+
// Audit log (JSONL) — compute delta from history before writing
|
|
286
|
+
const hist = loadHistory();
|
|
287
|
+
const prevScore = hist.length ? hist[hist.length - 1].score : null;
|
|
288
|
+
const delta = prevScore != null ? score - prevScore : null;
|
|
289
|
+
auditLogAppend({
|
|
290
|
+
cmd: 'audit',
|
|
291
|
+
trigger: 'manual',
|
|
292
|
+
score,
|
|
293
|
+
delta,
|
|
294
|
+
findings: failed.map(f => ({ id: f.id, severity: f.severity })),
|
|
295
|
+
blocked: null,
|
|
296
|
+
skill: null,
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
// Persist history (atomic)
|
|
265
300
|
appendHistory({
|
|
266
301
|
timestamp: new Date().toISOString(),
|
|
267
302
|
score,
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
// T-CRED-001 — Credential File Permission Hygiene
|
|
2
|
+
// Checks ~/.openclaw/ directory and file permissions, and scans JSON
|
|
3
|
+
// files for API key patterns (key names only — never values).
|
|
4
|
+
|
|
5
|
+
import { existsSync, readdirSync, statSync, readFileSync } from 'fs';
|
|
6
|
+
import { join } from 'path';
|
|
7
|
+
import { homedir } from 'os';
|
|
8
|
+
|
|
9
|
+
const HOME = homedir();
|
|
10
|
+
const OPENCLAW_DIR = join(HOME, '.openclaw');
|
|
11
|
+
|
|
12
|
+
// Same pattern shape as git-credential-leak: matches key names + long value
|
|
13
|
+
// We only use this to DETECT presence — we never log the value
|
|
14
|
+
const SECRET_PATTERN = /(?:api[_-]?key|token|secret|password|credential)["']?\s*[:=]\s*["']?([a-zA-Z0-9_\-]{16,})/i;
|
|
15
|
+
|
|
16
|
+
// ── check 1: directory permissions ─────────────────────────────────────────
|
|
17
|
+
|
|
18
|
+
export function checkCredDirPermissions() {
|
|
19
|
+
if (!existsSync(OPENCLAW_DIR)) {
|
|
20
|
+
return { id: 'cred.dir_permissions', severity: 'MEDIUM', passed: true,
|
|
21
|
+
passedMsg: '~/.openclaw/ not found — credential directory check skipped' };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
let dirStat;
|
|
25
|
+
try { dirStat = statSync(OPENCLAW_DIR); }
|
|
26
|
+
catch {
|
|
27
|
+
return { id: 'cred.dir_permissions', severity: 'MEDIUM', passed: true,
|
|
28
|
+
passedMsg: 'Could not stat ~/.openclaw/ — skipped' };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const mode = dirStat.mode & 0o777;
|
|
32
|
+
if (mode > 0o700) {
|
|
33
|
+
return {
|
|
34
|
+
id: 'cred.dir_permissions',
|
|
35
|
+
severity: 'MEDIUM',
|
|
36
|
+
passed: false,
|
|
37
|
+
title: `~/.openclaw/ directory permissions are too open (${mode.toString(8)})`,
|
|
38
|
+
description: `The directory containing your credentials has permissions ${mode.toString(8)}.\nIt should be 700 (owner-only access). Overly permissive directory permissions\nallow other users or groups to list and access your credential files.`,
|
|
39
|
+
fix: `chmod 700 ${OPENCLAW_DIR}`,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return { id: 'cred.dir_permissions', severity: 'MEDIUM', passed: true,
|
|
44
|
+
passedMsg: `~/.openclaw/ directory permissions are secure (${mode.toString(8)})` };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// ── check 2: file permissions ───────────────────────────────────────────────
|
|
48
|
+
|
|
49
|
+
export function checkCredFilePermissions() {
|
|
50
|
+
if (!existsSync(OPENCLAW_DIR)) {
|
|
51
|
+
return { id: 'cred.file_permissions', severity: 'CRITICAL', passed: true,
|
|
52
|
+
passedMsg: '~/.openclaw/ not found — credential file permission check skipped' };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
let entries;
|
|
56
|
+
try { entries = readdirSync(OPENCLAW_DIR, { withFileTypes: true }); }
|
|
57
|
+
catch {
|
|
58
|
+
return { id: 'cred.file_permissions', severity: 'CRITICAL', passed: true,
|
|
59
|
+
passedMsg: 'Could not read ~/.openclaw/ — skipped' };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const worldReadable = [];
|
|
63
|
+
const groupReadable = [];
|
|
64
|
+
|
|
65
|
+
for (const entry of entries) {
|
|
66
|
+
if (!entry.isFile()) continue;
|
|
67
|
+
const filePath = join(OPENCLAW_DIR, entry.name);
|
|
68
|
+
let s;
|
|
69
|
+
try { s = statSync(filePath); } catch { continue; }
|
|
70
|
+
const mode = s.mode & 0o777;
|
|
71
|
+
|
|
72
|
+
if (mode & 0o004) {
|
|
73
|
+
// world-readable bit set → CRITICAL
|
|
74
|
+
worldReadable.push({ name: entry.name, path: filePath, mode: mode.toString(8) });
|
|
75
|
+
} else if (mode & 0o040) {
|
|
76
|
+
// group-readable bit set (but not world) → HIGH
|
|
77
|
+
groupReadable.push({ name: entry.name, path: filePath, mode: mode.toString(8) });
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (worldReadable.length) {
|
|
82
|
+
const fixLines = worldReadable.map(f => ` chmod 600 ${f.path}`).join('\n');
|
|
83
|
+
const fileList = worldReadable.map(f => `• ${f.name} (${f.mode})`).join('\n');
|
|
84
|
+
return {
|
|
85
|
+
id: 'cred.file_permissions',
|
|
86
|
+
severity: 'CRITICAL',
|
|
87
|
+
passed: false,
|
|
88
|
+
title: `World-readable credential files in ~/.openclaw/ (${worldReadable.length})`,
|
|
89
|
+
description: `The following files are readable by any user on the system:\n${fileList}\n\nAny local process or user can read your API keys and tokens.`,
|
|
90
|
+
fix: `Fix immediately:\n${fixLines}`,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (groupReadable.length) {
|
|
95
|
+
const fixLines = groupReadable.map(f => ` chmod 600 ${f.path}`).join('\n');
|
|
96
|
+
const fileList = groupReadable.map(f => `• ${f.name} (${f.mode})`).join('\n');
|
|
97
|
+
return {
|
|
98
|
+
id: 'cred.file_permissions',
|
|
99
|
+
severity: 'HIGH',
|
|
100
|
+
passed: false,
|
|
101
|
+
title: `Group-readable credential files in ~/.openclaw/ (${groupReadable.length})`,
|
|
102
|
+
description: `The following files are readable by your Unix group:\n${fileList}\n\nOther users in your group can read your credentials.`,
|
|
103
|
+
fix: `Fix:\n${fixLines}`,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return { id: 'cred.file_permissions', severity: 'CRITICAL', passed: true,
|
|
108
|
+
passedMsg: 'Credential file permissions are secure (all ≤ 0600)' };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ── check 3: JSON content scan for API key patterns ─────────────────────────
|
|
112
|
+
|
|
113
|
+
export function checkCredJsonSecrets() {
|
|
114
|
+
if (!existsSync(OPENCLAW_DIR)) {
|
|
115
|
+
return { id: 'cred.json_secrets', severity: 'HIGH', passed: true,
|
|
116
|
+
passedMsg: '~/.openclaw/ not found — JSON secret pattern check skipped' };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
let entries;
|
|
120
|
+
try { entries = readdirSync(OPENCLAW_DIR, { withFileTypes: true }); }
|
|
121
|
+
catch {
|
|
122
|
+
return { id: 'cred.json_secrets', severity: 'HIGH', passed: true,
|
|
123
|
+
passedMsg: 'Could not read ~/.openclaw/ — skipped' };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const flagged = [];
|
|
127
|
+
|
|
128
|
+
for (const entry of entries) {
|
|
129
|
+
if (!entry.isFile() || !entry.name.endsWith('.json')) continue;
|
|
130
|
+
const filePath = join(OPENCLAW_DIR, entry.name);
|
|
131
|
+
let content;
|
|
132
|
+
try { content = readFileSync(filePath, 'utf8'); }
|
|
133
|
+
catch { continue; }
|
|
134
|
+
if (content.length > 1_000_000) continue;
|
|
135
|
+
|
|
136
|
+
if (SECRET_PATTERN.test(content)) {
|
|
137
|
+
flagged.push(entry.name);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (!flagged.length) {
|
|
142
|
+
return { id: 'cred.json_secrets', severity: 'HIGH', passed: true,
|
|
143
|
+
passedMsg: 'No API key patterns found in ~/.openclaw/ JSON files' };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return {
|
|
147
|
+
id: 'cred.json_secrets',
|
|
148
|
+
severity: 'HIGH',
|
|
149
|
+
passed: false,
|
|
150
|
+
title: `API key patterns found in ~/.openclaw/ JSON files (${flagged.length} file${flagged.length > 1 ? 's' : ''})`,
|
|
151
|
+
description: `The following JSON files in ~/.openclaw/ contain patterns matching API keys or secrets:\n${flagged.map(f => `• ${f}`).join('\n')}\n\nNote: Only key name patterns are detected — actual values are never read or stored.\nCredentials in the wrong files may be at risk if file permissions are too open.`,
|
|
152
|
+
fix: `Ensure all credential files use 0600 permissions:\n chmod 600 ~/.openclaw/*.json\n\nIf credentials are in unexpected files, move them to agent-accounts.json.`,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export default [checkCredDirPermissions, checkCredFilePermissions, checkCredJsonSecrets];
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// T-EXEC-004 — Exec Approval Coverage
|
|
2
|
+
// Checks that exec commands require user approval.
|
|
3
|
+
|
|
4
|
+
import { get } from '../config.js';
|
|
5
|
+
|
|
6
|
+
export function checkExecApproval(config) {
|
|
7
|
+
const ask = get(config, 'tools.exec.ask', null);
|
|
8
|
+
const allowed = get(config, 'tools.exec.allowed', null);
|
|
9
|
+
const hasAllowlist = Array.isArray(allowed) && allowed.length > 0;
|
|
10
|
+
|
|
11
|
+
// ask === 'off' — no approvals at all
|
|
12
|
+
if (ask === 'off') {
|
|
13
|
+
return {
|
|
14
|
+
id: 'exec.approval',
|
|
15
|
+
severity: 'HIGH',
|
|
16
|
+
passed: false,
|
|
17
|
+
title: 'Exec approval disabled — all shell commands run without confirmation',
|
|
18
|
+
description: `tools.exec.ask="off" means every shell command the agent triggers\nruns immediately with zero user approval. Any prompt injection or malicious\nskill can execute arbitrary commands on your system without you seeing them.\nAttack: attacker injects "run rm -rf ~/important" — it executes silently.`,
|
|
19
|
+
fix: `openclaw config set tools.exec.ask always\n# or, to allow a specific set without prompts:\nopenctl config set tools.exec.ask on-miss\nopenctl config set tools.exec.allowed '["git","npm","node"]'`,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// ask === 'on-miss' with no allowlist — unbounded
|
|
24
|
+
if (ask === 'on-miss' && !hasAllowlist) {
|
|
25
|
+
return {
|
|
26
|
+
id: 'exec.approval',
|
|
27
|
+
severity: 'MEDIUM',
|
|
28
|
+
passed: false,
|
|
29
|
+
title: 'Exec approval set to on-miss but no allowlist defined',
|
|
30
|
+
description: `tools.exec.ask="on-miss" only prompts for commands not on the allowed list.\nWith no allowed list set, the effective behaviour depends on how openclaw handles\nan empty list — this is ambiguous and may allow all commands silently.\nAttack: attacker runs any command that happens to be implicitly allowed.`,
|
|
31
|
+
fix: `Either require approval for everything:\n openclaw config set tools.exec.ask always\nOr define an explicit allowlist:\n openclaw config set tools.exec.allowed '["git","npm","node"]'`,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// ask === 'always' — best practice
|
|
36
|
+
if (ask === 'always') {
|
|
37
|
+
return { id: 'exec.approval', severity: 'HIGH', passed: true,
|
|
38
|
+
passedMsg: 'Exec approval set to always — all commands require confirmation' };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ask === 'on-miss' with a non-empty allowlist — acceptable
|
|
42
|
+
if (ask === 'on-miss' && hasAllowlist) {
|
|
43
|
+
return { id: 'exec.approval', severity: 'HIGH', passed: true,
|
|
44
|
+
passedMsg: `Exec approval: on-miss with ${allowed.length}-command allowlist` };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// ask is null/undefined — default behaviour is unknown; treat as warn
|
|
48
|
+
if (ask == null) {
|
|
49
|
+
return {
|
|
50
|
+
id: 'exec.approval',
|
|
51
|
+
severity: 'MEDIUM',
|
|
52
|
+
passed: false,
|
|
53
|
+
title: 'Exec approval not explicitly configured',
|
|
54
|
+
description: `tools.exec.ask is not set. The default approval behaviour is unknown\nand may change across openclaw versions. Explicit configuration is safer.`,
|
|
55
|
+
fix: `openclaw config set tools.exec.ask always`,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Unknown value — pass with info
|
|
60
|
+
return { id: 'exec.approval', severity: 'HIGH', passed: true,
|
|
61
|
+
passedMsg: `Exec approval: ask="${ask}"` };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export default [checkExecApproval];
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// T-EXFIL-003 — Workspace Git Credential Leak
|
|
2
|
+
// Scans the workspace git history and .env files for committed secrets.
|
|
3
|
+
// NEVER prints actual secret values — only reports key names and locations.
|
|
4
|
+
|
|
5
|
+
import { existsSync, readdirSync, readFileSync } from 'fs';
|
|
6
|
+
import { join, basename } from 'path';
|
|
7
|
+
import { homedir } from 'os';
|
|
8
|
+
import { execSync } from 'child_process';
|
|
9
|
+
import { get } from '../config.js';
|
|
10
|
+
|
|
11
|
+
const HOME = homedir();
|
|
12
|
+
const DEFAULT_WORKSPACE = join(HOME, 'clawd');
|
|
13
|
+
|
|
14
|
+
// Pattern: key/token/secret = "value_at_least_16_chars"
|
|
15
|
+
// NEVER logs the value; only the matched key name
|
|
16
|
+
const SECRET_PATTERN = /(?:api[_-]?key|token|secret|password|credential)["']?\s*[:=]\s*["']?([a-zA-Z0-9_\-]{16,})/i;
|
|
17
|
+
// Pattern to find the key name from the line (for reporting)
|
|
18
|
+
const KEY_NAME_PATTERN = /^[^=:]*?(api[_-]?key|token|secret|password|credential)/i;
|
|
19
|
+
|
|
20
|
+
const ENV_FILE_NAMES = ['.env', '.env.local', '.env.production', '.env.staging', '.env.development'];
|
|
21
|
+
|
|
22
|
+
function isGitRepo(dir) {
|
|
23
|
+
return existsSync(join(dir, '.git'));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function scanEnvFiles(workspaceDir) {
|
|
27
|
+
const findings = [];
|
|
28
|
+
|
|
29
|
+
// Scan workspace root for .env files
|
|
30
|
+
let files;
|
|
31
|
+
try { files = readdirSync(workspaceDir); }
|
|
32
|
+
catch { return findings; }
|
|
33
|
+
|
|
34
|
+
for (const filename of files) {
|
|
35
|
+
if (!ENV_FILE_NAMES.includes(filename)) continue;
|
|
36
|
+
const filePath = join(workspaceDir, filename);
|
|
37
|
+
let lines;
|
|
38
|
+
try {
|
|
39
|
+
lines = readFileSync(filePath, 'utf8').split('\n');
|
|
40
|
+
} catch { continue; }
|
|
41
|
+
|
|
42
|
+
for (let i = 0; i < lines.length; i++) {
|
|
43
|
+
const line = lines[i];
|
|
44
|
+
if (SECRET_PATTERN.test(line)) {
|
|
45
|
+
const keyMatch = line.match(KEY_NAME_PATTERN);
|
|
46
|
+
const keyName = keyMatch ? keyMatch[0].trim().replace(/["']/g, '') : 'unknown-key';
|
|
47
|
+
findings.push(`${filename}:${i + 1} — key matching "${keyName.slice(0, 40)}"`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return findings;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function scanGitLog(workspaceDir) {
|
|
56
|
+
const findings = [];
|
|
57
|
+
|
|
58
|
+
try {
|
|
59
|
+
// Get last 50 commits' diffs — limit output to avoid huge repos
|
|
60
|
+
const output = execSync(
|
|
61
|
+
'git log -p --max-count=50 --no-color --unified=0',
|
|
62
|
+
{ cwd: workspaceDir, timeout: 15000, maxBuffer: 5 * 1024 * 1024, stdio: ['ignore', 'pipe', 'ignore'] }
|
|
63
|
+
).toString('utf8');
|
|
64
|
+
|
|
65
|
+
const lines = output.split('\n');
|
|
66
|
+
let currentCommit = '';
|
|
67
|
+
let currentFile = '';
|
|
68
|
+
|
|
69
|
+
for (let i = 0; i < lines.length; i++) {
|
|
70
|
+
const line = lines[i];
|
|
71
|
+
|
|
72
|
+
if (line.startsWith('commit ')) {
|
|
73
|
+
currentCommit = line.slice(7, 15); // short hash
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
if (line.startsWith('+++ b/')) {
|
|
77
|
+
currentFile = line.slice(6);
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
// Only check added lines (prefixed with +)
|
|
81
|
+
if (!line.startsWith('+') || line.startsWith('+++')) continue;
|
|
82
|
+
|
|
83
|
+
if (SECRET_PATTERN.test(line)) {
|
|
84
|
+
const keyMatch = line.match(KEY_NAME_PATTERN);
|
|
85
|
+
const keyName = keyMatch ? keyMatch[0].trim().replace(/["']/g, '') : 'unknown-key';
|
|
86
|
+
const location = `commit ${currentCommit}, file ${currentFile || 'unknown'}, line pattern "${keyName.slice(0, 40)}"`;
|
|
87
|
+
if (!findings.includes(location)) {
|
|
88
|
+
findings.push(location);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
} catch {
|
|
93
|
+
// git not available, not a git repo, or timeout — skip silently
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return findings;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export async function checkGitCredentialLeak(config) {
|
|
100
|
+
// Resolve workspace directory from config or default
|
|
101
|
+
const workspaceDir = get(config, 'workspace.dir', null) || DEFAULT_WORKSPACE;
|
|
102
|
+
|
|
103
|
+
if (!existsSync(workspaceDir)) {
|
|
104
|
+
return { id: 'exfil.git_credential_leak', severity: 'CRITICAL', passed: true,
|
|
105
|
+
passedMsg: 'Workspace directory not found — git credential leak check skipped' };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const envFindings = scanEnvFiles(workspaceDir);
|
|
109
|
+
|
|
110
|
+
let gitFindings = [];
|
|
111
|
+
if (isGitRepo(workspaceDir)) {
|
|
112
|
+
gitFindings = scanGitLog(workspaceDir);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const allFindings = [...envFindings, ...gitFindings];
|
|
116
|
+
|
|
117
|
+
if (!allFindings.length) {
|
|
118
|
+
return { id: 'exfil.git_credential_leak', severity: 'CRITICAL', passed: true,
|
|
119
|
+
passedMsg: 'No credential patterns found in workspace .env files or git history' };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const list = allFindings.slice(0, 10).map(f => `• ${f}`).join('\n');
|
|
123
|
+
const more = allFindings.length > 10 ? `\n ...and ${allFindings.length - 10} more` : '';
|
|
124
|
+
|
|
125
|
+
return {
|
|
126
|
+
id: 'exfil.git_credential_leak',
|
|
127
|
+
severity: 'CRITICAL',
|
|
128
|
+
passed: false,
|
|
129
|
+
title: `Credential patterns found in workspace (${allFindings.length} location${allFindings.length > 1 ? 's' : ''})`,
|
|
130
|
+
description: `Secret-like patterns matching credential keys were found in your workspace.\nThis may mean API keys or tokens were committed to git or left in .env files.\n\n${list}${more}\n\nNote: Only key names are shown — no actual values are printed.`,
|
|
131
|
+
fix: `For .env files: add them to .gitignore immediately:\n echo '.env*' >> ${workspaceDir}/.gitignore\n\nFor committed secrets, scrub git history:\n git filter-repo --path-glob '*.env' --invert-paths\n # or: https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/removing-sensitive-data-from-a-repository\n\nRotate any exposed credentials immediately.`,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export default [checkGitCredentialLeak];
|