clawarmor 1.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 +163 -0
- package/SECURITY.md +40 -0
- package/SKILL.md +87 -0
- package/cli.js +155 -0
- package/lib/audit.js +257 -0
- package/lib/checks/allowfrom.js +104 -0
- package/lib/checks/auth.js +63 -0
- package/lib/checks/channels.js +69 -0
- package/lib/checks/filesystem.js +87 -0
- package/lib/checks/gateway.js +177 -0
- package/lib/checks/hooks.js +33 -0
- package/lib/checks/tools.js +75 -0
- package/lib/checks/version.js +61 -0
- package/lib/compare.js +96 -0
- package/lib/config.js +48 -0
- package/lib/discovery.js +92 -0
- package/lib/fix.js +175 -0
- package/lib/output/colors.js +24 -0
- package/lib/output/formatter.js +212 -0
- package/lib/output/progress.js +27 -0
- package/lib/probes/gateway-probe.js +260 -0
- package/lib/scan.js +129 -0
- package/lib/scanner/file-scanner.js +69 -0
- package/lib/scanner/patterns.js +74 -0
- package/lib/scanner/skill-finder.js +58 -0
- package/lib/scanner/skill-md-scanner.js +121 -0
- package/lib/trend.js +180 -0
- package/lib/verify.js +149 -0
- package/package.json +29 -0
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { get } from '../config.js';
|
|
2
|
+
import { execSync } from 'child_process';
|
|
3
|
+
|
|
4
|
+
const WEAK_TOKEN_PATTERNS = [
|
|
5
|
+
/^change.?me$/i, /^your.?token/i, /^example/i, /^test/i,
|
|
6
|
+
/^demo/i, /^default/i, /^openclaw$/i, /^secret$/i,
|
|
7
|
+
/^password$/i, /^12345/, /^token$/i,
|
|
8
|
+
];
|
|
9
|
+
|
|
10
|
+
function isWeakToken(t) {
|
|
11
|
+
if (!t || typeof t !== 'string') return true;
|
|
12
|
+
if (t.length < 16) return true;
|
|
13
|
+
if (/^(.)\1+$/.test(t)) return true;
|
|
14
|
+
if (WEAK_TOKEN_PATTERNS.some(p => p.test(t))) return true;
|
|
15
|
+
if (t.length < 20 && /^[a-z]+$/.test(t)) return true;
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function checkGatewayBind(config) {
|
|
20
|
+
const bind = get(config, 'gateway.bind', '');
|
|
21
|
+
const mode = get(config, 'gateway.mode', '');
|
|
22
|
+
const authMode = get(config, 'gateway.auth.mode', '');
|
|
23
|
+
const isLoopback = !bind || bind === 'loopback' || bind === 'localhost' || bind === '127.0.0.1' || mode === 'local';
|
|
24
|
+
const isExposed = !isLoopback;
|
|
25
|
+
const hasAuth = authMode && authMode !== 'none';
|
|
26
|
+
|
|
27
|
+
if (isExposed && !hasAuth) {
|
|
28
|
+
return { id: 'gateway.bind_no_auth', severity: 'CRITICAL', passed: false,
|
|
29
|
+
title: 'Gateway exposed to network with NO authentication',
|
|
30
|
+
description: `gateway.bind="${bind}", auth.mode="${authMode||'none'}" — anyone on your network\nor the internet can connect, read conversations, and run tool calls.\nAttack scenario: attacker sends exec("cat ~/.openclaw/agent-accounts.json")\nto your open gateway and gets all your API keys in seconds.`,
|
|
31
|
+
fix: `openclaw config set gateway.auth.mode token\nopenctl config set gateway.auth.token $(node -e "console.log(require('crypto').randomBytes(32).toString('base64url'))")\nopenctl config set gateway.bind loopback\nopenctl gateway restart` };
|
|
32
|
+
}
|
|
33
|
+
if (isExposed) {
|
|
34
|
+
return { id: 'gateway.bind', severity: 'HIGH', passed: false,
|
|
35
|
+
title: 'Gateway exposed to network',
|
|
36
|
+
description: `gateway.bind="${bind}" — reachable from your network. Auth is set but\ntoken brute-force and protocol exploits are possible over the network.\nAttack scenario: attacker on same LAN brute-forces your short token.`,
|
|
37
|
+
fix: `openclaw config set gateway.bind loopback\nopenctl gateway restart\nUse Tailscale Serve for secure remote access instead.` };
|
|
38
|
+
}
|
|
39
|
+
return { id: 'gateway.bind', severity: 'CRITICAL', passed: true, passedMsg: 'Gateway bound to loopback only' };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function checkTailscaleFunnel(config) {
|
|
43
|
+
const tsMode = get(config, 'gateway.tailscale.mode', 'off');
|
|
44
|
+
const authMode = get(config, 'gateway.auth.mode', '');
|
|
45
|
+
if (tsMode === 'funnel' && !['password','token'].includes(authMode)) {
|
|
46
|
+
return { id: 'tailscale.funnel', severity: 'CRITICAL', passed: false,
|
|
47
|
+
title: 'Tailscale Funnel without authentication',
|
|
48
|
+
description: `Funnel is active — gateway is on the public internet. auth.mode="${authMode||'none'}"\nmeans anyone with your Tailscale URL can access your agent.\nAttack: your Funnel URL is <machine>.ts.net — trivially discoverable.`,
|
|
49
|
+
fix: `openclaw config set gateway.auth.mode password` };
|
|
50
|
+
}
|
|
51
|
+
return { id: 'tailscale.funnel', severity: 'CRITICAL', passed: true,
|
|
52
|
+
passedMsg: tsMode === 'funnel' ? 'Funnel active with auth' : 'Tailscale Funnel not enabled' };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function checkAuthToken(config) {
|
|
56
|
+
const authMode = get(config, 'gateway.auth.mode', '');
|
|
57
|
+
if (authMode !== 'token') return { id: 'gateway.auth.token', severity: 'MEDIUM', passed: true,
|
|
58
|
+
passedMsg: `Auth mode: "${authMode||'none'}"` };
|
|
59
|
+
const token = get(config, 'gateway.auth.token', '');
|
|
60
|
+
if (isWeakToken(token)) {
|
|
61
|
+
return { id: 'gateway.auth.token', severity: 'MEDIUM', passed: false,
|
|
62
|
+
title: 'Weak gateway auth token',
|
|
63
|
+
description: `Token is short, simple, or a known default. Brute-forceable.\nAttack: automated tools try common tokens in seconds.`,
|
|
64
|
+
fix: `node -e "console.log(require('crypto').randomBytes(32).toString('base64url'))"\nopenctl config set gateway.auth.token <output>` };
|
|
65
|
+
}
|
|
66
|
+
return { id: 'gateway.auth.token', severity: 'MEDIUM', passed: true, passedMsg: 'Auth token is strong' };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function checkDangerousFlags(config) {
|
|
70
|
+
const checks = [
|
|
71
|
+
['gateway.controlUi.dangerouslyDisableDeviceAuth', 'Disables device pairing — any browser can access Control UI'],
|
|
72
|
+
['gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback', 'DNS rebinding attacks possible'],
|
|
73
|
+
['gateway.controlUi.allowInsecureAuth', 'Downgrades Control UI auth security'],
|
|
74
|
+
];
|
|
75
|
+
const on = checks.filter(([p]) => get(config, p, false) === true);
|
|
76
|
+
if (on.length) {
|
|
77
|
+
return { id: 'gateway.dangerous_flags', severity: 'CRITICAL', passed: false,
|
|
78
|
+
title: `Dangerous flags enabled (${on.length})`,
|
|
79
|
+
description: on.map(([p,d]) => `• ${p.split('.').pop()}: ${d}`).join('\n'),
|
|
80
|
+
fix: on.map(([p]) => `openclaw config set ${p} false`).join('\n') };
|
|
81
|
+
}
|
|
82
|
+
return { id: 'gateway.dangerous_flags', severity: 'CRITICAL', passed: true, passedMsg: 'No dangerous flags enabled' };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function checkMdns(config) {
|
|
86
|
+
const mode = get(config, 'discovery.mdns.mode', 'minimal');
|
|
87
|
+
if (mode === 'full') {
|
|
88
|
+
return { id: 'discovery.mdns', severity: 'MEDIUM', passed: false,
|
|
89
|
+
title: 'mDNS leaking CLI path and SSH port on LAN',
|
|
90
|
+
description: `mdns.mode="full" broadcasts your binary path (reveals username) and SSH port\nto everyone on your local network — passive reconnaissance, no auth needed.`,
|
|
91
|
+
fix: `openclaw config set discovery.mdns.mode minimal` };
|
|
92
|
+
}
|
|
93
|
+
return { id: 'discovery.mdns', severity: 'MEDIUM', passed: true, passedMsg: `mDNS mode: "${mode}" (not leaking sensitive data)` };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function checkRealIpFallback(config) {
|
|
97
|
+
if (get(config, 'gateway.allowRealIpFallback', false) === true) {
|
|
98
|
+
return { id: 'gateway.realip', severity: 'HIGH', passed: false,
|
|
99
|
+
title: 'Real-IP fallback enables IP spoofing',
|
|
100
|
+
description: `allowRealIpFallback=true means forged X-Real-IP: 127.0.0.1 headers\ncan make attacker requests appear to come from localhost, bypassing local trust checks.`,
|
|
101
|
+
fix: `openclaw config set gateway.allowRealIpFallback false` };
|
|
102
|
+
}
|
|
103
|
+
return { id: 'gateway.realip', severity: 'HIGH', passed: true, passedMsg: 'Real-IP fallback disabled' };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export default [checkGatewayBind, checkTailscaleFunnel, checkAuthToken, checkDangerousFlags, checkMdns, checkRealIpFallback, checkTrustedProxies, checkMultiUserTrustModel];
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
// Check: trustedProxies configured when behind reverse proxy
|
|
110
|
+
export function checkTrustedProxies(config) {
|
|
111
|
+
const gw = config.gateway || {};
|
|
112
|
+
const trustedProxies = gw.trustedProxies || config.trustedProxies;
|
|
113
|
+
// Read bind from multiple possible config locations
|
|
114
|
+
const bind = gw.bind || config.bind || '127.0.0.1';
|
|
115
|
+
|
|
116
|
+
// Only relevant if bind is not loopback (i.e., they're behind a proxy or exposed)
|
|
117
|
+
const isLoopback = bind === '127.0.0.1' || bind === 'localhost' || bind === '::1' || bind === '' || bind === 'loopback';
|
|
118
|
+
if (isLoopback) {
|
|
119
|
+
return { id: 'gateway.trusted_proxies', severity: 'INFO', passed: true,
|
|
120
|
+
passedMsg: 'Gateway is loopback-only — trustedProxies not needed' };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (!trustedProxies || !Array.isArray(trustedProxies) || trustedProxies.length === 0) {
|
|
124
|
+
return {
|
|
125
|
+
id: 'gateway.trusted_proxies',
|
|
126
|
+
severity: 'HIGH',
|
|
127
|
+
passed: false,
|
|
128
|
+
title: 'No trustedProxies configured for public-facing gateway',
|
|
129
|
+
description: 'Gateway is not loopback-only but trustedProxies is not set. ' +
|
|
130
|
+
'Without this, proxy IP spoofing can bypass authentication checks. ' +
|
|
131
|
+
'Attack: attacker sends X-Forwarded-For header to impersonate a trusted IP.',
|
|
132
|
+
fix: 'openclaw config set gateway.trustedProxies \'["<your-proxy-ip>"]\''
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
return { id: 'gateway.trusted_proxies', severity: 'INFO', passed: true,
|
|
136
|
+
passedMsg: `trustedProxies configured (${trustedProxies.length} proxy IPs)` };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Check: multi-user heuristic — multiple channels with different users but no sandbox
|
|
140
|
+
export function checkMultiUserTrustModel(config) {
|
|
141
|
+
const channels = config.channels || {};
|
|
142
|
+
const channelCount = Object.keys(channels).filter(k =>
|
|
143
|
+
channels[k] && typeof channels[k] === 'object' && channels[k].enabled !== false
|
|
144
|
+
).length;
|
|
145
|
+
|
|
146
|
+
const groups = Object.values(channels).flatMap(ch => {
|
|
147
|
+
if (ch && ch.groups) return Object.values(ch.groups);
|
|
148
|
+
return [];
|
|
149
|
+
});
|
|
150
|
+
const hasGroups = groups.length > 0;
|
|
151
|
+
|
|
152
|
+
const sandboxMode = config.agents?.defaults?.sandbox?.mode;
|
|
153
|
+
const sandboxEnabled = sandboxMode && sandboxMode !== 'off';
|
|
154
|
+
|
|
155
|
+
// Multi-user signal: groups with non-allowlisted access and no sandbox
|
|
156
|
+
// Single-operator allowlisted groups are fine without sandbox
|
|
157
|
+
const hasOpenGroups = groups.some(g => {
|
|
158
|
+
const policy = g.policy || g.groupPolicy || 'allowlist';
|
|
159
|
+
const allowFrom = g.allowFrom || [];
|
|
160
|
+
return policy !== 'allowlist' && policy !== 'disabled' &&
|
|
161
|
+
(allowFrom.length === 0 || allowFrom.includes('*'));
|
|
162
|
+
});
|
|
163
|
+
if (hasOpenGroups && !sandboxEnabled) {
|
|
164
|
+
return {
|
|
165
|
+
id: 'security.trust_model.multi_user',
|
|
166
|
+
severity: 'MEDIUM',
|
|
167
|
+
passed: false,
|
|
168
|
+
title: 'Multi-user setup without sandbox isolation',
|
|
169
|
+
description: `You have ${channelCount} channel(s)${hasGroups ? ' with group access' : ''} but no sandbox isolation. ` +
|
|
170
|
+
'Multiple users sharing an agent without sandboxing means one user\'s session can affect another\'s workspace. ' +
|
|
171
|
+
'Attack: user A triggers a task that reads or writes workspace files belonging to user B.',
|
|
172
|
+
fix: 'Install Docker, then: openclaw config set agents.defaults.sandbox.mode non-main'
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
return { id: 'security.trust_model.multi_user', severity: 'INFO', passed: true,
|
|
176
|
+
passedMsg: 'Trust model appropriate for current channel configuration' };
|
|
177
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { get } from '../config.js';
|
|
2
|
+
|
|
3
|
+
export function checkHooksSessionKey(config) {
|
|
4
|
+
if (get(config, 'hooks.allowRequestSessionKey', false) === true) {
|
|
5
|
+
const prefixes = get(config, 'hooks.allowedSessionKeyPrefixes', null);
|
|
6
|
+
if (!prefixes || (Array.isArray(prefixes) && prefixes.length === 0)) {
|
|
7
|
+
return { id: 'hooks.sessionKey', severity: 'HIGH', passed: false,
|
|
8
|
+
title: 'Webhooks allow external session key control (unbounded)',
|
|
9
|
+
description: `hooks.allowRequestSessionKey=true with no allowedSessionKeyPrefixes.\nExternal callers can inject into ANY session by choosing its key.\nAttack: attacker sends webhook with sessionKey="main" to hijack your primary session.`,
|
|
10
|
+
fix: `openclaw config set hooks.allowRequestSessionKey false\nOR set hooks.allowedSessionKeyPrefixes to restrict shapes` };
|
|
11
|
+
}
|
|
12
|
+
return { id: 'hooks.sessionKey', severity: 'MEDIUM', passed: false,
|
|
13
|
+
title: 'Webhooks allow external session key control (prefixes set)',
|
|
14
|
+
description: `hooks.allowRequestSessionKey=true — external callers can pick session keys\nmatching your allowedSessionKeyPrefixes. Review if this is intentional.`,
|
|
15
|
+
fix: `Set hooks.allowRequestSessionKey false if not needed` };
|
|
16
|
+
}
|
|
17
|
+
return { id: 'hooks.sessionKey', severity: 'HIGH', passed: true,
|
|
18
|
+
passedMsg: 'Webhooks cannot control session routing' };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function checkHooksTokenLength(config) {
|
|
22
|
+
const token = get(config, 'hooks.token', null);
|
|
23
|
+
if (!token) return { id: 'hooks.token', severity: 'LOW', passed: true, passedMsg: 'No webhook token configured' };
|
|
24
|
+
if (token.length < 16) {
|
|
25
|
+
return { id: 'hooks.token', severity: 'MEDIUM', passed: false,
|
|
26
|
+
title: 'Webhook token is too short (brute-forceable)',
|
|
27
|
+
description: `hooks.token is ${token.length} chars — minimum 16 required.\nAttack: attacker brute-forces short token to trigger arbitrary webhook sessions.`,
|
|
28
|
+
fix: `node -e "console.log(require('crypto').randomBytes(24).toString('base64url'))"\nopenctl config set hooks.token <output>` };
|
|
29
|
+
}
|
|
30
|
+
return { id: 'hooks.token', severity: 'MEDIUM', passed: true, passedMsg: `Webhook token length: ${token.length} chars (sufficient)` };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export default [checkHooksSessionKey, checkHooksTokenLength];
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { get } from '../config.js';
|
|
2
|
+
|
|
3
|
+
export function checkElevatedTools(config) {
|
|
4
|
+
const elevated = get(config, 'tools.elevated', null);
|
|
5
|
+
const allowFrom = get(config, 'tools.elevated.allowFrom', null);
|
|
6
|
+
if (!elevated) return { id: 'tools.elevated', severity: 'MEDIUM', passed: true, passedMsg: 'Elevated tools not configured' };
|
|
7
|
+
const restricted = Array.isArray(allowFrom) && allowFrom.length > 0 &&
|
|
8
|
+
!allowFrom.includes('*') && !allowFrom.includes('all');
|
|
9
|
+
if (!restricted) {
|
|
10
|
+
return { id: 'tools.elevated', severity: 'MEDIUM', passed: false,
|
|
11
|
+
title: 'Elevated tools not restricted to trusted sources',
|
|
12
|
+
description: `tools.elevated.allowFrom is unrestricted.\nElevated exec runs on the host bypassing any sandbox.\nAttack: anyone who can reach the agent can trigger host-level commands.`,
|
|
13
|
+
fix: `openclaw config set tools.elevated.allowFrom ["your-session-key"]` };
|
|
14
|
+
}
|
|
15
|
+
return { id: 'tools.elevated', severity: 'MEDIUM', passed: true, passedMsg: 'Elevated tools restricted to allowlist' };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function checkWorkspaceOnly(config) {
|
|
19
|
+
const wo = get(config, 'tools.fs.workspaceOnly', null);
|
|
20
|
+
if (wo !== true) {
|
|
21
|
+
return { id: 'tools.fs.workspaceOnly', severity: 'LOW', passed: false,
|
|
22
|
+
title: 'Agent can read/write anywhere on filesystem',
|
|
23
|
+
description: `tools.fs.workspaceOnly is not true — the agent can read/write any file\nyour user account can access: SSH keys, ~/.zshenv, browser profiles, etc.\nAttack: prompt injection causes read("~/.ssh/id_rsa") and exfiltrates your private key.`,
|
|
24
|
+
fix: `openclaw config set tools.fs.workspaceOnly true` };
|
|
25
|
+
}
|
|
26
|
+
return { id: 'tools.fs.workspaceOnly', severity: 'LOW', passed: true, passedMsg: 'Filesystem restricted to workspace' };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function checkApplyPatchWorkspaceOnly(config) {
|
|
30
|
+
const wo = get(config, 'tools.exec.applyPatch.workspaceOnly', true);
|
|
31
|
+
if (wo === false) {
|
|
32
|
+
return { id: 'tools.applyPatch.workspaceOnly', severity: 'MEDIUM', passed: false,
|
|
33
|
+
title: 'apply_patch can write files outside workspace',
|
|
34
|
+
description: `tools.exec.applyPatch.workspaceOnly=false allows apply_patch to create\nor delete files anywhere on your system, even without exec permissions.\nAttack: attacker uses apply_patch to overwrite ~/.zshenv or crontabs.`,
|
|
35
|
+
fix: `openclaw config set tools.exec.applyPatch.workspaceOnly true` };
|
|
36
|
+
}
|
|
37
|
+
return { id: 'tools.applyPatch.workspaceOnly', severity: 'MEDIUM', passed: true, passedMsg: 'apply_patch restricted to workspace' };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function checkBrowserSsrf(config) {
|
|
41
|
+
const allowPrivate = get(config, 'browser.ssrfPolicy.dangerouslyAllowPrivateNetwork', true);
|
|
42
|
+
if (allowPrivate !== false) {
|
|
43
|
+
return { id: 'browser.ssrf', severity: 'MEDIUM', passed: false,
|
|
44
|
+
title: 'Browser tool can access private/internal network',
|
|
45
|
+
description: `browser.ssrfPolicy.dangerouslyAllowPrivateNetwork is not explicitly false.\nDefault allows browser to reach 192.168.x.x, 10.x.x.x, 172.16.x.x, localhost.\nAttack: prompt injection causes browser.navigate("http://192.168.1.1/admin")\nand exfiltrates your router admin panel or internal services.`,
|
|
46
|
+
fix: `openclaw config set browser.ssrfPolicy.dangerouslyAllowPrivateNetwork false` };
|
|
47
|
+
}
|
|
48
|
+
return { id: 'browser.ssrf', severity: 'MEDIUM', passed: true, passedMsg: 'Browser SSRF to private networks blocked' };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function checkPluginAllowlist(config) {
|
|
52
|
+
const plugins = get(config, 'plugins', null);
|
|
53
|
+
const allow = get(config, 'plugins.allow', null);
|
|
54
|
+
if (plugins && (!allow || (Array.isArray(allow) && allow.length === 0))) {
|
|
55
|
+
return { id: 'plugins.allowlist', severity: 'MEDIUM', passed: false,
|
|
56
|
+
title: 'Plugins loaded without explicit allowlist',
|
|
57
|
+
description: `plugins are configured but plugins.allow is not set.\nPlugins run in-process with the gateway — treat as fully trusted code.\nAttack: malicious ClawHub plugin installed without review, runs arbitrary code\nin the gateway process, reads all credentials and sessions.`,
|
|
58
|
+
fix: `openclaw config set plugins.allow ["your-trusted-plugin-id"]` };
|
|
59
|
+
}
|
|
60
|
+
return { id: 'plugins.allowlist', severity: 'MEDIUM', passed: true, passedMsg: 'Plugin allowlist configured' };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function checkLogRedaction(config) {
|
|
64
|
+
const redact = get(config, 'logging.redactSensitive', 'tools');
|
|
65
|
+
if (redact === false || redact === 'off' || redact === 'none') {
|
|
66
|
+
return { id: 'logging.redact', severity: 'MEDIUM', passed: false,
|
|
67
|
+
title: 'Log redaction disabled — tokens leak to disk logs',
|
|
68
|
+
description: `logging.redactSensitive="${redact}" — gateway logs will contain\nraw API keys, bot tokens, and tool arguments in plaintext.\nAttack: attacker with log read access extracts all credentials passively.`,
|
|
69
|
+
fix: `openclaw config set logging.redactSensitive tools` };
|
|
70
|
+
}
|
|
71
|
+
return { id: 'logging.redact', severity: 'MEDIUM', passed: true, passedMsg: 'Log redaction enabled' };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export default [checkElevatedTools, checkWorkspaceOnly, checkApplyPatchWorkspaceOnly,
|
|
75
|
+
checkBrowserSsrf, checkPluginAllowlist, checkLogRedaction];
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { execSync } from 'child_process';
|
|
2
|
+
|
|
3
|
+
export async function checkVersion() {
|
|
4
|
+
let installed = null;
|
|
5
|
+
let latest = null;
|
|
6
|
+
|
|
7
|
+
// Read from binary, not config (v0.1 false positive fix)
|
|
8
|
+
const candidates = [
|
|
9
|
+
process.env.HOME + '/.npm-global/bin/openclaw',
|
|
10
|
+
'/opt/homebrew/bin/openclaw',
|
|
11
|
+
'/usr/local/bin/openclaw',
|
|
12
|
+
'openclaw',
|
|
13
|
+
];
|
|
14
|
+
for (const bin of candidates) {
|
|
15
|
+
try {
|
|
16
|
+
const out = execSync(`${bin} --version 2>/dev/null`, { timeout: 3000, encoding: 'utf8' }).trim();
|
|
17
|
+
if (out && /^\d{4}\./.test(out)) { installed = out; break; }
|
|
18
|
+
} catch { continue; }
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if (!installed) {
|
|
22
|
+
return { id: 'version.check', severity: 'LOW', passed: true,
|
|
23
|
+
passedMsg: 'Could not detect OpenClaw version (binary not in PATH)' };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Fetch latest from npm registry
|
|
27
|
+
try {
|
|
28
|
+
const res = await fetch('https://registry.npmjs.org/openclaw/latest', {
|
|
29
|
+
signal: AbortSignal.timeout(4000),
|
|
30
|
+
headers: { 'User-Agent': 'clawarmor-audit/0.5.0' },
|
|
31
|
+
});
|
|
32
|
+
if (res.ok) {
|
|
33
|
+
const data = await res.json();
|
|
34
|
+
latest = data.version;
|
|
35
|
+
}
|
|
36
|
+
} catch { /* no network — skip */ }
|
|
37
|
+
|
|
38
|
+
if (!latest) {
|
|
39
|
+
return { id: 'version.check', severity: 'LOW', passed: true,
|
|
40
|
+
passedMsg: `OpenClaw ${installed} (could not check latest — offline?)` };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (installed === latest) {
|
|
44
|
+
return { id: 'version.check', severity: 'MEDIUM', passed: true,
|
|
45
|
+
passedMsg: `OpenClaw ${installed} (up to date)` };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Compare: installed < latest?
|
|
49
|
+
const toNum = v => v.replace(/\./g, '').padStart(10, '0');
|
|
50
|
+
if (toNum(installed) < toNum(latest)) {
|
|
51
|
+
return { id: 'version.check', severity: 'MEDIUM', passed: false,
|
|
52
|
+
title: 'OpenClaw is out of date',
|
|
53
|
+
description: `Installed: ${installed} → Latest: ${latest}\nOutdated versions may have known security vulnerabilities.\nAttack: known CVEs against your version are publicly documented.`,
|
|
54
|
+
fix: `npm install -g openclaw@latest && openclaw gateway restart` };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return { id: 'version.check', severity: 'MEDIUM', passed: true,
|
|
58
|
+
passedMsg: `OpenClaw ${installed} (up to date)` };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export default [checkVersion];
|
package/lib/compare.js
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { paint, severityColor } from './output/colors.js';
|
|
2
|
+
import { execSync } from 'child_process';
|
|
3
|
+
|
|
4
|
+
const SEP = paint.dim('─'.repeat(52));
|
|
5
|
+
|
|
6
|
+
function box(title) {
|
|
7
|
+
const W=52, pad=W-2-title.length, l=Math.floor(pad/2), r=pad-l;
|
|
8
|
+
return [paint.dim('╔'+'═'.repeat(W-2)+'╗'),
|
|
9
|
+
paint.dim('║')+' '.repeat(l)+paint.bold(title)+' '.repeat(r)+paint.dim('║'),
|
|
10
|
+
paint.dim('╚'+'═'.repeat(W-2)+'╝')].join('\n');
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export async function runCompare() {
|
|
14
|
+
console.log(''); console.log(box('ClawArmor vs openclaw audit')); console.log('');
|
|
15
|
+
|
|
16
|
+
// Run ClawArmor audit (capture)
|
|
17
|
+
console.log(` ${paint.dim('Running clawarmor audit...')}`);
|
|
18
|
+
let caFindings = [];
|
|
19
|
+
try {
|
|
20
|
+
const { loadConfig } = await import('./config.js');
|
|
21
|
+
const { config } = loadConfig();
|
|
22
|
+
const mods = ['./checks/gateway.js','./checks/filesystem.js','./checks/channels.js',
|
|
23
|
+
'./checks/auth.js','./checks/tools.js','./checks/version.js','./checks/hooks.js'];
|
|
24
|
+
for (const m of mods) {
|
|
25
|
+
const mod = await import(m);
|
|
26
|
+
const checks = mod.default || [];
|
|
27
|
+
for (const check of checks) {
|
|
28
|
+
try { const r = await check(config); if (!r.passed) caFindings.push(r); }
|
|
29
|
+
catch { /* skip */ }
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
} catch(e) { console.log(` ${paint.red('✗')} ClawArmor error: ${e.message}`); }
|
|
33
|
+
|
|
34
|
+
// Run openclaw security audit --json
|
|
35
|
+
console.log(` ${paint.dim('Running openclaw security audit --json...')}`);
|
|
36
|
+
let ocFindings = [];
|
|
37
|
+
let ocAvailable = false;
|
|
38
|
+
try {
|
|
39
|
+
const raw = execSync('openclaw security audit --json 2>/dev/null', { timeout: 15000, encoding: 'utf8' });
|
|
40
|
+
const data = JSON.parse(raw);
|
|
41
|
+
ocFindings = (data.findings || data.checks || []).filter(f => !f.passed);
|
|
42
|
+
ocAvailable = true;
|
|
43
|
+
} catch { /* openclaw audit not available or no --json */ }
|
|
44
|
+
|
|
45
|
+
console.log('');
|
|
46
|
+
|
|
47
|
+
// What ClawArmor catches
|
|
48
|
+
console.log(SEP);
|
|
49
|
+
console.log(` ${paint.cyan('ClawArmor findings')} ${paint.dim('('+caFindings.length+')')}`);
|
|
50
|
+
console.log(SEP);
|
|
51
|
+
if (!caFindings.length) {
|
|
52
|
+
console.log(` ${paint.green('✓')} No issues found`);
|
|
53
|
+
} else {
|
|
54
|
+
for (const f of caFindings) {
|
|
55
|
+
const col = severityColor[f.severity] || paint.dim;
|
|
56
|
+
console.log(` ${paint.red('✗')} ${col('['+f.severity+']')} ${f.title}`);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (ocAvailable) {
|
|
61
|
+
console.log('');
|
|
62
|
+
console.log(SEP);
|
|
63
|
+
console.log(` ${paint.cyan('openclaw security audit findings')} ${paint.dim('('+ocFindings.length+')')}`);
|
|
64
|
+
console.log(SEP);
|
|
65
|
+
if (!ocFindings.length) {
|
|
66
|
+
console.log(` ${paint.green('✓')} No issues found`);
|
|
67
|
+
} else {
|
|
68
|
+
for (const f of ocFindings) {
|
|
69
|
+
console.log(` ${paint.yellow('!')} [${f.severity||'warn'}] ${f.checkId||f.id||f.title||'unknown'}`);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Unique to each
|
|
74
|
+
console.log(''); console.log(SEP);
|
|
75
|
+
console.log(` ${paint.bold('Coverage gap analysis')}`);
|
|
76
|
+
console.log(SEP);
|
|
77
|
+
console.log(` ${paint.cyan('Only in ClawArmor:')} supply chain scan, compare command`);
|
|
78
|
+
console.log(` ${paint.cyan('Only in openclaw audit:')} gateway probe (--deep), live WS test, auto-fix`);
|
|
79
|
+
console.log(` ${paint.cyan('Both cover:')} config checks, file permissions, channel policies`);
|
|
80
|
+
} else {
|
|
81
|
+
console.log('');
|
|
82
|
+
console.log(` ${paint.dim('openclaw security audit --json not available.')}`);
|
|
83
|
+
console.log(` ${paint.dim('Run both manually to compare.')}`);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
console.log('');
|
|
87
|
+
console.log(SEP);
|
|
88
|
+
console.log(` ${paint.bold('What ClawArmor adds over the built-in auditor:')}`);
|
|
89
|
+
console.log(` ${paint.green('✓')} ${paint.dim('Skill supply chain scan (ALL files, not just SKILL.md)')}`);
|
|
90
|
+
console.log(` ${paint.green('✓')} ${paint.dim('External exposure detection (github.com/pinzasai/clawarmor)')}`);
|
|
91
|
+
console.log(` ${paint.green('✓')} ${paint.dim('Zero-FP scoring with floor rules for CRITICAL findings')}`);
|
|
92
|
+
console.log(` ${paint.green('✓')} ${paint.dim('Context-aware scan (binary wrappers, built-in skills)')}`);
|
|
93
|
+
console.log(` ${paint.green('✓')} ${paint.dim('Attack scenario descriptions per finding')}`);
|
|
94
|
+
console.log('');
|
|
95
|
+
return 0;
|
|
96
|
+
}
|
package/lib/config.js
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { readFileSync, statSync, existsSync } from 'fs';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
import { homedir } from 'os';
|
|
4
|
+
|
|
5
|
+
const HOME = homedir();
|
|
6
|
+
const OC_DIR = join(HOME, '.openclaw');
|
|
7
|
+
|
|
8
|
+
export function getConfigPath() { return join(OC_DIR, 'openclaw.json'); }
|
|
9
|
+
export function getAgentAccountsPath() { return join(OC_DIR, 'agent-accounts.json'); }
|
|
10
|
+
|
|
11
|
+
export function getOctalPermissions(filePath) {
|
|
12
|
+
if (!existsSync(filePath)) return null;
|
|
13
|
+
try { return (statSync(filePath).mode & 0o777).toString(8).padStart(3, '0'); }
|
|
14
|
+
catch { return null; }
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function get(obj, path, def = undefined) {
|
|
18
|
+
if (!obj || typeof obj !== 'object') return def;
|
|
19
|
+
const parts = path.split('.');
|
|
20
|
+
let cur = obj;
|
|
21
|
+
for (const p of parts) {
|
|
22
|
+
if (cur == null || typeof cur !== 'object') return def;
|
|
23
|
+
cur = cur[p];
|
|
24
|
+
}
|
|
25
|
+
return cur === undefined ? def : cur;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function loadConfig(overridePath = null) {
|
|
29
|
+
const configPath = overridePath || getConfigPath();
|
|
30
|
+
if (!existsSync(configPath)) {
|
|
31
|
+
return { config: {}, configPath, error: `Config not found at ${configPath}\nRun: openclaw doctor` };
|
|
32
|
+
}
|
|
33
|
+
try {
|
|
34
|
+
const raw = readFileSync(configPath, 'utf8');
|
|
35
|
+
// Strip JS-style comments and trailing commas (openclaw.json may use JSON5)
|
|
36
|
+
let clean = raw
|
|
37
|
+
.replace(/\/\/[^\n]*/g, '') // single-line comments
|
|
38
|
+
.replace(/\/\*[\s\S]*?\*\//g, '') // block comments
|
|
39
|
+
.replace(/,(\s*[}\]])/g, '$1'); // trailing commas
|
|
40
|
+
// Try cleaned first, fall back to raw
|
|
41
|
+
let config;
|
|
42
|
+
try { config = JSON.parse(clean); }
|
|
43
|
+
catch { config = JSON.parse(raw); } // raw is valid JSON — use it
|
|
44
|
+
return { config, configPath, error: null };
|
|
45
|
+
} catch (err) {
|
|
46
|
+
return { config: {}, configPath, error: `Failed to parse openclaw.json: ${err.message}` };
|
|
47
|
+
}
|
|
48
|
+
}
|
package/lib/discovery.js
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// ClawArmor — Discovers the actual running OpenClaw instance
|
|
2
|
+
// Uses only Node.js built-ins: child_process, fs, os
|
|
3
|
+
// Runs fast (<500ms) via execSync with timeout
|
|
4
|
+
|
|
5
|
+
import { execSync } from 'child_process';
|
|
6
|
+
import { existsSync } from 'fs';
|
|
7
|
+
import { join } from 'path';
|
|
8
|
+
import { homedir } from 'os';
|
|
9
|
+
|
|
10
|
+
const DEFAULT_CONFIG_PATH = join(homedir(), '.openclaw', 'openclaw.json');
|
|
11
|
+
const DEFAULT_PORT = 18789;
|
|
12
|
+
|
|
13
|
+
function runPs() {
|
|
14
|
+
try {
|
|
15
|
+
const out = execSync('ps aux', { timeout: 400, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
|
|
16
|
+
return out;
|
|
17
|
+
} catch {
|
|
18
|
+
return '';
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function parseInstances(psOutput) {
|
|
23
|
+
const instances = [];
|
|
24
|
+
for (const line of psOutput.split('\n')) {
|
|
25
|
+
// Match node processes running openclaw gateway
|
|
26
|
+
if (!/node\b/.test(line)) continue;
|
|
27
|
+
if (!/openclaw/.test(line)) continue;
|
|
28
|
+
if (!/gateway/.test(line)) continue;
|
|
29
|
+
|
|
30
|
+
// Extract PID (second field in ps aux)
|
|
31
|
+
const fields = line.trim().split(/\s+/);
|
|
32
|
+
const pid = parseInt(fields[1], 10);
|
|
33
|
+
|
|
34
|
+
// Extract --config flag value
|
|
35
|
+
const configMatch = line.match(/--config\s+([^\s]+)/);
|
|
36
|
+
const configPath = configMatch ? configMatch[1] : DEFAULT_CONFIG_PATH;
|
|
37
|
+
|
|
38
|
+
// Extract --port flag value
|
|
39
|
+
const portMatch = line.match(/--port\s+(\d+)/);
|
|
40
|
+
const port = portMatch ? parseInt(portMatch[1], 10) : DEFAULT_PORT;
|
|
41
|
+
|
|
42
|
+
instances.push({ pid, configPath, port });
|
|
43
|
+
}
|
|
44
|
+
return instances;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Discovers the running OpenClaw instance.
|
|
49
|
+
*
|
|
50
|
+
* Returns:
|
|
51
|
+
* { configPath, port, pid, multiple: false, instances: [] }
|
|
52
|
+
* or
|
|
53
|
+
* { configPath, port, pid, multiple: true, instances: [{ pid, configPath, port }, ...] }
|
|
54
|
+
*
|
|
55
|
+
* Falls back gracefully: if ps fails or no process found, returns defaults.
|
|
56
|
+
*/
|
|
57
|
+
export async function discoverRunningInstance() {
|
|
58
|
+
const psOutput = runPs();
|
|
59
|
+
const instances = parseInstances(psOutput);
|
|
60
|
+
|
|
61
|
+
if (instances.length === 0) {
|
|
62
|
+
// No process found — return defaults (probe will check if port responds)
|
|
63
|
+
return {
|
|
64
|
+
configPath: existsSync(DEFAULT_CONFIG_PATH) ? DEFAULT_CONFIG_PATH : null,
|
|
65
|
+
port: DEFAULT_PORT,
|
|
66
|
+
pid: null,
|
|
67
|
+
multiple: false,
|
|
68
|
+
instances: [],
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (instances.length === 1) {
|
|
73
|
+
const inst = instances[0];
|
|
74
|
+
return {
|
|
75
|
+
configPath: inst.configPath,
|
|
76
|
+
port: inst.port,
|
|
77
|
+
pid: inst.pid,
|
|
78
|
+
multiple: false,
|
|
79
|
+
instances,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Multiple instances: prefer the one on default port, else first
|
|
84
|
+
const preferred = instances.find(i => i.port === DEFAULT_PORT) || instances[0];
|
|
85
|
+
return {
|
|
86
|
+
configPath: preferred.configPath,
|
|
87
|
+
port: preferred.port,
|
|
88
|
+
pid: preferred.pid,
|
|
89
|
+
multiple: true,
|
|
90
|
+
instances,
|
|
91
|
+
};
|
|
92
|
+
}
|