skillscan 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/.eslintrc.json +15 -0
- package/README.md +177 -0
- package/dist/cli/commands/scan.d.ts +5 -0
- package/dist/cli/commands/scan.d.ts.map +1 -0
- package/dist/cli/commands/scan.js +67 -0
- package/dist/cli/commands/scan.js.map +1 -0
- package/dist/cli/index.d.ts +3 -0
- package/dist/cli/index.d.ts.map +1 -0
- package/dist/cli/index.js +18 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +30 -0
- package/dist/index.js.map +1 -0
- package/dist/output/formatters.d.ts +3 -0
- package/dist/output/formatters.d.ts.map +1 -0
- package/dist/output/formatters.js +256 -0
- package/dist/output/formatters.js.map +1 -0
- package/dist/scanner/engine.d.ts +7 -0
- package/dist/scanner/engine.d.ts.map +1 -0
- package/dist/scanner/engine.js +119 -0
- package/dist/scanner/engine.js.map +1 -0
- package/dist/scanner/parsers/skilljson.d.ts +3 -0
- package/dist/scanner/parsers/skilljson.d.ts.map +1 -0
- package/dist/scanner/parsers/skilljson.js +38 -0
- package/dist/scanner/parsers/skilljson.js.map +1 -0
- package/dist/scanner/parsers/skillmd.d.ts +3 -0
- package/dist/scanner/parsers/skillmd.d.ts.map +1 -0
- package/dist/scanner/parsers/skillmd.js +48 -0
- package/dist/scanner/parsers/skillmd.js.map +1 -0
- package/dist/scanner/rules/file-access.d.ts +11 -0
- package/dist/scanner/rules/file-access.d.ts.map +1 -0
- package/dist/scanner/rules/file-access.js +76 -0
- package/dist/scanner/rules/file-access.js.map +1 -0
- package/dist/scanner/rules/hidden-instructions.d.ts +13 -0
- package/dist/scanner/rules/hidden-instructions.d.ts.map +1 -0
- package/dist/scanner/rules/hidden-instructions.js +88 -0
- package/dist/scanner/rules/hidden-instructions.js.map +1 -0
- package/dist/scanner/rules/index.d.ts +4 -0
- package/dist/scanner/rules/index.d.ts.map +1 -0
- package/dist/scanner/rules/index.js +21 -0
- package/dist/scanner/rules/index.js.map +1 -0
- package/dist/scanner/rules/prompt-injection.d.ts +11 -0
- package/dist/scanner/rules/prompt-injection.d.ts.map +1 -0
- package/dist/scanner/rules/prompt-injection.js +130 -0
- package/dist/scanner/rules/prompt-injection.js.map +1 -0
- package/dist/scanner/rules/sensitive-paths.d.ts +11 -0
- package/dist/scanner/rules/sensitive-paths.d.ts.map +1 -0
- package/dist/scanner/rules/sensitive-paths.js +142 -0
- package/dist/scanner/rules/sensitive-paths.js.map +1 -0
- package/dist/scoring/trust-score.d.ts +5 -0
- package/dist/scoring/trust-score.d.ts.map +1 -0
- package/dist/scoring/trust-score.js +35 -0
- package/dist/scoring/trust-score.js.map +1 -0
- package/dist/types.d.ts +47 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +4 -0
- package/dist/types.js.map +1 -0
- package/jest.config.js +9 -0
- package/package.json +42 -0
- package/skill/SKILL.md +76 -0
- package/src/cli/commands/scan.ts +35 -0
- package/src/cli/index.ts +19 -0
- package/src/index.ts +5 -0
- package/src/output/formatters.ts +296 -0
- package/src/scanner/engine.ts +99 -0
- package/src/scanner/parsers/skilljson.ts +37 -0
- package/src/scanner/parsers/skillmd.ts +46 -0
- package/src/scanner/rules/file-access.ts +78 -0
- package/src/scanner/rules/hidden-instructions.ts +92 -0
- package/src/scanner/rules/index.ts +20 -0
- package/src/scanner/rules/prompt-injection.ts +133 -0
- package/src/scanner/rules/sensitive-paths.ts +144 -0
- package/src/scoring/trust-score.ts +34 -0
- package/src/types.ts +54 -0
- package/tests/fixtures/malicious-skill/SKILL.md +26 -0
- package/tests/fixtures/safe-skill/SKILL.md +25 -0
- package/tests/rules/prompt-injection.test.ts +123 -0
- package/tests/rules/sensitive-paths.test.ts +115 -0
- package/tests/scoring/trust-score.test.ts +100 -0
- package/tsconfig.json +19 -0
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
import { ScanResult, ScanOptions, Severity, Finding } from '../types';
|
|
2
|
+
import { getRatingEmoji } from '../scoring/trust-score';
|
|
3
|
+
|
|
4
|
+
export function formatOutput(result: ScanResult, options: ScanOptions): string {
|
|
5
|
+
switch (options.format) {
|
|
6
|
+
case 'json':
|
|
7
|
+
return formatJson(result);
|
|
8
|
+
case 'ci':
|
|
9
|
+
return formatCi(result);
|
|
10
|
+
case 'text':
|
|
11
|
+
default:
|
|
12
|
+
return formatText(result, options.verbose || false);
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function formatJson(result: ScanResult): string {
|
|
17
|
+
return JSON.stringify(result, null, 2);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function formatCi(result: ScanResult): string {
|
|
21
|
+
const lines: string[] = [];
|
|
22
|
+
|
|
23
|
+
for (const finding of result.findings) {
|
|
24
|
+
// Format: file:line:column: severity: message
|
|
25
|
+
const location = finding.line ? `${finding.file}:${finding.line}` : finding.file;
|
|
26
|
+
lines.push(`${location}: ${finding.severity}: [${finding.ruleId}] ${finding.message}`);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
lines.push('');
|
|
30
|
+
lines.push(`Score: ${result.score}/100 (${result.rating})`);
|
|
31
|
+
|
|
32
|
+
return lines.join('\n');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function formatText(result: ScanResult, verbose: boolean): string {
|
|
36
|
+
const lines: string[] = [];
|
|
37
|
+
|
|
38
|
+
if (result.findings.length === 0) {
|
|
39
|
+
return formatSafeResult(result);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return formatUnsafeResult(result, verbose);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function formatSafeResult(result: ScanResult): string {
|
|
46
|
+
const lines: string[] = [];
|
|
47
|
+
|
|
48
|
+
lines.push('');
|
|
49
|
+
lines.push('╔═══════════════════════════════════════════════════════════════╗');
|
|
50
|
+
lines.push(`║ ✅ SAFE TO INSTALL ║`);
|
|
51
|
+
lines.push(`║ ${padRight(result.skillName, 20)} • Score: ${result.score}/100${' '.repeat(20)}║`);
|
|
52
|
+
lines.push('╚═══════════════════════════════════════════════════════════════╝');
|
|
53
|
+
lines.push('');
|
|
54
|
+
lines.push('No security issues detected. This skill looks safe!');
|
|
55
|
+
lines.push('');
|
|
56
|
+
|
|
57
|
+
return lines.join('\n');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function formatUnsafeResult(result: ScanResult, verbose: boolean): string {
|
|
61
|
+
const lines: string[] = [];
|
|
62
|
+
const recommendation = getRecommendation(result.rating);
|
|
63
|
+
const tldr = generateTldr(result);
|
|
64
|
+
|
|
65
|
+
// Header with clear verdict
|
|
66
|
+
lines.push('');
|
|
67
|
+
lines.push('╔═══════════════════════════════════════════════════════════════╗');
|
|
68
|
+
lines.push(`║ ${recommendation.icon} ${padRight(recommendation.text, 55)}║`);
|
|
69
|
+
lines.push(`║ ${padRight(result.skillName, 20)} • Score: ${result.score}/100${' '.repeat(20)}║`);
|
|
70
|
+
lines.push('╚═══════════════════════════════════════════════════════════════╝');
|
|
71
|
+
lines.push('');
|
|
72
|
+
|
|
73
|
+
// TL;DR
|
|
74
|
+
lines.push(`TL;DR: ${recommendation.icon} ${tldr}`);
|
|
75
|
+
lines.push('');
|
|
76
|
+
|
|
77
|
+
// Group findings by rule for cleaner display
|
|
78
|
+
const groupedFindings = groupByRule(result.findings);
|
|
79
|
+
|
|
80
|
+
lines.push('⚠️ Issues Found:');
|
|
81
|
+
lines.push('');
|
|
82
|
+
|
|
83
|
+
// Show CRITICAL and HIGH issues (skip LOW in non-verbose mode)
|
|
84
|
+
const severityOrder: Severity[] = verbose
|
|
85
|
+
? ['CRITICAL', 'HIGH', 'MEDIUM', 'LOW']
|
|
86
|
+
: ['CRITICAL', 'HIGH', 'MEDIUM'];
|
|
87
|
+
|
|
88
|
+
// Sort groups by highest severity (CRITICAL first)
|
|
89
|
+
const sortedGroups = Object.entries(groupedFindings).sort(([, a], [, b]) => {
|
|
90
|
+
const severityRank: Record<Severity, number> = { CRITICAL: 0, HIGH: 1, MEDIUM: 2, LOW: 3 };
|
|
91
|
+
const aRank = severityRank[getHighestSeverity(a)];
|
|
92
|
+
const bRank = severityRank[getHighestSeverity(b)];
|
|
93
|
+
return aRank - bRank;
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
for (const [ruleId, findings] of sortedGroups) {
|
|
97
|
+
// Filter to relevant severities
|
|
98
|
+
const relevantFindings = findings.filter(f => severityOrder.includes(f.severity));
|
|
99
|
+
if (relevantFindings.length === 0) continue;
|
|
100
|
+
|
|
101
|
+
const highestSeverity = getHighestSeverity(relevantFindings);
|
|
102
|
+
const icon = getSeverityIcon(highestSeverity);
|
|
103
|
+
const description = getUserFriendlyDescription(ruleId, relevantFindings);
|
|
104
|
+
const example = getBestExample(relevantFindings);
|
|
105
|
+
|
|
106
|
+
// Show grouped issue with count if multiple
|
|
107
|
+
const countSuffix = relevantFindings.length > 1 ? ` (${relevantFindings.length} instances)` : '';
|
|
108
|
+
lines.push(` ${icon} ${description}${countSuffix}`);
|
|
109
|
+
|
|
110
|
+
// Show the most relevant code snippet (full in verbose, truncated in default)
|
|
111
|
+
if (example) {
|
|
112
|
+
const snippet = verbose ? example : truncate(example, 80);
|
|
113
|
+
lines.push(` "${snippet}"`);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// In verbose mode, show all locations
|
|
117
|
+
if (verbose) {
|
|
118
|
+
for (const finding of relevantFindings) {
|
|
119
|
+
const location = finding.line
|
|
120
|
+
? `${getFileName(finding.file)}:${finding.line}`
|
|
121
|
+
: getFileName(finding.file);
|
|
122
|
+
lines.push(` └─ ${location} [${finding.ruleId}]`);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
lines.push('');
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// What this means section
|
|
130
|
+
lines.push('💡 What this means:');
|
|
131
|
+
lines.push(` ${getExplanation(result)}`);
|
|
132
|
+
lines.push('');
|
|
133
|
+
|
|
134
|
+
// Final recommendation
|
|
135
|
+
lines.push(`${recommendation.icon} Recommendation: ${recommendation.advice}`);
|
|
136
|
+
lines.push('');
|
|
137
|
+
|
|
138
|
+
if (verbose) {
|
|
139
|
+
lines.push(`📊 Details: ${result.findings.length} findings in ${result.scanDuration}ms`);
|
|
140
|
+
lines.push(` Path: ${result.skillPath}`);
|
|
141
|
+
lines.push('');
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return lines.join('\n');
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Group findings by ruleId for cleaner display
|
|
148
|
+
function groupByRule(findings: Finding[]): Record<string, Finding[]> {
|
|
149
|
+
const groups: Record<string, Finding[]> = {};
|
|
150
|
+
|
|
151
|
+
for (const finding of findings) {
|
|
152
|
+
if (!groups[finding.ruleId]) {
|
|
153
|
+
groups[finding.ruleId] = [];
|
|
154
|
+
}
|
|
155
|
+
groups[finding.ruleId].push(finding);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return groups;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function getHighestSeverity(findings: Finding[]): Severity {
|
|
162
|
+
const order: Severity[] = ['CRITICAL', 'HIGH', 'MEDIUM', 'LOW'];
|
|
163
|
+
for (const severity of order) {
|
|
164
|
+
if (findings.some(f => f.severity === severity)) {
|
|
165
|
+
return severity;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return 'LOW';
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function getUserFriendlyDescription(ruleId: string, findings: Finding[]): string {
|
|
172
|
+
// Map technical rule IDs to user-friendly descriptions
|
|
173
|
+
const descriptions: Record<string, string> = {
|
|
174
|
+
'prompt-injection': 'Asks the AI to ignore its safety rules',
|
|
175
|
+
'hidden-instructions': 'Contains hidden text you cannot see',
|
|
176
|
+
'sensitive-paths': 'Tries to access your private files',
|
|
177
|
+
'file-access': 'Suspicious file access patterns detected'
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
// Try to give more specific description based on findings
|
|
181
|
+
const finding = findings[0];
|
|
182
|
+
if (ruleId === 'sensitive-paths') {
|
|
183
|
+
if (finding.message.includes('SSH')) return 'Tries to access your SSH keys';
|
|
184
|
+
if (finding.message.includes('AWS')) return 'Tries to access your AWS credentials';
|
|
185
|
+
if (finding.message.includes('env')) return 'Tries to read your environment secrets';
|
|
186
|
+
return 'Tries to access your private credentials';
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (ruleId === 'prompt-injection') {
|
|
190
|
+
if (finding.message.includes('override')) return 'Asks the AI to ignore its safety rules';
|
|
191
|
+
if (finding.message.includes('system prompt')) return 'Tries to extract AI system instructions';
|
|
192
|
+
if (finding.message.includes('jailbreak')) return 'Contains known jailbreak patterns';
|
|
193
|
+
if (finding.message.includes('Role')) return 'Tries to manipulate the AI identity';
|
|
194
|
+
if (finding.message.includes('base64')) return 'Contains hidden encoded commands';
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return descriptions[ruleId] || 'Suspicious pattern detected';
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function getBestExample(findings: Finding[]): string | null {
|
|
201
|
+
// Prefer findings with context
|
|
202
|
+
const withContext = findings.find(f => f.context && f.context.length > 10);
|
|
203
|
+
if (withContext?.context) {
|
|
204
|
+
return withContext.context;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Fall back to extracting from message
|
|
208
|
+
const first = findings[0];
|
|
209
|
+
const match = first.message.match(/"([^"]+)"/);
|
|
210
|
+
return match ? match[1] : null;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function generateTldr(result: ScanResult): string {
|
|
214
|
+
const grouped = groupByRule(result.findings);
|
|
215
|
+
const issues: string[] = [];
|
|
216
|
+
|
|
217
|
+
if (grouped['prompt-injection']) {
|
|
218
|
+
issues.push('manipulate AI behavior');
|
|
219
|
+
}
|
|
220
|
+
if (grouped['sensitive-paths']) {
|
|
221
|
+
issues.push('steal your credentials');
|
|
222
|
+
}
|
|
223
|
+
if (grouped['hidden-instructions']) {
|
|
224
|
+
issues.push('hide malicious instructions');
|
|
225
|
+
}
|
|
226
|
+
if (grouped['file-access']) {
|
|
227
|
+
issues.push('access sensitive files');
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if (issues.length === 0) {
|
|
231
|
+
return 'Review before installing';
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
if (result.rating === 'WARNING') {
|
|
235
|
+
return `Don't install - this skill may ${issues.slice(0, 2).join(' and ')}`;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
return `Review carefully - found attempts to ${issues[0]}`;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function getExplanation(result: ScanResult): string {
|
|
242
|
+
if (result.rating === 'WARNING') {
|
|
243
|
+
return 'This skill shows signs of malicious behavior. It may attempt to steal sensitive data or manipulate AI responses in harmful ways.';
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
if (result.rating === 'CAUTION') {
|
|
247
|
+
return 'This skill has some concerning patterns that should be reviewed. It might be safe, but verify the flagged items before installing.';
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
return 'Some minor issues were detected. Review them to ensure they are expected behavior.';
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function getRecommendation(rating: 'VERIFIED' | 'CAUTION' | 'WARNING'): { icon: string; text: string; advice: string } {
|
|
254
|
+
switch (rating) {
|
|
255
|
+
case 'WARNING':
|
|
256
|
+
return {
|
|
257
|
+
icon: '🔴',
|
|
258
|
+
text: 'DO NOT INSTALL',
|
|
259
|
+
advice: 'Do not install this skill. It shows signs of malicious behavior.'
|
|
260
|
+
};
|
|
261
|
+
case 'CAUTION':
|
|
262
|
+
return {
|
|
263
|
+
icon: '🟡',
|
|
264
|
+
text: 'REVIEW BEFORE INSTALLING',
|
|
265
|
+
advice: 'Review the flagged items carefully before deciding to install.'
|
|
266
|
+
};
|
|
267
|
+
default:
|
|
268
|
+
return {
|
|
269
|
+
icon: '🟢',
|
|
270
|
+
text: 'LIKELY SAFE',
|
|
271
|
+
advice: 'This skill appears safe, but always review what you install.'
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function getSeverityIcon(severity: Severity): string {
|
|
277
|
+
switch (severity) {
|
|
278
|
+
case 'CRITICAL': return '🔴';
|
|
279
|
+
case 'HIGH': return '🟠';
|
|
280
|
+
case 'MEDIUM': return '🟡';
|
|
281
|
+
case 'LOW': return '🔵';
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function padRight(str: string, length: number): string {
|
|
286
|
+
return str.length >= length ? str.slice(0, length) : str + ' '.repeat(length - str.length);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function truncate(str: string, maxLength: number): string {
|
|
290
|
+
if (str.length <= maxLength) return str;
|
|
291
|
+
return str.slice(0, maxLength - 3) + '...';
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function getFileName(filePath: string): string {
|
|
295
|
+
return filePath.split('/').pop() || filePath;
|
|
296
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
import { ScanOptions, ScanResult, Finding, SkillMetadata } from '../types';
|
|
4
|
+
import { parseSkillMd } from './parsers/skillmd';
|
|
5
|
+
import { parseSkillJson } from './parsers/skilljson';
|
|
6
|
+
import { getAllRules } from './rules';
|
|
7
|
+
import { calculateScore, getRating } from '../scoring/trust-score';
|
|
8
|
+
|
|
9
|
+
export class ScanEngine {
|
|
10
|
+
async scan(options: ScanOptions): Promise<ScanResult> {
|
|
11
|
+
const startTime = Date.now();
|
|
12
|
+
const { path: skillPath } = options;
|
|
13
|
+
|
|
14
|
+
const stats = fs.statSync(skillPath);
|
|
15
|
+
const isDirectory = stats.isDirectory();
|
|
16
|
+
|
|
17
|
+
const filesToScan = isDirectory
|
|
18
|
+
? this.discoverFiles(skillPath)
|
|
19
|
+
: [skillPath];
|
|
20
|
+
|
|
21
|
+
const allFindings: Finding[] = [];
|
|
22
|
+
let skillName = path.basename(skillPath);
|
|
23
|
+
|
|
24
|
+
for (const file of filesToScan) {
|
|
25
|
+
const content = fs.readFileSync(file, 'utf-8');
|
|
26
|
+
const metadata = this.parseFile(file, content);
|
|
27
|
+
|
|
28
|
+
if (metadata.name) {
|
|
29
|
+
skillName = metadata.name;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const rules = getAllRules();
|
|
33
|
+
for (const rule of rules) {
|
|
34
|
+
const findings = rule.check(content, metadata, file);
|
|
35
|
+
allFindings.push(...findings);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const score = calculateScore(allFindings);
|
|
40
|
+
const rating = getRating(score);
|
|
41
|
+
|
|
42
|
+
return {
|
|
43
|
+
skillPath,
|
|
44
|
+
skillName,
|
|
45
|
+
findings: allFindings,
|
|
46
|
+
score,
|
|
47
|
+
rating,
|
|
48
|
+
scannedAt: new Date().toISOString(),
|
|
49
|
+
scanDuration: Date.now() - startTime
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
private discoverFiles(dirPath: string): string[] {
|
|
54
|
+
const files: string[] = [];
|
|
55
|
+
const entries = fs.readdirSync(dirPath, { withFileTypes: true });
|
|
56
|
+
|
|
57
|
+
for (const entry of entries) {
|
|
58
|
+
const fullPath = path.join(dirPath, entry.name);
|
|
59
|
+
|
|
60
|
+
if (entry.isDirectory()) {
|
|
61
|
+
// Skip node_modules and hidden directories
|
|
62
|
+
if (!entry.name.startsWith('.') && entry.name !== 'node_modules') {
|
|
63
|
+
files.push(...this.discoverFiles(fullPath));
|
|
64
|
+
}
|
|
65
|
+
} else {
|
|
66
|
+
// Include relevant skill files
|
|
67
|
+
const ext = path.extname(entry.name).toLowerCase();
|
|
68
|
+
const name = entry.name.toLowerCase();
|
|
69
|
+
|
|
70
|
+
if (name === 'skill.md' || name === 'skill.json' ||
|
|
71
|
+
ext === '.md' || ext === '.json' || ext === '.yaml' || ext === '.yml') {
|
|
72
|
+
files.push(fullPath);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return files;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
private parseFile(filePath: string, content: string): SkillMetadata {
|
|
81
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
82
|
+
const name = path.basename(filePath).toLowerCase();
|
|
83
|
+
|
|
84
|
+
if (name === 'skill.md' || ext === '.md') {
|
|
85
|
+
return parseSkillMd(content);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (name === 'skill.json' || ext === '.json') {
|
|
89
|
+
return parseSkillJson(content);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Default metadata for other files
|
|
93
|
+
return {
|
|
94
|
+
name: path.basename(filePath),
|
|
95
|
+
rawContent: content,
|
|
96
|
+
frontmatter: {}
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { SkillMetadata, ToolDefinition } from '../../types';
|
|
2
|
+
|
|
3
|
+
export function parseSkillJson(content: string): SkillMetadata {
|
|
4
|
+
try {
|
|
5
|
+
const json = JSON.parse(content);
|
|
6
|
+
|
|
7
|
+
const tools: ToolDefinition[] = [];
|
|
8
|
+
if (json.tools && Array.isArray(json.tools)) {
|
|
9
|
+
for (const tool of json.tools) {
|
|
10
|
+
if (typeof tool === 'object' && tool.name) {
|
|
11
|
+
tools.push({
|
|
12
|
+
name: tool.name,
|
|
13
|
+
description: tool.description,
|
|
14
|
+
parameters: tool.parameters
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return {
|
|
21
|
+
name: json.name || '',
|
|
22
|
+
description: json.description,
|
|
23
|
+
version: json.version,
|
|
24
|
+
author: json.author,
|
|
25
|
+
tools,
|
|
26
|
+
rawContent: content,
|
|
27
|
+
frontmatter: json
|
|
28
|
+
};
|
|
29
|
+
} catch {
|
|
30
|
+
// Invalid JSON, return minimal metadata
|
|
31
|
+
return {
|
|
32
|
+
name: '',
|
|
33
|
+
rawContent: content,
|
|
34
|
+
frontmatter: {}
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import matter from 'gray-matter';
|
|
2
|
+
import { SkillMetadata, ToolDefinition } from '../../types';
|
|
3
|
+
|
|
4
|
+
export function parseSkillMd(content: string): SkillMetadata {
|
|
5
|
+
const { data: frontmatter, content: bodyContent } = matter(content);
|
|
6
|
+
|
|
7
|
+
// Extract tools from frontmatter if present
|
|
8
|
+
const tools: ToolDefinition[] = [];
|
|
9
|
+
if (frontmatter.tools && Array.isArray(frontmatter.tools)) {
|
|
10
|
+
for (const tool of frontmatter.tools) {
|
|
11
|
+
if (typeof tool === 'object' && tool.name) {
|
|
12
|
+
tools.push({
|
|
13
|
+
name: tool.name,
|
|
14
|
+
description: tool.description,
|
|
15
|
+
parameters: tool.parameters
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
return {
|
|
22
|
+
name: frontmatter.name || frontmatter.title || '',
|
|
23
|
+
description: frontmatter.description || extractFirstParagraph(bodyContent),
|
|
24
|
+
version: frontmatter.version,
|
|
25
|
+
author: frontmatter.author,
|
|
26
|
+
tools,
|
|
27
|
+
rawContent: content,
|
|
28
|
+
frontmatter
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function extractFirstParagraph(content: string): string {
|
|
33
|
+
const lines = content.split('\n');
|
|
34
|
+
const paragraphLines: string[] = [];
|
|
35
|
+
|
|
36
|
+
for (const line of lines) {
|
|
37
|
+
const trimmed = line.trim();
|
|
38
|
+
if (trimmed.startsWith('#')) continue; // Skip headers
|
|
39
|
+
if (trimmed === '' && paragraphLines.length > 0) break;
|
|
40
|
+
if (trimmed !== '') {
|
|
41
|
+
paragraphLines.push(trimmed);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return paragraphLines.join(' ').slice(0, 200);
|
|
46
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { Rule, Finding, SkillMetadata, Severity } from '../../types';
|
|
2
|
+
|
|
3
|
+
export class FileAccessRule implements Rule {
|
|
4
|
+
id = 'file-access';
|
|
5
|
+
name = 'Suspicious File Access Detection';
|
|
6
|
+
description = 'Detects suspicious file access patterns that could exfiltrate data';
|
|
7
|
+
severity: Severity = 'HIGH';
|
|
8
|
+
|
|
9
|
+
private patterns = [
|
|
10
|
+
{
|
|
11
|
+
name: 'path-traversal',
|
|
12
|
+
regex: /\.\.\/|\.\.\\|\.\.[\/\\]/gi,
|
|
13
|
+
severity: 'HIGH' as Severity,
|
|
14
|
+
message: 'Path traversal pattern detected - may access files outside intended directory'
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
name: 'home-directory-access',
|
|
18
|
+
regex: /~\/|%HOME%|%USERPROFILE%|\$HOME/gi,
|
|
19
|
+
severity: 'HIGH' as Severity,
|
|
20
|
+
message: 'Home directory access pattern detected'
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
name: 'wildcard-read',
|
|
24
|
+
regex: /\*\.\w{2,4}|\*\*\/\*|glob\s*\(/gi,
|
|
25
|
+
severity: 'MEDIUM' as Severity,
|
|
26
|
+
message: 'Wildcard file pattern detected - may sweep multiple files'
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
name: 'sensitive-file-extensions',
|
|
30
|
+
regex: /\.(pem|key|crt|pfx|p12|jks|keystore|env|credentials)\b/gi,
|
|
31
|
+
severity: 'HIGH' as Severity,
|
|
32
|
+
message: 'Reference to sensitive file type detected'
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
name: 'read-file-operations',
|
|
36
|
+
regex: /readFile|readFileSync|fs\.read|open\s*\(|fopen|file_get_contents/gi,
|
|
37
|
+
severity: 'LOW' as Severity,
|
|
38
|
+
message: 'File read operation detected - verify intended behavior'
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
name: 'network-after-read',
|
|
42
|
+
regex: /(fetch|axios|request|http\.|https\.|XMLHttpRequest|curl|wget)/gi,
|
|
43
|
+
severity: 'MEDIUM' as Severity,
|
|
44
|
+
message: 'Network operation detected - check if combined with file access'
|
|
45
|
+
}
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
check(content: string, _metadata: SkillMetadata, filePath: string): Finding[] {
|
|
49
|
+
const findings: Finding[] = [];
|
|
50
|
+
const lines = content.split('\n');
|
|
51
|
+
|
|
52
|
+
for (const pattern of this.patterns) {
|
|
53
|
+
let match;
|
|
54
|
+
const regex = new RegExp(pattern.regex.source, pattern.regex.flags);
|
|
55
|
+
|
|
56
|
+
while ((match = regex.exec(content)) !== null) {
|
|
57
|
+
const lineNumber = this.getLineNumber(content, match.index);
|
|
58
|
+
const contextLine = lines[lineNumber - 1] || '';
|
|
59
|
+
|
|
60
|
+
findings.push({
|
|
61
|
+
ruleId: this.id,
|
|
62
|
+
ruleName: this.name,
|
|
63
|
+
severity: pattern.severity,
|
|
64
|
+
message: `${pattern.message}: "${match[0]}"`,
|
|
65
|
+
file: filePath,
|
|
66
|
+
line: lineNumber,
|
|
67
|
+
context: contextLine.trim().slice(0, 100)
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return findings;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
private getLineNumber(content: string, index: number): number {
|
|
76
|
+
return content.slice(0, index).split('\n').length;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { Rule, Finding, SkillMetadata, Severity } from '../../types';
|
|
2
|
+
|
|
3
|
+
export class HiddenInstructionsRule implements Rule {
|
|
4
|
+
id = 'hidden-instructions';
|
|
5
|
+
name = 'Hidden Instructions Detection';
|
|
6
|
+
description = 'Detects instructions hidden using whitespace, Unicode tricks, or HTML comments';
|
|
7
|
+
severity: Severity = 'HIGH';
|
|
8
|
+
|
|
9
|
+
// Zero-width characters that can hide text
|
|
10
|
+
private zeroWidthChars = [
|
|
11
|
+
'\u200B', // Zero-width space
|
|
12
|
+
'\u200C', // Zero-width non-joiner
|
|
13
|
+
'\u200D', // Zero-width joiner
|
|
14
|
+
'\u2060', // Word joiner
|
|
15
|
+
'\uFEFF', // Zero-width no-break space
|
|
16
|
+
'\u00AD', // Soft hyphen
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
// Patterns for hidden instructions
|
|
20
|
+
private patterns = [
|
|
21
|
+
{
|
|
22
|
+
name: 'zero-width-characters',
|
|
23
|
+
regex: /[\u200B\u200C\u200D\u2060\uFEFF\u00AD]+/g,
|
|
24
|
+
severity: 'HIGH' as Severity,
|
|
25
|
+
message: 'Zero-width characters detected - may hide malicious instructions'
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
name: 'html-comments',
|
|
29
|
+
regex: /<!--[\s\S]*?-->/g,
|
|
30
|
+
severity: 'MEDIUM' as Severity,
|
|
31
|
+
message: 'HTML comment found - may contain hidden instructions'
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
name: 'excessive-whitespace',
|
|
35
|
+
regex: /[^\S\n]{20,}/g, // 20+ consecutive whitespace chars (not newlines)
|
|
36
|
+
severity: 'MEDIUM' as Severity,
|
|
37
|
+
message: 'Excessive whitespace detected - may hide text visually'
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
name: 'unicode-homoglyphs',
|
|
41
|
+
regex: /[\u0400-\u04FF\u0370-\u03FF]/g, // Cyrillic/Greek chars that look like Latin
|
|
42
|
+
severity: 'MEDIUM' as Severity,
|
|
43
|
+
message: 'Unicode homoglyphs detected - characters that look like ASCII but are different'
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
name: 'rtl-override',
|
|
47
|
+
regex: /[\u202E\u202D\u202C\u2066\u2067\u2068\u2069]/g, // RTL/LTR override characters
|
|
48
|
+
severity: 'HIGH' as Severity,
|
|
49
|
+
message: 'Text direction override character detected - can reverse visible text'
|
|
50
|
+
}
|
|
51
|
+
];
|
|
52
|
+
|
|
53
|
+
check(content: string, _metadata: SkillMetadata, filePath: string): Finding[] {
|
|
54
|
+
const findings: Finding[] = [];
|
|
55
|
+
const lines = content.split('\n');
|
|
56
|
+
|
|
57
|
+
for (const pattern of this.patterns) {
|
|
58
|
+
let match;
|
|
59
|
+
const regex = new RegExp(pattern.regex.source, pattern.regex.flags);
|
|
60
|
+
|
|
61
|
+
while ((match = regex.exec(content)) !== null) {
|
|
62
|
+
const lineNumber = this.getLineNumber(content, match.index);
|
|
63
|
+
const contextLine = lines[lineNumber - 1] || '';
|
|
64
|
+
|
|
65
|
+
findings.push({
|
|
66
|
+
ruleId: this.id,
|
|
67
|
+
ruleName: this.name,
|
|
68
|
+
severity: pattern.severity,
|
|
69
|
+
message: pattern.message,
|
|
70
|
+
file: filePath,
|
|
71
|
+
line: lineNumber,
|
|
72
|
+
context: this.sanitizeContext(contextLine)
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return findings;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
private getLineNumber(content: string, index: number): number {
|
|
81
|
+
return content.slice(0, index).split('\n').length;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
private sanitizeContext(line: string): string {
|
|
85
|
+
// Replace zero-width chars with visible markers for display
|
|
86
|
+
let sanitized = line;
|
|
87
|
+
for (const char of this.zeroWidthChars) {
|
|
88
|
+
sanitized = sanitized.replace(new RegExp(char, 'g'), '[ZW]');
|
|
89
|
+
}
|
|
90
|
+
return sanitized.slice(0, 100);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { Rule } from '../../types';
|
|
2
|
+
import { HiddenInstructionsRule } from './hidden-instructions';
|
|
3
|
+
import { FileAccessRule } from './file-access';
|
|
4
|
+
import { PromptInjectionRule } from './prompt-injection';
|
|
5
|
+
import { SensitivePathsRule } from './sensitive-paths';
|
|
6
|
+
|
|
7
|
+
const rules: Rule[] = [
|
|
8
|
+
new HiddenInstructionsRule(),
|
|
9
|
+
new FileAccessRule(),
|
|
10
|
+
new PromptInjectionRule(),
|
|
11
|
+
new SensitivePathsRule()
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
export function getAllRules(): Rule[] {
|
|
15
|
+
return rules;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function getRuleById(id: string): Rule | undefined {
|
|
19
|
+
return rules.find(r => r.id === id);
|
|
20
|
+
}
|