skillscokac 1.4.1 → 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.
Files changed (2) hide show
  1. package/bin/skillscokac.js +344 -13
  2. package/package.json +1 -1
@@ -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 = content.match(frontmatterRegex)
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
- const metadata = yaml.parse(match[1])
73
- const markdownContent = content.slice(match[0].length).trim()
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
- totalSize += entry.header.size
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/${collectionId}`, {
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 })
@@ -1180,6 +1380,137 @@ async function findSkillByName(skillName, apiKey) {
1180
1380
  }
1181
1381
  }
1182
1382
 
1383
+ /**
1384
+ * Update skill using individual API calls (alternative to batch)
1385
+ */
1386
+ async function updateSkillIndividually(postId, skillMdContent, skillDir, apiKey, silent = false) {
1387
+ // Collect all files to upload
1388
+ const files = []
1389
+
1390
+ function findFiles(dir, baseDir) {
1391
+ const entries = fs.readdirSync(dir, { withFileTypes: true })
1392
+
1393
+ for (const entry of entries) {
1394
+ const fullPath = path.join(dir, entry.name)
1395
+
1396
+ if (entry.isDirectory()) {
1397
+ if (entry.name.startsWith('.') || entry.name === '__pycache__' || entry.name === 'node_modules') {
1398
+ continue
1399
+ }
1400
+ findFiles(fullPath, baseDir)
1401
+ } else if (entry.isFile()) {
1402
+ const relativePath = path.relative(baseDir, fullPath)
1403
+ if (relativePath === 'SKILL.md') {
1404
+ continue
1405
+ }
1406
+ if (entry.name.startsWith('.')) {
1407
+ continue
1408
+ }
1409
+
1410
+ // Check file size
1411
+ const stats = fs.statSync(fullPath)
1412
+ if (stats.size > MAX_FILE_SIZE) {
1413
+ if (!silent) {
1414
+ console.warn(chalk.yellow(`⚠ Skipping large file (${Math.round(stats.size / 1024 / 1024)}MB): ${relativePath}`))
1415
+ }
1416
+ continue
1417
+ }
1418
+
1419
+ // Try to read as text
1420
+ let content
1421
+ try {
1422
+ content = fs.readFileSync(fullPath, 'utf8')
1423
+ } catch (err) {
1424
+ // Skip binary files
1425
+ continue
1426
+ }
1427
+
1428
+ files.push({
1429
+ path: relativePath.replace(/\\/g, '/'),
1430
+ content: content
1431
+ })
1432
+ }
1433
+ }
1434
+ }
1435
+
1436
+ findFiles(skillDir, skillDir)
1437
+
1438
+ // Step 1: Update SKILL.md content
1439
+ await axios.patch(
1440
+ `${API_BASE_URL}/api/posts/${postId}`,
1441
+ { skillMd: skillMdContent },
1442
+ {
1443
+ headers: {
1444
+ 'Authorization': `Bearer ${apiKey}`,
1445
+ 'Content-Type': 'application/json',
1446
+ 'User-Agent': `skillscokac-cli/${VERSION}`
1447
+ },
1448
+ timeout: AXIOS_TIMEOUT
1449
+ }
1450
+ )
1451
+
1452
+ // Step 2: Get existing files
1453
+ const existingSkill = await axios.get(`${API_BASE_URL}/api/posts/${postId}`, {
1454
+ headers: {
1455
+ 'Authorization': `Bearer ${apiKey}`,
1456
+ 'User-Agent': `skillscokac-cli/${VERSION}`
1457
+ },
1458
+ timeout: AXIOS_TIMEOUT
1459
+ })
1460
+
1461
+ const existingFiles = existingSkill.data.skillFiles || []
1462
+
1463
+ // Step 3: Delete existing files
1464
+ for (const file of existingFiles) {
1465
+ try {
1466
+ await axios.delete(
1467
+ `${API_BASE_URL}/api/posts/${postId}/files/${file.id}`,
1468
+ {
1469
+ headers: {
1470
+ 'Authorization': `Bearer ${apiKey}`,
1471
+ 'User-Agent': `skillscokac-cli/${VERSION}`
1472
+ },
1473
+ timeout: AXIOS_TIMEOUT
1474
+ }
1475
+ )
1476
+ } catch (err) {
1477
+ // Continue even if delete fails
1478
+ if (!silent) {
1479
+ console.warn(chalk.yellow(`⚠ Failed to delete file: ${file.path}`))
1480
+ }
1481
+ }
1482
+ }
1483
+
1484
+ // Step 4: Create new files
1485
+ let uploadedCount = 0
1486
+ for (const file of files) {
1487
+ try {
1488
+ await axios.post(
1489
+ `${API_BASE_URL}/api/posts/${postId}/files`,
1490
+ file,
1491
+ {
1492
+ headers: {
1493
+ 'Authorization': `Bearer ${apiKey}`,
1494
+ 'Content-Type': 'application/json',
1495
+ 'User-Agent': `skillscokac-cli/${VERSION}`
1496
+ },
1497
+ timeout: AXIOS_TIMEOUT
1498
+ }
1499
+ )
1500
+ uploadedCount++
1501
+ } catch (err) {
1502
+ if (!silent) {
1503
+ console.warn(chalk.yellow(`⚠ Failed to upload file: ${file.path}`))
1504
+ }
1505
+ }
1506
+ }
1507
+
1508
+ return {
1509
+ uploadedCount: uploadedCount,
1510
+ deletedCount: existingFiles.length
1511
+ }
1512
+ }
1513
+
1183
1514
  /**
1184
1515
  * Update skill using batch API
1185
1516
  */
@@ -1334,7 +1665,7 @@ async function uploadModifySkillCommand(skillDir, apiKey) {
1334
1665
  console.log(chalk.yellow(`Skill "${skillName}" already exists. Updating...`))
1335
1666
 
1336
1667
  spinner.text = 'Updating skill and files...'
1337
- const { uploadedCount, deletedCount } = await updateSkillWithBatch(
1668
+ const { uploadedCount, deletedCount } = await updateSkillIndividually(
1338
1669
  existingSkill.id,
1339
1670
  skillMdContent,
1340
1671
  resolvedSkillDir,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skillscokac",
3
- "version": "1.4.1",
3
+ "version": "1.5.0",
4
4
  "description": "CLI tool to install and manage Claude Code skills from skills.cokac.com",
5
5
  "main": "bin/skillscokac.js",
6
6
  "bin": {