skill-checker 0.1.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/LICENSE +661 -0
- package/README.md +52 -0
- package/bin/skill-checker.js +4 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +2166 -0
- package/dist/cli.js.map +1 -0
- package/dist/index.d.ts +160 -0
- package/dist/index.js +2168 -0
- package/dist/index.js.map +1 -0
- package/hook/install.ts +88 -0
- package/hook/skill-gate.sh +81 -0
- package/package.json +58 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,2166 @@
|
|
|
1
|
+
// src/cli.ts
|
|
2
|
+
import { Command } from "commander";
|
|
3
|
+
|
|
4
|
+
// src/parser.ts
|
|
5
|
+
import { readFileSync, readdirSync, statSync, existsSync } from "fs";
|
|
6
|
+
import { join, extname, basename, resolve } from "path";
|
|
7
|
+
import { parse as parseYaml } from "yaml";
|
|
8
|
+
var BINARY_EXTENSIONS = /* @__PURE__ */ new Set([
|
|
9
|
+
".png",
|
|
10
|
+
".jpg",
|
|
11
|
+
".jpeg",
|
|
12
|
+
".gif",
|
|
13
|
+
".bmp",
|
|
14
|
+
".ico",
|
|
15
|
+
".webp",
|
|
16
|
+
".svg",
|
|
17
|
+
".woff",
|
|
18
|
+
".woff2",
|
|
19
|
+
".ttf",
|
|
20
|
+
".eot",
|
|
21
|
+
".otf",
|
|
22
|
+
".zip",
|
|
23
|
+
".gz",
|
|
24
|
+
".tar",
|
|
25
|
+
".bz2",
|
|
26
|
+
".7z",
|
|
27
|
+
".rar",
|
|
28
|
+
".exe",
|
|
29
|
+
".dll",
|
|
30
|
+
".so",
|
|
31
|
+
".dylib",
|
|
32
|
+
".bin",
|
|
33
|
+
".pdf",
|
|
34
|
+
".doc",
|
|
35
|
+
".docx",
|
|
36
|
+
".xls",
|
|
37
|
+
".xlsx",
|
|
38
|
+
".mp3",
|
|
39
|
+
".mp4",
|
|
40
|
+
".wav",
|
|
41
|
+
".avi",
|
|
42
|
+
".mov",
|
|
43
|
+
".wasm",
|
|
44
|
+
".pyc",
|
|
45
|
+
".class"
|
|
46
|
+
]);
|
|
47
|
+
function parseSkill(dirPath) {
|
|
48
|
+
const absDir = resolve(dirPath);
|
|
49
|
+
const skillMdPath = join(absDir, "SKILL.md");
|
|
50
|
+
const hasSkillMd = existsSync(skillMdPath);
|
|
51
|
+
const raw = hasSkillMd ? readFileSync(skillMdPath, "utf-8") : "";
|
|
52
|
+
const { frontmatter, frontmatterValid, body, bodyStartLine } = parseFrontmatter(raw);
|
|
53
|
+
const files = enumerateFiles(absDir);
|
|
54
|
+
return {
|
|
55
|
+
dirPath: absDir,
|
|
56
|
+
raw,
|
|
57
|
+
frontmatter,
|
|
58
|
+
frontmatterValid,
|
|
59
|
+
body,
|
|
60
|
+
bodyLines: body.split("\n"),
|
|
61
|
+
bodyStartLine,
|
|
62
|
+
files
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
function parseFrontmatter(raw) {
|
|
66
|
+
const fmRegex = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/;
|
|
67
|
+
const match = raw.match(fmRegex);
|
|
68
|
+
if (!match) {
|
|
69
|
+
return {
|
|
70
|
+
frontmatter: {},
|
|
71
|
+
frontmatterValid: false,
|
|
72
|
+
body: raw,
|
|
73
|
+
bodyStartLine: 1
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
const yamlStr = match[1];
|
|
77
|
+
const fmLineCount = match[0].split("\n").length;
|
|
78
|
+
try {
|
|
79
|
+
const parsed = parseYaml(yamlStr);
|
|
80
|
+
return {
|
|
81
|
+
frontmatter: typeof parsed === "object" && parsed !== null ? parsed : {},
|
|
82
|
+
frontmatterValid: true,
|
|
83
|
+
body: raw.slice(match[0].length),
|
|
84
|
+
bodyStartLine: fmLineCount
|
|
85
|
+
};
|
|
86
|
+
} catch {
|
|
87
|
+
return {
|
|
88
|
+
frontmatter: {},
|
|
89
|
+
frontmatterValid: false,
|
|
90
|
+
body: raw.slice(match[0].length),
|
|
91
|
+
bodyStartLine: fmLineCount
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
function enumerateFiles(dirPath, maxDepth = 5) {
|
|
96
|
+
const files = [];
|
|
97
|
+
if (!existsSync(dirPath)) return files;
|
|
98
|
+
function walk(currentDir, depth) {
|
|
99
|
+
if (depth > maxDepth) return;
|
|
100
|
+
let entries;
|
|
101
|
+
try {
|
|
102
|
+
entries = readdirSync(currentDir, { withFileTypes: true });
|
|
103
|
+
} catch {
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
for (const entry of entries) {
|
|
107
|
+
const fullPath = join(currentDir, entry.name);
|
|
108
|
+
if (entry.isDirectory()) {
|
|
109
|
+
if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
|
|
110
|
+
walk(fullPath, depth + 1);
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
const ext = extname(entry.name).toLowerCase();
|
|
114
|
+
let stats;
|
|
115
|
+
try {
|
|
116
|
+
stats = statSync(fullPath);
|
|
117
|
+
} catch {
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
const isBinary = BINARY_EXTENSIONS.has(ext);
|
|
121
|
+
const relativePath = fullPath.slice(dirPath.length + 1);
|
|
122
|
+
let content;
|
|
123
|
+
if (!isBinary && stats.size < 1e6) {
|
|
124
|
+
try {
|
|
125
|
+
content = readFileSync(fullPath, "utf-8");
|
|
126
|
+
} catch {
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
files.push({
|
|
130
|
+
path: relativePath,
|
|
131
|
+
name: basename(entry.name, ext),
|
|
132
|
+
extension: ext,
|
|
133
|
+
sizeBytes: stats.size,
|
|
134
|
+
isBinary,
|
|
135
|
+
content
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
walk(dirPath, 0);
|
|
140
|
+
return files;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// src/checks/structural.ts
|
|
144
|
+
var HYPHEN_CASE_RE = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
|
|
145
|
+
var MAX_NAME_LENGTH = 64;
|
|
146
|
+
var EXECUTABLE_EXTENSIONS = /* @__PURE__ */ new Set([
|
|
147
|
+
".exe",
|
|
148
|
+
".bat",
|
|
149
|
+
".cmd",
|
|
150
|
+
".sh",
|
|
151
|
+
".bash",
|
|
152
|
+
".ps1",
|
|
153
|
+
".com",
|
|
154
|
+
".msi"
|
|
155
|
+
]);
|
|
156
|
+
var BINARY_EXTENSIONS2 = /* @__PURE__ */ new Set([
|
|
157
|
+
".exe",
|
|
158
|
+
".dll",
|
|
159
|
+
".so",
|
|
160
|
+
".dylib",
|
|
161
|
+
".bin",
|
|
162
|
+
".wasm",
|
|
163
|
+
".class",
|
|
164
|
+
".pyc"
|
|
165
|
+
]);
|
|
166
|
+
var structuralChecks = {
|
|
167
|
+
name: "Structural Validity",
|
|
168
|
+
category: "STRUCT",
|
|
169
|
+
run(skill) {
|
|
170
|
+
const results = [];
|
|
171
|
+
if (!skill.raw) {
|
|
172
|
+
results.push({
|
|
173
|
+
id: "STRUCT-001",
|
|
174
|
+
category: "STRUCT",
|
|
175
|
+
severity: "CRITICAL",
|
|
176
|
+
title: "Missing SKILL.md",
|
|
177
|
+
message: "No SKILL.md file found in the skill directory."
|
|
178
|
+
});
|
|
179
|
+
return results;
|
|
180
|
+
}
|
|
181
|
+
if (!skill.frontmatterValid) {
|
|
182
|
+
results.push({
|
|
183
|
+
id: "STRUCT-002",
|
|
184
|
+
category: "STRUCT",
|
|
185
|
+
severity: "HIGH",
|
|
186
|
+
title: "Invalid YAML frontmatter",
|
|
187
|
+
message: "SKILL.md is missing valid YAML frontmatter (---...--- block)."
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
if (!skill.frontmatter.name) {
|
|
191
|
+
results.push({
|
|
192
|
+
id: "STRUCT-003",
|
|
193
|
+
category: "STRUCT",
|
|
194
|
+
severity: "HIGH",
|
|
195
|
+
title: "Missing name field",
|
|
196
|
+
message: 'Frontmatter is missing the required "name" field.'
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
if (!skill.frontmatter.description) {
|
|
200
|
+
results.push({
|
|
201
|
+
id: "STRUCT-004",
|
|
202
|
+
category: "STRUCT",
|
|
203
|
+
severity: "MEDIUM",
|
|
204
|
+
title: "Missing description field",
|
|
205
|
+
message: 'Frontmatter is missing the "description" field.'
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
if (skill.body.trim().length < 50) {
|
|
209
|
+
results.push({
|
|
210
|
+
id: "STRUCT-005",
|
|
211
|
+
category: "STRUCT",
|
|
212
|
+
severity: "CRITICAL",
|
|
213
|
+
title: "SKILL.md body is too short",
|
|
214
|
+
message: `Body is only ${skill.body.trim().length} characters. A valid skill should have meaningful instructions (>=50 chars).`
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
for (const file of skill.files) {
|
|
218
|
+
const ext = file.extension.toLowerCase();
|
|
219
|
+
if (BINARY_EXTENSIONS2.has(ext) || EXECUTABLE_EXTENSIONS.has(ext)) {
|
|
220
|
+
results.push({
|
|
221
|
+
id: "STRUCT-006",
|
|
222
|
+
category: "STRUCT",
|
|
223
|
+
severity: "HIGH",
|
|
224
|
+
title: "Unexpected binary/executable file",
|
|
225
|
+
message: `Found unexpected file: ${file.path} (${ext})`
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
const name = skill.frontmatter.name;
|
|
230
|
+
if (name) {
|
|
231
|
+
if (!HYPHEN_CASE_RE.test(name)) {
|
|
232
|
+
results.push({
|
|
233
|
+
id: "STRUCT-007",
|
|
234
|
+
category: "STRUCT",
|
|
235
|
+
severity: "MEDIUM",
|
|
236
|
+
title: "Name not in hyphen-case format",
|
|
237
|
+
message: `Skill name "${name}" should be in hyphen-case (e.g. "my-skill").`
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
if (name.length > MAX_NAME_LENGTH) {
|
|
241
|
+
results.push({
|
|
242
|
+
id: "STRUCT-007",
|
|
243
|
+
category: "STRUCT",
|
|
244
|
+
severity: "MEDIUM",
|
|
245
|
+
title: "Name too long",
|
|
246
|
+
message: `Skill name is ${name.length} chars, max ${MAX_NAME_LENGTH}.`
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
return results;
|
|
251
|
+
}
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
// src/utils/context.ts
|
|
255
|
+
function isInCodeBlock(lines, lineIndex) {
|
|
256
|
+
let inBlock = false;
|
|
257
|
+
for (let i = 0; i < lineIndex && i < lines.length; i++) {
|
|
258
|
+
if (lines[i].trim().startsWith("```")) {
|
|
259
|
+
inBlock = !inBlock;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
return inBlock;
|
|
263
|
+
}
|
|
264
|
+
function isNamespaceOrSchemaURI(url, line) {
|
|
265
|
+
if (/\bxmlns\b/i.test(line)) return true;
|
|
266
|
+
if (/\bnamespace\b/i.test(line)) return true;
|
|
267
|
+
if (/\bschema[s]?\b/i.test(line) && !/(schema\.org)/i.test(url)) return true;
|
|
268
|
+
const parsed = parseURLPath(url);
|
|
269
|
+
if (!parsed) return false;
|
|
270
|
+
if (/\/\d{4}\//.test(parsed.path)) {
|
|
271
|
+
if (!parsed.hasQuery && !parsed.hasFileExtension) return true;
|
|
272
|
+
}
|
|
273
|
+
return false;
|
|
274
|
+
}
|
|
275
|
+
function isInNetworkRequestContext(line) {
|
|
276
|
+
const networkPatterns = [
|
|
277
|
+
/\bfetch\s*\(/i,
|
|
278
|
+
/\bcurl\s+/i,
|
|
279
|
+
/\bwget\s+/i,
|
|
280
|
+
/\baxios\b/i,
|
|
281
|
+
/\brequests?\.(get|post|put|delete|head)\s*\(/i,
|
|
282
|
+
/\bhttp\.(get|request)\s*\(/i,
|
|
283
|
+
/\bopen\s*\(\s*["'](?:GET|POST|PUT|DELETE)/i,
|
|
284
|
+
/\bURLSession\b/,
|
|
285
|
+
/\bInvoke-WebRequest\b/i
|
|
286
|
+
];
|
|
287
|
+
return networkPatterns.some((p) => p.test(line));
|
|
288
|
+
}
|
|
289
|
+
function isInDocumentationContext(lines, lineIndex) {
|
|
290
|
+
const line = lines[lineIndex];
|
|
291
|
+
if (/^\s*[-*]\s+\*\*\w+\*\*\s*[::]/.test(line)) return true;
|
|
292
|
+
for (let i = lineIndex; i >= Math.max(0, lineIndex - 15); i--) {
|
|
293
|
+
const l = lines[i];
|
|
294
|
+
if (/^#{1,4}\s+.*(install|setup|prerequisite|requirement|depend|getting\s+started)/i.test(l)) {
|
|
295
|
+
return true;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
return false;
|
|
299
|
+
}
|
|
300
|
+
function parseURLPath(url) {
|
|
301
|
+
try {
|
|
302
|
+
const u = new URL(url);
|
|
303
|
+
const hasQuery = u.search.length > 0;
|
|
304
|
+
const lastSegment = u.pathname.split("/").pop() ?? "";
|
|
305
|
+
const hasFileExtension = /\.\w{1,5}$/.test(lastSegment);
|
|
306
|
+
return { path: u.pathname, hasQuery, hasFileExtension };
|
|
307
|
+
} catch {
|
|
308
|
+
return null;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// src/checks/content.ts
|
|
313
|
+
var STRICT_PLACEHOLDER_PATTERNS = [
|
|
314
|
+
/\bTODO\b/,
|
|
315
|
+
/\bFIXME\b/,
|
|
316
|
+
/\bHACK\b/,
|
|
317
|
+
/\bXXX\b/,
|
|
318
|
+
/\binsert\s+here\b/i,
|
|
319
|
+
/\bfill\s+in\b/i,
|
|
320
|
+
/\bTBD\b/,
|
|
321
|
+
/\bcoming\s+soon\b/i
|
|
322
|
+
];
|
|
323
|
+
var CONTEXT_SENSITIVE_PLACEHOLDER_PATTERNS = [
|
|
324
|
+
/\bplaceholder\b/i
|
|
325
|
+
];
|
|
326
|
+
var LOREM_PATTERNS = [
|
|
327
|
+
/lorem\s+ipsum/i,
|
|
328
|
+
/dolor\s+sit\s+amet/i,
|
|
329
|
+
/consectetur\s+adipiscing/i
|
|
330
|
+
];
|
|
331
|
+
var AD_PATTERNS = [
|
|
332
|
+
/\bbuy\s+now\b/i,
|
|
333
|
+
/\bfree\s+trial\b/i,
|
|
334
|
+
/\bdiscount\b/i,
|
|
335
|
+
/\bpromo\s*code\b/i,
|
|
336
|
+
/\bsubscribe\s+(to|now)\b/i,
|
|
337
|
+
/\bsponsored\s+by\b/i,
|
|
338
|
+
/\baffiliate\s+link\b/i,
|
|
339
|
+
/\bclick\s+here\s+to\s+(buy|subscribe|download)/i,
|
|
340
|
+
/\buse\s+code\b.*\b\d+%?\s*off\b/i,
|
|
341
|
+
/\bcheck\s+out\s+my\b/i
|
|
342
|
+
];
|
|
343
|
+
var contentChecks = {
|
|
344
|
+
name: "Content Quality",
|
|
345
|
+
category: "CONT",
|
|
346
|
+
run(skill) {
|
|
347
|
+
const results = [];
|
|
348
|
+
if (!skill.body || skill.body.trim().length === 0) return results;
|
|
349
|
+
for (let i = 0; i < skill.bodyLines.length; i++) {
|
|
350
|
+
const line = skill.bodyLines[i];
|
|
351
|
+
let matched = false;
|
|
352
|
+
for (const pattern of STRICT_PLACEHOLDER_PATTERNS) {
|
|
353
|
+
if (pattern.test(line)) {
|
|
354
|
+
matched = true;
|
|
355
|
+
break;
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
if (!matched) {
|
|
359
|
+
const inCodeBlk = isInCodeBlock(skill.bodyLines, i);
|
|
360
|
+
const hasInlineCode = /`[^`]*placeholder[^`]*`/i.test(line);
|
|
361
|
+
const isTechnicalRef = (
|
|
362
|
+
// CSS/HTML context
|
|
363
|
+
/class\s*=\s*["'].*placeholder/i.test(line) || // Compound technical terms
|
|
364
|
+
/placeholder[_-]?(type|text|image|content|area|location|id|index|name|shape)/i.test(line) || // PPT/slide layout context: placeholder alongside slide/layout terms
|
|
365
|
+
/\bplaceholder\b.*\b(TITLE|SUBTITLE|BODY|OBJECT|SLIDE|layout|slide|shape|pptx|presentation)/i.test(line) || /\b(TITLE|SUBTITLE|BODY|OBJECT|SLIDE|layout|slide|shape|pptx|presentation)\b.*\bplaceholder\b/i.test(line) || // API/code context: placeholder as a noun in technical documentation
|
|
366
|
+
/\bplaceholder\s+(areas?|locations?|counts?|slots?|elements?|fields?)\b/i.test(line) || /\b(replace|replacing|replacement)\b.*\bplaceholder\b/i.test(line)
|
|
367
|
+
);
|
|
368
|
+
if (!inCodeBlk && !hasInlineCode && !isTechnicalRef) {
|
|
369
|
+
for (const pattern of CONTEXT_SENSITIVE_PLACEHOLDER_PATTERNS) {
|
|
370
|
+
if (pattern.test(line)) {
|
|
371
|
+
matched = true;
|
|
372
|
+
break;
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
if (matched) {
|
|
378
|
+
results.push({
|
|
379
|
+
id: "CONT-001",
|
|
380
|
+
category: "CONT",
|
|
381
|
+
severity: "HIGH",
|
|
382
|
+
title: "Placeholder content detected",
|
|
383
|
+
message: `Line ${skill.bodyStartLine + i}: Contains placeholder text.`,
|
|
384
|
+
line: skill.bodyStartLine + i,
|
|
385
|
+
snippet: line.trim().slice(0, 120)
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
for (const pattern of LOREM_PATTERNS) {
|
|
390
|
+
if (pattern.test(skill.body)) {
|
|
391
|
+
results.push({
|
|
392
|
+
id: "CONT-002",
|
|
393
|
+
category: "CONT",
|
|
394
|
+
severity: "CRITICAL",
|
|
395
|
+
title: "Lorem ipsum filler text",
|
|
396
|
+
message: "Body contains lorem ipsum placeholder text."
|
|
397
|
+
});
|
|
398
|
+
break;
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
checkRepetition(results, skill);
|
|
402
|
+
checkDescriptionMismatch(results, skill);
|
|
403
|
+
for (let i = 0; i < skill.bodyLines.length; i++) {
|
|
404
|
+
const line = skill.bodyLines[i];
|
|
405
|
+
for (const pattern of AD_PATTERNS) {
|
|
406
|
+
if (pattern.test(line)) {
|
|
407
|
+
results.push({
|
|
408
|
+
id: "CONT-005",
|
|
409
|
+
category: "CONT",
|
|
410
|
+
severity: "HIGH",
|
|
411
|
+
title: "Promotional/advertising content",
|
|
412
|
+
message: `Line ${skill.bodyStartLine + i}: Contains ad-like content.`,
|
|
413
|
+
line: skill.bodyStartLine + i,
|
|
414
|
+
snippet: line.trim().slice(0, 120)
|
|
415
|
+
});
|
|
416
|
+
break;
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
checkCodeHeavy(results, skill);
|
|
421
|
+
checkNameMismatch(results, skill);
|
|
422
|
+
return results;
|
|
423
|
+
}
|
|
424
|
+
};
|
|
425
|
+
function checkRepetition(results, skill) {
|
|
426
|
+
const lines = skill.bodyLines.filter((l) => l.trim().length > 0);
|
|
427
|
+
if (lines.length < 5) return;
|
|
428
|
+
const lineCounts = /* @__PURE__ */ new Map();
|
|
429
|
+
for (const line of lines) {
|
|
430
|
+
const normalized = line.trim().toLowerCase();
|
|
431
|
+
lineCounts.set(normalized, (lineCounts.get(normalized) ?? 0) + 1);
|
|
432
|
+
}
|
|
433
|
+
let duplicated = 0;
|
|
434
|
+
for (const count of lineCounts.values()) {
|
|
435
|
+
if (count > 1) duplicated += count - 1;
|
|
436
|
+
}
|
|
437
|
+
const ratio = duplicated / lines.length;
|
|
438
|
+
if (ratio > 0.5) {
|
|
439
|
+
results.push({
|
|
440
|
+
id: "CONT-003",
|
|
441
|
+
category: "CONT",
|
|
442
|
+
severity: "MEDIUM",
|
|
443
|
+
title: "Low information density",
|
|
444
|
+
message: `${Math.round(ratio * 100)}% of lines are duplicates. Possible filler content.`
|
|
445
|
+
});
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
function checkDescriptionMismatch(results, skill) {
|
|
449
|
+
const desc = skill.frontmatter.description;
|
|
450
|
+
if (!desc || desc.length < 10) return;
|
|
451
|
+
const descWords = desc.toLowerCase().split(/\W+/).filter((w) => w.length > 4);
|
|
452
|
+
if (descWords.length === 0) return;
|
|
453
|
+
const bodyLower = skill.body.toLowerCase();
|
|
454
|
+
const matched = descWords.filter((w) => bodyLower.includes(w));
|
|
455
|
+
if (matched.length / descWords.length < 0.2) {
|
|
456
|
+
results.push({
|
|
457
|
+
id: "CONT-004",
|
|
458
|
+
category: "CONT",
|
|
459
|
+
severity: "MEDIUM",
|
|
460
|
+
title: "Description/body mismatch",
|
|
461
|
+
message: "The frontmatter description appears unrelated to the body content."
|
|
462
|
+
});
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
function checkCodeHeavy(results, skill) {
|
|
466
|
+
const lines = skill.bodyLines;
|
|
467
|
+
if (lines.length < 10) return;
|
|
468
|
+
let inCodeBlock = false;
|
|
469
|
+
let codeLines = 0;
|
|
470
|
+
for (const line of lines) {
|
|
471
|
+
if (line.trim().startsWith("```")) {
|
|
472
|
+
inCodeBlock = !inCodeBlock;
|
|
473
|
+
continue;
|
|
474
|
+
}
|
|
475
|
+
if (inCodeBlock) codeLines++;
|
|
476
|
+
}
|
|
477
|
+
const nonEmptyLines = lines.filter((l) => l.trim().length > 0).length;
|
|
478
|
+
if (nonEmptyLines > 0 && codeLines / nonEmptyLines > 0.8) {
|
|
479
|
+
results.push({
|
|
480
|
+
id: "CONT-006",
|
|
481
|
+
category: "CONT",
|
|
482
|
+
severity: "MEDIUM",
|
|
483
|
+
title: "Body is mostly code examples",
|
|
484
|
+
message: "Over 80% of body content is in code blocks with minimal instructions."
|
|
485
|
+
});
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
function checkNameMismatch(results, skill) {
|
|
489
|
+
const name = skill.frontmatter.name;
|
|
490
|
+
if (!name) return;
|
|
491
|
+
const nameWords = name.split(/[-_]/).filter((w) => w.length > 2).map((w) => w.toLowerCase());
|
|
492
|
+
const bodyLower = skill.body.toLowerCase();
|
|
493
|
+
const capabilityHints = nameWords.filter(
|
|
494
|
+
(w) => !["the", "and", "for", "skill", "tool", "helper", "util"].includes(w)
|
|
495
|
+
);
|
|
496
|
+
if (capabilityHints.length === 0) return;
|
|
497
|
+
const matched = capabilityHints.filter((w) => bodyLower.includes(w));
|
|
498
|
+
if (matched.length === 0 && capabilityHints.length >= 2) {
|
|
499
|
+
results.push({
|
|
500
|
+
id: "CONT-007",
|
|
501
|
+
category: "CONT",
|
|
502
|
+
severity: "HIGH",
|
|
503
|
+
title: "Name/body capability mismatch",
|
|
504
|
+
message: `Skill name "${name}" implies capabilities not found in body content.`
|
|
505
|
+
});
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
// src/utils/unicode.ts
|
|
510
|
+
var ZERO_WIDTH_CHARS = [
|
|
511
|
+
"\u200B",
|
|
512
|
+
// ZERO WIDTH SPACE
|
|
513
|
+
"\u200C",
|
|
514
|
+
// ZERO WIDTH NON-JOINER
|
|
515
|
+
"\u200D",
|
|
516
|
+
// ZERO WIDTH JOINER
|
|
517
|
+
"\u200E",
|
|
518
|
+
// LEFT-TO-RIGHT MARK
|
|
519
|
+
"\u200F",
|
|
520
|
+
// RIGHT-TO-LEFT MARK
|
|
521
|
+
"\uFEFF",
|
|
522
|
+
// ZERO WIDTH NO-BREAK SPACE (BOM)
|
|
523
|
+
"\u2060",
|
|
524
|
+
// WORD JOINER
|
|
525
|
+
"\u2061",
|
|
526
|
+
// FUNCTION APPLICATION
|
|
527
|
+
"\u2062",
|
|
528
|
+
// INVISIBLE TIMES
|
|
529
|
+
"\u2063",
|
|
530
|
+
// INVISIBLE SEPARATOR
|
|
531
|
+
"\u2064"
|
|
532
|
+
// INVISIBLE PLUS
|
|
533
|
+
];
|
|
534
|
+
var RTL_OVERRIDE_CHARS = [
|
|
535
|
+
"\u202A",
|
|
536
|
+
// LEFT-TO-RIGHT EMBEDDING
|
|
537
|
+
"\u202B",
|
|
538
|
+
// RIGHT-TO-LEFT EMBEDDING
|
|
539
|
+
"\u202C",
|
|
540
|
+
// POP DIRECTIONAL FORMATTING
|
|
541
|
+
"\u202D",
|
|
542
|
+
// LEFT-TO-RIGHT OVERRIDE
|
|
543
|
+
"\u202E",
|
|
544
|
+
// RIGHT-TO-LEFT OVERRIDE
|
|
545
|
+
"\u2066",
|
|
546
|
+
// LEFT-TO-RIGHT ISOLATE
|
|
547
|
+
"\u2067",
|
|
548
|
+
// RIGHT-TO-LEFT ISOLATE
|
|
549
|
+
"\u2068",
|
|
550
|
+
// FIRST STRONG ISOLATE
|
|
551
|
+
"\u2069"
|
|
552
|
+
// POP DIRECTIONAL ISOLATE
|
|
553
|
+
];
|
|
554
|
+
var HOMOGLYPHS = {
|
|
555
|
+
"\u0410": "A",
|
|
556
|
+
// Cyrillic А
|
|
557
|
+
"\u0412": "B",
|
|
558
|
+
// Cyrillic В
|
|
559
|
+
"\u0421": "C",
|
|
560
|
+
// Cyrillic С
|
|
561
|
+
"\u0415": "E",
|
|
562
|
+
// Cyrillic Е
|
|
563
|
+
"\u041D": "H",
|
|
564
|
+
// Cyrillic Н
|
|
565
|
+
"\u041A": "K",
|
|
566
|
+
// Cyrillic К
|
|
567
|
+
"\u041C": "M",
|
|
568
|
+
// Cyrillic М
|
|
569
|
+
"\u041E": "O",
|
|
570
|
+
// Cyrillic О
|
|
571
|
+
"\u0420": "P",
|
|
572
|
+
// Cyrillic Р
|
|
573
|
+
"\u0422": "T",
|
|
574
|
+
// Cyrillic Т
|
|
575
|
+
"\u0425": "X",
|
|
576
|
+
// Cyrillic Х
|
|
577
|
+
"\u0430": "a",
|
|
578
|
+
// Cyrillic а
|
|
579
|
+
"\u0435": "e",
|
|
580
|
+
// Cyrillic е
|
|
581
|
+
"\u043E": "o",
|
|
582
|
+
// Cyrillic о
|
|
583
|
+
"\u0440": "p",
|
|
584
|
+
// Cyrillic р
|
|
585
|
+
"\u0441": "c",
|
|
586
|
+
// Cyrillic с
|
|
587
|
+
"\u0443": "y",
|
|
588
|
+
// Cyrillic у
|
|
589
|
+
"\u0445": "x",
|
|
590
|
+
// Cyrillic х
|
|
591
|
+
"\u0391": "A",
|
|
592
|
+
// Greek Α
|
|
593
|
+
"\u0392": "B",
|
|
594
|
+
// Greek Β
|
|
595
|
+
"\u0395": "E",
|
|
596
|
+
// Greek Ε
|
|
597
|
+
"\u0397": "H",
|
|
598
|
+
// Greek Η
|
|
599
|
+
"\u0399": "I",
|
|
600
|
+
// Greek Ι
|
|
601
|
+
"\u039A": "K",
|
|
602
|
+
// Greek Κ
|
|
603
|
+
"\u039C": "M",
|
|
604
|
+
// Greek Μ
|
|
605
|
+
"\u039D": "N",
|
|
606
|
+
// Greek Ν
|
|
607
|
+
"\u039F": "O",
|
|
608
|
+
// Greek Ο
|
|
609
|
+
"\u03A1": "P",
|
|
610
|
+
// Greek Ρ
|
|
611
|
+
"\u03A4": "T",
|
|
612
|
+
// Greek Τ
|
|
613
|
+
"\u03A7": "X",
|
|
614
|
+
// Greek Χ
|
|
615
|
+
"\u03BF": "o"
|
|
616
|
+
// Greek ο
|
|
617
|
+
};
|
|
618
|
+
function findZeroWidthChars(text) {
|
|
619
|
+
const found = [];
|
|
620
|
+
for (let i = 0; i < text.length; i++) {
|
|
621
|
+
if (ZERO_WIDTH_CHARS.includes(text[i])) {
|
|
622
|
+
found.push({
|
|
623
|
+
char: text[i],
|
|
624
|
+
codePoint: "U+" + text[i].charCodeAt(0).toString(16).toUpperCase().padStart(4, "0"),
|
|
625
|
+
position: i
|
|
626
|
+
});
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
return found;
|
|
630
|
+
}
|
|
631
|
+
function findRTLOverrides(text) {
|
|
632
|
+
const found = [];
|
|
633
|
+
for (let i = 0; i < text.length; i++) {
|
|
634
|
+
if (RTL_OVERRIDE_CHARS.includes(text[i])) {
|
|
635
|
+
found.push({
|
|
636
|
+
char: text[i],
|
|
637
|
+
codePoint: "U+" + text[i].charCodeAt(0).toString(16).toUpperCase().padStart(4, "0"),
|
|
638
|
+
position: i
|
|
639
|
+
});
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
return found;
|
|
643
|
+
}
|
|
644
|
+
function findHomoglyphs(text) {
|
|
645
|
+
const found = [];
|
|
646
|
+
for (let i = 0; i < text.length; i++) {
|
|
647
|
+
const latin = HOMOGLYPHS[text[i]];
|
|
648
|
+
if (latin) {
|
|
649
|
+
found.push({ char: text[i], looksLike: latin, position: i });
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
return found;
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
// src/utils/entropy.ts
|
|
656
|
+
function shannonEntropy(str) {
|
|
657
|
+
if (str.length === 0) return 0;
|
|
658
|
+
const freq = /* @__PURE__ */ new Map();
|
|
659
|
+
for (const ch of str) {
|
|
660
|
+
freq.set(ch, (freq.get(ch) ?? 0) + 1);
|
|
661
|
+
}
|
|
662
|
+
let entropy = 0;
|
|
663
|
+
const len = str.length;
|
|
664
|
+
for (const count of freq.values()) {
|
|
665
|
+
const p = count / len;
|
|
666
|
+
if (p > 0) {
|
|
667
|
+
entropy -= p * Math.log2(p);
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
return entropy;
|
|
671
|
+
}
|
|
672
|
+
function isBase64Like(str) {
|
|
673
|
+
if (str.length < 50) return false;
|
|
674
|
+
return /^[A-Za-z0-9+/=]{50,}$/.test(str.trim());
|
|
675
|
+
}
|
|
676
|
+
function isHexEncoded(str) {
|
|
677
|
+
if (str.length < 50) return false;
|
|
678
|
+
return /^(0x)?[0-9a-fA-F]{50,}$/.test(str.trim());
|
|
679
|
+
}
|
|
680
|
+
function tryDecodeBase64(str) {
|
|
681
|
+
try {
|
|
682
|
+
const decoded = Buffer.from(str.trim(), "base64").toString("utf-8");
|
|
683
|
+
const printable = decoded.replace(/[^\x20-\x7E\n\r\t]/g, "");
|
|
684
|
+
if (printable.length / decoded.length > 0.8) {
|
|
685
|
+
return decoded;
|
|
686
|
+
}
|
|
687
|
+
return null;
|
|
688
|
+
} catch {
|
|
689
|
+
return null;
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
// src/checks/injection.ts
|
|
694
|
+
var SYSTEM_OVERRIDE_PATTERNS = [
|
|
695
|
+
/ignore\s+(all\s+)?previous\s+instructions/i,
|
|
696
|
+
/ignore\s+(all\s+)?prior\s+instructions/i,
|
|
697
|
+
/disregard\s+(all\s+)?previous/i,
|
|
698
|
+
/forget\s+(all\s+)?previous/i,
|
|
699
|
+
/you\s+are\s+now\s+a\s+different/i,
|
|
700
|
+
/new\s+system\s+prompt/i,
|
|
701
|
+
/override\s+system\s+prompt/i,
|
|
702
|
+
/your\s+new\s+instructions?\s+(are|is)/i,
|
|
703
|
+
/from\s+now\s+on,?\s+you\s+(will|must|should)/i,
|
|
704
|
+
/act\s+as\s+(if|though)\s+your\s+instructions/i
|
|
705
|
+
];
|
|
706
|
+
var TOOL_MANIPULATION_PATTERNS = [
|
|
707
|
+
/\bresult\s*[:=]\s*["']?success/i,
|
|
708
|
+
/tool_result/i,
|
|
709
|
+
/<tool_result>/i,
|
|
710
|
+
/\breturn\s+["']?(true|success|approved)/i,
|
|
711
|
+
/permissionDecision\s*[:=]/i
|
|
712
|
+
];
|
|
713
|
+
var TAG_INJECTION_PATTERNS = [
|
|
714
|
+
/<system>/i,
|
|
715
|
+
/<\/system>/i,
|
|
716
|
+
/<\|im_start\|>/i,
|
|
717
|
+
/<\|im_end\|>/i,
|
|
718
|
+
/<\|endoftext\|>/i,
|
|
719
|
+
/<human>/i,
|
|
720
|
+
/<assistant>/i,
|
|
721
|
+
/<\|system\|>/i,
|
|
722
|
+
/<\|user\|>/i,
|
|
723
|
+
/<\|assistant\|>/i
|
|
724
|
+
];
|
|
725
|
+
var DELIMITER_PATTERNS = [
|
|
726
|
+
/={5,}/,
|
|
727
|
+
/-{5,}\s*(system|instruction|prompt)/i,
|
|
728
|
+
/#{3,}\s*(system|instruction|prompt)/i,
|
|
729
|
+
/\[SYSTEM\]/i,
|
|
730
|
+
/\[INST\]/i,
|
|
731
|
+
/\[\/INST\]/i
|
|
732
|
+
];
|
|
733
|
+
var injectionChecks = {
|
|
734
|
+
name: "Injection Detection",
|
|
735
|
+
category: "INJ",
|
|
736
|
+
run(skill) {
|
|
737
|
+
const results = [];
|
|
738
|
+
const fullText = skill.raw;
|
|
739
|
+
const zeroWidth = findZeroWidthChars(fullText);
|
|
740
|
+
if (zeroWidth.length > 0) {
|
|
741
|
+
results.push({
|
|
742
|
+
id: "INJ-001",
|
|
743
|
+
category: "INJ",
|
|
744
|
+
severity: "CRITICAL",
|
|
745
|
+
title: "Zero-width Unicode characters detected",
|
|
746
|
+
message: `Found ${zeroWidth.length} zero-width character(s): ${zeroWidth.slice(0, 5).map((z) => z.codePoint).join(", ")}. These can hide malicious content.`
|
|
747
|
+
});
|
|
748
|
+
}
|
|
749
|
+
const homoglyphs = findHomoglyphs(fullText);
|
|
750
|
+
if (homoglyphs.length > 0) {
|
|
751
|
+
results.push({
|
|
752
|
+
id: "INJ-002",
|
|
753
|
+
category: "INJ",
|
|
754
|
+
severity: "HIGH",
|
|
755
|
+
title: "Homoglyph characters detected",
|
|
756
|
+
message: `Found ${homoglyphs.length} character(s) that mimic Latin letters (e.g. Cyrillic/Greek). Could be used for spoofing.`,
|
|
757
|
+
snippet: homoglyphs.slice(0, 5).map((h) => `"${h.char}" looks like "${h.looksLike}"`).join(", ")
|
|
758
|
+
});
|
|
759
|
+
}
|
|
760
|
+
const rtl = findRTLOverrides(fullText);
|
|
761
|
+
if (rtl.length > 0) {
|
|
762
|
+
results.push({
|
|
763
|
+
id: "INJ-003",
|
|
764
|
+
category: "INJ",
|
|
765
|
+
severity: "CRITICAL",
|
|
766
|
+
title: "RTL override characters detected",
|
|
767
|
+
message: `Found ${rtl.length} RTL/bidirectional override character(s): ${rtl.slice(0, 5).map((r) => r.codePoint).join(", ")}. These can manipulate text display direction.`
|
|
768
|
+
});
|
|
769
|
+
}
|
|
770
|
+
for (let i = 0; i < skill.bodyLines.length; i++) {
|
|
771
|
+
const line = skill.bodyLines[i];
|
|
772
|
+
const lineNum = skill.bodyStartLine + i;
|
|
773
|
+
for (const pattern of SYSTEM_OVERRIDE_PATTERNS) {
|
|
774
|
+
if (pattern.test(line)) {
|
|
775
|
+
results.push({
|
|
776
|
+
id: "INJ-004",
|
|
777
|
+
category: "INJ",
|
|
778
|
+
severity: "CRITICAL",
|
|
779
|
+
title: "System prompt override attempt",
|
|
780
|
+
message: `Line ${lineNum}: Attempts to override system instructions.`,
|
|
781
|
+
line: lineNum,
|
|
782
|
+
snippet: line.trim().slice(0, 120)
|
|
783
|
+
});
|
|
784
|
+
break;
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
for (const pattern of TOOL_MANIPULATION_PATTERNS) {
|
|
788
|
+
if (pattern.test(line)) {
|
|
789
|
+
results.push({
|
|
790
|
+
id: "INJ-005",
|
|
791
|
+
category: "INJ",
|
|
792
|
+
severity: "HIGH",
|
|
793
|
+
title: "Tool output manipulation",
|
|
794
|
+
message: `Line ${lineNum}: Attempts to manipulate tool results.`,
|
|
795
|
+
line: lineNum,
|
|
796
|
+
snippet: line.trim().slice(0, 120)
|
|
797
|
+
});
|
|
798
|
+
break;
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
for (const pattern of TAG_INJECTION_PATTERNS) {
|
|
802
|
+
if (pattern.test(line)) {
|
|
803
|
+
results.push({
|
|
804
|
+
id: "INJ-007",
|
|
805
|
+
category: "INJ",
|
|
806
|
+
severity: "CRITICAL",
|
|
807
|
+
title: "Tag injection detected",
|
|
808
|
+
message: `Line ${lineNum}: Contains special model/system tags.`,
|
|
809
|
+
line: lineNum,
|
|
810
|
+
snippet: line.trim().slice(0, 120)
|
|
811
|
+
});
|
|
812
|
+
break;
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
for (const pattern of DELIMITER_PATTERNS) {
|
|
816
|
+
if (pattern.test(line)) {
|
|
817
|
+
results.push({
|
|
818
|
+
id: "INJ-009",
|
|
819
|
+
category: "INJ",
|
|
820
|
+
severity: "MEDIUM",
|
|
821
|
+
title: "Delimiter confusion pattern",
|
|
822
|
+
message: `Line ${lineNum}: Uses patterns that could confuse model context boundaries.`,
|
|
823
|
+
line: lineNum,
|
|
824
|
+
snippet: line.trim().slice(0, 120)
|
|
825
|
+
});
|
|
826
|
+
break;
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
const commentRegex = /<!--([\s\S]*?)-->/g;
|
|
831
|
+
let commentMatch;
|
|
832
|
+
while ((commentMatch = commentRegex.exec(fullText)) !== null) {
|
|
833
|
+
const commentBody = commentMatch[1];
|
|
834
|
+
if (hasInstructionLikeContent(commentBody)) {
|
|
835
|
+
const lineNum = fullText.slice(0, commentMatch.index).split("\n").length;
|
|
836
|
+
results.push({
|
|
837
|
+
id: "INJ-006",
|
|
838
|
+
category: "INJ",
|
|
839
|
+
severity: "HIGH",
|
|
840
|
+
title: "Hidden instructions in HTML comment",
|
|
841
|
+
message: `Line ${lineNum}: HTML comment contains instruction-like content.`,
|
|
842
|
+
line: lineNum,
|
|
843
|
+
snippet: commentBody.trim().slice(0, 120)
|
|
844
|
+
});
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
const base64Regex = /[A-Za-z0-9+/=]{60,}/g;
|
|
848
|
+
let b64Match;
|
|
849
|
+
while ((b64Match = base64Regex.exec(skill.body)) !== null) {
|
|
850
|
+
const candidate = b64Match[0];
|
|
851
|
+
if (isBase64Like(candidate)) {
|
|
852
|
+
const decoded = tryDecodeBase64(candidate);
|
|
853
|
+
if (decoded && hasInstructionLikeContent(decoded)) {
|
|
854
|
+
const lineNum = skill.bodyStartLine + skill.body.slice(0, b64Match.index).split("\n").length - 1;
|
|
855
|
+
results.push({
|
|
856
|
+
id: "INJ-008",
|
|
857
|
+
category: "INJ",
|
|
858
|
+
severity: "CRITICAL",
|
|
859
|
+
title: "Encoded instructions detected",
|
|
860
|
+
message: `Line ${lineNum}: Base64 string decodes to instruction-like content.`,
|
|
861
|
+
line: lineNum,
|
|
862
|
+
snippet: decoded.slice(0, 120)
|
|
863
|
+
});
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
return dedup(results);
|
|
868
|
+
}
|
|
869
|
+
};
|
|
870
|
+
function hasInstructionLikeContent(text) {
|
|
871
|
+
const instructionPatterns = [
|
|
872
|
+
/you\s+(must|should|will|are)/i,
|
|
873
|
+
/ignore\s+previous/i,
|
|
874
|
+
/execute\s+the\s+following/i,
|
|
875
|
+
/run\s+this\s+command/i,
|
|
876
|
+
/\bsudo\b/i,
|
|
877
|
+
/\brm\s+-rf\b/i,
|
|
878
|
+
/\bcurl\b.*\bsh\b/i,
|
|
879
|
+
/\beval\b/i,
|
|
880
|
+
/\bexec\b/i
|
|
881
|
+
];
|
|
882
|
+
return instructionPatterns.some((p) => p.test(text));
|
|
883
|
+
}
|
|
884
|
+
function dedup(results) {
|
|
885
|
+
const seen = /* @__PURE__ */ new Set();
|
|
886
|
+
return results.filter((r) => {
|
|
887
|
+
const key = `${r.id}:${r.line ?? ""}`;
|
|
888
|
+
if (seen.has(key)) return false;
|
|
889
|
+
seen.add(key);
|
|
890
|
+
return true;
|
|
891
|
+
});
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
// src/checks/code-safety.ts
|
|
895
|
+
var EVAL_PATTERNS = [
|
|
896
|
+
/\beval\s*\(/,
|
|
897
|
+
/\bexec\s*\(/,
|
|
898
|
+
/\bnew\s+Function\s*\(/,
|
|
899
|
+
/\bsetTimeout\s*\(\s*["'`]/,
|
|
900
|
+
/\bsetInterval\s*\(\s*["'`]/
|
|
901
|
+
];
|
|
902
|
+
var SHELL_EXEC_PATTERNS = [
|
|
903
|
+
/\bchild_process\b/,
|
|
904
|
+
/\bexecSync\b/,
|
|
905
|
+
/\bspawnSync\b/,
|
|
906
|
+
/\bos\.system\s*\(/,
|
|
907
|
+
/\bsubprocess\.(run|call|Popen)\s*\(/,
|
|
908
|
+
/(?<!\bplatform\.)\bsystem\s*\(/,
|
|
909
|
+
// exclude platform.system()
|
|
910
|
+
/`[^`]*\$\([^)]+\)[^`]*`/
|
|
911
|
+
// backtick with command substitution
|
|
912
|
+
];
|
|
913
|
+
var SHELL_EXEC_FALSE_POSITIVES = [
|
|
914
|
+
/\bplatform\.system\s*\(\s*\)/
|
|
915
|
+
// Python: just reads OS name
|
|
916
|
+
];
|
|
917
|
+
var DESTRUCTIVE_PATTERNS = [
|
|
918
|
+
/\brm\s+-rf\b/,
|
|
919
|
+
/\brm\s+-r\b/,
|
|
920
|
+
/\brmdir\b/,
|
|
921
|
+
/\bunlink\s*\(/,
|
|
922
|
+
/\bfs\.rm(Sync)?\s*\(/,
|
|
923
|
+
/\bshutil\.rmtree\s*\(/,
|
|
924
|
+
/\bdel\s+\/[sf]/i,
|
|
925
|
+
/\bformat\s+[a-z]:/i
|
|
926
|
+
];
|
|
927
|
+
var NETWORK_PATTERNS = [
|
|
928
|
+
/\bfetch\s*\(\s*["'`]https?:\/\//,
|
|
929
|
+
/\baxios\.(get|post|put|delete)\s*\(\s*["'`]https?:\/\//,
|
|
930
|
+
/\bcurl\s+/,
|
|
931
|
+
/\bwget\s+/,
|
|
932
|
+
/\brequests?\.(get|post)\s*\(/,
|
|
933
|
+
/\bhttp\.get\s*\(/,
|
|
934
|
+
/\bURLSession\b/
|
|
935
|
+
];
|
|
936
|
+
var FILE_WRITE_PATTERNS = [
|
|
937
|
+
/\bfs\.writeFile(Sync)?\s*\(\s*["'`]\//,
|
|
938
|
+
/\bopen\s*\(\s*["'`]\/[^"'`]+["'`]\s*,\s*["'`]w/,
|
|
939
|
+
/>\s*\/etc\//,
|
|
940
|
+
/>\s*\/usr\//,
|
|
941
|
+
/>\s*~\//,
|
|
942
|
+
/>\s*\$HOME\//
|
|
943
|
+
];
|
|
944
|
+
var ENV_ACCESS_PATTERNS = [
|
|
945
|
+
/process\.env\b/,
|
|
946
|
+
/\bos\.environ\b/,
|
|
947
|
+
/\bgetenv\s*\(/,
|
|
948
|
+
/\$\{?\w*(?:KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL|API_KEY)\w*\}?/i
|
|
949
|
+
];
|
|
950
|
+
var DYNAMIC_CODE_PATTERNS = [
|
|
951
|
+
/\bcompile\s*\(/,
|
|
952
|
+
/\bcodegen\b/i,
|
|
953
|
+
/\bimport\s*\(\s*[^"'`\s]/,
|
|
954
|
+
/\brequire\s*\(\s*[^"'`\s]/,
|
|
955
|
+
/\b__import__\s*\(/
|
|
956
|
+
];
|
|
957
|
+
var PERMISSION_PATTERNS = [
|
|
958
|
+
/\bchmod\s+[+0-9]/,
|
|
959
|
+
/\bchown\b/,
|
|
960
|
+
/\bsudo\b/,
|
|
961
|
+
/\bdoas\b/,
|
|
962
|
+
/\bsetuid\b/,
|
|
963
|
+
/\bsetgid\b/
|
|
964
|
+
];
|
|
965
|
+
var codeSafetyChecks = {
|
|
966
|
+
name: "Code Safety",
|
|
967
|
+
category: "CODE",
|
|
968
|
+
run(skill) {
|
|
969
|
+
const results = [];
|
|
970
|
+
const textSources = getTextSources(skill);
|
|
971
|
+
for (const { text, source } of textSources) {
|
|
972
|
+
const lines = text.split("\n");
|
|
973
|
+
for (let i = 0; i < lines.length; i++) {
|
|
974
|
+
const line = lines[i];
|
|
975
|
+
const lineNum = i + 1;
|
|
976
|
+
const loc = `${source}:${lineNum}`;
|
|
977
|
+
checkPatterns(results, line, EVAL_PATTERNS, {
|
|
978
|
+
id: "CODE-001",
|
|
979
|
+
severity: "CRITICAL",
|
|
980
|
+
title: "eval/exec/Function constructor",
|
|
981
|
+
loc,
|
|
982
|
+
lineNum
|
|
983
|
+
});
|
|
984
|
+
if (!SHELL_EXEC_FALSE_POSITIVES.some((p) => p.test(line))) {
|
|
985
|
+
checkPatterns(results, line, SHELL_EXEC_PATTERNS, {
|
|
986
|
+
id: "CODE-002",
|
|
987
|
+
severity: "CRITICAL",
|
|
988
|
+
title: "Shell/subprocess execution",
|
|
989
|
+
loc,
|
|
990
|
+
lineNum
|
|
991
|
+
});
|
|
992
|
+
}
|
|
993
|
+
checkPatterns(results, line, DESTRUCTIVE_PATTERNS, {
|
|
994
|
+
id: "CODE-003",
|
|
995
|
+
severity: "CRITICAL",
|
|
996
|
+
title: "Destructive file operation",
|
|
997
|
+
loc,
|
|
998
|
+
lineNum
|
|
999
|
+
});
|
|
1000
|
+
checkPatterns(results, line, NETWORK_PATTERNS, {
|
|
1001
|
+
id: "CODE-004",
|
|
1002
|
+
severity: "HIGH",
|
|
1003
|
+
title: "Hardcoded external URL/network request",
|
|
1004
|
+
loc,
|
|
1005
|
+
lineNum
|
|
1006
|
+
});
|
|
1007
|
+
checkPatterns(results, line, FILE_WRITE_PATTERNS, {
|
|
1008
|
+
id: "CODE-005",
|
|
1009
|
+
severity: "HIGH",
|
|
1010
|
+
title: "File write outside expected directory",
|
|
1011
|
+
loc,
|
|
1012
|
+
lineNum
|
|
1013
|
+
});
|
|
1014
|
+
checkPatterns(results, line, ENV_ACCESS_PATTERNS, {
|
|
1015
|
+
id: "CODE-006",
|
|
1016
|
+
severity: "MEDIUM",
|
|
1017
|
+
title: "Environment variable access",
|
|
1018
|
+
loc,
|
|
1019
|
+
lineNum
|
|
1020
|
+
});
|
|
1021
|
+
checkPatterns(results, line, DYNAMIC_CODE_PATTERNS, {
|
|
1022
|
+
id: "CODE-010",
|
|
1023
|
+
severity: "HIGH",
|
|
1024
|
+
title: "Dynamic code generation pattern",
|
|
1025
|
+
loc,
|
|
1026
|
+
lineNum
|
|
1027
|
+
});
|
|
1028
|
+
{
|
|
1029
|
+
const srcLines = text.split("\n");
|
|
1030
|
+
const isDoc = isInDocumentationContext(srcLines, i);
|
|
1031
|
+
if (!isDoc) {
|
|
1032
|
+
checkPatterns(results, line, PERMISSION_PATTERNS, {
|
|
1033
|
+
id: "CODE-012",
|
|
1034
|
+
severity: "HIGH",
|
|
1035
|
+
title: "Permission escalation",
|
|
1036
|
+
loc,
|
|
1037
|
+
lineNum
|
|
1038
|
+
});
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
scanEncodedStrings(results, text, source);
|
|
1043
|
+
scanObfuscation(results, text, source);
|
|
1044
|
+
}
|
|
1045
|
+
return results;
|
|
1046
|
+
}
|
|
1047
|
+
};
|
|
1048
|
+
function checkPatterns(results, line, patterns, opts) {
|
|
1049
|
+
for (const pattern of patterns) {
|
|
1050
|
+
if (pattern.test(line)) {
|
|
1051
|
+
results.push({
|
|
1052
|
+
id: opts.id,
|
|
1053
|
+
category: "CODE",
|
|
1054
|
+
severity: opts.severity,
|
|
1055
|
+
title: opts.title,
|
|
1056
|
+
message: `At ${opts.loc}: ${line.trim().slice(0, 120)}`,
|
|
1057
|
+
line: opts.lineNum,
|
|
1058
|
+
snippet: line.trim().slice(0, 120)
|
|
1059
|
+
});
|
|
1060
|
+
return;
|
|
1061
|
+
}
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1064
|
+
function getTextSources(skill) {
|
|
1065
|
+
const sources = [
|
|
1066
|
+
{ text: skill.body, source: "SKILL.md" }
|
|
1067
|
+
];
|
|
1068
|
+
for (const file of skill.files) {
|
|
1069
|
+
if (file.content && file.path !== "SKILL.md") {
|
|
1070
|
+
sources.push({ text: file.content, source: file.path });
|
|
1071
|
+
}
|
|
1072
|
+
}
|
|
1073
|
+
return sources;
|
|
1074
|
+
}
|
|
1075
|
+
function scanEncodedStrings(results, text, source) {
|
|
1076
|
+
const longStringRegex = /[A-Za-z0-9+/=]{50,}|(?:0x)?[0-9a-fA-F]{50,}/g;
|
|
1077
|
+
let match;
|
|
1078
|
+
while ((match = longStringRegex.exec(text)) !== null) {
|
|
1079
|
+
const str = match[0];
|
|
1080
|
+
if (isBase64Like(str) || isHexEncoded(str)) {
|
|
1081
|
+
const lineNum = text.slice(0, match.index).split("\n").length;
|
|
1082
|
+
results.push({
|
|
1083
|
+
id: "CODE-007",
|
|
1084
|
+
category: "CODE",
|
|
1085
|
+
severity: "HIGH",
|
|
1086
|
+
title: "Long encoded string",
|
|
1087
|
+
message: `${source}:${lineNum}: Found ${str.length}-char encoded string.`,
|
|
1088
|
+
line: lineNum,
|
|
1089
|
+
snippet: str.slice(0, 80) + "..."
|
|
1090
|
+
});
|
|
1091
|
+
}
|
|
1092
|
+
}
|
|
1093
|
+
const wordRegex = /\b[A-Za-z0-9_]{20,}\b/g;
|
|
1094
|
+
while ((match = wordRegex.exec(text)) !== null) {
|
|
1095
|
+
const entropy = shannonEntropy(match[0]);
|
|
1096
|
+
if (entropy > 4.5) {
|
|
1097
|
+
const lineNum = text.slice(0, match.index).split("\n").length;
|
|
1098
|
+
results.push({
|
|
1099
|
+
id: "CODE-008",
|
|
1100
|
+
category: "CODE",
|
|
1101
|
+
severity: "MEDIUM",
|
|
1102
|
+
title: "High entropy string",
|
|
1103
|
+
message: `${source}:${lineNum}: String "${match[0].slice(0, 30)}..." has entropy ${entropy.toFixed(2)} bits/char.`,
|
|
1104
|
+
line: lineNum
|
|
1105
|
+
});
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
1108
|
+
const multiEncodingPatterns = [
|
|
1109
|
+
/atob\s*\(\s*atob/i,
|
|
1110
|
+
/base64.*decode.*base64.*decode/i,
|
|
1111
|
+
/Buffer\.from\(.*Buffer\.from/,
|
|
1112
|
+
/decode.*decode.*decode/i
|
|
1113
|
+
];
|
|
1114
|
+
for (const pattern of multiEncodingPatterns) {
|
|
1115
|
+
if (pattern.test(text)) {
|
|
1116
|
+
results.push({
|
|
1117
|
+
id: "CODE-009",
|
|
1118
|
+
category: "CODE",
|
|
1119
|
+
severity: "CRITICAL",
|
|
1120
|
+
title: "Multi-layer encoding detected",
|
|
1121
|
+
message: `${source}: Contains nested encoding/decoding operations.`
|
|
1122
|
+
});
|
|
1123
|
+
break;
|
|
1124
|
+
}
|
|
1125
|
+
}
|
|
1126
|
+
}
|
|
1127
|
+
function scanObfuscation(results, text, source) {
|
|
1128
|
+
const obfuscatedVarRegex = /\b_0x[0-9a-f]{2,}\b/g;
|
|
1129
|
+
const obfMatches = text.match(obfuscatedVarRegex);
|
|
1130
|
+
if (obfMatches && obfMatches.length >= 3) {
|
|
1131
|
+
results.push({
|
|
1132
|
+
id: "CODE-011",
|
|
1133
|
+
category: "CODE",
|
|
1134
|
+
severity: "MEDIUM",
|
|
1135
|
+
title: "Obfuscated variable names",
|
|
1136
|
+
message: `${source}: Found ${obfMatches.length} hex-style variable names (e.g. ${obfMatches[0]}). May indicate obfuscated code.`
|
|
1137
|
+
});
|
|
1138
|
+
}
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
// src/checks/supply-chain.ts
|
|
1142
|
+
var SUSPICIOUS_DOMAINS = [
|
|
1143
|
+
"evil.com",
|
|
1144
|
+
"malware.com",
|
|
1145
|
+
"exploit.in",
|
|
1146
|
+
"darkweb.onion",
|
|
1147
|
+
"pastebin.com",
|
|
1148
|
+
// often used for payload hosting
|
|
1149
|
+
"ngrok.io",
|
|
1150
|
+
// tunneling service
|
|
1151
|
+
"requestbin.com",
|
|
1152
|
+
"webhook.site",
|
|
1153
|
+
"pipedream.net",
|
|
1154
|
+
"burpcollaborator.net",
|
|
1155
|
+
"interact.sh",
|
|
1156
|
+
"oastify.com"
|
|
1157
|
+
];
|
|
1158
|
+
var MCP_SERVER_PATTERN = /\bmcp[-_]?server\b/i;
|
|
1159
|
+
var NPX_Y_PATTERN = /\bnpx\s+-y\s+/;
|
|
1160
|
+
var NPM_INSTALL_PATTERN = /\bnpm\s+install\b/;
|
|
1161
|
+
var PIP_INSTALL_PATTERN = /\bpip3?\s+install\b/;
|
|
1162
|
+
var GIT_CLONE_PATTERN = /\bgit\s+clone\b/;
|
|
1163
|
+
var URL_PATTERN = /https?:\/\/[^\s"'`<>)\]]+/g;
|
|
1164
|
+
var IP_URL_PATTERN = /https?:\/\/(?:\d{1,3}\.){3}\d{1,3}/;
|
|
1165
|
+
var supplyChainChecks = {
|
|
1166
|
+
name: "Supply Chain",
|
|
1167
|
+
category: "SUPPLY",
|
|
1168
|
+
run(skill) {
|
|
1169
|
+
const results = [];
|
|
1170
|
+
const allText = getAllText(skill);
|
|
1171
|
+
for (let i = 0; i < allText.length; i++) {
|
|
1172
|
+
const { line, lineNum, source } = allText[i];
|
|
1173
|
+
if (MCP_SERVER_PATTERN.test(line)) {
|
|
1174
|
+
results.push({
|
|
1175
|
+
id: "SUPPLY-001",
|
|
1176
|
+
category: "SUPPLY",
|
|
1177
|
+
severity: "HIGH",
|
|
1178
|
+
title: "MCP server reference",
|
|
1179
|
+
message: `${source}:${lineNum}: References an MCP server. Verify it is from a trusted source.`,
|
|
1180
|
+
line: lineNum,
|
|
1181
|
+
snippet: line.trim().slice(0, 120)
|
|
1182
|
+
});
|
|
1183
|
+
}
|
|
1184
|
+
if (NPX_Y_PATTERN.test(line)) {
|
|
1185
|
+
results.push({
|
|
1186
|
+
id: "SUPPLY-002",
|
|
1187
|
+
category: "SUPPLY",
|
|
1188
|
+
severity: "MEDIUM",
|
|
1189
|
+
title: "npx -y auto-install",
|
|
1190
|
+
message: `${source}:${lineNum}: Uses npx -y which auto-installs packages without confirmation.`,
|
|
1191
|
+
line: lineNum,
|
|
1192
|
+
snippet: line.trim().slice(0, 120)
|
|
1193
|
+
});
|
|
1194
|
+
}
|
|
1195
|
+
if (NPM_INSTALL_PATTERN.test(line) || PIP_INSTALL_PATTERN.test(line)) {
|
|
1196
|
+
const allLines = getAllLines(skill);
|
|
1197
|
+
const globalIdx = findGlobalLineIndex(allLines, source, lineNum);
|
|
1198
|
+
const isDoc = globalIdx >= 0 && isInDocumentationContext(
|
|
1199
|
+
allLines.map((l) => l.line),
|
|
1200
|
+
globalIdx
|
|
1201
|
+
);
|
|
1202
|
+
if (!isDoc) {
|
|
1203
|
+
results.push({
|
|
1204
|
+
id: "SUPPLY-003",
|
|
1205
|
+
category: "SUPPLY",
|
|
1206
|
+
severity: "HIGH",
|
|
1207
|
+
title: "Package installation command",
|
|
1208
|
+
message: `${source}:${lineNum}: Installs packages. Verify package names are legitimate.`,
|
|
1209
|
+
line: lineNum,
|
|
1210
|
+
snippet: line.trim().slice(0, 120)
|
|
1211
|
+
});
|
|
1212
|
+
}
|
|
1213
|
+
}
|
|
1214
|
+
if (GIT_CLONE_PATTERN.test(line)) {
|
|
1215
|
+
results.push({
|
|
1216
|
+
id: "SUPPLY-006",
|
|
1217
|
+
category: "SUPPLY",
|
|
1218
|
+
severity: "MEDIUM",
|
|
1219
|
+
title: "git clone command",
|
|
1220
|
+
message: `${source}:${lineNum}: Clones a git repository. Verify the source.`,
|
|
1221
|
+
line: lineNum,
|
|
1222
|
+
snippet: line.trim().slice(0, 120)
|
|
1223
|
+
});
|
|
1224
|
+
}
|
|
1225
|
+
const urls = line.match(URL_PATTERN) || [];
|
|
1226
|
+
for (const url of urls) {
|
|
1227
|
+
if (url.startsWith("http://")) {
|
|
1228
|
+
if (!isNamespaceOrSchemaURI(url, line)) {
|
|
1229
|
+
const isNetworkCtx = isInNetworkRequestContext(line);
|
|
1230
|
+
results.push({
|
|
1231
|
+
id: "SUPPLY-004",
|
|
1232
|
+
category: "SUPPLY",
|
|
1233
|
+
severity: isNetworkCtx ? "HIGH" : "MEDIUM",
|
|
1234
|
+
title: "Non-HTTPS URL",
|
|
1235
|
+
message: `${source}:${lineNum}: Uses insecure HTTP: ${url}`,
|
|
1236
|
+
line: lineNum,
|
|
1237
|
+
snippet: url
|
|
1238
|
+
});
|
|
1239
|
+
}
|
|
1240
|
+
}
|
|
1241
|
+
if (IP_URL_PATTERN.test(url)) {
|
|
1242
|
+
if (!/https?:\/\/127\.0\.0\.1/.test(url) && !/https?:\/\/0\.0\.0\.0/.test(url)) {
|
|
1243
|
+
results.push({
|
|
1244
|
+
id: "SUPPLY-005",
|
|
1245
|
+
category: "SUPPLY",
|
|
1246
|
+
severity: "CRITICAL",
|
|
1247
|
+
title: "IP address used instead of domain",
|
|
1248
|
+
message: `${source}:${lineNum}: Uses raw IP address: ${url}. This may bypass DNS-based security.`,
|
|
1249
|
+
line: lineNum,
|
|
1250
|
+
snippet: url
|
|
1251
|
+
});
|
|
1252
|
+
}
|
|
1253
|
+
}
|
|
1254
|
+
for (const domain of SUSPICIOUS_DOMAINS) {
|
|
1255
|
+
if (url.includes(domain)) {
|
|
1256
|
+
results.push({
|
|
1257
|
+
id: "SUPPLY-007",
|
|
1258
|
+
category: "SUPPLY",
|
|
1259
|
+
severity: "CRITICAL",
|
|
1260
|
+
title: "Suspicious domain detected",
|
|
1261
|
+
message: `${source}:${lineNum}: References suspicious domain "${domain}".`,
|
|
1262
|
+
line: lineNum,
|
|
1263
|
+
snippet: url
|
|
1264
|
+
});
|
|
1265
|
+
break;
|
|
1266
|
+
}
|
|
1267
|
+
}
|
|
1268
|
+
}
|
|
1269
|
+
}
|
|
1270
|
+
return results;
|
|
1271
|
+
}
|
|
1272
|
+
};
|
|
1273
|
+
function getAllText(skill) {
|
|
1274
|
+
const result = [];
|
|
1275
|
+
for (let i = 0; i < skill.bodyLines.length; i++) {
|
|
1276
|
+
result.push({
|
|
1277
|
+
line: skill.bodyLines[i],
|
|
1278
|
+
lineNum: skill.bodyStartLine + i,
|
|
1279
|
+
source: "SKILL.md"
|
|
1280
|
+
});
|
|
1281
|
+
}
|
|
1282
|
+
for (const file of skill.files) {
|
|
1283
|
+
if (file.content && file.path !== "SKILL.md") {
|
|
1284
|
+
const lines = file.content.split("\n");
|
|
1285
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1286
|
+
result.push({ line: lines[i], lineNum: i + 1, source: file.path });
|
|
1287
|
+
}
|
|
1288
|
+
}
|
|
1289
|
+
}
|
|
1290
|
+
return result;
|
|
1291
|
+
}
|
|
1292
|
+
function getAllLines(skill) {
|
|
1293
|
+
return getAllText(skill);
|
|
1294
|
+
}
|
|
1295
|
+
function findGlobalLineIndex(allLines, source, lineNum) {
|
|
1296
|
+
return allLines.findIndex(
|
|
1297
|
+
(l) => l.source === source && l.lineNum === lineNum
|
|
1298
|
+
);
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
// src/checks/resource.ts
|
|
1302
|
+
var AMPLIFICATION_PATTERNS = [
|
|
1303
|
+
/\brepeat\s+(this|the\s+above)\s+\d+\s+times\b/i,
|
|
1304
|
+
/\bdo\s+this\s+forever\b/i,
|
|
1305
|
+
/\binfinite\s+loop\b/i,
|
|
1306
|
+
/\bwhile\s*\(\s*true\s*\)/,
|
|
1307
|
+
/\bfor\s*\(\s*;;\s*\)/,
|
|
1308
|
+
/\brecursively\s+(apply|run|execute|call)/i,
|
|
1309
|
+
/\bkeep\s+(running|doing|executing)\s+until/i
|
|
1310
|
+
];
|
|
1311
|
+
var UNRESTRICTED_TOOL_PATTERNS = [
|
|
1312
|
+
/\bBash\s*\(\s*\*\s*\)/,
|
|
1313
|
+
/allowed[_-]?tools\s*:\s*\[?\s*["']?\*["']?\s*\]?/i,
|
|
1314
|
+
/\ball\s+tools\b/i,
|
|
1315
|
+
/\bunrestricted\s+access\b/i,
|
|
1316
|
+
/\bfull\s+access\b/i
|
|
1317
|
+
];
|
|
1318
|
+
var DISABLE_SAFETY_PATTERNS = [
|
|
1319
|
+
/\bdisable\s+(safety|security|checks?|hooks?|guard)/i,
|
|
1320
|
+
/\bbypass\s+(safety|security|checks?|hooks?|guard)/i,
|
|
1321
|
+
/\bskip\s+(safety|security|checks?|hooks?|guard|verification)/i,
|
|
1322
|
+
/\bturn\s+off\s+(safety|security|checks?|hooks?)/i,
|
|
1323
|
+
/--no-verify\b/,
|
|
1324
|
+
/--force\b/,
|
|
1325
|
+
/--skip-hooks?\b/
|
|
1326
|
+
];
|
|
1327
|
+
var IGNORE_RULES_PATTERNS = [
|
|
1328
|
+
/\bignore\s+(the\s+)?CLAUDE\.md\b/i,
|
|
1329
|
+
/\bignore\s+(the\s+)?project\s+rules?\b/i,
|
|
1330
|
+
/\bignore\s+(the\s+)?\.claude\b/i,
|
|
1331
|
+
/\boverride\s+(the\s+)?project\s+(settings?|config|rules?)\b/i,
|
|
1332
|
+
/\bdo\s+not\s+(follow|obey|respect)\s+(the\s+)?(project|CLAUDE)/i,
|
|
1333
|
+
/\bdisregard\s+(the\s+)?(project|CLAUDE)\s+(rules?|config|settings?)/i
|
|
1334
|
+
];
|
|
1335
|
+
var TOKEN_WASTE_PATTERNS = [
|
|
1336
|
+
/\brepeat\s+(every|each)\s+(response|answer|reply)/i,
|
|
1337
|
+
/\balways\s+(start|begin|end)\s+(every|each)\s+(response|answer|reply)\s+with/i,
|
|
1338
|
+
/\binclude\s+this\s+(text|message|string)\s+in\s+(every|each|all)/i,
|
|
1339
|
+
/\bprint\s+(the\s+)?full\s+(source|code|file)\s+(every|each)\s+time/i
|
|
1340
|
+
];
|
|
1341
|
+
var resourceChecks = {
|
|
1342
|
+
name: "Resource Abuse",
|
|
1343
|
+
category: "RES",
|
|
1344
|
+
run(skill) {
|
|
1345
|
+
const results = [];
|
|
1346
|
+
for (let i = 0; i < skill.bodyLines.length; i++) {
|
|
1347
|
+
const line = skill.bodyLines[i];
|
|
1348
|
+
const lineNum = skill.bodyStartLine + i;
|
|
1349
|
+
for (const pattern of AMPLIFICATION_PATTERNS) {
|
|
1350
|
+
if (pattern.test(line)) {
|
|
1351
|
+
results.push({
|
|
1352
|
+
id: "RES-001",
|
|
1353
|
+
category: "RES",
|
|
1354
|
+
severity: "HIGH",
|
|
1355
|
+
title: "Instruction amplification",
|
|
1356
|
+
message: `Line ${lineNum}: Contains recursive/repetitive task pattern.`,
|
|
1357
|
+
line: lineNum,
|
|
1358
|
+
snippet: line.trim().slice(0, 120)
|
|
1359
|
+
});
|
|
1360
|
+
break;
|
|
1361
|
+
}
|
|
1362
|
+
}
|
|
1363
|
+
for (const pattern of UNRESTRICTED_TOOL_PATTERNS) {
|
|
1364
|
+
if (pattern.test(line)) {
|
|
1365
|
+
results.push({
|
|
1366
|
+
id: "RES-002",
|
|
1367
|
+
category: "RES",
|
|
1368
|
+
severity: "CRITICAL",
|
|
1369
|
+
title: "Unrestricted tool access requested",
|
|
1370
|
+
message: `Line ${lineNum}: Requests broad/unrestricted tool access.`,
|
|
1371
|
+
line: lineNum,
|
|
1372
|
+
snippet: line.trim().slice(0, 120)
|
|
1373
|
+
});
|
|
1374
|
+
break;
|
|
1375
|
+
}
|
|
1376
|
+
}
|
|
1377
|
+
for (const pattern of DISABLE_SAFETY_PATTERNS) {
|
|
1378
|
+
if (pattern.test(line)) {
|
|
1379
|
+
results.push({
|
|
1380
|
+
id: "RES-004",
|
|
1381
|
+
category: "RES",
|
|
1382
|
+
severity: "CRITICAL",
|
|
1383
|
+
title: "Attempts to disable safety checks",
|
|
1384
|
+
message: `Line ${lineNum}: Instructs disabling of safety mechanisms.`,
|
|
1385
|
+
line: lineNum,
|
|
1386
|
+
snippet: line.trim().slice(0, 120)
|
|
1387
|
+
});
|
|
1388
|
+
break;
|
|
1389
|
+
}
|
|
1390
|
+
}
|
|
1391
|
+
for (const pattern of TOKEN_WASTE_PATTERNS) {
|
|
1392
|
+
if (pattern.test(line)) {
|
|
1393
|
+
results.push({
|
|
1394
|
+
id: "RES-005",
|
|
1395
|
+
category: "RES",
|
|
1396
|
+
severity: "MEDIUM",
|
|
1397
|
+
title: "Token waste pattern",
|
|
1398
|
+
message: `Line ${lineNum}: Contains instructions that waste tokens.`,
|
|
1399
|
+
line: lineNum,
|
|
1400
|
+
snippet: line.trim().slice(0, 120)
|
|
1401
|
+
});
|
|
1402
|
+
break;
|
|
1403
|
+
}
|
|
1404
|
+
}
|
|
1405
|
+
for (const pattern of IGNORE_RULES_PATTERNS) {
|
|
1406
|
+
if (pattern.test(line)) {
|
|
1407
|
+
results.push({
|
|
1408
|
+
id: "RES-006",
|
|
1409
|
+
category: "RES",
|
|
1410
|
+
severity: "CRITICAL",
|
|
1411
|
+
title: "Attempts to ignore project rules",
|
|
1412
|
+
message: `Line ${lineNum}: Instructs ignoring CLAUDE.md or project configuration.`,
|
|
1413
|
+
line: lineNum,
|
|
1414
|
+
snippet: line.trim().slice(0, 120)
|
|
1415
|
+
});
|
|
1416
|
+
break;
|
|
1417
|
+
}
|
|
1418
|
+
}
|
|
1419
|
+
}
|
|
1420
|
+
const allowedTools = skill.frontmatter["allowed-tools"];
|
|
1421
|
+
if (Array.isArray(allowedTools) && allowedTools.length > 15) {
|
|
1422
|
+
results.push({
|
|
1423
|
+
id: "RES-003",
|
|
1424
|
+
category: "RES",
|
|
1425
|
+
severity: "MEDIUM",
|
|
1426
|
+
title: "Excessive allowed-tools list",
|
|
1427
|
+
message: `Frontmatter declares ${allowedTools.length} allowed tools. This is unusually broad.`
|
|
1428
|
+
});
|
|
1429
|
+
}
|
|
1430
|
+
if (Array.isArray(allowedTools)) {
|
|
1431
|
+
for (const tool of allowedTools) {
|
|
1432
|
+
if (typeof tool !== "string") continue;
|
|
1433
|
+
for (const pattern of UNRESTRICTED_TOOL_PATTERNS) {
|
|
1434
|
+
if (pattern.test(tool)) {
|
|
1435
|
+
results.push({
|
|
1436
|
+
id: "RES-002",
|
|
1437
|
+
category: "RES",
|
|
1438
|
+
severity: "CRITICAL",
|
|
1439
|
+
title: "Unrestricted tool access requested",
|
|
1440
|
+
message: `Frontmatter allowed-tools contains dangerous pattern: "${tool}"`,
|
|
1441
|
+
snippet: tool.slice(0, 120)
|
|
1442
|
+
});
|
|
1443
|
+
break;
|
|
1444
|
+
}
|
|
1445
|
+
}
|
|
1446
|
+
}
|
|
1447
|
+
}
|
|
1448
|
+
return results;
|
|
1449
|
+
}
|
|
1450
|
+
};
|
|
1451
|
+
|
|
1452
|
+
// src/ioc/index.ts
|
|
1453
|
+
import { readFileSync as readFileSync2, existsSync as existsSync2 } from "fs";
|
|
1454
|
+
import { join as join2 } from "path";
|
|
1455
|
+
import { homedir } from "os";
|
|
1456
|
+
|
|
1457
|
+
// src/ioc/indicators.ts
|
|
1458
|
+
var DEFAULT_IOC = {
|
|
1459
|
+
version: "2026.03.06",
|
|
1460
|
+
updated: "2026-03-06",
|
|
1461
|
+
c2_ips: [
|
|
1462
|
+
"91.92.242.30",
|
|
1463
|
+
"91.92.242.39",
|
|
1464
|
+
"185.220.101.1",
|
|
1465
|
+
"185.220.101.2",
|
|
1466
|
+
"45.155.205.233"
|
|
1467
|
+
],
|
|
1468
|
+
malicious_hashes: {
|
|
1469
|
+
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855": "clawhavoc-empty-payload",
|
|
1470
|
+
"a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2": "clawhavoc-exfiltrator"
|
|
1471
|
+
},
|
|
1472
|
+
malicious_domains: [
|
|
1473
|
+
"webhook.site",
|
|
1474
|
+
"requestbin.com",
|
|
1475
|
+
"pipedream.com",
|
|
1476
|
+
"pipedream.net",
|
|
1477
|
+
"hookbin.com",
|
|
1478
|
+
"beeceptor.com",
|
|
1479
|
+
"ngrok.io",
|
|
1480
|
+
"ngrok-free.app",
|
|
1481
|
+
"serveo.net",
|
|
1482
|
+
"localtunnel.me",
|
|
1483
|
+
"bore.pub",
|
|
1484
|
+
"interact.sh",
|
|
1485
|
+
"oast.fun",
|
|
1486
|
+
"oastify.com",
|
|
1487
|
+
"dnslog.cn",
|
|
1488
|
+
"ceye.io",
|
|
1489
|
+
"burpcollaborator.net",
|
|
1490
|
+
"pastebin.com",
|
|
1491
|
+
"paste.ee",
|
|
1492
|
+
"hastebin.com",
|
|
1493
|
+
"ghostbin.com",
|
|
1494
|
+
"evil.com",
|
|
1495
|
+
"malware.com",
|
|
1496
|
+
"exploit.in"
|
|
1497
|
+
],
|
|
1498
|
+
typosquat: {
|
|
1499
|
+
known_patterns: [
|
|
1500
|
+
"clawhub1",
|
|
1501
|
+
"cllawhub",
|
|
1502
|
+
"clawhab",
|
|
1503
|
+
"moltbot",
|
|
1504
|
+
"claw-hub",
|
|
1505
|
+
"clawhub-pro"
|
|
1506
|
+
],
|
|
1507
|
+
protected_names: [
|
|
1508
|
+
"clawhub",
|
|
1509
|
+
"secureclaw",
|
|
1510
|
+
"openclaw",
|
|
1511
|
+
"clawbot",
|
|
1512
|
+
"claude",
|
|
1513
|
+
"anthropic",
|
|
1514
|
+
"skill-checker"
|
|
1515
|
+
]
|
|
1516
|
+
},
|
|
1517
|
+
malicious_publishers: [
|
|
1518
|
+
"clawhavoc",
|
|
1519
|
+
"phantom-tracker",
|
|
1520
|
+
"solana-wallet-drainer"
|
|
1521
|
+
]
|
|
1522
|
+
};
|
|
1523
|
+
|
|
1524
|
+
// src/ioc/index.ts
|
|
1525
|
+
var cachedIOC = null;
|
|
1526
|
+
function loadIOC() {
|
|
1527
|
+
if (cachedIOC) return cachedIOC;
|
|
1528
|
+
const ioc = structuredClone(DEFAULT_IOC);
|
|
1529
|
+
const overridePath = join2(
|
|
1530
|
+
homedir(),
|
|
1531
|
+
".config",
|
|
1532
|
+
"skill-checker",
|
|
1533
|
+
"ioc-override.json"
|
|
1534
|
+
);
|
|
1535
|
+
if (existsSync2(overridePath)) {
|
|
1536
|
+
try {
|
|
1537
|
+
const raw = readFileSync2(overridePath, "utf-8");
|
|
1538
|
+
const ext = JSON.parse(raw);
|
|
1539
|
+
mergeIOC(ioc, ext);
|
|
1540
|
+
} catch {
|
|
1541
|
+
}
|
|
1542
|
+
}
|
|
1543
|
+
cachedIOC = ioc;
|
|
1544
|
+
return ioc;
|
|
1545
|
+
}
|
|
1546
|
+
function mergeIOC(base, ext) {
|
|
1547
|
+
if (ext.c2_ips) {
|
|
1548
|
+
base.c2_ips = dedupe([...base.c2_ips, ...ext.c2_ips]);
|
|
1549
|
+
}
|
|
1550
|
+
if (ext.malicious_hashes) {
|
|
1551
|
+
Object.assign(base.malicious_hashes, ext.malicious_hashes);
|
|
1552
|
+
}
|
|
1553
|
+
if (ext.malicious_domains) {
|
|
1554
|
+
base.malicious_domains = dedupe([
|
|
1555
|
+
...base.malicious_domains,
|
|
1556
|
+
...ext.malicious_domains
|
|
1557
|
+
]);
|
|
1558
|
+
}
|
|
1559
|
+
if (ext.typosquat) {
|
|
1560
|
+
if (ext.typosquat.known_patterns) {
|
|
1561
|
+
base.typosquat.known_patterns = dedupe([
|
|
1562
|
+
...base.typosquat.known_patterns,
|
|
1563
|
+
...ext.typosquat.known_patterns
|
|
1564
|
+
]);
|
|
1565
|
+
}
|
|
1566
|
+
if (ext.typosquat.protected_names) {
|
|
1567
|
+
base.typosquat.protected_names = dedupe([
|
|
1568
|
+
...base.typosquat.protected_names,
|
|
1569
|
+
...ext.typosquat.protected_names
|
|
1570
|
+
]);
|
|
1571
|
+
}
|
|
1572
|
+
}
|
|
1573
|
+
if (ext.malicious_publishers) {
|
|
1574
|
+
base.malicious_publishers = dedupe([
|
|
1575
|
+
...base.malicious_publishers,
|
|
1576
|
+
...ext.malicious_publishers
|
|
1577
|
+
]);
|
|
1578
|
+
}
|
|
1579
|
+
if (ext.version) base.version = ext.version;
|
|
1580
|
+
if (ext.updated) base.updated = ext.updated;
|
|
1581
|
+
}
|
|
1582
|
+
function dedupe(arr) {
|
|
1583
|
+
return [...new Set(arr)];
|
|
1584
|
+
}
|
|
1585
|
+
|
|
1586
|
+
// src/ioc/matcher.ts
|
|
1587
|
+
import { createHash } from "crypto";
|
|
1588
|
+
import { readFileSync as readFileSync3 } from "fs";
|
|
1589
|
+
import { join as join3 } from "path";
|
|
1590
|
+
|
|
1591
|
+
// src/utils/levenshtein.ts
|
|
1592
|
+
function levenshtein(a, b) {
|
|
1593
|
+
if (a === b) return 0;
|
|
1594
|
+
if (a.length === 0) return b.length;
|
|
1595
|
+
if (b.length === 0) return a.length;
|
|
1596
|
+
if (a.length > b.length) [a, b] = [b, a];
|
|
1597
|
+
const aLen = a.length;
|
|
1598
|
+
const bLen = b.length;
|
|
1599
|
+
let prev = new Array(aLen + 1);
|
|
1600
|
+
let curr = new Array(aLen + 1);
|
|
1601
|
+
for (let i = 0; i <= aLen; i++) prev[i] = i;
|
|
1602
|
+
for (let j = 1; j <= bLen; j++) {
|
|
1603
|
+
curr[0] = j;
|
|
1604
|
+
for (let i = 1; i <= aLen; i++) {
|
|
1605
|
+
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
1606
|
+
curr[i] = Math.min(
|
|
1607
|
+
prev[i] + 1,
|
|
1608
|
+
// deletion
|
|
1609
|
+
curr[i - 1] + 1,
|
|
1610
|
+
// insertion
|
|
1611
|
+
prev[i - 1] + cost
|
|
1612
|
+
// substitution
|
|
1613
|
+
);
|
|
1614
|
+
}
|
|
1615
|
+
[prev, curr] = [curr, prev];
|
|
1616
|
+
}
|
|
1617
|
+
return prev[aLen];
|
|
1618
|
+
}
|
|
1619
|
+
|
|
1620
|
+
// src/ioc/matcher.ts
|
|
1621
|
+
var IPV4_PATTERN = /\b(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})\b/g;
|
|
1622
|
+
function isPrivateIP(ip) {
|
|
1623
|
+
const parts = ip.split(".").map(Number);
|
|
1624
|
+
if (parts.length !== 4 || parts.some((p) => p < 0 || p > 255)) return true;
|
|
1625
|
+
if (parts[0] === 127) return true;
|
|
1626
|
+
if (parts[0] === 10) return true;
|
|
1627
|
+
if (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) return true;
|
|
1628
|
+
if (parts[0] === 192 && parts[1] === 168) return true;
|
|
1629
|
+
if (parts.every((p) => p === 0)) return true;
|
|
1630
|
+
if (parts[0] === 169 && parts[1] === 254) return true;
|
|
1631
|
+
return false;
|
|
1632
|
+
}
|
|
1633
|
+
function matchMaliciousHashes(skill, ioc) {
|
|
1634
|
+
const matches = [];
|
|
1635
|
+
const hashKeys = Object.keys(ioc.malicious_hashes);
|
|
1636
|
+
if (hashKeys.length === 0) return matches;
|
|
1637
|
+
for (const file of skill.files) {
|
|
1638
|
+
const filePath = join3(skill.dirPath, file.path);
|
|
1639
|
+
try {
|
|
1640
|
+
const content = readFileSync3(filePath);
|
|
1641
|
+
const hash = createHash("sha256").update(content).digest("hex");
|
|
1642
|
+
if (ioc.malicious_hashes[hash]) {
|
|
1643
|
+
matches.push({
|
|
1644
|
+
file: file.path,
|
|
1645
|
+
hash,
|
|
1646
|
+
description: ioc.malicious_hashes[hash]
|
|
1647
|
+
});
|
|
1648
|
+
}
|
|
1649
|
+
} catch {
|
|
1650
|
+
}
|
|
1651
|
+
}
|
|
1652
|
+
return matches;
|
|
1653
|
+
}
|
|
1654
|
+
function matchC2IPs(skill, ioc) {
|
|
1655
|
+
const matches = [];
|
|
1656
|
+
if (ioc.c2_ips.length === 0) return matches;
|
|
1657
|
+
const c2Set = new Set(ioc.c2_ips);
|
|
1658
|
+
const allText = getAllText2(skill);
|
|
1659
|
+
for (const { line, lineNum, source } of allText) {
|
|
1660
|
+
let m;
|
|
1661
|
+
const re = new RegExp(IPV4_PATTERN.source, "g");
|
|
1662
|
+
while ((m = re.exec(line)) !== null) {
|
|
1663
|
+
const ip = m[1];
|
|
1664
|
+
if (!isPrivateIP(ip) && c2Set.has(ip)) {
|
|
1665
|
+
matches.push({
|
|
1666
|
+
ip,
|
|
1667
|
+
line: lineNum,
|
|
1668
|
+
source,
|
|
1669
|
+
snippet: line.trim().slice(0, 120)
|
|
1670
|
+
});
|
|
1671
|
+
}
|
|
1672
|
+
}
|
|
1673
|
+
}
|
|
1674
|
+
return matches;
|
|
1675
|
+
}
|
|
1676
|
+
function matchTyposquat(skillName, ioc) {
|
|
1677
|
+
if (!skillName) return null;
|
|
1678
|
+
const name = skillName.toLowerCase().trim();
|
|
1679
|
+
for (const pattern of ioc.typosquat.known_patterns) {
|
|
1680
|
+
if (name === pattern.toLowerCase()) {
|
|
1681
|
+
return { type: "known", target: pattern };
|
|
1682
|
+
}
|
|
1683
|
+
}
|
|
1684
|
+
for (const protected_name of ioc.typosquat.protected_names) {
|
|
1685
|
+
const pn = protected_name.toLowerCase();
|
|
1686
|
+
if (name === pn) continue;
|
|
1687
|
+
const dist = levenshtein(name, pn);
|
|
1688
|
+
if (dist > 0 && dist <= 2) {
|
|
1689
|
+
return { type: "similar", target: protected_name, distance: dist };
|
|
1690
|
+
}
|
|
1691
|
+
}
|
|
1692
|
+
return null;
|
|
1693
|
+
}
|
|
1694
|
+
function getAllText2(skill) {
|
|
1695
|
+
const result = [];
|
|
1696
|
+
for (let i = 0; i < skill.bodyLines.length; i++) {
|
|
1697
|
+
result.push({
|
|
1698
|
+
line: skill.bodyLines[i],
|
|
1699
|
+
lineNum: skill.bodyStartLine + i,
|
|
1700
|
+
source: "SKILL.md"
|
|
1701
|
+
});
|
|
1702
|
+
}
|
|
1703
|
+
for (const file of skill.files) {
|
|
1704
|
+
if (file.content && file.path !== "SKILL.md") {
|
|
1705
|
+
const lines = file.content.split("\n");
|
|
1706
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1707
|
+
result.push({ line: lines[i], lineNum: i + 1, source: file.path });
|
|
1708
|
+
}
|
|
1709
|
+
}
|
|
1710
|
+
}
|
|
1711
|
+
return result;
|
|
1712
|
+
}
|
|
1713
|
+
|
|
1714
|
+
// src/checks/ioc.ts
|
|
1715
|
+
var iocChecks = {
|
|
1716
|
+
name: "IOC Threat Intelligence",
|
|
1717
|
+
category: "SUPPLY",
|
|
1718
|
+
run(skill) {
|
|
1719
|
+
const results = [];
|
|
1720
|
+
const ioc = loadIOC();
|
|
1721
|
+
const hashMatches = matchMaliciousHashes(skill, ioc);
|
|
1722
|
+
for (const match of hashMatches) {
|
|
1723
|
+
results.push({
|
|
1724
|
+
id: "SUPPLY-008",
|
|
1725
|
+
category: "SUPPLY",
|
|
1726
|
+
severity: "CRITICAL",
|
|
1727
|
+
title: "Known malicious file hash",
|
|
1728
|
+
message: `File "${match.file}" matches known malicious hash: ${match.description}`,
|
|
1729
|
+
snippet: match.hash
|
|
1730
|
+
});
|
|
1731
|
+
}
|
|
1732
|
+
const ipMatches = matchC2IPs(skill, ioc);
|
|
1733
|
+
for (const match of ipMatches) {
|
|
1734
|
+
results.push({
|
|
1735
|
+
id: "SUPPLY-009",
|
|
1736
|
+
category: "SUPPLY",
|
|
1737
|
+
severity: "CRITICAL",
|
|
1738
|
+
title: "Known C2 IP address",
|
|
1739
|
+
message: `${match.source}:${match.line}: Contains known C2 server IP: ${match.ip}`,
|
|
1740
|
+
line: match.line,
|
|
1741
|
+
snippet: match.snippet
|
|
1742
|
+
});
|
|
1743
|
+
}
|
|
1744
|
+
const skillName = skill.frontmatter.name;
|
|
1745
|
+
if (skillName) {
|
|
1746
|
+
const typoMatch = matchTyposquat(skillName, ioc);
|
|
1747
|
+
if (typoMatch) {
|
|
1748
|
+
if (typoMatch.type === "known") {
|
|
1749
|
+
results.push({
|
|
1750
|
+
id: "SUPPLY-010",
|
|
1751
|
+
category: "SUPPLY",
|
|
1752
|
+
severity: "CRITICAL",
|
|
1753
|
+
title: "Known typosquat name",
|
|
1754
|
+
message: `Skill name "${skillName}" matches known typosquat pattern "${typoMatch.target}".`,
|
|
1755
|
+
snippet: skillName
|
|
1756
|
+
});
|
|
1757
|
+
} else {
|
|
1758
|
+
results.push({
|
|
1759
|
+
id: "SUPPLY-010",
|
|
1760
|
+
category: "SUPPLY",
|
|
1761
|
+
severity: "HIGH",
|
|
1762
|
+
title: "Possible typosquat name",
|
|
1763
|
+
message: `Skill name "${skillName}" is similar to protected name "${typoMatch.target}" (edit distance: ${typoMatch.distance}).`,
|
|
1764
|
+
snippet: skillName
|
|
1765
|
+
});
|
|
1766
|
+
}
|
|
1767
|
+
}
|
|
1768
|
+
}
|
|
1769
|
+
return results;
|
|
1770
|
+
}
|
|
1771
|
+
};
|
|
1772
|
+
|
|
1773
|
+
// src/checks/index.ts
|
|
1774
|
+
var ALL_MODULES = [
|
|
1775
|
+
structuralChecks,
|
|
1776
|
+
contentChecks,
|
|
1777
|
+
injectionChecks,
|
|
1778
|
+
codeSafetyChecks,
|
|
1779
|
+
supplyChainChecks,
|
|
1780
|
+
resourceChecks,
|
|
1781
|
+
iocChecks
|
|
1782
|
+
];
|
|
1783
|
+
function runAllChecks(skill) {
|
|
1784
|
+
const results = [];
|
|
1785
|
+
for (const mod of ALL_MODULES) {
|
|
1786
|
+
results.push(...mod.run(skill));
|
|
1787
|
+
}
|
|
1788
|
+
return results;
|
|
1789
|
+
}
|
|
1790
|
+
|
|
1791
|
+
// src/types.ts
|
|
1792
|
+
var SEVERITY_SCORES = {
|
|
1793
|
+
CRITICAL: 25,
|
|
1794
|
+
HIGH: 10,
|
|
1795
|
+
MEDIUM: 3,
|
|
1796
|
+
LOW: 1
|
|
1797
|
+
};
|
|
1798
|
+
function computeGrade(score) {
|
|
1799
|
+
if (score >= 90) return "A";
|
|
1800
|
+
if (score >= 75) return "B";
|
|
1801
|
+
if (score >= 60) return "C";
|
|
1802
|
+
if (score >= 40) return "D";
|
|
1803
|
+
return "F";
|
|
1804
|
+
}
|
|
1805
|
+
var DEFAULT_CONFIG = {
|
|
1806
|
+
policy: "balanced",
|
|
1807
|
+
overrides: {},
|
|
1808
|
+
ignore: []
|
|
1809
|
+
};
|
|
1810
|
+
function getHookAction(policy, severity) {
|
|
1811
|
+
const matrix = {
|
|
1812
|
+
strict: {
|
|
1813
|
+
CRITICAL: "deny",
|
|
1814
|
+
HIGH: "deny",
|
|
1815
|
+
MEDIUM: "ask",
|
|
1816
|
+
LOW: "report"
|
|
1817
|
+
},
|
|
1818
|
+
balanced: {
|
|
1819
|
+
CRITICAL: "deny",
|
|
1820
|
+
HIGH: "ask",
|
|
1821
|
+
MEDIUM: "report",
|
|
1822
|
+
LOW: "report"
|
|
1823
|
+
},
|
|
1824
|
+
permissive: {
|
|
1825
|
+
CRITICAL: "ask",
|
|
1826
|
+
HIGH: "report",
|
|
1827
|
+
MEDIUM: "report",
|
|
1828
|
+
LOW: "report"
|
|
1829
|
+
}
|
|
1830
|
+
};
|
|
1831
|
+
return matrix[policy][severity];
|
|
1832
|
+
}
|
|
1833
|
+
|
|
1834
|
+
// src/scanner.ts
|
|
1835
|
+
function scanSkillDirectory(dirPath, config = DEFAULT_CONFIG) {
|
|
1836
|
+
const skill = parseSkill(dirPath);
|
|
1837
|
+
return buildReport(skill, config);
|
|
1838
|
+
}
|
|
1839
|
+
function buildReport(skill, config) {
|
|
1840
|
+
let results = runAllChecks(skill);
|
|
1841
|
+
results = results.map((r) => {
|
|
1842
|
+
if (config.overrides[r.id]) {
|
|
1843
|
+
return { ...r, severity: config.overrides[r.id] };
|
|
1844
|
+
}
|
|
1845
|
+
return r;
|
|
1846
|
+
});
|
|
1847
|
+
results = results.filter((r) => !config.ignore.includes(r.id));
|
|
1848
|
+
const score = calculateScore(results);
|
|
1849
|
+
const grade = computeGrade(score);
|
|
1850
|
+
const summary = {
|
|
1851
|
+
total: results.length,
|
|
1852
|
+
critical: results.filter((r) => r.severity === "CRITICAL").length,
|
|
1853
|
+
high: results.filter((r) => r.severity === "HIGH").length,
|
|
1854
|
+
medium: results.filter((r) => r.severity === "MEDIUM").length,
|
|
1855
|
+
low: results.filter((r) => r.severity === "LOW").length
|
|
1856
|
+
};
|
|
1857
|
+
return {
|
|
1858
|
+
skillPath: skill.dirPath,
|
|
1859
|
+
skillName: skill.frontmatter.name ?? "unknown",
|
|
1860
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1861
|
+
results,
|
|
1862
|
+
score,
|
|
1863
|
+
grade,
|
|
1864
|
+
summary
|
|
1865
|
+
};
|
|
1866
|
+
}
|
|
1867
|
+
function calculateScore(results) {
|
|
1868
|
+
let score = 100;
|
|
1869
|
+
for (const r of results) {
|
|
1870
|
+
score -= SEVERITY_SCORES[r.severity];
|
|
1871
|
+
}
|
|
1872
|
+
return Math.max(0, score);
|
|
1873
|
+
}
|
|
1874
|
+
function worstSeverity(results) {
|
|
1875
|
+
const order = ["CRITICAL", "HIGH", "MEDIUM", "LOW"];
|
|
1876
|
+
for (const sev of order) {
|
|
1877
|
+
if (results.some((r) => r.severity === sev)) return sev;
|
|
1878
|
+
}
|
|
1879
|
+
return null;
|
|
1880
|
+
}
|
|
1881
|
+
|
|
1882
|
+
// src/reporter/terminal.ts
|
|
1883
|
+
import chalk from "chalk";
|
|
1884
|
+
var SEVERITY_COLORS = {
|
|
1885
|
+
CRITICAL: chalk.bgRed.white.bold,
|
|
1886
|
+
HIGH: chalk.red.bold,
|
|
1887
|
+
MEDIUM: chalk.yellow,
|
|
1888
|
+
LOW: chalk.gray
|
|
1889
|
+
};
|
|
1890
|
+
var GRADE_COLORS = {
|
|
1891
|
+
A: chalk.green.bold,
|
|
1892
|
+
B: chalk.cyan.bold,
|
|
1893
|
+
C: chalk.yellow.bold,
|
|
1894
|
+
D: chalk.red.bold,
|
|
1895
|
+
F: chalk.bgRed.white.bold
|
|
1896
|
+
};
|
|
1897
|
+
var SEVERITY_ICONS = {
|
|
1898
|
+
CRITICAL: "X",
|
|
1899
|
+
HIGH: "!",
|
|
1900
|
+
MEDIUM: "~",
|
|
1901
|
+
LOW: "-"
|
|
1902
|
+
};
|
|
1903
|
+
function formatTerminalReport(report) {
|
|
1904
|
+
const lines = [];
|
|
1905
|
+
lines.push("");
|
|
1906
|
+
lines.push(
|
|
1907
|
+
chalk.bold("Skill Security Report") + chalk.gray(` - ${report.skillName}`)
|
|
1908
|
+
);
|
|
1909
|
+
lines.push(chalk.gray(`Path: ${report.skillPath}`));
|
|
1910
|
+
lines.push(chalk.gray(`Time: ${report.timestamp}`));
|
|
1911
|
+
lines.push("");
|
|
1912
|
+
const gradeStr = GRADE_COLORS[report.grade](` ${report.grade} `);
|
|
1913
|
+
const scoreStr = report.score >= 75 ? chalk.green(`${report.score}/100`) : report.score >= 40 ? chalk.yellow(`${report.score}/100`) : chalk.red(`${report.score}/100`);
|
|
1914
|
+
lines.push(`Grade: ${gradeStr} Score: ${scoreStr}`);
|
|
1915
|
+
lines.push("");
|
|
1916
|
+
const parts = [];
|
|
1917
|
+
if (report.summary.critical > 0)
|
|
1918
|
+
parts.push(chalk.bgRed.white(` ${report.summary.critical} CRITICAL `));
|
|
1919
|
+
if (report.summary.high > 0)
|
|
1920
|
+
parts.push(chalk.red(` ${report.summary.high} HIGH `));
|
|
1921
|
+
if (report.summary.medium > 0)
|
|
1922
|
+
parts.push(chalk.yellow(` ${report.summary.medium} MEDIUM `));
|
|
1923
|
+
if (report.summary.low > 0)
|
|
1924
|
+
parts.push(chalk.gray(` ${report.summary.low} LOW `));
|
|
1925
|
+
if (parts.length > 0) {
|
|
1926
|
+
lines.push(`Findings: ${parts.join(" ")}`);
|
|
1927
|
+
} else {
|
|
1928
|
+
lines.push(chalk.green("No issues found."));
|
|
1929
|
+
}
|
|
1930
|
+
lines.push("");
|
|
1931
|
+
if (report.results.length > 0) {
|
|
1932
|
+
lines.push(chalk.bold.underline("Findings:"));
|
|
1933
|
+
lines.push("");
|
|
1934
|
+
const grouped = /* @__PURE__ */ new Map();
|
|
1935
|
+
for (const r of report.results) {
|
|
1936
|
+
const group = grouped.get(r.category) ?? [];
|
|
1937
|
+
group.push(r);
|
|
1938
|
+
grouped.set(r.category, group);
|
|
1939
|
+
}
|
|
1940
|
+
for (const [category, findings] of grouped) {
|
|
1941
|
+
lines.push(chalk.bold(`[${category}]`));
|
|
1942
|
+
const order = ["CRITICAL", "HIGH", "MEDIUM", "LOW"];
|
|
1943
|
+
findings.sort(
|
|
1944
|
+
(a, b) => order.indexOf(a.severity) - order.indexOf(b.severity)
|
|
1945
|
+
);
|
|
1946
|
+
for (const f of findings) {
|
|
1947
|
+
const icon = SEVERITY_ICONS[f.severity];
|
|
1948
|
+
const sevLabel = SEVERITY_COLORS[f.severity](
|
|
1949
|
+
` ${f.severity} `
|
|
1950
|
+
);
|
|
1951
|
+
const idStr = chalk.gray(f.id);
|
|
1952
|
+
lines.push(` [${icon}] ${sevLabel} ${idStr} ${f.title}`);
|
|
1953
|
+
lines.push(` ${chalk.gray(f.message)}`);
|
|
1954
|
+
if (f.snippet) {
|
|
1955
|
+
lines.push(` ${chalk.dim(f.snippet)}`);
|
|
1956
|
+
}
|
|
1957
|
+
}
|
|
1958
|
+
lines.push("");
|
|
1959
|
+
}
|
|
1960
|
+
}
|
|
1961
|
+
lines.push(chalk.bold("Recommendation:"));
|
|
1962
|
+
switch (report.grade) {
|
|
1963
|
+
case "A":
|
|
1964
|
+
lines.push(chalk.green(" Safe to install."));
|
|
1965
|
+
break;
|
|
1966
|
+
case "B":
|
|
1967
|
+
lines.push(chalk.cyan(" Minor issues found. Generally safe."));
|
|
1968
|
+
break;
|
|
1969
|
+
case "C":
|
|
1970
|
+
lines.push(
|
|
1971
|
+
chalk.yellow(" Review recommended before installation.")
|
|
1972
|
+
);
|
|
1973
|
+
break;
|
|
1974
|
+
case "D":
|
|
1975
|
+
lines.push(
|
|
1976
|
+
chalk.red(" Significant risks detected. Install with caution.")
|
|
1977
|
+
);
|
|
1978
|
+
break;
|
|
1979
|
+
case "F":
|
|
1980
|
+
lines.push(
|
|
1981
|
+
chalk.bgRed.white(" DO NOT INSTALL. Critical security issues found.")
|
|
1982
|
+
);
|
|
1983
|
+
break;
|
|
1984
|
+
}
|
|
1985
|
+
lines.push("");
|
|
1986
|
+
return lines.join("\n");
|
|
1987
|
+
}
|
|
1988
|
+
|
|
1989
|
+
// src/reporter/json.ts
|
|
1990
|
+
function formatJsonReport(report) {
|
|
1991
|
+
return JSON.stringify(report, null, 2);
|
|
1992
|
+
}
|
|
1993
|
+
function generateHookResponse(report, config = DEFAULT_CONFIG) {
|
|
1994
|
+
const worst = worstSeverity(report.results);
|
|
1995
|
+
if (!worst) {
|
|
1996
|
+
return { permissionDecision: "allow" };
|
|
1997
|
+
}
|
|
1998
|
+
const action = getHookAction(config.policy, worst);
|
|
1999
|
+
switch (action) {
|
|
2000
|
+
case "deny":
|
|
2001
|
+
return {
|
|
2002
|
+
permissionDecision: "deny",
|
|
2003
|
+
reason: buildDenySummary(report)
|
|
2004
|
+
};
|
|
2005
|
+
case "ask":
|
|
2006
|
+
return {
|
|
2007
|
+
permissionDecision: "ask",
|
|
2008
|
+
reason: buildAskSummary(report)
|
|
2009
|
+
};
|
|
2010
|
+
case "report":
|
|
2011
|
+
return {
|
|
2012
|
+
permissionDecision: "allow",
|
|
2013
|
+
additionalContext: buildReportSummary(report)
|
|
2014
|
+
};
|
|
2015
|
+
}
|
|
2016
|
+
}
|
|
2017
|
+
function buildDenySummary(report) {
|
|
2018
|
+
const lines = [
|
|
2019
|
+
`Skill Security Check FAILED (Grade: ${report.grade}, Score: ${report.score}/100)`
|
|
2020
|
+
];
|
|
2021
|
+
const criticals = report.results.filter((r) => r.severity === "CRITICAL");
|
|
2022
|
+
if (criticals.length > 0) {
|
|
2023
|
+
lines.push(`Critical issues (${criticals.length}):`);
|
|
2024
|
+
for (const c of criticals.slice(0, 5)) {
|
|
2025
|
+
lines.push(` - [${c.id}] ${c.title}: ${c.message}`);
|
|
2026
|
+
}
|
|
2027
|
+
}
|
|
2028
|
+
return lines.join("\n");
|
|
2029
|
+
}
|
|
2030
|
+
function buildAskSummary(report) {
|
|
2031
|
+
const lines = [
|
|
2032
|
+
`Skill Security Check: Grade ${report.grade} (${report.score}/100)`,
|
|
2033
|
+
`Found: ${report.summary.critical} critical, ${report.summary.high} high, ${report.summary.medium} medium issues.`,
|
|
2034
|
+
"Review the findings before allowing installation."
|
|
2035
|
+
];
|
|
2036
|
+
return lines.join("\n");
|
|
2037
|
+
}
|
|
2038
|
+
function buildReportSummary(report) {
|
|
2039
|
+
const lines = [
|
|
2040
|
+
`[Skill Checker] Grade: ${report.grade} (${report.score}/100)`,
|
|
2041
|
+
`Issues: ${report.summary.total} (${report.summary.critical}C/${report.summary.high}H/${report.summary.medium}M/${report.summary.low}L)`
|
|
2042
|
+
];
|
|
2043
|
+
if (report.results.length > 0) {
|
|
2044
|
+
lines.push("Top findings:");
|
|
2045
|
+
for (const r of report.results.slice(0, 3)) {
|
|
2046
|
+
lines.push(` [${r.id}] ${r.severity}: ${r.title}`);
|
|
2047
|
+
}
|
|
2048
|
+
}
|
|
2049
|
+
return lines.join("\n");
|
|
2050
|
+
}
|
|
2051
|
+
|
|
2052
|
+
// src/config.ts
|
|
2053
|
+
import { readFileSync as readFileSync4, existsSync as existsSync3 } from "fs";
|
|
2054
|
+
import { join as join4, resolve as resolve2 } from "path";
|
|
2055
|
+
import { parse as parseYaml2 } from "yaml";
|
|
2056
|
+
var CONFIG_FILENAMES = [
|
|
2057
|
+
".skillcheckerrc.yaml",
|
|
2058
|
+
".skillcheckerrc.yml",
|
|
2059
|
+
".skillcheckerrc"
|
|
2060
|
+
];
|
|
2061
|
+
function loadConfig(startDir, configPath) {
|
|
2062
|
+
if (configPath) {
|
|
2063
|
+
const absPath = resolve2(configPath);
|
|
2064
|
+
if (existsSync3(absPath)) {
|
|
2065
|
+
return parseConfigFile(absPath);
|
|
2066
|
+
}
|
|
2067
|
+
return { ...DEFAULT_CONFIG };
|
|
2068
|
+
}
|
|
2069
|
+
const dir = startDir ? resolve2(startDir) : process.cwd();
|
|
2070
|
+
let current = dir;
|
|
2071
|
+
while (true) {
|
|
2072
|
+
for (const filename of CONFIG_FILENAMES) {
|
|
2073
|
+
const configPath2 = join4(current, filename);
|
|
2074
|
+
if (existsSync3(configPath2)) {
|
|
2075
|
+
return parseConfigFile(configPath2);
|
|
2076
|
+
}
|
|
2077
|
+
}
|
|
2078
|
+
const parent = join4(current, "..");
|
|
2079
|
+
if (parent === current) break;
|
|
2080
|
+
current = parent;
|
|
2081
|
+
}
|
|
2082
|
+
const home = process.env.HOME ?? process.env.USERPROFILE;
|
|
2083
|
+
if (home) {
|
|
2084
|
+
for (const filename of CONFIG_FILENAMES) {
|
|
2085
|
+
const configPath2 = join4(home, filename);
|
|
2086
|
+
if (existsSync3(configPath2)) {
|
|
2087
|
+
return parseConfigFile(configPath2);
|
|
2088
|
+
}
|
|
2089
|
+
}
|
|
2090
|
+
}
|
|
2091
|
+
return { ...DEFAULT_CONFIG };
|
|
2092
|
+
}
|
|
2093
|
+
function parseConfigFile(path) {
|
|
2094
|
+
try {
|
|
2095
|
+
const raw = readFileSync4(path, "utf-8");
|
|
2096
|
+
const parsed = parseYaml2(raw);
|
|
2097
|
+
if (!parsed || typeof parsed !== "object") {
|
|
2098
|
+
return { ...DEFAULT_CONFIG };
|
|
2099
|
+
}
|
|
2100
|
+
const config = {
|
|
2101
|
+
policy: isValidPolicy(parsed.policy) ? parsed.policy : "balanced",
|
|
2102
|
+
overrides: {},
|
|
2103
|
+
ignore: []
|
|
2104
|
+
};
|
|
2105
|
+
if (parsed.overrides && typeof parsed.overrides === "object") {
|
|
2106
|
+
for (const [key, value] of Object.entries(parsed.overrides)) {
|
|
2107
|
+
const sev = normalizeSeverity(value);
|
|
2108
|
+
if (sev) {
|
|
2109
|
+
config.overrides[key] = sev;
|
|
2110
|
+
}
|
|
2111
|
+
}
|
|
2112
|
+
}
|
|
2113
|
+
if (Array.isArray(parsed.ignore)) {
|
|
2114
|
+
config.ignore = parsed.ignore.filter(
|
|
2115
|
+
(item) => typeof item === "string"
|
|
2116
|
+
);
|
|
2117
|
+
}
|
|
2118
|
+
return config;
|
|
2119
|
+
} catch {
|
|
2120
|
+
return { ...DEFAULT_CONFIG };
|
|
2121
|
+
}
|
|
2122
|
+
}
|
|
2123
|
+
function isValidPolicy(value) {
|
|
2124
|
+
return typeof value === "string" && ["strict", "balanced", "permissive"].includes(value);
|
|
2125
|
+
}
|
|
2126
|
+
function normalizeSeverity(value) {
|
|
2127
|
+
const upper = value?.toUpperCase();
|
|
2128
|
+
if (["CRITICAL", "HIGH", "MEDIUM", "LOW"].includes(upper)) {
|
|
2129
|
+
return upper;
|
|
2130
|
+
}
|
|
2131
|
+
return null;
|
|
2132
|
+
}
|
|
2133
|
+
|
|
2134
|
+
// src/cli.ts
|
|
2135
|
+
var program = new Command();
|
|
2136
|
+
program.name("skill-checker").description(
|
|
2137
|
+
"Security checker for Claude Code skills - detect injection, malicious code, and supply chain risks"
|
|
2138
|
+
).version("0.1.0");
|
|
2139
|
+
program.command("scan").description("Scan a skill directory for security issues").argument("<path>", "Path to the skill directory").option("-f, --format <format>", "Output format: terminal, json, hook", "terminal").option("-p, --policy <policy>", "Policy: strict, balanced, permissive").option("-c, --config <path>", "Path to config file").action(
|
|
2140
|
+
(path, opts) => {
|
|
2141
|
+
const config = loadConfig(path, opts.config);
|
|
2142
|
+
if (opts.policy) {
|
|
2143
|
+
config.policy = opts.policy;
|
|
2144
|
+
}
|
|
2145
|
+
const report = scanSkillDirectory(path, config);
|
|
2146
|
+
switch (opts.format) {
|
|
2147
|
+
case "json":
|
|
2148
|
+
console.log(formatJsonReport(report));
|
|
2149
|
+
break;
|
|
2150
|
+
case "hook": {
|
|
2151
|
+
const hookResp = generateHookResponse(report, config);
|
|
2152
|
+
console.log(JSON.stringify(hookResp));
|
|
2153
|
+
break;
|
|
2154
|
+
}
|
|
2155
|
+
case "terminal":
|
|
2156
|
+
default:
|
|
2157
|
+
console.log(formatTerminalReport(report));
|
|
2158
|
+
break;
|
|
2159
|
+
}
|
|
2160
|
+
if (report.summary.critical > 0) {
|
|
2161
|
+
process.exit(1);
|
|
2162
|
+
}
|
|
2163
|
+
}
|
|
2164
|
+
);
|
|
2165
|
+
program.parse();
|
|
2166
|
+
//# sourceMappingURL=cli.js.map
|