clawarmor 2.2.0 → 3.0.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/README.md +25 -0
- package/cli.js +8 -1
- package/lib/scanner/obfuscation.js +45 -1
- package/lib/scanner/patterns.js +31 -0
- package/lib/stack/index.js +91 -0
- package/lib/stack/invariant.js +224 -0
- package/lib/stack/ironcurtain.js +195 -0
- package/lib/stack.js +410 -0
- package/lib/status.js +23 -1
- package/package.json +2 -2
- package/demo-preview.gif +0 -0
- package/demo.cast +0 -680
- package/demo.gif +0 -0
package/README.md
CHANGED
|
@@ -40,6 +40,11 @@ clawarmor audit
|
|
|
40
40
|
| `trend` | ASCII chart of your security score over time |
|
|
41
41
|
| `compare` | Compare coverage vs openclaw security audit |
|
|
42
42
|
| `fix` | Auto-apply safe fixes (--dry-run to preview, --apply to run) |
|
|
43
|
+
| `snapshot` | Save a config snapshot manually (auto-saved before every harden/fix) |
|
|
44
|
+
| `rollback` | Restore config from auto-snapshot (--list, --id <id>) |
|
|
45
|
+
| `harden --monitor` | Enable monitor mode — observe before enforcing |
|
|
46
|
+
| `harden --monitor-report` | Show what monitor mode has observed |
|
|
47
|
+
| `harden --monitor-off` | Disable monitor mode |
|
|
43
48
|
|
|
44
49
|
## What it catches
|
|
45
50
|
|
|
@@ -56,6 +61,26 @@ clawarmor audit
|
|
|
56
61
|
| Gateway exposure | TCP-connects to every non-loopback interface | Full |
|
|
57
62
|
| Runtime policy enforcement | Requires a runtime layer (SupraWall) | None |
|
|
58
63
|
|
|
64
|
+
## Safety features
|
|
65
|
+
|
|
66
|
+
**Impact classification** — Every fix is tagged 🟢 Safe, 🟡 Caution, or 🔴 Breaking. `--auto` mode skips breaking changes unless you pass `--force`.
|
|
67
|
+
|
|
68
|
+
**Config snapshots** — ClawArmor auto-saves your config before every `harden` or `fix` run. If something breaks, roll back instantly:
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
clawarmor rollback --list # see all snapshots
|
|
72
|
+
clawarmor rollback # restore the latest
|
|
73
|
+
clawarmor rollback --id <n> # restore a specific one
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
**Monitor mode** — Observe what `harden` would do before enforcing:
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
clawarmor harden --monitor # start monitoring
|
|
80
|
+
clawarmor harden --monitor-report # see what it observed
|
|
81
|
+
clawarmor harden --monitor-off # stop monitoring
|
|
82
|
+
```
|
|
83
|
+
|
|
59
84
|
## Philosophy
|
|
60
85
|
|
|
61
86
|
ClawArmor runs entirely on your machine — no telemetry, no cloud, no accounts.
|
package/cli.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
|
|
4
4
|
import { paint } from './lib/output/colors.js';
|
|
5
5
|
|
|
6
|
-
const VERSION = '
|
|
6
|
+
const VERSION = '3.0.0';
|
|
7
7
|
const GATEWAY_PORT_DEFAULT = 18789;
|
|
8
8
|
|
|
9
9
|
function isLocalhost(host) {
|
|
@@ -51,6 +51,7 @@ function usage() {
|
|
|
51
51
|
console.log(` ${paint.cyan('watch')} Monitor config and skill changes in real time`);
|
|
52
52
|
console.log(` ${paint.cyan('protect')} Install/uninstall/status the full guard system`);
|
|
53
53
|
console.log(` ${paint.cyan('prescan')} Pre-scan a skill before installing it`);
|
|
54
|
+
console.log(` ${paint.cyan('stack')} Security orchestrator — deploy Invariant + IronCurtain from audit data`);
|
|
54
55
|
console.log(` ${paint.cyan('log')} View the audit event log`);
|
|
55
56
|
console.log(` ${paint.cyan('digest')} Show weekly security digest`);
|
|
56
57
|
console.log('');
|
|
@@ -232,5 +233,11 @@ if (cmd === 'digest') {
|
|
|
232
233
|
process.exit(await runDigest());
|
|
233
234
|
}
|
|
234
235
|
|
|
236
|
+
if (cmd === 'stack') {
|
|
237
|
+
const { runStack } = await import('./lib/stack.js');
|
|
238
|
+
const stackArgs = args.slice(1);
|
|
239
|
+
process.exit(await runStack(stackArgs));
|
|
240
|
+
}
|
|
241
|
+
|
|
235
242
|
console.log(` ${paint.red('✗')} Unknown command: ${paint.bold(cmd)}`);
|
|
236
243
|
usage(); process.exit(1);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// obfuscation.js — v1.
|
|
1
|
+
// obfuscation.js — v1.3.0
|
|
2
2
|
// Detects obfuscated code patterns that bypass naive string-grep analysis.
|
|
3
3
|
// Zero external dependencies. Pure regex, adversarially reviewed.
|
|
4
4
|
//
|
|
@@ -10,6 +10,10 @@
|
|
|
10
10
|
// - globalThis/global bracket access to dangerous names
|
|
11
11
|
// - ['constructor'] escape pattern
|
|
12
12
|
// - Unicode/hex escape sequences for dangerous keywords
|
|
13
|
+
// - Dynamic import() with runtime-assembled module name
|
|
14
|
+
// - eval/exec called with interpolated template literal
|
|
15
|
+
// - Proxy/Reflect wrapping of dangerous objects
|
|
16
|
+
// - Variable aliasing of dangerous functions (const e = eval)
|
|
13
17
|
|
|
14
18
|
export const OBFUSCATION_PATTERNS = [
|
|
15
19
|
{
|
|
@@ -81,6 +85,46 @@ export const OBFUSCATION_PATTERNS = [
|
|
|
81
85
|
description: "Calls require() or import() with a runtime-decoded string argument (atob, fromCharCode, etc.).",
|
|
82
86
|
regex: /(?:require|import)\s*\(\s*(?:atob|String\.fromCharCode|Buffer\.from)\s*\(/,
|
|
83
87
|
},
|
|
88
|
+
{
|
|
89
|
+
// Pattern: const mod = 'child' + '_process'; import(mod)
|
|
90
|
+
// The module name is never visible as a literal string, bypassing child_process regex.
|
|
91
|
+
id: 'obfus-dynamic-import-concat',
|
|
92
|
+
severity: 'CRITICAL',
|
|
93
|
+
title: 'Dynamic import() with runtime-assembled module name',
|
|
94
|
+
description: "import() called with a variable or concatenated string — module name assembled at runtime, bypassing static child_process/net detection.",
|
|
95
|
+
note: "Pattern: const mod = 'child' + '_process'; import(mod). The dangerous module name never appears intact in source.",
|
|
96
|
+
regex: /\bimport\s*\(\s*(?:[a-zA-Z_$][a-zA-Z0-9_$]*\s*\)|['"`][^'"`]*['"`]\s*\+)/,
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
// Pattern: eval(`(function() { ${userCode} })()`)
|
|
100
|
+
// Template literal interpolation allows runtime code injection hidden from literal-string scanners.
|
|
101
|
+
id: 'obfus-template-literal',
|
|
102
|
+
severity: 'HIGH',
|
|
103
|
+
title: 'eval/exec called with interpolated template literal',
|
|
104
|
+
description: "eval or exec invoked with a template literal containing ${...} interpolation — injects runtime values into executed code.",
|
|
105
|
+
note: "eval(`code ${var}`) assembles executable code from runtime values. Evades scanners that only check string literals.",
|
|
106
|
+
regex: /\b(?:eval|Function|exec|execSync)\s*\(\s*`[^`]*\$\{/,
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
// Pattern: new Proxy(process, handler) or Reflect.get(globalThis, 'eval')
|
|
110
|
+
// Proxying dangerous objects intercepts property access for exfiltration or modification.
|
|
111
|
+
id: 'obfus-proxy-reflect',
|
|
112
|
+
severity: 'HIGH',
|
|
113
|
+
title: 'Proxy/Reflect wrapping of dangerous object',
|
|
114
|
+
description: "Wrapping process, require, or globalThis in a Proxy intercepts all property access — used for covert exfiltration or to modify dangerous function behavior.",
|
|
115
|
+
note: "new Proxy(process, handler) can log every process property access. Reflect.get(globalThis, 'eval') accesses eval indirectly.",
|
|
116
|
+
regex: /new\s+Proxy\s*\(\s*(?:process|require|global|globalThis)\b|Reflect\s*\.\s*(?:get|apply)\s*\(\s*(?:globalThis|global|process)\b/,
|
|
117
|
+
},
|
|
118
|
+
{
|
|
119
|
+
// Pattern: const e = eval; e(code) or const {execSync: run} = require('child_process')
|
|
120
|
+
// Alias hides the dangerous function name at all call sites, bypassing keyword scanners.
|
|
121
|
+
id: 'obfus-var-alias',
|
|
122
|
+
severity: 'HIGH',
|
|
123
|
+
title: 'Variable aliasing of dangerous function',
|
|
124
|
+
description: "Assigning eval, exec, or spawn to a new variable name so call sites evade keyword detection.",
|
|
125
|
+
note: "const e = eval; e(code) — the dangerous eval() call is hidden as e(). Destructuring rename: const {execSync: run} = require('child_process').",
|
|
126
|
+
regex: /(?:const|let|var)\s+\w+\s*=\s*eval\b|(?:const|let|var)\s+\{[^}]*(?:exec|spawn)[^}]*:\s*\w+[^}]*\}\s*=/,
|
|
127
|
+
},
|
|
84
128
|
];
|
|
85
129
|
|
|
86
130
|
/**
|
package/lib/scanner/patterns.js
CHANGED
|
@@ -12,6 +12,12 @@ export const CRITICAL_PATTERNS = [
|
|
|
12
12
|
title: 'Pipe-to-shell pattern', description: 'curl|bash or wget|sh — classic RCE.' },
|
|
13
13
|
{ id: 'vm-run', regex: /vm\.(runInNewContext|runInThisContext)\s*\(/,
|
|
14
14
|
title: 'vm module code execution', description: 'Executes code in Node.js VM.' },
|
|
15
|
+
// Binding a raw TCP server is the primary reverse-shell / C2 setup technique.
|
|
16
|
+
// net.createServer in skill code has virtually no legitimate use case.
|
|
17
|
+
{ id: 'reverse-shell',
|
|
18
|
+
regex: /net\.createServer\s*\(|(?:require\(['"`]net['"`]\)|import\(['"`]net['"`]\))[\s\S]{0,300}\.createServer\s*\(/,
|
|
19
|
+
title: 'net.createServer() — reverse shell / port binding',
|
|
20
|
+
description: 'Creating a raw TCP server is the primary mechanism for reverse shells and covert C2 listeners.' },
|
|
15
21
|
];
|
|
16
22
|
|
|
17
23
|
export const HIGH_PATTERNS = [
|
|
@@ -27,6 +33,31 @@ export const HIGH_PATTERNS = [
|
|
|
27
33
|
{ id: 'exfil-combo', regex: /process\.env[\s\S]{0,200}(fetch|axios|http|request)\s*\(/,
|
|
28
34
|
title: 'Env vars + network call (exfil pattern)',
|
|
29
35
|
description: 'Reading env vars then making network calls — credential exfiltration pattern.' },
|
|
36
|
+
// WebSocket bypasses fetch/axios-based detection entirely — a silent exfil channel.
|
|
37
|
+
{ id: 'websocket-exfil',
|
|
38
|
+
regex: /new\s+WebSocket\s*\(|(?:ws|socket)\s*\.send\s*\(/,
|
|
39
|
+
title: 'WebSocket usage (potential data exfiltration)',
|
|
40
|
+
description: 'WebSocket connections can silently exfiltrate data — not caught by fetch/axios-based detection rules.' },
|
|
41
|
+
// DNS can encode secrets in subdomain queries; no HTTP logs, evades most monitoring.
|
|
42
|
+
{ id: 'dns-exfil',
|
|
43
|
+
regex: /require\(['"`](?:dns|node:dns)['"`]\)|from\s+['"`](?:dns|node:dns)['"`]/,
|
|
44
|
+
title: 'DNS module imported (covert channel risk)',
|
|
45
|
+
description: 'DNS can encode data in subdomain queries — a covert exfiltration channel that evades HTTP monitoring.' },
|
|
46
|
+
// __proto__ assignment or Object.prototype mutation corrupts the global object graph.
|
|
47
|
+
{ id: 'proto-pollution',
|
|
48
|
+
regex: /__proto__\s*["'`]|Object\.prototype\s*\[/,
|
|
49
|
+
title: 'Prototype pollution',
|
|
50
|
+
description: 'Assigning to __proto__ or Object.prototype mutates all JS objects — enables object injection attacks.' },
|
|
51
|
+
// Extends exfil-combo to cover outbound channels beyond fetch: WebSocket and DNS.
|
|
52
|
+
{ id: 'exfil-combo-broad',
|
|
53
|
+
regex: /process\.env[\s\S]{0,200}(?:new\s+WebSocket|ws\.send\s*\(|dns\.resolve\s*\(|dns\.lookup\s*\()/,
|
|
54
|
+
title: 'Env vars + WebSocket/DNS outbound (broad exfil)',
|
|
55
|
+
description: 'process.env followed by WebSocket or DNS send — exfiltration path not caught by fetch-only rules.' },
|
|
56
|
+
// Credential file + network call within same scope = high-confidence theft combo.
|
|
57
|
+
{ id: 'cred-read-network',
|
|
58
|
+
regex: /readFileSync\s*\(['"`][^'"`]*(?:\.openclaw|agent-accounts|credentials)[^'"`]*['"`][\s\S]{0,500}(?:fetch|axios|new\s+WebSocket|ws\.send|http\.request)/,
|
|
59
|
+
title: 'Credential file read + outbound network call',
|
|
60
|
+
description: 'Reading a credential file then making a network call in the same scope — credential theft combo.' },
|
|
30
61
|
];
|
|
31
62
|
|
|
32
63
|
export const MEDIUM_PATTERNS = [
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// lib/stack/index.js — Stack orchestrator
|
|
2
|
+
// Reads latest audit result from ~/.clawarmor/audit.log (JSONL),
|
|
3
|
+
// maps score + findings to a risk profile, and determines deployment plan.
|
|
4
|
+
|
|
5
|
+
import { existsSync, readFileSync } from 'fs';
|
|
6
|
+
import { join } from 'path';
|
|
7
|
+
import { homedir } from 'os';
|
|
8
|
+
|
|
9
|
+
const HOME = homedir();
|
|
10
|
+
const AUDIT_LOG = join(HOME, '.clawarmor', 'audit.log');
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Read the latest scored audit entry from ~/.clawarmor/audit.log (JSONL).
|
|
14
|
+
* Prefers entries where score !== null (cmd==='audit'), skips scan entries.
|
|
15
|
+
* Falls back to last-score.json combined with history.json if no scored entry found.
|
|
16
|
+
*/
|
|
17
|
+
function readLatestAudit() {
|
|
18
|
+
if (existsSync(AUDIT_LOG)) {
|
|
19
|
+
try {
|
|
20
|
+
const lines = readFileSync(AUDIT_LOG, 'utf8').split('\n').filter(Boolean);
|
|
21
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
22
|
+
try {
|
|
23
|
+
const entry = JSON.parse(lines[i]);
|
|
24
|
+
if (entry && entry.score != null) return entry;
|
|
25
|
+
} catch { /* skip bad lines */ }
|
|
26
|
+
}
|
|
27
|
+
} catch { /* non-fatal */ }
|
|
28
|
+
}
|
|
29
|
+
// Fallback: last-score.json (minimal — no findings detail)
|
|
30
|
+
const lastScoreFile = join(HOME, '.clawarmor', 'last-score.json');
|
|
31
|
+
if (existsSync(lastScoreFile)) {
|
|
32
|
+
try {
|
|
33
|
+
const s = JSON.parse(readFileSync(lastScoreFile, 'utf8'));
|
|
34
|
+
if (s && s.score != null) return { score: s.score, grade: s.grade, findings: [] };
|
|
35
|
+
} catch { /* non-fatal */ }
|
|
36
|
+
}
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Map audit score + findings to a risk profile.
|
|
42
|
+
* @param {Object|null} audit
|
|
43
|
+
* @returns {{ level: string, label: string, score: number|null, findings: Array }}
|
|
44
|
+
*/
|
|
45
|
+
export function getRiskProfile(audit) {
|
|
46
|
+
if (!audit) return { level: 'unknown', label: 'No audit data', score: null, findings: [] };
|
|
47
|
+
const score = audit.score ?? null;
|
|
48
|
+
const findings = audit.findings ?? [];
|
|
49
|
+
let level, label;
|
|
50
|
+
if (score == null) { level = 'unknown'; label = 'Unknown risk'; }
|
|
51
|
+
else if (score < 50) { level = 'critical'; label = 'Critical / High risk'; }
|
|
52
|
+
else if (score < 75) { level = 'medium'; label = 'Medium risk'; }
|
|
53
|
+
else { level = 'low'; label = 'Low risk'; }
|
|
54
|
+
return { level, label, score, findings };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Read latest audit + derive risk profile.
|
|
59
|
+
* @returns {Promise<{ audit: Object|null, profile: Object }>}
|
|
60
|
+
*/
|
|
61
|
+
export async function getStackStatus() {
|
|
62
|
+
const audit = readLatestAudit();
|
|
63
|
+
const profile = getRiskProfile(audit);
|
|
64
|
+
return { audit, profile };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Get recommended deployment plan based on risk profile.
|
|
69
|
+
* @param {Object} profile - from getRiskProfile()
|
|
70
|
+
* @returns {{ invariant: boolean, ironcurtain: boolean, reason: string }}
|
|
71
|
+
*/
|
|
72
|
+
export function getPlan(profile) {
|
|
73
|
+
const { level } = profile;
|
|
74
|
+
if (level === 'critical') return {
|
|
75
|
+
invariant: true, ironcurtain: true,
|
|
76
|
+
reason: 'Critical risk: deploy Invariant flow guardrails + generate IronCurtain constitution',
|
|
77
|
+
};
|
|
78
|
+
if (level === 'medium') return {
|
|
79
|
+
invariant: true, ironcurtain: true,
|
|
80
|
+
reason: 'Medium risk: deploy Invariant flow guardrails + generate IronCurtain constitution',
|
|
81
|
+
};
|
|
82
|
+
if (level === 'low') return {
|
|
83
|
+
invariant: false, ironcurtain: true,
|
|
84
|
+
reason: 'Low risk: generate IronCurtain constitution as reference hardening',
|
|
85
|
+
};
|
|
86
|
+
// unknown — no audit yet
|
|
87
|
+
return {
|
|
88
|
+
invariant: true, ironcurtain: true,
|
|
89
|
+
reason: 'No audit data: run clawarmor audit first for precise recommendations',
|
|
90
|
+
};
|
|
91
|
+
}
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
// lib/stack/invariant.js — Invariant Guardrails integration
|
|
2
|
+
// Invariant is a Python guardrailing library (invariantlabs-ai/invariant).
|
|
3
|
+
// Rules are plain Invariant DSL strings generated from audit findings.
|
|
4
|
+
|
|
5
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync, statSync } from 'fs';
|
|
6
|
+
import { join } from 'path';
|
|
7
|
+
import { homedir } from 'os';
|
|
8
|
+
import { execSync, spawnSync } from 'child_process';
|
|
9
|
+
|
|
10
|
+
const HOME = homedir();
|
|
11
|
+
const CLAWARMOR_DIR = join(HOME, '.clawarmor');
|
|
12
|
+
const RULES_PATH = join(CLAWARMOR_DIR, 'invariant-rules.inv');
|
|
13
|
+
|
|
14
|
+
/** Check if invariant-ai Python package is installed. */
|
|
15
|
+
export function checkInstalled() {
|
|
16
|
+
try {
|
|
17
|
+
const r = spawnSync('pip3', ['show', 'invariant-ai'], { encoding: 'utf8', timeout: 10000 });
|
|
18
|
+
return r.status === 0 && !!(r.stdout && r.stdout.includes('Name:'));
|
|
19
|
+
} catch { return false; }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Install invariant-ai via pip3.
|
|
24
|
+
* @returns {{ ok: boolean, err?: string }}
|
|
25
|
+
*/
|
|
26
|
+
export function install() {
|
|
27
|
+
try {
|
|
28
|
+
execSync('pip3 install invariant-ai', { stdio: 'pipe', timeout: 120000 });
|
|
29
|
+
return { ok: true };
|
|
30
|
+
} catch (e) {
|
|
31
|
+
return { ok: false, err: e.message?.split('\n')[0] || 'pip3 install failed' };
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Generate Invariant DSL rules from audit findings.
|
|
37
|
+
*
|
|
38
|
+
* Key mappings (from research):
|
|
39
|
+
* exec.security=full + exec.ask=off → flag unrestricted exec tool calls
|
|
40
|
+
* channels with no allowFrom → flag ungated message sends
|
|
41
|
+
* credential files world-readable → block reads on sensitive paths
|
|
42
|
+
* skill supply chain findings → block tool calls from unverified skill sources
|
|
43
|
+
* elevated.allowFrom empty → flag escalated calls from non-allowlisted sources
|
|
44
|
+
*
|
|
45
|
+
* @param {Array} findings - array of { id, severity, title, detail }
|
|
46
|
+
* @returns {string} Invariant policy (.inv) text
|
|
47
|
+
*/
|
|
48
|
+
export function generateRules(findings) {
|
|
49
|
+
const now = new Date().toISOString().slice(0, 10);
|
|
50
|
+
const lines = [
|
|
51
|
+
`# ClawArmor-Generated Invariant Policy`,
|
|
52
|
+
`# Generated: ${now} by clawarmor stack`,
|
|
53
|
+
`# Source: clawarmor stack deploy --invariant`,
|
|
54
|
+
'',
|
|
55
|
+
];
|
|
56
|
+
|
|
57
|
+
if (!findings || !findings.length) {
|
|
58
|
+
lines.push('# No findings from latest audit — no findings-specific rules generated.');
|
|
59
|
+
lines.push('# Run: clawarmor audit, then: clawarmor stack sync');
|
|
60
|
+
lines.push('');
|
|
61
|
+
lines.push('# Generic fallback: prompt injection via web tool → send_message');
|
|
62
|
+
lines.push('raise "Possible prompt injection via tool output" if:');
|
|
63
|
+
lines.push(' (output: ToolOutput) -> (call2: ToolCall)');
|
|
64
|
+
lines.push(' output is tool:get_website');
|
|
65
|
+
lines.push(' prompt_injection(output.content, threshold=0.7)');
|
|
66
|
+
lines.push(' call2 is tool:send_message');
|
|
67
|
+
lines.push('');
|
|
68
|
+
return lines.join('\n');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const generated = new Set();
|
|
72
|
+
|
|
73
|
+
for (const finding of findings) {
|
|
74
|
+
const id = (finding.id || '').toLowerCase();
|
|
75
|
+
const detail = (finding.detail || '').toLowerCase();
|
|
76
|
+
const title = (finding.title || '').toLowerCase();
|
|
77
|
+
// label shown in rule comment — id is always present; title/severity optional
|
|
78
|
+
const label = finding.title || finding.severity || finding.id;
|
|
79
|
+
|
|
80
|
+
// exec.ask=off / exec unrestricted → flag bare exec tool calls
|
|
81
|
+
// Matches: exec.approval, exec.ask, tools.exec.*
|
|
82
|
+
if (
|
|
83
|
+
!generated.has('exec.unrestricted') &&
|
|
84
|
+
(id.includes('exec') || title.includes('exec')) &&
|
|
85
|
+
(id.includes('ask') || id.includes('approval') || id.includes('exec') ||
|
|
86
|
+
detail.includes('ask') || title.includes('approval') || title.includes('unrestricted') ||
|
|
87
|
+
detail.includes('unrestricted'))
|
|
88
|
+
) {
|
|
89
|
+
generated.add('exec.unrestricted');
|
|
90
|
+
lines.push(`# Finding: ${finding.id} — ${label}`);
|
|
91
|
+
lines.push(`raise "Unrestricted exec tool call detected (no approval gate)" if:`);
|
|
92
|
+
lines.push(` (call: ToolCall)`);
|
|
93
|
+
lines.push(` call is tool:exec`);
|
|
94
|
+
lines.push('');
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// channels with no allowFrom / open group policy → ungated message sends
|
|
98
|
+
// Matches: channel.groupPolicy, channel.allowFrom, channels.*
|
|
99
|
+
if (
|
|
100
|
+
!generated.has('channel.ungated') &&
|
|
101
|
+
(id.includes('channel') || title.includes('channel')) &&
|
|
102
|
+
(id.includes('allow') || id.includes('group') || id.includes('policy') ||
|
|
103
|
+
detail.includes('allowfrom') || title.includes('restriction') ||
|
|
104
|
+
title.includes('gate') || title.includes('group') || detail.includes('open'))
|
|
105
|
+
) {
|
|
106
|
+
generated.add('channel.ungated');
|
|
107
|
+
lines.push(`# Finding: ${finding.id} — ${label}`);
|
|
108
|
+
lines.push(`raise "Message sent via ungated channel (no allowFrom restriction)" if:`);
|
|
109
|
+
lines.push(` (call: ToolCall) -> (call2: ToolCall)`);
|
|
110
|
+
lines.push(` call is tool:read_file`);
|
|
111
|
+
lines.push(` call2 is tool:send_message({channel: ".*"})`);
|
|
112
|
+
lines.push('');
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// credential files / secret exposure → block reads on sensitive paths
|
|
116
|
+
// Matches: cred.*, credential-file, cred.json_secrets, filesystem.perms
|
|
117
|
+
if (
|
|
118
|
+
!generated.has('cred.worldread') &&
|
|
119
|
+
(id.includes('cred') || id.includes('credential') || id.includes('filesystem') ||
|
|
120
|
+
id.includes('secret')) &&
|
|
121
|
+
(id.includes('perm') || id.includes('secret') || id.includes('file') ||
|
|
122
|
+
detail.includes('world') || detail.includes('readable') || detail.includes('permission') ||
|
|
123
|
+
title.includes('world') || title.includes('permission') || title.includes('secret'))
|
|
124
|
+
) {
|
|
125
|
+
generated.add('cred.worldread');
|
|
126
|
+
lines.push(`# Finding: ${finding.id} — ${label}`);
|
|
127
|
+
lines.push(`raise "File read on sensitive credential path" if:`);
|
|
128
|
+
lines.push(` (call: ToolCall)`);
|
|
129
|
+
lines.push(` call is tool:read_file`);
|
|
130
|
+
lines.push(` any(s in str(call.args.get("path", "")) for s in [".ssh", ".aws", "agent-accounts", ".openclaw"])`);
|
|
131
|
+
lines.push('');
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// skill supply chain — block tool calls from unverified sources
|
|
135
|
+
// Matches: skill.pinning, skill.supplychain, skills.*
|
|
136
|
+
if (
|
|
137
|
+
!generated.has('skill.supplychain') &&
|
|
138
|
+
id.includes('skill') &&
|
|
139
|
+
(id.includes('pin') || id.includes('supply') || id.includes('md') ||
|
|
140
|
+
detail.includes('supply') || detail.includes('unverified') ||
|
|
141
|
+
title.includes('pin') || title.includes('supply'))
|
|
142
|
+
) {
|
|
143
|
+
generated.add('skill.supplychain');
|
|
144
|
+
lines.push(`# Finding: ${finding.id} — ${label}`);
|
|
145
|
+
lines.push(`raise "Tool call from unverified skill source (no version pin)" if:`);
|
|
146
|
+
lines.push(` (call: ToolCall)`);
|
|
147
|
+
lines.push(` not call.metadata.get("skill_verified", False)`);
|
|
148
|
+
lines.push('');
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// elevated permissions with no allowFrom restriction
|
|
152
|
+
// Matches: elevated.allowFrom, tools.elevated.*, allowFrom.*
|
|
153
|
+
if (
|
|
154
|
+
!generated.has('elevated.ungated') &&
|
|
155
|
+
(id.includes('elevated') || id.includes('allowfrom') || title.includes('elevated'))
|
|
156
|
+
) {
|
|
157
|
+
generated.add('elevated.ungated');
|
|
158
|
+
lines.push(`# Finding: ${finding.id} — ${label}`);
|
|
159
|
+
lines.push(`raise "Elevated tool call from ungated source" if:`);
|
|
160
|
+
lines.push(` (call: ToolCall)`);
|
|
161
|
+
lines.push(` call.metadata.get("elevated", False)`);
|
|
162
|
+
lines.push(` not call.metadata.get("allowFrom_restricted", False)`);
|
|
163
|
+
lines.push('');
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (!generated.size) {
|
|
168
|
+
lines.push('# No specific rule mappings matched. Generic fallback:');
|
|
169
|
+
lines.push('');
|
|
170
|
+
lines.push('raise "Possible prompt injection via tool output" if:');
|
|
171
|
+
lines.push(' (output: ToolOutput) -> (call2: ToolCall)');
|
|
172
|
+
lines.push(' output is tool:get_website');
|
|
173
|
+
lines.push(' prompt_injection(output.content, threshold=0.7)');
|
|
174
|
+
lines.push(' call2 is tool:send_message');
|
|
175
|
+
lines.push('');
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return lines.join('\n');
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Write rules to ~/.clawarmor/invariant-rules.inv and validate syntax if invariant-ai is installed.
|
|
183
|
+
* @param {string} rulesContent
|
|
184
|
+
* @returns {{ ok: boolean, err?: string, rulesPath: string }}
|
|
185
|
+
*/
|
|
186
|
+
export function deploy(rulesContent) {
|
|
187
|
+
try {
|
|
188
|
+
if (!existsSync(CLAWARMOR_DIR)) mkdirSync(CLAWARMOR_DIR, { recursive: true });
|
|
189
|
+
writeFileSync(RULES_PATH, rulesContent, 'utf8');
|
|
190
|
+
|
|
191
|
+
// Validate syntax if invariant-ai is available (non-fatal if not)
|
|
192
|
+
if (checkInstalled()) {
|
|
193
|
+
const v = spawnSync('python3', [
|
|
194
|
+
'-c',
|
|
195
|
+
`from invariant.analyzer import LocalPolicy; LocalPolicy.from_file('${RULES_PATH}'); print('ok')`,
|
|
196
|
+
], { encoding: 'utf8', timeout: 30000 });
|
|
197
|
+
if (v.status !== 0) {
|
|
198
|
+
const msg = (v.stderr || '').split('\n')[0];
|
|
199
|
+
return { ok: false, err: `Syntax validation failed: ${msg}`, rulesPath: RULES_PATH };
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
return { ok: true, rulesPath: RULES_PATH };
|
|
203
|
+
} catch (e) {
|
|
204
|
+
return { ok: false, err: e.message?.split('\n')[0] || 'deploy failed', rulesPath: RULES_PATH };
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Get current status of Invariant integration.
|
|
210
|
+
* @returns {{ installed: boolean, rulesExist: boolean, rulesPath: string, ruleCount: number, lastDeployed: string|null }}
|
|
211
|
+
*/
|
|
212
|
+
export function getStatus() {
|
|
213
|
+
const installed = checkInstalled();
|
|
214
|
+
const rulesExist = existsSync(RULES_PATH);
|
|
215
|
+
let ruleCount = 0, lastDeployed = null;
|
|
216
|
+
if (rulesExist) {
|
|
217
|
+
try {
|
|
218
|
+
const content = readFileSync(RULES_PATH, 'utf8');
|
|
219
|
+
ruleCount = (content.match(/^raise /gm) || []).length;
|
|
220
|
+
lastDeployed = statSync(RULES_PATH).mtime.toISOString();
|
|
221
|
+
} catch { /* non-fatal */ }
|
|
222
|
+
}
|
|
223
|
+
return { installed, rulesExist, rulesPath: RULES_PATH, ruleCount, lastDeployed };
|
|
224
|
+
}
|