clawarmor 1.1.0 → 1.2.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/cli.js CHANGED
@@ -1,9 +1,9 @@
1
1
  #!/usr/bin/env node
2
- // ClawArmor v1.1.0 — Security armor for OpenClaw agents
2
+ // ClawArmor v1.2.0 — Security armor for OpenClaw agents
3
3
 
4
4
  import { paint } from './lib/output/colors.js';
5
5
 
6
- const VERSION = '1.1.0';
6
+ const VERSION = '1.2.0';
7
7
  const GATEWAY_PORT_DEFAULT = 18789;
8
8
 
9
9
  function isLocalhost(host) {
@@ -50,7 +50,8 @@ function usage() {
50
50
  console.log(` ${paint.dim('--url <host:port>')} Probe a specific host:port instead of 127.0.0.1`);
51
51
  console.log(` ${paint.dim('--config <path>')} Use a specific config file instead of ~/.openclaw/openclaw.json`);
52
52
  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`);
53
+ console.log(` ${paint.dim('--explain-reads')} Print every file read and network call before executing
54
+ ${paint.dim('--accept-changes')} Update config baseline after reviewing detected changes`);
54
55
  console.log('');
55
56
  console.log(` ${paint.dim('Examples:')}`);
56
57
  console.log(` ${paint.dim('clawarmor audit')} ${paint.dim('# local, default')}`);
@@ -79,6 +80,7 @@ const flags = {
79
80
  targetHost: parsedUrl?.host || null,
80
81
  targetPort: parsedUrl?.port || null,
81
82
  configPath: configPathArg || null,
83
+ acceptChanges: args.includes('--accept-changes'),
82
84
  };
83
85
 
84
86
  if (!cmd || cmd === '--help' || cmd === '-h' || cmd === 'help') { usage(); process.exit(0); }
package/lib/audit.js CHANGED
@@ -14,11 +14,12 @@ import allowFromChecks from './checks/allowfrom.js';
14
14
  import { writeFileSync, mkdirSync, existsSync, readFileSync, renameSync } from 'fs';
15
15
  import { join } from 'path';
16
16
  import { homedir } from 'os';
17
+ import { checkIntegrity, updateBaseline } from './integrity.js';
17
18
 
18
19
  const W = { CRITICAL: 25, HIGH: 15, MEDIUM: 8, LOW: 3, INFO: 0 };
19
20
  const SEP = paint.dim('─'.repeat(52));
20
21
  const W52 = 52;
21
- const VERSION = '1.1.0';
22
+ const VERSION = '1.2.0';
22
23
 
23
24
  const HISTORY_DIR = join(homedir(), '.clawarmor');
24
25
  const HISTORY_FILE = join(HISTORY_DIR, 'history.json');
@@ -242,7 +243,25 @@ export async function runAudit(flags = {}) {
242
243
  console.log(` ${paint.dim('Continuous monitoring:')} ${paint.cyan('github.com/pinzasai/clawarmor')}`);
243
244
  console.log('');
244
245
 
245
- // Persist history (atomic)
246
+ // ── CONFIG INTEGRITY CHECK ─────────────────────────────────────────────
247
+ if (!isRemote && configPath) {
248
+ const integ = checkIntegrity(configPath, score);
249
+ if (integ.status === 'baseline') {
250
+ console.log(` ${paint.dim('ℹ')} ${paint.dim('Config baseline established — future changes will be flagged.')}`);
251
+ } else if (integ.status === 'changed') {
252
+ console.log('');
253
+ console.log(` ${paint.yellow('!')} ${paint.bold('Config changed since last clean audit')}`);
254
+ for (const c of integ.changes) console.log(` ${paint.dim(c)}`);
255
+ console.log(` ${paint.dim('Baseline set: ' + integ.baselineAt?.slice(0,10))}`);
256
+ console.log(` ${paint.dim('Run clawarmor audit --accept-changes to update baseline')}`);
257
+ }
258
+ }
259
+ if (flags.acceptChanges && configPath) {
260
+ updateBaseline(configPath, score);
261
+ console.log(` ${paint.green('✓')} Config baseline updated.`);
262
+ }
263
+
264
+ // Persist history (atomic)
246
265
  appendHistory({
247
266
  timestamp: new Date().toISOString(),
248
267
  score,
@@ -0,0 +1,102 @@
1
+ // integrity.js — Config integrity hashing (P2-3)
2
+ // On first clean audit: hashes the config and saves the baseline.
3
+ // On subsequent runs: detects changes and surfaces them.
4
+ // Zero external deps — uses Node.js built-in crypto.
5
+
6
+ import { createHash } from 'crypto';
7
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs';
8
+ import { join } from 'path';
9
+ import { homedir } from 'os';
10
+
11
+ const INTEGRITY_FILE = join(homedir(), '.clawarmor', 'integrity.json');
12
+
13
+ function hashFile(filePath) {
14
+ try {
15
+ const content = readFileSync(filePath, 'utf8');
16
+ return {
17
+ hash: createHash('sha256').update(content).digest('hex').slice(0, 16),
18
+ size: content.length,
19
+ lines: content.split('\n').length,
20
+ };
21
+ } catch {
22
+ return null;
23
+ }
24
+ }
25
+
26
+ function loadIntegrity() {
27
+ if (!existsSync(INTEGRITY_FILE)) return null;
28
+ try { return JSON.parse(readFileSync(INTEGRITY_FILE, 'utf8')); }
29
+ catch { return null; }
30
+ }
31
+
32
+ function saveIntegrity(data) {
33
+ try {
34
+ mkdirSync(join(homedir(), '.clawarmor'), { recursive: true });
35
+ writeFileSync(INTEGRITY_FILE, JSON.stringify(data, null, 2), 'utf8');
36
+ } catch { /* non-fatal */ }
37
+ }
38
+
39
+ /**
40
+ * Check config integrity. Call this after a successful (score > 0) audit.
41
+ * Returns { status, changes } where status is 'baseline'|'ok'|'changed'.
42
+ */
43
+ export function checkIntegrity(configPath, score) {
44
+ const current = hashFile(configPath);
45
+ if (!current) return { status: 'unreadable', changes: [] };
46
+
47
+ const stored = loadIntegrity();
48
+
49
+ if (!stored) {
50
+ // First run — establish baseline (only if clean or near-clean)
51
+ if (score >= 80) {
52
+ saveIntegrity({
53
+ configPath,
54
+ hash: current.hash,
55
+ size: current.size,
56
+ lines: current.lines,
57
+ baselineAt: new Date().toISOString(),
58
+ baselineScore: score,
59
+ });
60
+ return { status: 'baseline', changes: [] };
61
+ }
62
+ return { status: 'no-baseline', changes: [] };
63
+ }
64
+
65
+ // Check for changes
66
+ if (stored.hash === current.hash) {
67
+ return { status: 'ok', changes: [] };
68
+ }
69
+
70
+ const changes = [];
71
+ if (stored.size !== current.size) {
72
+ const delta = current.size - stored.size;
73
+ changes.push(`Size: ${stored.size} → ${current.size} bytes (${delta > 0 ? '+' : ''}${delta})`);
74
+ }
75
+ if (stored.lines !== current.lines) {
76
+ const delta = current.lines - stored.lines;
77
+ changes.push(`Lines: ${stored.lines} → ${current.lines} (${delta > 0 ? '+' : ''}${delta})`);
78
+ }
79
+ changes.push(`Hash: ${stored.hash} → ${current.hash}`);
80
+
81
+ return {
82
+ status: 'changed',
83
+ changes,
84
+ baselineAt: stored.baselineAt,
85
+ baselineScore: stored.baselineScore,
86
+ };
87
+ }
88
+
89
+ /** Update baseline after a clean audit (call when user explicitly passes --accept-changes). */
90
+ export function updateBaseline(configPath, score) {
91
+ const current = hashFile(configPath);
92
+ if (!current) return false;
93
+ saveIntegrity({
94
+ configPath,
95
+ hash: current.hash,
96
+ size: current.size,
97
+ lines: current.lines,
98
+ baselineAt: new Date().toISOString(),
99
+ baselineScore: score,
100
+ });
101
+ return true;
102
+ }
@@ -1,7 +1,8 @@
1
1
  import { readFileSync } from 'fs';
2
- import { extname, basename } from 'path';
2
+ import { extname } from 'path';
3
3
  import { CRITICAL_PATTERNS, HIGH_PATTERNS, MEDIUM_PATTERNS,
4
4
  SCANNABLE_EXTENSIONS, SKIP_EXTENSIONS, isSpawnInBinaryWrapper } from './patterns.js';
5
+ import { scanForObfuscation } from './obfuscation.js';
5
6
 
6
7
  function getExt(p) { return extname(p).replace('.','').toLowerCase(); }
7
8
 
@@ -65,5 +66,10 @@ export function scanFile(filePath, isBuiltin = false) {
65
66
  findings.push({ patternId: pattern.id, severity, title: pattern.title,
66
67
  description: pattern.description, file: filePath, matches, note });
67
68
  }
69
+
70
+ // Obfuscation analysis (catches what naive grep misses)
71
+ const obfusFindings = scanForObfuscation(filePath, content, isBuiltin);
72
+ findings.push(...obfusFindings);
73
+
68
74
  return findings;
69
75
  }
@@ -0,0 +1,130 @@
1
+ // obfuscation.js — v1.2.0
2
+ // Detects obfuscated code patterns that bypass naive string-grep analysis.
3
+ // Zero external dependencies. Pure regex, adversarially reviewed.
4
+ //
5
+ // Targets:
6
+ // - String concatenation reassembly: 'ev'+'al', 'ex'+'ec'
7
+ // - Bracket property access with concat: obj['ex'+'ec']
8
+ // - Buffer.from(base64).toString() decode chains
9
+ // - eval(atob(...)) decode+exec
10
+ // - globalThis/global bracket access to dangerous names
11
+ // - ['constructor'] escape pattern
12
+ // - Unicode/hex escape sequences for dangerous keywords
13
+
14
+ export const OBFUSCATION_PATTERNS = [
15
+ {
16
+ id: 'obfus-str-concat-eval',
17
+ severity: 'CRITICAL',
18
+ title: "String-concat 'eval' reassembly",
19
+ description: "Obfuscated eval via string concat: 'ev'+'al'. Bypasses naive keyword grep.",
20
+ note: 'Legitimate code rarely splits the word eval across string literals.',
21
+ regex: /'ev'\s*\+\s*'al'|"ev"\s*\+\s*"al"|'e'\s*\+\s*'val'|"e"\s*\+\s*"val"/,
22
+ },
23
+ {
24
+ id: 'obfus-str-concat-exec',
25
+ severity: 'HIGH',
26
+ title: "String-concat 'exec' reassembly",
27
+ description: "Obfuscated exec via string concat: 'ex'+'ec'. Common shell command injection prep.",
28
+ note: 'Legitimate code rarely splits exec across string literals.',
29
+ regex: /'ex'\s*\+\s*'ec'|"ex"\s*\+\s*"ec"|'e'\s*\+\s*'xec'|"e"\s*\+\s*"xec"/,
30
+ },
31
+ {
32
+ id: 'obfus-bracket-concat',
33
+ severity: 'HIGH',
34
+ title: 'Bracket property access via string concat',
35
+ description: "obj['ex'+'ec'] bypasses static method-name analysis. Common obfuscation technique.",
36
+ note: 'Legitimate code almost never accesses properties via concatenated string literals.',
37
+ regex: /\[\s*'[a-zA-Z]{1,5}'\s*\+\s*'[a-zA-Z]{1,5}'\s*\]|\[\s*"[a-zA-Z]{1,5}"\s*\+\s*"[a-zA-Z]{1,5}"\s*\]/,
38
+ },
39
+ {
40
+ id: 'obfus-buffer-base64',
41
+ severity: 'HIGH',
42
+ title: 'Buffer.from(base64).toString() decode chain',
43
+ description: "Decodes a base64 payload at runtime — common technique for hiding malicious code strings.",
44
+ note: 'May be legitimate for binary data handling, but unusual in skill files.',
45
+ regex: /Buffer\.from\((?:'[A-Za-z0-9+\/=]{20,}'|"[A-Za-z0-9+\/=]{20,}")\s*,\s*(?:'base64'|"base64")\)/,
46
+ },
47
+ {
48
+ id: 'obfus-atob-exec',
49
+ severity: 'CRITICAL',
50
+ title: 'eval(atob(...)) decode+execute',
51
+ description: "Decodes and executes a base64 string. Textbook obfuscation for hiding eval payloads.",
52
+ regex: /(?:eval|Function)\s*\(\s*atob\s*\(/,
53
+ },
54
+ {
55
+ id: 'obfus-globalthis-bracket',
56
+ severity: 'HIGH',
57
+ title: 'globalThis[...] bracket access',
58
+ description: "Accesses global scope via bracket notation — hides dangerous function names from static analysis.",
59
+ note: 'globalThis["eval"] is equivalent to eval but bypasses keyword scanners.',
60
+ regex: /globalThis\s*\[\s*['"][^'"]{1,30}['"]\s*\]|global\s*\[\s*['"][^'"]{1,30}['"]\s*\]/,
61
+ },
62
+ {
63
+ id: 'obfus-constructor-escape',
64
+ severity: 'CRITICAL',
65
+ title: "['constructor'] Function escape",
66
+ description: "Accesses Function constructor via bracket notation to execute arbitrary code strings.",
67
+ note: "Pattern: obj['constructor']('return process')(). Classic prototype escape.",
68
+ regex: /\[\s*['"]constructor['"]\s*\]/,
69
+ },
70
+ {
71
+ id: 'obfus-unicode-escape',
72
+ severity: 'HIGH',
73
+ title: 'Unicode escape for dangerous keyword',
74
+ description: "Uses \\u escapes to spell dangerous keywords: \\u0065val = eval. Bypasses string matching.",
75
+ regex: /\\u00(?:65|45)\\u00(?:76|56)\\u00(?:61|41)\\u00(?:6c|4c)|\\u0065\\u0076\\u0061\\u006c/i,
76
+ },
77
+ {
78
+ id: 'obfus-encoded-require',
79
+ severity: 'HIGH',
80
+ title: 'Encoded require() or import()',
81
+ description: "Calls require() or import() with a runtime-decoded string argument (atob, fromCharCode, etc.).",
82
+ regex: /(?:require|import)\s*\(\s*(?:atob|String\.fromCharCode|Buffer\.from)\s*\(/,
83
+ },
84
+ ];
85
+
86
+ /**
87
+ * Scan file content for obfuscation patterns.
88
+ * Returns findings array (same shape as file-scanner.js output).
89
+ */
90
+ export function scanForObfuscation(filePath, content, isBuiltin = false) {
91
+ const findings = [];
92
+ const lines = content.split('\n');
93
+
94
+ for (const pattern of OBFUSCATION_PATTERNS) {
95
+ const regex = new RegExp(pattern.regex.source, 'gi');
96
+ const matches = [];
97
+ let m;
98
+
99
+ while ((m = regex.exec(content)) !== null) {
100
+ const lineNum = content.substring(0, m.index).split('\n').length;
101
+ const line = lines[lineNum - 1]?.trim() || '';
102
+
103
+ // Skip comment lines
104
+ if (/^\s*(\/\/|#|\*|<!--)/.test(line)) continue;
105
+
106
+ matches.push({ line: lineNum, snippet: line.substring(0, 120) });
107
+ if (matches.length >= 3) break;
108
+ }
109
+
110
+ if (!matches.length) continue;
111
+
112
+ // Built-in skills: downgrade severity (still worth reporting but lower urgency)
113
+ let severity = pattern.severity;
114
+ if (isBuiltin) {
115
+ severity = severity === 'CRITICAL' ? 'MEDIUM' : 'LOW';
116
+ }
117
+
118
+ findings.push({
119
+ patternId: pattern.id,
120
+ severity,
121
+ title: pattern.title,
122
+ description: pattern.description,
123
+ note: pattern.note || null,
124
+ file: filePath,
125
+ matches,
126
+ });
127
+ }
128
+
129
+ return findings;
130
+ }
package/package.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "name": "clawarmor",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "Security armor for OpenClaw agents — audit, scan, monitor",
5
5
  "bin": {
6
- "clawarmor": "./cli.js"
6
+ "clawarmor": "cli.js"
7
7
  },
8
8
  "type": "module",
9
9
  "engines": {
@@ -23,7 +23,7 @@
23
23
  "license": "MIT",
24
24
  "repository": {
25
25
  "type": "git",
26
- "url": "https://github.com/pinzasai/clawarmor"
26
+ "url": "git+https://github.com/pinzasai/clawarmor.git"
27
27
  },
28
28
  "homepage": "https://clawarmor.dev"
29
29
  }