skillscokac 1.4.3 → 1.5.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/bin/skillscokac.js +212 -12
- package/package.json +1 -1
package/bin/skillscokac.js
CHANGED
|
@@ -57,24 +57,134 @@ function validateSkillName(skillName) {
|
|
|
57
57
|
return trimmed
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
+
/**
|
|
61
|
+
* Preprocess frontmatter to fix common YAML issues
|
|
62
|
+
* Automatically quotes values that contain special characters
|
|
63
|
+
*/
|
|
64
|
+
function preprocessFrontmatter(frontmatterText) {
|
|
65
|
+
const lines = frontmatterText.split('\n')
|
|
66
|
+
|
|
67
|
+
// Limit line count to prevent DoS
|
|
68
|
+
if (lines.length > 1000) {
|
|
69
|
+
throw new Error('Frontmatter too complex (max 1000 lines)')
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const processedLines = []
|
|
73
|
+
|
|
74
|
+
for (const line of lines) {
|
|
75
|
+
// Limit line length to prevent ReDoS
|
|
76
|
+
if (line.length > 2000) {
|
|
77
|
+
throw new Error('Frontmatter line too long (max 2000 characters)')
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Skip empty lines or comments
|
|
81
|
+
if (!line.trim() || line.trim().startsWith('#')) {
|
|
82
|
+
processedLines.push(line)
|
|
83
|
+
continue
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Match key-value pairs with non-greedy match to prevent ReDoS
|
|
87
|
+
// Key must start with letter, value limited to prevent backtracking
|
|
88
|
+
const match = line.match(/^(\s*)([a-zA-Z][a-zA-Z0-9_-]*):\s*([^\n\r]{0,1500})$/)
|
|
89
|
+
|
|
90
|
+
if (!match || match.length !== 4) {
|
|
91
|
+
// Not a simple key-value pair, keep as is
|
|
92
|
+
processedLines.push(line)
|
|
93
|
+
continue
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const indent = match[1]
|
|
97
|
+
const key = match[2]
|
|
98
|
+
const value = match[3]
|
|
99
|
+
|
|
100
|
+
// Skip if value is empty
|
|
101
|
+
if (!value.trim()) {
|
|
102
|
+
processedLines.push(line)
|
|
103
|
+
continue
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Skip if already quoted (starts and ends with quotes)
|
|
107
|
+
const trimmedValue = value.trim()
|
|
108
|
+
if ((trimmedValue.startsWith('"') && trimmedValue.endsWith('"')) ||
|
|
109
|
+
(trimmedValue.startsWith("'") && trimmedValue.endsWith("'"))) {
|
|
110
|
+
processedLines.push(line)
|
|
111
|
+
continue
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Skip if it's a block scalar (| or >)
|
|
115
|
+
if (trimmedValue === '|' || trimmedValue === '>') {
|
|
116
|
+
processedLines.push(line)
|
|
117
|
+
continue
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Skip if it's an array or object
|
|
121
|
+
if (trimmedValue.startsWith('[') || trimmedValue.startsWith('{')) {
|
|
122
|
+
processedLines.push(line)
|
|
123
|
+
continue
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Check if value contains special YAML characters that need quoting
|
|
127
|
+
const needsQuoting = /[:{}[\]|>@`&*!%#]/.test(value)
|
|
128
|
+
|
|
129
|
+
if (needsQuoting) {
|
|
130
|
+
// Proper escaping for YAML double-quoted strings
|
|
131
|
+
let escapedValue = value
|
|
132
|
+
.replace(/\\/g, '\\\\') // Escape backslashes first
|
|
133
|
+
.replace(/"/g, '\\"') // Escape double quotes
|
|
134
|
+
.replace(/\n/g, '\\n') // Escape newlines
|
|
135
|
+
.replace(/\r/g, '\\r') // Escape carriage returns
|
|
136
|
+
.replace(/\t/g, '\\t') // Escape tabs
|
|
137
|
+
|
|
138
|
+
processedLines.push(`${indent}${key}: "${escapedValue}"`)
|
|
139
|
+
} else {
|
|
140
|
+
processedLines.push(line)
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return processedLines.join('\n')
|
|
145
|
+
}
|
|
146
|
+
|
|
60
147
|
/**
|
|
61
148
|
* Parse frontmatter from markdown content
|
|
149
|
+
* Handles both Unix (\n) and Windows (\r\n) line endings
|
|
62
150
|
*/
|
|
63
151
|
function parseFrontmatter(content) {
|
|
152
|
+
// Normalize line endings to handle both CRLF and LF
|
|
153
|
+
const normalizedContent = content.replace(/\r\n/g, '\n')
|
|
64
154
|
const frontmatterRegex = /^---\n([\s\S]*?)\n---/
|
|
65
|
-
const match =
|
|
155
|
+
const match = normalizedContent.match(frontmatterRegex)
|
|
66
156
|
|
|
67
157
|
if (!match) {
|
|
68
|
-
return { metadata: {}, content }
|
|
158
|
+
return { metadata: {}, content: normalizedContent }
|
|
69
159
|
}
|
|
70
160
|
|
|
71
161
|
try {
|
|
72
|
-
|
|
73
|
-
|
|
162
|
+
// Limit frontmatter size to prevent YAML bombs
|
|
163
|
+
if (match[1].length > 10000) {
|
|
164
|
+
throw new Error('Frontmatter too large (max 10KB)')
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Preprocess frontmatter to fix common YAML issues
|
|
168
|
+
const preprocessed = preprocessFrontmatter(match[1])
|
|
169
|
+
|
|
170
|
+
// Parse with strict options to prevent YAML bombs
|
|
171
|
+
const metadata = yaml.parse(preprocessed, {
|
|
172
|
+
maxAliasCount: 10, // Limit alias expansion to prevent Billion Laughs
|
|
173
|
+
strict: true, // Strict YAML parsing
|
|
174
|
+
uniqueKeys: true, // Reject duplicate keys
|
|
175
|
+
version: '1.2' // Use YAML 1.2 spec
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
// Validate metadata is a plain object
|
|
179
|
+
if (typeof metadata !== 'object' || metadata === null || Array.isArray(metadata)) {
|
|
180
|
+
throw new Error('Invalid frontmatter: must be an object')
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const markdownContent = normalizedContent.slice(match[0].length).trim()
|
|
74
184
|
return { metadata, content: markdownContent }
|
|
75
185
|
} catch (error) {
|
|
76
186
|
console.error(chalk.red('Error parsing frontmatter:'), error.message)
|
|
77
|
-
return { metadata: {}, content }
|
|
187
|
+
return { metadata: {}, content: normalizedContent }
|
|
78
188
|
}
|
|
79
189
|
}
|
|
80
190
|
|
|
@@ -127,9 +237,17 @@ async function fetchSkill(skillName, options = {}) {
|
|
|
127
237
|
headers: {
|
|
128
238
|
'User-Agent': `skillscokac-cli/${VERSION}`
|
|
129
239
|
},
|
|
130
|
-
timeout: AXIOS_TIMEOUT
|
|
240
|
+
timeout: AXIOS_TIMEOUT,
|
|
241
|
+
maxRedirects: 5, // Limit redirects
|
|
242
|
+
validateStatus: (status) => status === 200 // Only accept 200 OK
|
|
131
243
|
})
|
|
132
244
|
|
|
245
|
+
// Validate content type
|
|
246
|
+
const contentType = marketplaceResponse.headers['content-type']
|
|
247
|
+
if (!contentType || !contentType.includes('application/json')) {
|
|
248
|
+
throw new Error('Invalid response type from server (expected JSON)')
|
|
249
|
+
}
|
|
250
|
+
|
|
133
251
|
const marketplace = marketplaceResponse.data
|
|
134
252
|
|
|
135
253
|
// Validate marketplace data structure
|
|
@@ -185,11 +303,50 @@ async function fetchSkill(skillName, options = {}) {
|
|
|
185
303
|
const zip = new AdmZip(Buffer.from(zipResponse.data))
|
|
186
304
|
const zipEntries = zip.getEntries()
|
|
187
305
|
|
|
188
|
-
// Check total uncompressed size
|
|
306
|
+
// Check total uncompressed size and validate entries
|
|
189
307
|
let totalSize = 0
|
|
308
|
+
const MAX_COMPRESSION_RATIO = 100 // 100:1 max ratio to prevent zip bombs
|
|
309
|
+
|
|
190
310
|
for (const entry of zipEntries) {
|
|
311
|
+
const entryName = entry.entryName
|
|
312
|
+
|
|
313
|
+
// Validate entry name for security
|
|
314
|
+
// Reject absolute paths
|
|
315
|
+
if (path.isAbsolute(entryName)) {
|
|
316
|
+
throw new Error(`Invalid zip entry: absolute path not allowed: ${entryName}`)
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// Reject path traversal attempts
|
|
320
|
+
const normalized = path.normalize(entryName).replace(/\\/g, '/')
|
|
321
|
+
if (normalized.includes('..') || normalized.startsWith('.')) {
|
|
322
|
+
throw new Error(`Invalid zip entry: path traversal detected: ${entryName}`)
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// Reject entries with null bytes
|
|
326
|
+
if (entryName.includes('\0')) {
|
|
327
|
+
throw new Error('Invalid zip entry: null byte in filename')
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// Validate file entries
|
|
191
331
|
if (!entry.isDirectory) {
|
|
192
|
-
|
|
332
|
+
const uncompressedSize = entry.header.size
|
|
333
|
+
const compressedSize = entry.header.compressedSize
|
|
334
|
+
|
|
335
|
+
// Check individual file size
|
|
336
|
+
if (uncompressedSize > MAX_FILE_SIZE * 2) {
|
|
337
|
+
throw new Error(`File too large in package: ${entryName} (${Math.round(uncompressedSize / 1024 / 1024)}MB)`)
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// Check compression ratio to detect zip bombs
|
|
341
|
+
if (compressedSize > 0) {
|
|
342
|
+
const ratio = uncompressedSize / compressedSize
|
|
343
|
+
if (ratio > MAX_COMPRESSION_RATIO) {
|
|
344
|
+
throw new Error(`Suspicious compression ratio detected in ${entryName} (possible zip bomb)`)
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// Check total uncompressed size
|
|
349
|
+
totalSize += uncompressedSize
|
|
193
350
|
if (totalSize > MAX_ZIP_SIZE * 2) {
|
|
194
351
|
throw new Error('Skill package contains too much data (possible zip bomb)')
|
|
195
352
|
}
|
|
@@ -202,10 +359,21 @@ async function fetchSkill(skillName, options = {}) {
|
|
|
202
359
|
)
|
|
203
360
|
|
|
204
361
|
if (!skillMdEntry) {
|
|
205
|
-
throw new Error('Invalid skill package')
|
|
362
|
+
throw new Error('Invalid skill package: SKILL.md not found')
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// Validate SKILL.md size before reading
|
|
366
|
+
if (skillMdEntry.header.size > MAX_FILE_SIZE) {
|
|
367
|
+
throw new Error(`SKILL.md too large (${Math.round(skillMdEntry.header.size / 1024)}KB). Maximum: ${Math.round(MAX_FILE_SIZE / 1024)}KB`)
|
|
206
368
|
}
|
|
207
369
|
|
|
208
370
|
const skillMdContent = skillMdEntry.getData().toString('utf8')
|
|
371
|
+
|
|
372
|
+
// Validate content length after conversion
|
|
373
|
+
if (skillMdContent.length > MAX_FILE_SIZE) {
|
|
374
|
+
throw new Error('SKILL.md content too large after conversion')
|
|
375
|
+
}
|
|
376
|
+
|
|
209
377
|
const { metadata } = parseFrontmatter(skillMdContent)
|
|
210
378
|
|
|
211
379
|
// Get additional files (excluding SKILL.md)
|
|
@@ -396,15 +564,32 @@ async function installSkillCommand(skillName) {
|
|
|
396
564
|
* Install collection command handler
|
|
397
565
|
*/
|
|
398
566
|
async function installCollectionCommand(collectionId) {
|
|
567
|
+
// Validate collection ID to prevent SSRF
|
|
568
|
+
if (!collectionId || typeof collectionId !== 'string') {
|
|
569
|
+
console.log(chalk.red('✗ Invalid collection ID'))
|
|
570
|
+
process.exit(1)
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
const trimmedId = collectionId.trim()
|
|
574
|
+
|
|
575
|
+
// Collection IDs should be alphanumeric with hyphens/underscores only
|
|
576
|
+
if (!/^[a-zA-Z0-9_-]+$/.test(trimmedId) || trimmedId.length > 100) {
|
|
577
|
+
console.log(chalk.red('✗ Invalid collection ID format'))
|
|
578
|
+
console.log(chalk.dim('Collection ID must contain only letters, numbers, hyphens, and underscores'))
|
|
579
|
+
process.exit(1)
|
|
580
|
+
}
|
|
581
|
+
|
|
399
582
|
const spinner = ora('Fetching collection...').start()
|
|
400
583
|
|
|
401
584
|
try {
|
|
402
|
-
// Fetch collection
|
|
403
|
-
const response = await axios.get(`${API_BASE_URL}/api/collections/${
|
|
585
|
+
// Fetch collection (using validated ID)
|
|
586
|
+
const response = await axios.get(`${API_BASE_URL}/api/collections/${trimmedId}`, {
|
|
404
587
|
headers: {
|
|
405
588
|
'User-Agent': `skillscokac-cli/${VERSION}`
|
|
406
589
|
},
|
|
407
|
-
timeout: AXIOS_TIMEOUT
|
|
590
|
+
timeout: AXIOS_TIMEOUT,
|
|
591
|
+
maxRedirects: 5, // Limit redirects
|
|
592
|
+
validateStatus: (status) => status >= 200 && status < 300 // Only accept 2xx
|
|
408
593
|
})
|
|
409
594
|
|
|
410
595
|
const collection = response.data
|
|
@@ -462,8 +647,23 @@ async function installCollectionCommand(collectionId) {
|
|
|
462
647
|
// Install each skill
|
|
463
648
|
for (let i = 0; i < skills.length; i++) {
|
|
464
649
|
const skillPost = skills[i]
|
|
650
|
+
|
|
651
|
+
// Validate skillPost structure
|
|
652
|
+
if (!skillPost || typeof skillPost !== 'object') {
|
|
653
|
+
console.log(chalk.red(' ✗') + chalk.dim(' Invalid skill data'))
|
|
654
|
+
failCount++
|
|
655
|
+
continue
|
|
656
|
+
}
|
|
657
|
+
|
|
465
658
|
const skillName = skillPost.skillName
|
|
466
659
|
|
|
660
|
+
// Validate skill name before processing
|
|
661
|
+
if (!skillName || typeof skillName !== 'string' || skillName.length > 100) {
|
|
662
|
+
console.log(chalk.red(' ✗') + chalk.dim(' Invalid skill name in collection'))
|
|
663
|
+
failCount++
|
|
664
|
+
continue
|
|
665
|
+
}
|
|
666
|
+
|
|
467
667
|
try {
|
|
468
668
|
// Fetch and install skill in silent mode
|
|
469
669
|
const skill = await fetchSkill(skillName, { silent: true })
|