clawarmor 3.1.0 → 3.4.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/lib/scan.js CHANGED
@@ -16,28 +16,40 @@ function box(title) {
16
16
  paint.dim('╚'+'═'.repeat(W-2)+'╝')].join('\n');
17
17
  }
18
18
 
19
- export async function runScan() {
20
- console.log(''); console.log(box('ClawArmor Skill Scan v0.6')); console.log('');
21
- console.log(` ${paint.dim('Scanning:')} Installed OpenClaw skills (code + SKILL.md)`);
22
- console.log(` ${paint.dim('Started:')} ${new Date().toLocaleString('en-US',{dateStyle:'medium',timeStyle:'short'})}`);
23
- console.log('');
19
+ export async function runScan(flags = {}) {
20
+ const jsonMode = flags.json || false;
21
+
22
+ if (!jsonMode) {
23
+ console.log(''); console.log(box('ClawArmor Skill Scan v0.6')); console.log('');
24
+ console.log(` ${paint.dim('Scanning:')} Installed OpenClaw skills (code + SKILL.md)`);
25
+ console.log(` ${paint.dim('Started:')} ${new Date().toLocaleString('en-US',{dateStyle:'medium',timeStyle:'short'})}`);
26
+ console.log('');
27
+ }
24
28
 
25
29
  const skills = findInstalledSkills();
26
30
  if (!skills.length) {
27
- console.log(` ${paint.dim('No installed skills found.')}`); console.log(''); return 0;
31
+ if (jsonMode) {
32
+ process.stdout.write(JSON.stringify({ verdict: 'PASS', score: 100, totalSkills: 0, flaggedSkills: 0, findings: [], scannedAt: new Date().toISOString() }, null, 2) + '\n');
33
+ } else {
34
+ console.log(` ${paint.dim('No installed skills found.')}`); console.log('');
35
+ }
36
+ return 0;
28
37
  }
29
38
 
30
39
  const userSkills = skills.filter(s => !s.isBuiltin);
31
40
  const builtinSkills = skills.filter(s => s.isBuiltin);
32
- console.log(` ${paint.dim('Found')} ${paint.bold(String(skills.length))} ${paint.dim('skills')} ${paint.dim(`(${userSkills.length} user-installed, ${builtinSkills.length} built-in)`)}`);
33
- console.log('');
41
+ if (!jsonMode) {
42
+ console.log(` ${paint.dim('Found')} ${paint.bold(String(skills.length))} ${paint.dim('skills')} ${paint.dim(`(${userSkills.length} user-installed, ${builtinSkills.length} built-in)`)}`);
43
+ console.log('');
44
+ }
34
45
 
35
46
  let totalCritical = 0, totalHigh = 0;
36
47
  const flagged = [];
37
48
  const auditFindings = []; // accumulated for audit log
49
+ const jsonFindings = []; // for --json output
38
50
 
39
51
  for (const skill of skills) {
40
- process.stdout.write(` ${skill.isBuiltin ? paint.dim('⊙') : paint.cyan('▶')} ${paint.bold(skill.name)}${paint.dim(skill.isBuiltin?' [built-in]':' [user]')}...`);
52
+ if (!jsonMode) process.stdout.write(` ${skill.isBuiltin ? paint.dim('⊙') : paint.cyan('▶')} ${paint.bold(skill.name)}${paint.dim(skill.isBuiltin?' [built-in]':' [user]')}...`);
41
53
 
42
54
  // Code findings (JS, py, sh, etc.)
43
55
  const codeFindings = skill.files.flatMap(f => scanFile(f, skill.isBuiltin));
@@ -56,21 +68,80 @@ export async function runScan() {
56
68
 
57
69
  totalCritical += critical.length; totalHigh += high.length;
58
70
 
59
- if (!allFindings.length) { process.stdout.write(` ${paint.green('✓ clean')}\n`); continue; }
71
+ // Collect findings for JSON output
72
+ for (const f of allFindings) {
73
+ if (['CRITICAL','HIGH','MEDIUM','LOW'].includes(f.severity)) {
74
+ for (const m of (f.matches || [])) {
75
+ jsonFindings.push({
76
+ skill: skill.name,
77
+ severity: f.severity,
78
+ patternId: f.patternId || f.id || 'unknown',
79
+ message: f.title || f.description || '',
80
+ file: (f.file || '').replace(HOME, '~'),
81
+ line: m.line,
82
+ });
83
+ }
84
+ if (!(f.matches && f.matches.length)) {
85
+ jsonFindings.push({
86
+ skill: skill.name,
87
+ severity: f.severity,
88
+ patternId: f.patternId || f.id || 'unknown',
89
+ message: f.title || f.description || '',
90
+ file: (f.file || '').replace(HOME, '~'),
91
+ line: null,
92
+ });
93
+ }
94
+ }
95
+ }
96
+
97
+ if (!jsonMode) {
98
+ if (!allFindings.length) { process.stdout.write(` ${paint.green('✓ clean')}\n`); continue; }
60
99
 
61
- const parts = [];
62
- if (critical.length) parts.push(paint.red(`${critical.length} critical`));
63
- if (high.length) parts.push(paint.yellow(`${high.length} high`));
64
- if (medium.length) parts.push(paint.cyan(`${medium.length} medium`));
65
- if (info.length) parts.push(paint.dim(`${info.length} info`));
66
- process.stdout.write(` ${parts.join(', ')}\n`);
100
+ const parts = [];
101
+ if (critical.length) parts.push(paint.red(`${critical.length} critical`));
102
+ if (high.length) parts.push(paint.yellow(`${high.length} high`));
103
+ if (medium.length) parts.push(paint.cyan(`${medium.length} medium`));
104
+ if (info.length) parts.push(paint.dim(`${info.length} info`));
105
+ process.stdout.write(` ${parts.join(', ')}\n`);
106
+ }
67
107
 
68
108
  if (critical.length || high.length || medium.length) {
69
109
  flagged.push({ skill, codeFindings, mdResults });
70
110
  }
71
111
  }
72
112
 
73
- // Detailed report for flagged skills
113
+ // JSON output mode
114
+ if (jsonMode) {
115
+ // Compute scan score: start 100, -25 per CRITICAL, -10 per HIGH, -3 per MEDIUM
116
+ let scanScore = 100;
117
+ for (const f of jsonFindings) {
118
+ if (f.severity === 'CRITICAL') scanScore -= 25;
119
+ else if (f.severity === 'HIGH') scanScore -= 10;
120
+ else if (f.severity === 'MEDIUM') scanScore -= 3;
121
+ }
122
+ scanScore = Math.max(0, scanScore);
123
+
124
+ let verdict = 'PASS';
125
+ if (totalCritical > 0) verdict = 'BLOCK';
126
+ else if (totalHigh > 0) verdict = 'WARN';
127
+
128
+ const output = {
129
+ verdict,
130
+ score: scanScore,
131
+ totalSkills: skills.length,
132
+ flaggedSkills: flagged.length,
133
+ findings: jsonFindings,
134
+ scannedAt: new Date().toISOString(),
135
+ };
136
+ process.stdout.write(JSON.stringify(output, null, 2) + '\n');
137
+
138
+ auditLogAppend({ cmd: 'scan', trigger: 'manual', score: null, delta: null,
139
+ findings: auditFindings, blocked: null, skill: null });
140
+
141
+ return totalCritical > 0 ? 1 : 0;
142
+ }
143
+
144
+ // Detailed human report for flagged skills
74
145
  for (const {skill, codeFindings, mdResults} of flagged) {
75
146
  console.log(''); console.log(SEP);
76
147
  console.log(` ${paint.bold(skill.name)} ${paint.dim(short(skill.path))}`);
@@ -0,0 +1,259 @@
1
+ // ClawArmor skill verify — check a skill directory against ClawGear publishing standards.
2
+ // Usage: clawarmor skill verify <skill-dir>
3
+ //
4
+ // Exit codes: 0=VERIFIED, 1=WARN, 2=BLOCK
5
+
6
+ import { existsSync, readdirSync, readFileSync, statSync } from 'fs';
7
+ import { join, extname, basename } from 'path';
8
+ import { paint } from './output/colors.js';
9
+ import { scanFile } from './scanner/file-scanner.js';
10
+
11
+ const SEP = '━'.repeat(52);
12
+
13
+ // Well-known API hosts that are allowed without flagging
14
+ const KNOWN_HOSTS = new Set([
15
+ 'github.com', 'api.github.com', 'raw.githubusercontent.com',
16
+ 'api.anthropic.com',
17
+ 'api.openai.com',
18
+ 'registry.npmjs.org',
19
+ 'api.cloudflare.com',
20
+ 'api.stripe.com',
21
+ 'hooks.slack.com',
22
+ 'discord.com', 'discordapp.com',
23
+ 'api.telegram.org',
24
+ 'clawhub.com', 'shopclawmart.com', 'clawgear.io',
25
+ ]);
26
+
27
+ // Patterns for hardcoded credentials (value, not variable reference)
28
+ const CRED_PATTERNS = [
29
+ /api_key\s*=\s*["'][^${\s"']{8,}/i,
30
+ /token\s*=\s*["'][^${\s"']{8,}/i,
31
+ /password\s*=\s*["'][^${\s"']{4,}/i,
32
+ /secret\s*=\s*["'][^${\s"']{8,}/i,
33
+ /api_key\s*:\s*["'][^${\s"']{8,}/i,
34
+ /token\s*:\s*["'][^${\s"']{8,}/i,
35
+ /password\s*:\s*["'][^${\s"']{4,}/i,
36
+ /secret\s*:\s*["'][^${\s"']{8,}/i,
37
+ ];
38
+
39
+ // Patterns for elevated/exec commands
40
+ const EXEC_PATTERNS = [
41
+ /\bexec\b/,
42
+ /child_process/,
43
+ /subprocess/,
44
+ /\bsudo\b/,
45
+ /\bchmod\b/,
46
+ /\bchown\b/,
47
+ /execSync|execFile|spawnSync/,
48
+ /\bsh\s+-c\b/,
49
+ /\bbash\s+-c\b/,
50
+ ];
51
+
52
+ // Patterns for external network calls
53
+ const FETCH_PATTERNS = [
54
+ /\bfetch\s*\(\s*["'`]https?:\/\/([^/"'`\s]+)/gi,
55
+ /\bcurl\s+(?:-\S+\s+)*["']?https?:\/\/([^"'\s]+)/gi,
56
+ /\bwget\s+(?:-\S+\s+)*["']?https?:\/\/([^"'\s]+)/gi,
57
+ /new\s+URL\s*\(\s*["'`]https?:\/\/([^/"'`\s]+)/gi,
58
+ /axios\.\w+\s*\(\s*["'`]https?:\/\/([^/"'`\s]+)/gi,
59
+ ];
60
+
61
+ const SCANNABLE_EXTS = new Set(['js', 'ts', 'sh', 'py', 'rb', 'md']);
62
+
63
+ function collectFiles(dir) {
64
+ const files = [];
65
+ try {
66
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
67
+ if (entry.name.startsWith('.')) continue;
68
+ const full = join(dir, entry.name);
69
+ if (entry.isDirectory()) {
70
+ files.push(...collectFiles(full));
71
+ } else {
72
+ files.push(full);
73
+ }
74
+ }
75
+ } catch { /* ignore unreadable dirs */ }
76
+ return files;
77
+ }
78
+
79
+ function readSafe(filePath) {
80
+ try { return readFileSync(filePath, 'utf8'); }
81
+ catch { return ''; }
82
+ }
83
+
84
+ function extractHosts(content) {
85
+ const hosts = [];
86
+ for (const pattern of FETCH_PATTERNS) {
87
+ const re = new RegExp(pattern.source, 'gi');
88
+ let m;
89
+ while ((m = re.exec(content)) !== null) {
90
+ const host = m[1].split('/')[0].split('?')[0];
91
+ if (host) hosts.push(host);
92
+ }
93
+ }
94
+ return [...new Set(hosts)];
95
+ }
96
+
97
+ /**
98
+ * Main skill verify command.
99
+ * @param {string} skillDir - path to skill directory
100
+ * @returns {Promise<number>} exit code: 0=VERIFIED, 1=WARN, 2=BLOCK
101
+ */
102
+ export async function runSkillVerify(skillDir) {
103
+ if (!skillDir) {
104
+ console.log(` Usage: clawarmor skill verify <skill-dir>`);
105
+ return 2;
106
+ }
107
+
108
+ const resolvedDir = skillDir.replace(/^~/, process.env.HOME || '');
109
+
110
+ if (!existsSync(resolvedDir)) {
111
+ console.log(` ${paint.red('✗')} Directory not found: ${skillDir}`);
112
+ return 2;
113
+ }
114
+
115
+ const skillName = basename(resolvedDir);
116
+ const files = collectFiles(resolvedDir);
117
+ const codeFiles = files.filter(f => {
118
+ const ext = extname(f).replace('.', '').toLowerCase();
119
+ return SCANNABLE_EXTS.has(ext);
120
+ });
121
+ const skillMdPath = join(resolvedDir, 'SKILL.md');
122
+
123
+ console.log('');
124
+ console.log(` ${paint.bold('ClawArmor Skill Verify')} ${paint.dim('—')} ${paint.cyan(skillName)}`);
125
+ console.log(` ${SEP}`);
126
+
127
+ const checks = [];
128
+ let hasWarn = false;
129
+ let hasBlock = false;
130
+
131
+ // ── Check 1: SKILL.md exists ────────────────────────────────────────────────
132
+ const skillMdExists = existsSync(skillMdPath);
133
+ if (skillMdExists) {
134
+ checks.push({ icon: '✅', label: 'SKILL.md present' });
135
+ } else {
136
+ checks.push({ icon: '❌', label: 'SKILL.md missing — required for ClawGear publishing', severity: 'BLOCK' });
137
+ hasBlock = true;
138
+ }
139
+
140
+ const skillMdContent = skillMdExists ? readSafe(skillMdPath) : '';
141
+ const allContent = files.map(f => ({ path: f, content: readSafe(f) }));
142
+
143
+ // ── Check 2: No hardcoded credentials ───────────────────────────────────────
144
+ let credFound = false;
145
+ let credDetails = null;
146
+ for (const { path: fp, content } of allContent) {
147
+ const ext = extname(fp).replace('.', '').toLowerCase();
148
+ if (!SCANNABLE_EXTS.has(ext)) continue;
149
+ for (const pattern of CRED_PATTERNS) {
150
+ if (pattern.test(content)) {
151
+ credFound = true;
152
+ credDetails = `${fp.replace(process.env.HOME || '', '~')}`;
153
+ break;
154
+ }
155
+ }
156
+ if (credFound) break;
157
+ }
158
+ if (credFound) {
159
+ checks.push({ icon: '❌', label: `Hardcoded credentials found in ${credDetails}`, severity: 'BLOCK' });
160
+ hasBlock = true;
161
+ } else {
162
+ checks.push({ icon: '✅', label: 'No hardcoded credentials' });
163
+ }
164
+
165
+ // ── Check 3: No obfuscation ──────────────────────────────────────────────────
166
+ let obfuscFound = false;
167
+ for (const fp of codeFiles) {
168
+ const findings = scanFile(fp, false);
169
+ const serious = findings.filter(f => ['CRITICAL', 'HIGH'].includes(f.severity));
170
+ if (serious.length) {
171
+ obfuscFound = true;
172
+ break;
173
+ }
174
+ }
175
+ if (obfuscFound) {
176
+ checks.push({ icon: '❌', label: 'Obfuscation or malicious patterns detected — run clawarmor scan for details', severity: 'BLOCK' });
177
+ hasBlock = true;
178
+ } else {
179
+ checks.push({ icon: '✅', label: 'No obfuscation patterns' });
180
+ }
181
+
182
+ // ── Check 4: Permissions declared if exec commands found ────────────────────
183
+ let execFound = false;
184
+ for (const { path: fp, content } of allContent) {
185
+ const ext = extname(fp).replace('.', '').toLowerCase();
186
+ if (!SCANNABLE_EXTS.has(ext)) continue;
187
+ for (const p of EXEC_PATTERNS) {
188
+ if (p.test(content)) {
189
+ execFound = true;
190
+ break;
191
+ }
192
+ }
193
+ if (execFound) break;
194
+ }
195
+
196
+ if (execFound) {
197
+ const permsDeclared = /\b(requires|permissions|elevated)\b/i.test(skillMdContent);
198
+ if (permsDeclared) {
199
+ checks.push({ icon: '✅', label: 'Exec commands found — permissions declared in SKILL.md' });
200
+ } else {
201
+ checks.push({ icon: '⚠️ ', label: 'Exec commands found — verify permissions are declared in SKILL.md', severity: 'WARN' });
202
+ hasWarn = true;
203
+ }
204
+ } else {
205
+ checks.push({ icon: '✅', label: 'No elevated exec commands' });
206
+ }
207
+
208
+ // ── Check 5: No external fetch to unknown hosts ─────────────────────────────
209
+ const unknownHosts = [];
210
+ for (const { content } of allContent) {
211
+ for (const host of extractHosts(content)) {
212
+ // Strip port
213
+ const cleanHost = host.split(':')[0];
214
+ if (!KNOWN_HOSTS.has(cleanHost)) {
215
+ unknownHosts.push(cleanHost);
216
+ }
217
+ }
218
+ }
219
+ const uniqueUnknown = [...new Set(unknownHosts)];
220
+ if (uniqueUnknown.length) {
221
+ checks.push({ icon: '⚠️ ', label: `External fetch to unknown host(s): ${uniqueUnknown.join(', ')}`, severity: 'WARN' });
222
+ hasWarn = true;
223
+ } else {
224
+ checks.push({ icon: '✅', label: 'No unknown external hosts' });
225
+ }
226
+
227
+ // ── Check 6: Description present in SKILL.md frontmatter ────────────────────
228
+ const descPresent = /^description\s*:/m.test(skillMdContent);
229
+ if (descPresent) {
230
+ checks.push({ icon: '✅', label: 'Description present in SKILL.md' });
231
+ } else {
232
+ checks.push({ icon: '⚠️ ', label: 'No description: field in SKILL.md frontmatter', severity: 'WARN' });
233
+ hasWarn = true;
234
+ }
235
+
236
+ // ── Print results ────────────────────────────────────────────────────────────
237
+ for (const c of checks) {
238
+ console.log(` ${c.icon} ${c.severity === 'BLOCK' ? paint.red(c.label) : c.severity === 'WARN' ? paint.yellow(c.label) : paint.dim(c.label)}`);
239
+ }
240
+
241
+ console.log('');
242
+
243
+ const advisories = checks.filter(c => c.severity === 'WARN').length;
244
+ const blocks = checks.filter(c => c.severity === 'BLOCK').length;
245
+
246
+ if (hasBlock) {
247
+ console.log(` ${paint.bold('Verdict:')} ${paint.red('❌ BLOCK')} ${paint.dim('— ' + blocks + ' blocking issue' + (blocks > 1 ? 's' : ''))}`);
248
+ console.log('');
249
+ return 2;
250
+ } else if (hasWarn) {
251
+ console.log(` ${paint.bold('Verdict:')} ${paint.yellow('⚠️ WARN')} ${paint.dim('— ' + advisories + ' advisor' + (advisories > 1 ? 'ies' : 'y'))}`);
252
+ console.log('');
253
+ return 1;
254
+ } else {
255
+ console.log(` ${paint.bold('Verdict:')} ${paint.green('✅ VERIFIED')}`);
256
+ console.log('');
257
+ return 0;
258
+ }
259
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clawarmor",
3
- "version": "3.1.0",
3
+ "version": "3.4.0",
4
4
  "description": "Security armor for OpenClaw agents — audit, scan, monitor",
5
5
  "bin": {
6
6
  "clawarmor": "cli.js"