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/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, 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
+ }