clawarmor 1.1.0 → 2.0.0-alpha.2
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 +47 -3
- package/lib/audit-log.js +38 -0
- package/lib/audit.js +55 -1
- 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/integrity.js +102 -0
- package/lib/log-viewer.js +140 -0
- package/lib/prescan.js +167 -0
- package/lib/protect.js +333 -0
- package/lib/scan.js +14 -0
- package/lib/scanner/file-scanner.js +7 -1
- package/lib/scanner/obfuscation.js +130 -0
- package/lib/watch.js +235 -0
- package/package.json +3 -3
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.1';
|
|
7
7
|
const GATEWAY_PORT_DEFAULT = 18789;
|
|
8
8
|
|
|
9
9
|
function isLocalhost(host) {
|
|
@@ -45,12 +45,17 @@ 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('watch')} Monitor config and skill changes in real time`);
|
|
49
|
+
console.log(` ${paint.cyan('protect')} Install/uninstall/status the full guard system`);
|
|
50
|
+
console.log(` ${paint.cyan('prescan')} Pre-scan a skill before installing it`);
|
|
51
|
+
console.log(` ${paint.cyan('log')} View the audit event log`);
|
|
48
52
|
console.log('');
|
|
49
53
|
console.log(` ${paint.dim('Flags:')}`);
|
|
50
54
|
console.log(` ${paint.dim('--url <host:port>')} Probe a specific host:port instead of 127.0.0.1`);
|
|
51
55
|
console.log(` ${paint.dim('--config <path>')} Use a specific config file instead of ~/.openclaw/openclaw.json`);
|
|
52
56
|
console.log(` ${paint.dim('--json')} Machine-readable JSON output (audit only)`);
|
|
53
|
-
console.log(` ${paint.dim('--explain-reads')} Print every file read and network call before executing
|
|
57
|
+
console.log(` ${paint.dim('--explain-reads')} Print every file read and network call before executing
|
|
58
|
+
${paint.dim('--accept-changes')} Update config baseline after reviewing detected changes`);
|
|
54
59
|
console.log('');
|
|
55
60
|
console.log(` ${paint.dim('Examples:')}`);
|
|
56
61
|
console.log(` ${paint.dim('clawarmor audit')} ${paint.dim('# local, default')}`);
|
|
@@ -79,6 +84,7 @@ const flags = {
|
|
|
79
84
|
targetHost: parsedUrl?.host || null,
|
|
80
85
|
targetPort: parsedUrl?.port || null,
|
|
81
86
|
configPath: configPathArg || null,
|
|
87
|
+
acceptChanges: args.includes('--accept-changes'),
|
|
82
88
|
};
|
|
83
89
|
|
|
84
90
|
if (!cmd || cmd === '--help' || cmd === '-h' || cmd === 'help') { usage(); process.exit(0); }
|
|
@@ -151,5 +157,43 @@ if (cmd === 'fix') {
|
|
|
151
157
|
process.exit(await runFix(fixFlags));
|
|
152
158
|
}
|
|
153
159
|
|
|
160
|
+
if (cmd === 'watch') {
|
|
161
|
+
const { runWatch } = await import('./lib/watch.js');
|
|
162
|
+
const watchFlags = { daemon: args.includes('--daemon') };
|
|
163
|
+
process.exit(await runWatch(watchFlags));
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (cmd === 'protect') {
|
|
167
|
+
const { runProtect } = await import('./lib/protect.js');
|
|
168
|
+
const protectFlags = {
|
|
169
|
+
install: args.includes('--install'),
|
|
170
|
+
uninstall: args.includes('--uninstall'),
|
|
171
|
+
status: args.includes('--status'),
|
|
172
|
+
};
|
|
173
|
+
process.exit(await runProtect(protectFlags));
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (cmd === 'prescan') {
|
|
177
|
+
const skillArg = args[1];
|
|
178
|
+
if (!skillArg) {
|
|
179
|
+
console.log(` Usage: clawarmor prescan <skill-name>`);
|
|
180
|
+
process.exit(1);
|
|
181
|
+
}
|
|
182
|
+
const { runPrescan } = await import('./lib/prescan.js');
|
|
183
|
+
process.exit(await runPrescan(skillArg));
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (cmd === 'log') {
|
|
187
|
+
const sinceIdx = args.indexOf('--since');
|
|
188
|
+
const sinceArg = sinceIdx !== -1 ? args[sinceIdx + 1] : null;
|
|
189
|
+
const logFlags = {
|
|
190
|
+
json: args.includes('--json'),
|
|
191
|
+
tokens: args.includes('--tokens'),
|
|
192
|
+
since: sinceArg || null,
|
|
193
|
+
};
|
|
194
|
+
const { runLog } = await import('./lib/log-viewer.js');
|
|
195
|
+
process.exit(await runLog(logFlags));
|
|
196
|
+
}
|
|
197
|
+
|
|
154
198
|
console.log(` ${paint.red('✗')} Unknown command: ${paint.bold(cmd)}`);
|
|
155
199
|
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,14 +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';
|
|
21
|
+
import { checkIntegrity, updateBaseline } from './integrity.js';
|
|
22
|
+
import { append as auditLogAppend } from './audit-log.js';
|
|
23
|
+
import credentialFilesChecks from './checks/credential-files.js';
|
|
17
24
|
|
|
18
25
|
const W = { CRITICAL: 25, HIGH: 15, MEDIUM: 8, LOW: 3, INFO: 0 };
|
|
19
26
|
const SEP = paint.dim('─'.repeat(52));
|
|
20
27
|
const W52 = 52;
|
|
21
|
-
const VERSION = '
|
|
28
|
+
const VERSION = '2.0.0-alpha.1';
|
|
22
29
|
|
|
23
30
|
const HISTORY_DIR = join(homedir(), '.clawarmor');
|
|
24
31
|
const HISTORY_FILE = join(HISTORY_DIR, 'history.json');
|
|
@@ -155,6 +162,9 @@ export async function runAudit(flags = {}) {
|
|
|
155
162
|
...gatewayChecks, ...filesystemChecks, ...channelChecks,
|
|
156
163
|
...authChecks, ...toolChecks, ...versionChecks, ...hooksChecks,
|
|
157
164
|
...allowFromChecks,
|
|
165
|
+
...tokenAgeChecks, ...execApprovalChecks, ...skillPinningChecks,
|
|
166
|
+
...gitCredentialLeakChecks,
|
|
167
|
+
...credentialFilesChecks,
|
|
158
168
|
];
|
|
159
169
|
const staticResults = [];
|
|
160
170
|
for (const check of allChecks) {
|
|
@@ -206,6 +216,18 @@ export async function runAudit(flags = {}) {
|
|
|
206
216
|
|
|
207
217
|
if (flags.json) {
|
|
208
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
|
+
});
|
|
209
231
|
appendHistory({ timestamp: new Date().toISOString(), score, grade,
|
|
210
232
|
findings: failed.length, criticals, version: VERSION,
|
|
211
233
|
failedIds: failed.map(f => f.id) });
|
|
@@ -242,6 +264,38 @@ export async function runAudit(flags = {}) {
|
|
|
242
264
|
console.log(` ${paint.dim('Continuous monitoring:')} ${paint.cyan('github.com/pinzasai/clawarmor')}`);
|
|
243
265
|
console.log('');
|
|
244
266
|
|
|
267
|
+
// ── CONFIG INTEGRITY CHECK ─────────────────────────────────────────────
|
|
268
|
+
if (!isRemote && configPath) {
|
|
269
|
+
const integ = checkIntegrity(configPath, score);
|
|
270
|
+
if (integ.status === 'baseline') {
|
|
271
|
+
console.log(` ${paint.dim('ℹ')} ${paint.dim('Config baseline established — future changes will be flagged.')}`);
|
|
272
|
+
} else if (integ.status === 'changed') {
|
|
273
|
+
console.log('');
|
|
274
|
+
console.log(` ${paint.yellow('!')} ${paint.bold('Config changed since last clean audit')}`);
|
|
275
|
+
for (const c of integ.changes) console.log(` ${paint.dim(c)}`);
|
|
276
|
+
console.log(` ${paint.dim('Baseline set: ' + integ.baselineAt?.slice(0,10))}`);
|
|
277
|
+
console.log(` ${paint.dim('Run clawarmor audit --accept-changes to update baseline')}`);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
if (flags.acceptChanges && configPath) {
|
|
281
|
+
updateBaseline(configPath, score);
|
|
282
|
+
console.log(` ${paint.green('✓')} Config baseline updated.`);
|
|
283
|
+
}
|
|
284
|
+
|
|
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
|
+
|
|
245
299
|
// Persist history (atomic)
|
|
246
300
|
appendHistory({
|
|
247
301
|
timestamp: new Date().toISOString(),
|
|
@@ -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];
|