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/lib/prescan.js
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
// ClawArmor v2.0 — Pre-scan a skill before installing
|
|
2
|
+
// Downloads the npm package to a temp dir, scans it with the full
|
|
3
|
+
// ClawArmor scanner, and exits 1 (blocks install) only on CRITICAL findings.
|
|
4
|
+
|
|
5
|
+
import { mkdirSync, rmSync, readdirSync } from 'fs';
|
|
6
|
+
import { join } from 'path';
|
|
7
|
+
import { tmpdir } from 'os';
|
|
8
|
+
import { execSync } from 'child_process';
|
|
9
|
+
import { scanFile } from './scanner/file-scanner.js';
|
|
10
|
+
import { scanSkillMdFiles } from './scanner/skill-md-scanner.js';
|
|
11
|
+
import { paint, severityColor } from './output/colors.js';
|
|
12
|
+
import { append } from './audit-log.js';
|
|
13
|
+
|
|
14
|
+
const SEP = paint.dim('─'.repeat(52));
|
|
15
|
+
|
|
16
|
+
function getAllFiles(dir, files = []) {
|
|
17
|
+
try {
|
|
18
|
+
for (const e of readdirSync(dir, { withFileTypes: true })) {
|
|
19
|
+
if (e.name.startsWith('.') || e.name === 'node_modules' || e.name === '__pycache__') continue;
|
|
20
|
+
const fp = join(dir, e.name);
|
|
21
|
+
if (e.isDirectory()) getAllFiles(fp, files);
|
|
22
|
+
else files.push(fp);
|
|
23
|
+
}
|
|
24
|
+
} catch { /* permission denied */ }
|
|
25
|
+
return files;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function cleanupTmp(dir) {
|
|
29
|
+
try { rmSync(dir, { recursive: true, force: true }); } catch { /* non-fatal */ }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function runPrescan(skillName) {
|
|
33
|
+
console.log('');
|
|
34
|
+
console.log(` ${paint.bold('ClawArmor Prescan')} — ${paint.cyan(skillName)}`);
|
|
35
|
+
console.log(` ${paint.dim('Fetching package from npm registry...')}`);
|
|
36
|
+
console.log('');
|
|
37
|
+
|
|
38
|
+
const tmpDir = join(tmpdir(), `clawarmor-prescan-${Date.now()}`);
|
|
39
|
+
mkdirSync(tmpDir, { recursive: true });
|
|
40
|
+
|
|
41
|
+
// ── Step 1: Download via npm pack ─────────────────────────────────────────
|
|
42
|
+
let tarball;
|
|
43
|
+
try {
|
|
44
|
+
execSync(`npm pack ${skillName}`, {
|
|
45
|
+
cwd: tmpDir,
|
|
46
|
+
timeout: 30000,
|
|
47
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
48
|
+
});
|
|
49
|
+
const tarballs = readdirSync(tmpDir).filter(f => f.endsWith('.tgz'));
|
|
50
|
+
if (!tarballs.length) throw new Error('npm pack produced no tarball');
|
|
51
|
+
tarball = join(tmpDir, tarballs[0]);
|
|
52
|
+
} catch {
|
|
53
|
+
cleanupTmp(tmpDir);
|
|
54
|
+
console.log(` ${paint.dim('ℹ')} Could not fetch skill for scanning`);
|
|
55
|
+
console.log(` ${paint.dim('(package not found or network error — install not blocked)')}`);
|
|
56
|
+
console.log('');
|
|
57
|
+
return 0;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ── Step 2: Extract tarball ────────────────────────────────────────────────
|
|
61
|
+
const extractDir = join(tmpDir, 'extracted');
|
|
62
|
+
mkdirSync(extractDir, { recursive: true });
|
|
63
|
+
try {
|
|
64
|
+
execSync(`tar -xzf "${tarball}" -C "${extractDir}"`, {
|
|
65
|
+
timeout: 15000,
|
|
66
|
+
stdio: ['ignore', 'ignore', 'ignore'],
|
|
67
|
+
});
|
|
68
|
+
} catch {
|
|
69
|
+
cleanupTmp(tmpDir);
|
|
70
|
+
console.log(` ${paint.dim('ℹ')} Could not extract skill package — install not blocked`);
|
|
71
|
+
console.log('');
|
|
72
|
+
return 0;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ── Step 3: Collect all files ──────────────────────────────────────────────
|
|
76
|
+
const allFiles = getAllFiles(extractDir);
|
|
77
|
+
console.log(` ${paint.dim('Scanning')} ${allFiles.length} file${allFiles.length !== 1 ? 's' : ''}...`);
|
|
78
|
+
console.log('');
|
|
79
|
+
|
|
80
|
+
// ── Step 4: Run ClawArmor scanners ────────────────────────────────────────
|
|
81
|
+
// Not a built-in — treat as third-party (isBuiltin = false)
|
|
82
|
+
const codeFindings = allFiles.flatMap(f => scanFile(f, false));
|
|
83
|
+
const mdResults = scanSkillMdFiles(allFiles, false);
|
|
84
|
+
const mdFindings = mdResults.flatMap(r => r.findings);
|
|
85
|
+
const allFindings = [...codeFindings, ...mdFindings];
|
|
86
|
+
|
|
87
|
+
const criticals = allFindings.filter(f => f.severity === 'CRITICAL');
|
|
88
|
+
const highs = allFindings.filter(f => f.severity === 'HIGH');
|
|
89
|
+
const mediums = allFindings.filter(f => f.severity === 'MEDIUM');
|
|
90
|
+
const lows = allFindings.filter(f => f.severity === 'LOW' || f.severity === 'INFO');
|
|
91
|
+
|
|
92
|
+
// ── Cleanup (always) ──────────────────────────────────────────────────────
|
|
93
|
+
cleanupTmp(tmpDir);
|
|
94
|
+
|
|
95
|
+
// ── Audit log ─────────────────────────────────────────────────────────────
|
|
96
|
+
append({
|
|
97
|
+
cmd: 'prescan',
|
|
98
|
+
trigger: 'prescan',
|
|
99
|
+
score: null,
|
|
100
|
+
delta: null,
|
|
101
|
+
findings: allFindings.map(f => ({ id: f.patternId || f.id || '?', severity: f.severity })),
|
|
102
|
+
blocked: criticals.length > 0,
|
|
103
|
+
skill: skillName,
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
// ── Output ────────────────────────────────────────────────────────────────
|
|
107
|
+
if (!allFindings.length) {
|
|
108
|
+
console.log(` ${paint.green('✓')} ClawArmor prescan: clean — 0 findings`);
|
|
109
|
+
console.log('');
|
|
110
|
+
return 0;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// CRITICAL → print details, block install (exit 1)
|
|
114
|
+
if (criticals.length) {
|
|
115
|
+
console.log(SEP);
|
|
116
|
+
console.log(` ${paint.red('✗')} ${paint.bold(`CRITICAL (${criticals.length}) — install blocked`)}`);
|
|
117
|
+
console.log(SEP);
|
|
118
|
+
for (const f of criticals) {
|
|
119
|
+
console.log('');
|
|
120
|
+
console.log(` ${paint.red('✗')} ${(severityColor['CRITICAL'] || paint.red)('[CRITICAL]')} ${paint.bold(f.title)}`);
|
|
121
|
+
console.log(` ${paint.dim(f.description || '')}`);
|
|
122
|
+
for (const m of (f.matches || []).slice(0, 2)) {
|
|
123
|
+
console.log(` ${paint.dim('→')} ${paint.cyan(':' + m.line)} ${paint.dim(m.snippet)}`);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (highs.length || mediums.length) {
|
|
128
|
+
console.log('');
|
|
129
|
+
const extra = [];
|
|
130
|
+
if (highs.length) extra.push(`${highs.length} HIGH`);
|
|
131
|
+
if (mediums.length) extra.push(`${mediums.length} MEDIUM`);
|
|
132
|
+
console.log(` ${paint.yellow('!')} Additional: ${extra.join(', ')} (fix criticals first)`);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
console.log('');
|
|
136
|
+
console.log(` ${paint.red('✗')} Skill blocked. Do NOT install ${paint.bold(skillName)}.`);
|
|
137
|
+
console.log('');
|
|
138
|
+
return 1;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// HIGH → warn, allow install
|
|
142
|
+
if (highs.length) {
|
|
143
|
+
console.log(SEP);
|
|
144
|
+
console.log(` ${paint.yellow('⚠')} ${paint.bold(`HIGH (${highs.length}) — review before using`)}`);
|
|
145
|
+
console.log(SEP);
|
|
146
|
+
for (const f of highs) {
|
|
147
|
+
console.log('');
|
|
148
|
+
console.log(` ${paint.yellow('!')} ${(severityColor['HIGH'] || paint.yellow)('[HIGH]')} ${paint.bold(f.title)}`);
|
|
149
|
+
console.log(` ${paint.dim(f.description || '')}`);
|
|
150
|
+
for (const m of (f.matches || []).slice(0, 2)) {
|
|
151
|
+
console.log(` ${paint.dim('→')} ${paint.cyan(':' + m.line)} ${paint.dim(m.snippet)}`);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
console.log('');
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// MEDIUM/LOW → summary line only
|
|
158
|
+
if (mediums.length || lows.length) {
|
|
159
|
+
const parts = [];
|
|
160
|
+
if (mediums.length) parts.push(`${mediums.length} medium`);
|
|
161
|
+
if (lows.length) parts.push(`${lows.length} low/info`);
|
|
162
|
+
console.log(` ${paint.dim('ℹ')} ${parts.join(', ')} additional finding${(mediums.length + lows.length) > 1 ? 's' : ''} (review manually)`);
|
|
163
|
+
console.log('');
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return 0;
|
|
167
|
+
}
|
package/lib/protect.js
ADDED
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
// clawarmor protect — Install/uninstall/status the full ClawArmor guard system.
|
|
2
|
+
// --install: writes hook files, adds shell intercept, starts watch daemon
|
|
3
|
+
// --uninstall: reverses all of the above cleanly
|
|
4
|
+
// --status: shows current state without modifying anything
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync, readdirSync, rmSync,
|
|
8
|
+
} from 'fs';
|
|
9
|
+
import { join } from 'path';
|
|
10
|
+
import { homedir } from 'os';
|
|
11
|
+
import { spawnSync } from 'child_process';
|
|
12
|
+
import { watchDaemonStatus } from './watch.js';
|
|
13
|
+
|
|
14
|
+
const HOME = homedir();
|
|
15
|
+
const OC_DIR = join(HOME, '.openclaw');
|
|
16
|
+
const CLAWARMOR_DIR = join(HOME, '.clawarmor');
|
|
17
|
+
const HOOKS_DIR = join(OC_DIR, 'hooks');
|
|
18
|
+
const GUARD_HOOK_DIR = join(HOOKS_DIR, 'clawarmor-guard');
|
|
19
|
+
const CLI_PATH = new URL('../cli.js', import.meta.url).pathname;
|
|
20
|
+
|
|
21
|
+
const SHELL_FUNCTION = `
|
|
22
|
+
# ClawArmor intercept — added by: clawarmor protect --install
|
|
23
|
+
# Wraps 'openclaw clawhub install' to scan skills before activation.
|
|
24
|
+
openclaw() {
|
|
25
|
+
if [ "$1" = "clawhub" ] && [ "$2" = "install" ] && [ -n "$3" ]; then
|
|
26
|
+
echo "ClawArmor: scanning $3 before install..."
|
|
27
|
+
clawarmor prescan "$3" || { echo "Blocked by ClawArmor. Use --force to override."; return 1; }
|
|
28
|
+
fi
|
|
29
|
+
command openclaw "$@"
|
|
30
|
+
}
|
|
31
|
+
# End ClawArmor intercept
|
|
32
|
+
`;
|
|
33
|
+
|
|
34
|
+
const SHELL_MARKER_START = '# ClawArmor intercept — added by: clawarmor protect --install';
|
|
35
|
+
const SHELL_MARKER_END = '# End ClawArmor intercept';
|
|
36
|
+
|
|
37
|
+
const HOOK_MD = `---
|
|
38
|
+
name: clawarmor-guard
|
|
39
|
+
description: Runs a silent security audit on gateway startup and alerts on regressions
|
|
40
|
+
events:
|
|
41
|
+
- gateway:startup
|
|
42
|
+
requires:
|
|
43
|
+
bins:
|
|
44
|
+
- clawarmor
|
|
45
|
+
---
|
|
46
|
+
|
|
47
|
+
# clawarmor-guard
|
|
48
|
+
|
|
49
|
+
Fires on every gateway startup. Runs \`clawarmor audit --json\` in the background,
|
|
50
|
+
compares the score to the last known baseline, and alerts the agent if the score
|
|
51
|
+
drops by 5 or more points, or if a new CRITICAL finding appears.
|
|
52
|
+
|
|
53
|
+
Install with: \`clawarmor protect --install\`
|
|
54
|
+
`;
|
|
55
|
+
|
|
56
|
+
const HANDLER_JS = `// clawarmor-guard hook handler
|
|
57
|
+
// Fires on gateway:startup. Silent unless score drops or CRITICAL finding appears.
|
|
58
|
+
// No external dependencies.
|
|
59
|
+
|
|
60
|
+
import { spawnSync } from 'child_process';
|
|
61
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
|
|
62
|
+
import { join } from 'path';
|
|
63
|
+
import { homedir } from 'os';
|
|
64
|
+
|
|
65
|
+
const HOME = homedir();
|
|
66
|
+
const CLAWARMOR_DIR = join(HOME, '.clawarmor');
|
|
67
|
+
const LAST_SCORE_FILE = join(CLAWARMOR_DIR, 'last-score.json');
|
|
68
|
+
|
|
69
|
+
function readLastScore() {
|
|
70
|
+
try {
|
|
71
|
+
if (existsSync(LAST_SCORE_FILE)) return JSON.parse(readFileSync(LAST_SCORE_FILE, 'utf8'));
|
|
72
|
+
} catch {}
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function writeLastScore(data) {
|
|
77
|
+
try {
|
|
78
|
+
mkdirSync(CLAWARMOR_DIR, { recursive: true });
|
|
79
|
+
writeFileSync(LAST_SCORE_FILE, JSON.stringify(data, null, 2), 'utf8');
|
|
80
|
+
} catch {}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function runAuditJson() {
|
|
84
|
+
try {
|
|
85
|
+
const result = spawnSync('clawarmor', ['audit', '--json'], {
|
|
86
|
+
encoding: 'utf8',
|
|
87
|
+
timeout: 30000,
|
|
88
|
+
maxBuffer: 1024 * 1024,
|
|
89
|
+
});
|
|
90
|
+
if (result.stdout) {
|
|
91
|
+
const jsonStart = result.stdout.indexOf('{');
|
|
92
|
+
if (jsonStart !== -1) return JSON.parse(result.stdout.slice(jsonStart));
|
|
93
|
+
}
|
|
94
|
+
} catch {}
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Main hook entry point — called by openclaw on gateway:startup
|
|
99
|
+
export default async function handler(event) {
|
|
100
|
+
let auditResult;
|
|
101
|
+
try {
|
|
102
|
+
auditResult = runAuditJson();
|
|
103
|
+
} catch (e) {
|
|
104
|
+
// If clawarmor itself fails, don't block startup
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (!auditResult) return;
|
|
109
|
+
|
|
110
|
+
const newScore = auditResult.score ?? null;
|
|
111
|
+
const lastState = readLastScore();
|
|
112
|
+
const lastScore = lastState?.score ?? null;
|
|
113
|
+
const isFirstRun = lastScore === null;
|
|
114
|
+
|
|
115
|
+
if (newScore !== null) {
|
|
116
|
+
if (isFirstRun) {
|
|
117
|
+
writeLastScore({ score: newScore, grade: auditResult.grade, timestamp: new Date().toISOString() });
|
|
118
|
+
// First run — establish baseline silently
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const drop = lastScore - newScore;
|
|
123
|
+
const newCriticals = (auditResult.failed || []).filter(f => f.severity === 'CRITICAL');
|
|
124
|
+
const hadCriticals = (lastState?.criticals || 0);
|
|
125
|
+
const newCriticalCount = newCriticals.length;
|
|
126
|
+
|
|
127
|
+
writeLastScore({
|
|
128
|
+
score: newScore,
|
|
129
|
+
grade: auditResult.grade,
|
|
130
|
+
criticals: newCriticalCount,
|
|
131
|
+
timestamp: new Date().toISOString(),
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
if (newCriticalCount > hadCriticals) {
|
|
135
|
+
// New CRITICAL finding — alert immediately
|
|
136
|
+
const names = newCriticals.map(f => f.id || f.title).join(', ');
|
|
137
|
+
console.error(\`[ClawArmor] CRITICAL security finding: \${names}\`);
|
|
138
|
+
console.error(\`[ClawArmor] Run: clawarmor audit for details and fix commands.\`);
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (drop >= 5) {
|
|
143
|
+
console.error(\`[ClawArmor] Security score dropped \${drop} points (\${lastScore} → \${newScore})\`);
|
|
144
|
+
console.error(\`[ClawArmor] Run: clawarmor audit to see what changed.\`);
|
|
145
|
+
}
|
|
146
|
+
// Score improved or unchanged — no output (don't interrupt users with good news)
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
`;
|
|
150
|
+
|
|
151
|
+
// ── Install ──────────────────────────────────────────────────────────────────
|
|
152
|
+
|
|
153
|
+
function writeHookFiles() {
|
|
154
|
+
mkdirSync(GUARD_HOOK_DIR, { recursive: true });
|
|
155
|
+
writeFileSync(join(GUARD_HOOK_DIR, 'HOOK.md'), HOOK_MD, 'utf8');
|
|
156
|
+
writeFileSync(join(GUARD_HOOK_DIR, 'handler.js'), HANDLER_JS, 'utf8');
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function removeHookFiles() {
|
|
160
|
+
if (!existsSync(GUARD_HOOK_DIR)) return false;
|
|
161
|
+
try {
|
|
162
|
+
rmSync(GUARD_HOOK_DIR, { recursive: true, force: true });
|
|
163
|
+
return true;
|
|
164
|
+
} catch { return false; }
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function hookFilesExist() {
|
|
168
|
+
return existsSync(join(GUARD_HOOK_DIR, 'HOOK.md')) &&
|
|
169
|
+
existsSync(join(GUARD_HOOK_DIR, 'handler.js'));
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// ── Shell function ────────────────────────────────────────────────────────────
|
|
173
|
+
|
|
174
|
+
function shellFunctionPresent(rcPath) {
|
|
175
|
+
if (!existsSync(rcPath)) return false;
|
|
176
|
+
try {
|
|
177
|
+
return readFileSync(rcPath, 'utf8').includes(SHELL_MARKER_START);
|
|
178
|
+
} catch { return false; }
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function injectShellFunction(rcPath) {
|
|
182
|
+
if (!existsSync(rcPath)) return false;
|
|
183
|
+
if (shellFunctionPresent(rcPath)) return true; // already there
|
|
184
|
+
try {
|
|
185
|
+
const existing = readFileSync(rcPath, 'utf8');
|
|
186
|
+
writeFileSync(rcPath, existing + '\n' + SHELL_FUNCTION, 'utf8');
|
|
187
|
+
return true;
|
|
188
|
+
} catch { return false; }
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function removeShellFunction(rcPath) {
|
|
192
|
+
if (!existsSync(rcPath)) return false;
|
|
193
|
+
try {
|
|
194
|
+
const content = readFileSync(rcPath, 'utf8');
|
|
195
|
+
const start = content.indexOf(SHELL_MARKER_START);
|
|
196
|
+
const end = content.indexOf(SHELL_MARKER_END);
|
|
197
|
+
if (start === -1) return false;
|
|
198
|
+
const after = end !== -1 ? end + SHELL_MARKER_END.length : start;
|
|
199
|
+
const newContent = content.slice(0, start).trimEnd() + '\n' + content.slice(after + 1);
|
|
200
|
+
writeFileSync(rcPath, newContent, 'utf8');
|
|
201
|
+
return true;
|
|
202
|
+
} catch { return false; }
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function startWatchDaemon() {
|
|
206
|
+
const result = spawnSync(process.execPath, [CLI_PATH, 'watch', '--daemon'], {
|
|
207
|
+
encoding: 'utf8',
|
|
208
|
+
timeout: 10000,
|
|
209
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
210
|
+
});
|
|
211
|
+
return result.status === 0;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// ── Public API ────────────────────────────────────────────────────────────────
|
|
215
|
+
|
|
216
|
+
export async function runProtect(flags = {}) {
|
|
217
|
+
const zshrc = join(HOME, '.zshrc');
|
|
218
|
+
const bashrc = join(HOME, '.bashrc');
|
|
219
|
+
|
|
220
|
+
if (flags.install) {
|
|
221
|
+
console.log('\n ClawArmor Protect — installing...\n');
|
|
222
|
+
|
|
223
|
+
// 1. Hook files
|
|
224
|
+
writeHookFiles();
|
|
225
|
+
console.log(` ✓ Hook files written to: ~/.openclaw/hooks/clawarmor-guard/`);
|
|
226
|
+
|
|
227
|
+
// 2. Shell intercept
|
|
228
|
+
let shellInstalled = false;
|
|
229
|
+
if (injectShellFunction(zshrc)) {
|
|
230
|
+
console.log(` ✓ Shell intercept added to ~/.zshrc`);
|
|
231
|
+
shellInstalled = true;
|
|
232
|
+
}
|
|
233
|
+
if (injectShellFunction(bashrc)) {
|
|
234
|
+
console.log(` ✓ Shell intercept added to ~/.bashrc`);
|
|
235
|
+
shellInstalled = true;
|
|
236
|
+
}
|
|
237
|
+
if (!shellInstalled) {
|
|
238
|
+
console.log(` ! No ~/.zshrc or ~/.bashrc found — shell intercept skipped`);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// 3. Start watch daemon
|
|
242
|
+
const daemonStarted = startWatchDaemon();
|
|
243
|
+
if (daemonStarted) {
|
|
244
|
+
console.log(` ✓ Watch daemon started`);
|
|
245
|
+
} else {
|
|
246
|
+
console.log(` ! Watch daemon could not be started automatically`);
|
|
247
|
+
console.log(` Run manually: clawarmor watch --daemon`);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
console.log('\n ClawArmor Protect is now active.\n');
|
|
251
|
+
console.log(` The guard hook fires on every gateway startup.`);
|
|
252
|
+
console.log(` The watcher monitors config and skill changes in real time.`);
|
|
253
|
+
if (shellInstalled) {
|
|
254
|
+
console.log(` Restart your shell (or: source ~/.zshrc) for the intercept to take effect.`);
|
|
255
|
+
}
|
|
256
|
+
console.log('');
|
|
257
|
+
return 0;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
if (flags.uninstall) {
|
|
261
|
+
console.log('\n ClawArmor Protect — uninstalling...\n');
|
|
262
|
+
|
|
263
|
+
// 1. Hook files
|
|
264
|
+
if (removeHookFiles()) {
|
|
265
|
+
console.log(` ✓ Hook files removed`);
|
|
266
|
+
} else {
|
|
267
|
+
console.log(` - Hook files were not present`);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// 2. Shell function
|
|
271
|
+
let shellRemoved = false;
|
|
272
|
+
if (removeShellFunction(zshrc)) {
|
|
273
|
+
console.log(` ✓ Shell intercept removed from ~/.zshrc`);
|
|
274
|
+
shellRemoved = true;
|
|
275
|
+
}
|
|
276
|
+
if (removeShellFunction(bashrc)) {
|
|
277
|
+
console.log(` ✓ Shell intercept removed from ~/.bashrc`);
|
|
278
|
+
shellRemoved = true;
|
|
279
|
+
}
|
|
280
|
+
if (!shellRemoved) {
|
|
281
|
+
console.log(` - No shell intercept found to remove`);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// 3. Stop watch daemon
|
|
285
|
+
const { stopDaemon } = await import('./watch.js');
|
|
286
|
+
stopDaemon();
|
|
287
|
+
|
|
288
|
+
console.log('\n ClawArmor Protect has been uninstalled.\n');
|
|
289
|
+
return 0;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
if (flags.status) {
|
|
293
|
+
console.log('\n ClawArmor Protect — Status\n');
|
|
294
|
+
|
|
295
|
+
// Hook files
|
|
296
|
+
const hookOk = hookFilesExist();
|
|
297
|
+
console.log(` Hook (gateway:startup) ${hookOk ? '✓ installed' : '✗ not installed'}`);
|
|
298
|
+
|
|
299
|
+
// Watch daemon
|
|
300
|
+
const daemon = watchDaemonStatus();
|
|
301
|
+
console.log(` Watch daemon ${daemon.running ? `● running (PID ${daemon.pid})` : '○ not running'}`);
|
|
302
|
+
|
|
303
|
+
// Shell function
|
|
304
|
+
const inZsh = shellFunctionPresent(zshrc);
|
|
305
|
+
const inBash = shellFunctionPresent(bashrc);
|
|
306
|
+
if (inZsh || inBash) {
|
|
307
|
+
const where = [inZsh && '~/.zshrc', inBash && '~/.bashrc'].filter(Boolean).join(', ');
|
|
308
|
+
console.log(` Shell intercept ✓ active (${where})`);
|
|
309
|
+
} else {
|
|
310
|
+
console.log(` Shell intercept ✗ not installed`);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const allActive = hookOk && daemon.running && (inZsh || inBash);
|
|
314
|
+
console.log('');
|
|
315
|
+
if (allActive) {
|
|
316
|
+
console.log(` Full protection active.`);
|
|
317
|
+
} else {
|
|
318
|
+
console.log(` Protection incomplete. Run: clawarmor protect --install`);
|
|
319
|
+
}
|
|
320
|
+
console.log('');
|
|
321
|
+
return 0;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// No flag — show usage
|
|
325
|
+
console.log('');
|
|
326
|
+
console.log(` Usage: clawarmor protect [--install | --uninstall | --status]`);
|
|
327
|
+
console.log('');
|
|
328
|
+
console.log(` --install Install the guard hook, shell intercept, and watch daemon`);
|
|
329
|
+
console.log(` --uninstall Remove all ClawArmor protect components`);
|
|
330
|
+
console.log(` --status Show current protection state`);
|
|
331
|
+
console.log('');
|
|
332
|
+
return 0;
|
|
333
|
+
}
|
package/lib/scan.js
CHANGED
|
@@ -2,6 +2,7 @@ import { paint, severityColor } from './output/colors.js';
|
|
|
2
2
|
import { scanFile } from './scanner/file-scanner.js';
|
|
3
3
|
import { findInstalledSkills } from './scanner/skill-finder.js';
|
|
4
4
|
import { scanSkillMdFiles } from './scanner/skill-md-scanner.js';
|
|
5
|
+
import { append as auditLogAppend } from './audit-log.js';
|
|
5
6
|
|
|
6
7
|
const SEP = paint.dim('─'.repeat(52));
|
|
7
8
|
const HOME = process.env.HOME || '';
|
|
@@ -33,6 +34,7 @@ export async function runScan() {
|
|
|
33
34
|
|
|
34
35
|
let totalCritical = 0, totalHigh = 0;
|
|
35
36
|
const flagged = [];
|
|
37
|
+
const auditFindings = []; // accumulated for audit log
|
|
36
38
|
|
|
37
39
|
for (const skill of skills) {
|
|
38
40
|
process.stdout.write(` ${skill.isBuiltin ? paint.dim('⊙') : paint.cyan('▶')} ${paint.bold(skill.name)}${paint.dim(skill.isBuiltin?' [built-in]':' [user]')}...`);
|
|
@@ -45,6 +47,7 @@ export async function runScan() {
|
|
|
45
47
|
const mdFindings = mdResults.flatMap(r => r.findings);
|
|
46
48
|
|
|
47
49
|
const allFindings = [...codeFindings, ...mdFindings];
|
|
50
|
+
for (const f of allFindings) auditFindings.push({ id: f.patternId || f.id || '?', severity: f.severity });
|
|
48
51
|
|
|
49
52
|
const critical = allFindings.filter(f => f.severity==='CRITICAL');
|
|
50
53
|
const high = allFindings.filter(f => f.severity==='HIGH');
|
|
@@ -125,5 +128,16 @@ export async function runScan() {
|
|
|
125
128
|
}
|
|
126
129
|
console.log(` ${paint.dim('Run')} ${paint.cyan('clawarmor audit')} ${paint.dim('to check your config.')}`);
|
|
127
130
|
console.log('');
|
|
131
|
+
|
|
132
|
+
auditLogAppend({
|
|
133
|
+
cmd: 'scan',
|
|
134
|
+
trigger: 'manual',
|
|
135
|
+
score: null,
|
|
136
|
+
delta: null,
|
|
137
|
+
findings: auditFindings,
|
|
138
|
+
blocked: null,
|
|
139
|
+
skill: null,
|
|
140
|
+
});
|
|
141
|
+
|
|
128
142
|
return totalCritical > 0 ? 1 : 0;
|
|
129
143
|
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { readFileSync } from 'fs';
|
|
2
|
-
import { extname
|
|
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
|
+
}
|