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.
Files changed (81) hide show
  1. package/.eslintrc.json +15 -0
  2. package/README.md +177 -0
  3. package/dist/cli/commands/scan.d.ts +5 -0
  4. package/dist/cli/commands/scan.d.ts.map +1 -0
  5. package/dist/cli/commands/scan.js +67 -0
  6. package/dist/cli/commands/scan.js.map +1 -0
  7. package/dist/cli/index.d.ts +3 -0
  8. package/dist/cli/index.d.ts.map +1 -0
  9. package/dist/cli/index.js +18 -0
  10. package/dist/cli/index.js.map +1 -0
  11. package/dist/index.d.ts +6 -0
  12. package/dist/index.d.ts.map +1 -0
  13. package/dist/index.js +30 -0
  14. package/dist/index.js.map +1 -0
  15. package/dist/output/formatters.d.ts +3 -0
  16. package/dist/output/formatters.d.ts.map +1 -0
  17. package/dist/output/formatters.js +256 -0
  18. package/dist/output/formatters.js.map +1 -0
  19. package/dist/scanner/engine.d.ts +7 -0
  20. package/dist/scanner/engine.d.ts.map +1 -0
  21. package/dist/scanner/engine.js +119 -0
  22. package/dist/scanner/engine.js.map +1 -0
  23. package/dist/scanner/parsers/skilljson.d.ts +3 -0
  24. package/dist/scanner/parsers/skilljson.d.ts.map +1 -0
  25. package/dist/scanner/parsers/skilljson.js +38 -0
  26. package/dist/scanner/parsers/skilljson.js.map +1 -0
  27. package/dist/scanner/parsers/skillmd.d.ts +3 -0
  28. package/dist/scanner/parsers/skillmd.d.ts.map +1 -0
  29. package/dist/scanner/parsers/skillmd.js +48 -0
  30. package/dist/scanner/parsers/skillmd.js.map +1 -0
  31. package/dist/scanner/rules/file-access.d.ts +11 -0
  32. package/dist/scanner/rules/file-access.d.ts.map +1 -0
  33. package/dist/scanner/rules/file-access.js +76 -0
  34. package/dist/scanner/rules/file-access.js.map +1 -0
  35. package/dist/scanner/rules/hidden-instructions.d.ts +13 -0
  36. package/dist/scanner/rules/hidden-instructions.d.ts.map +1 -0
  37. package/dist/scanner/rules/hidden-instructions.js +88 -0
  38. package/dist/scanner/rules/hidden-instructions.js.map +1 -0
  39. package/dist/scanner/rules/index.d.ts +4 -0
  40. package/dist/scanner/rules/index.d.ts.map +1 -0
  41. package/dist/scanner/rules/index.js +21 -0
  42. package/dist/scanner/rules/index.js.map +1 -0
  43. package/dist/scanner/rules/prompt-injection.d.ts +11 -0
  44. package/dist/scanner/rules/prompt-injection.d.ts.map +1 -0
  45. package/dist/scanner/rules/prompt-injection.js +130 -0
  46. package/dist/scanner/rules/prompt-injection.js.map +1 -0
  47. package/dist/scanner/rules/sensitive-paths.d.ts +11 -0
  48. package/dist/scanner/rules/sensitive-paths.d.ts.map +1 -0
  49. package/dist/scanner/rules/sensitive-paths.js +142 -0
  50. package/dist/scanner/rules/sensitive-paths.js.map +1 -0
  51. package/dist/scoring/trust-score.d.ts +5 -0
  52. package/dist/scoring/trust-score.d.ts.map +1 -0
  53. package/dist/scoring/trust-score.js +35 -0
  54. package/dist/scoring/trust-score.js.map +1 -0
  55. package/dist/types.d.ts +47 -0
  56. package/dist/types.d.ts.map +1 -0
  57. package/dist/types.js +4 -0
  58. package/dist/types.js.map +1 -0
  59. package/jest.config.js +9 -0
  60. package/package.json +42 -0
  61. package/skill/SKILL.md +76 -0
  62. package/src/cli/commands/scan.ts +35 -0
  63. package/src/cli/index.ts +19 -0
  64. package/src/index.ts +5 -0
  65. package/src/output/formatters.ts +296 -0
  66. package/src/scanner/engine.ts +99 -0
  67. package/src/scanner/parsers/skilljson.ts +37 -0
  68. package/src/scanner/parsers/skillmd.ts +46 -0
  69. package/src/scanner/rules/file-access.ts +78 -0
  70. package/src/scanner/rules/hidden-instructions.ts +92 -0
  71. package/src/scanner/rules/index.ts +20 -0
  72. package/src/scanner/rules/prompt-injection.ts +133 -0
  73. package/src/scanner/rules/sensitive-paths.ts +144 -0
  74. package/src/scoring/trust-score.ts +34 -0
  75. package/src/types.ts +54 -0
  76. package/tests/fixtures/malicious-skill/SKILL.md +26 -0
  77. package/tests/fixtures/safe-skill/SKILL.md +25 -0
  78. package/tests/rules/prompt-injection.test.ts +123 -0
  79. package/tests/rules/sensitive-paths.test.ts +115 -0
  80. package/tests/scoring/trust-score.test.ts +100 -0
  81. 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
+ }