vskill 1.2.4 → 1.2.7

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 (114) hide show
  1. package/README.md +51 -3
  2. package/agents.json +6 -1
  3. package/dist/agents/agents-registry.d.ts +43 -16
  4. package/dist/agents/agents-registry.js +119 -33
  5. package/dist/agents/agents-registry.js.map +1 -1
  6. package/dist/agents/global-dirs.d.ts +19 -0
  7. package/dist/agents/global-dirs.js +53 -0
  8. package/dist/agents/global-dirs.js.map +1 -0
  9. package/dist/audit/fix-suggestions.js +6 -0
  10. package/dist/audit/fix-suggestions.js.map +1 -1
  11. package/dist/commands/add-github-bundle.d.ts +29 -0
  12. package/dist/commands/add-github-bundle.js +135 -0
  13. package/dist/commands/add-github-bundle.js.map +1 -0
  14. package/dist/commands/add-lockfile.d.ts +2 -0
  15. package/dist/commands/add-lockfile.js +1 -1
  16. package/dist/commands/add-lockfile.js.map +1 -1
  17. package/dist/commands/add.d.ts +5 -7
  18. package/dist/commands/add.js +207 -111
  19. package/dist/commands/add.js.map +1 -1
  20. package/dist/commands/audit-webmcp.d.ts +25 -0
  21. package/dist/commands/audit-webmcp.js +167 -0
  22. package/dist/commands/audit-webmcp.js.map +1 -0
  23. package/dist/commands/doctor.d.ts +69 -0
  24. package/dist/commands/doctor.js +760 -0
  25. package/dist/commands/doctor.js.map +1 -0
  26. package/dist/commands/remove.js +43 -8
  27. package/dist/commands/remove.js.map +1 -1
  28. package/dist/commands/scan.js +1 -1
  29. package/dist/commands/scan.js.map +1 -1
  30. package/dist/commands/update.js +12 -4
  31. package/dist/commands/update.js.map +1 -1
  32. package/dist/discovery/github-skill-bundle.d.ts +74 -0
  33. package/dist/discovery/github-skill-bundle.js +323 -0
  34. package/dist/discovery/github-skill-bundle.js.map +1 -0
  35. package/dist/eval/skill-scanner.js +7 -1
  36. package/dist/eval/skill-scanner.js.map +1 -1
  37. package/dist/eval-server/install-skill-routes.js +10 -3
  38. package/dist/eval-server/install-skill-routes.js.map +1 -1
  39. package/dist/eval-server/remove-skill-routes.js +262 -29
  40. package/dist/eval-server/remove-skill-routes.js.map +1 -1
  41. package/dist/eval-server/studio-privacy.js +2 -3
  42. package/dist/eval-server/studio-privacy.js.map +1 -1
  43. package/dist/eval-server/utils/scan-install-locations.js +22 -3
  44. package/dist/eval-server/utils/scan-install-locations.js.map +1 -1
  45. package/dist/eval-ui/assets/{CreateSkillPage-BUrgworx.js → CreateSkillPage-CXDd6gDN.js} +1 -1
  46. package/dist/eval-ui/assets/{FindSkillsPalette-BstJ22TI.js → FindSkillsPalette-DQ6uGhFP.js} +2 -2
  47. package/dist/eval-ui/assets/{SearchPaletteCore-Dagnc-E7.js → SearchPaletteCore-DpHCf0mf.js} +1 -1
  48. package/dist/eval-ui/assets/{SkillDetailPanel-DnujVk6g.js → SkillDetailPanel-BjHFjGxg.js} +1 -1
  49. package/dist/eval-ui/assets/{UpdateDropdown-B7D2vXrm.js → UpdateDropdown-Cn4Vsq5d.js} +1 -1
  50. package/dist/eval-ui/assets/main-BmB7Zj9I.js +86 -0
  51. package/dist/eval-ui/index.html +1 -1
  52. package/dist/index.js +34 -0
  53. package/dist/index.js.map +1 -1
  54. package/dist/installer/bundle-files.d.ts +39 -2
  55. package/dist/installer/bundle-files.js +80 -10
  56. package/dist/installer/bundle-files.js.map +1 -1
  57. package/dist/installer/canonical.d.ts +39 -4
  58. package/dist/installer/canonical.js +178 -24
  59. package/dist/installer/canonical.js.map +1 -1
  60. package/dist/installer/multi-install.d.ts +11 -2
  61. package/dist/installer/multi-install.js +41 -9
  62. package/dist/installer/multi-install.js.map +1 -1
  63. package/dist/installer/transformers/index.d.ts +8 -0
  64. package/dist/installer/yaml-safe-mutate.js +48 -2
  65. package/dist/installer/yaml-safe-mutate.js.map +1 -1
  66. package/dist/lib/github-fetch.js +2 -0
  67. package/dist/lib/github-fetch.js.map +1 -1
  68. package/dist/lib/private-source.d.ts +8 -0
  69. package/dist/lib/private-source.js +16 -2
  70. package/dist/lib/private-source.js.map +1 -1
  71. package/dist/scanner/hidden-content.d.ts +47 -0
  72. package/dist/scanner/hidden-content.js +904 -0
  73. package/dist/scanner/hidden-content.js.map +1 -0
  74. package/dist/scanner/installer-hosts.d.ts +26 -0
  75. package/dist/scanner/installer-hosts.js +92 -0
  76. package/dist/scanner/installer-hosts.js.map +1 -0
  77. package/dist/scanner/judge-guard.d.ts +119 -0
  78. package/dist/scanner/judge-guard.js +481 -0
  79. package/dist/scanner/judge-guard.js.map +1 -0
  80. package/dist/scanner/patterns.d.ts +12 -0
  81. package/dist/scanner/patterns.js +353 -113
  82. package/dist/scanner/patterns.js.map +1 -1
  83. package/dist/scanner/repo-scanner.d.ts +4 -0
  84. package/dist/scanner/repo-scanner.js +4 -1
  85. package/dist/scanner/repo-scanner.js.map +1 -1
  86. package/dist/scanner/tier1.js +37 -7
  87. package/dist/scanner/tier1.js.map +1 -1
  88. package/dist/scanner/tier2-llm.d.ts +22 -4
  89. package/dist/scanner/tier2-llm.js +163 -23
  90. package/dist/scanner/tier2-llm.js.map +1 -1
  91. package/dist/scanner/types.d.ts +8 -0
  92. package/dist/webmcp/args.d.ts +34 -0
  93. package/dist/webmcp/args.js +87 -0
  94. package/dist/webmcp/args.js.map +1 -0
  95. package/dist/webmcp/attribution.d.ts +38 -0
  96. package/dist/webmcp/attribution.js +141 -0
  97. package/dist/webmcp/attribution.js.map +1 -0
  98. package/dist/webmcp/enumerate.d.ts +81 -0
  99. package/dist/webmcp/enumerate.js +436 -0
  100. package/dist/webmcp/enumerate.js.map +1 -0
  101. package/dist/webmcp/format.d.ts +9 -0
  102. package/dist/webmcp/format.js +105 -0
  103. package/dist/webmcp/format.js.map +1 -0
  104. package/dist/webmcp/merge.d.ts +49 -0
  105. package/dist/webmcp/merge.js +167 -0
  106. package/dist/webmcp/merge.js.map +1 -0
  107. package/dist/webmcp/score.d.ts +70 -0
  108. package/dist/webmcp/score.js +586 -0
  109. package/dist/webmcp/score.js.map +1 -0
  110. package/dist/webmcp/types.d.ts +92 -0
  111. package/dist/webmcp/types.js +8 -0
  112. package/dist/webmcp/types.js.map +1 -0
  113. package/package.json +1 -1
  114. package/dist/eval-ui/assets/main-DjA5gpjV.js +0 -86
@@ -1,8 +1,29 @@
1
1
  // ---------------------------------------------------------------------------
2
2
  // Tier 1 Security Scan Patterns
3
- // 52 regex-based patterns for static analysis of skill content.
3
+ // 58 regex-based patterns for static analysis of skill content, plus the
4
+ // hidden-content checks (HC-*) in hidden-content.ts.
4
5
  // ---------------------------------------------------------------------------
5
- // ---- Patterns (52 total) --------------------------------------------------
6
+ import { HIDDEN_CONTENT_RULES, scanHiddenContent } from "./hidden-content.js";
7
+ import { DOT_SEGMENT_URL_SOURCE, OFFICIAL_INSTALLER_URL_SOURCE } from "./installer-hosts.js";
8
+ // ---- Shared fragments ------------------------------------------------------
9
+ /**
10
+ * netcat in command position: at the start of a line or string or after a
11
+ * list marker or shell prompt there, after a shell separator, colon, quote,
12
+ * backtick or `$(`, or after a wrapper or verb such as sudo, nohup,
13
+ * timeout N, run or Run; an absolute path is allowed. No fragment repeats and
14
+ * no whitespace run crosses a newline, so a long line or a run of blank lines
15
+ * is scanned in one pass.
16
+ */
17
+ const NETCAT_COMMAND = String.raw `(?:^|^[^\S\n]*(?:[-*+>$#]|\d+[.)])(?=\s)|[\n|;&(\x60'":]|\$\(|\b(?:sudo|doas|busybox|nohup|exec|env|command|time|then|do|[Rr]un|[Ee]xecute)(?=\s)|\btimeout[^\S\n]+[\w.]+(?=\s))[^\S\n]*(?:(?:\/[\w.-]+)*\/)?(?:nc|ncat|netcat)\b`;
18
+ /**
19
+ * An official installer URL start with no dot segment in reach: a dot
20
+ * segment can climb out of an allowlisted owner's path on
21
+ * raw.githubusercontent.com, so such a URL is not official.
22
+ */
23
+ const OFFICIAL_INSTALLER_URL_SOURCE_NO_DOT_SEGMENTS = `(?!${DOT_SEGMENT_URL_SOURCE})(?:${OFFICIAL_INSTALLER_URL_SOURCE})`;
24
+ /** A filesystem API call opening its argument list (FS-003). */
25
+ const FS_CALL = String.raw `(?:readFile|writeFile|appendFile|createReadStream|createWriteStream|open|unlink|stat|lstat|fstat|access|rename|copyFile|chmod|chown|truncate)(?:Sync)?\s*\(`;
26
+ // ---- Patterns (58 total) --------------------------------------------------
6
27
  export const SCAN_PATTERNS = [
7
28
  // --- Command Injection (1-7) ---------------------------------------------
8
29
  {
@@ -50,7 +71,7 @@ export const SCAN_PATTERNS = [
50
71
  name: "Shell pipe operator",
51
72
  severity: "medium",
52
73
  description: "Detects shell piping within exec-like calls",
53
- pattern: /exec\s*\([^)]*\|[^)]*\)/g,
74
+ pattern: /exec\s*\((?:(?!exec\s*\()[^)|\n])*\|(?:(?!exec\s*\()[^)\n])*\)/g,
54
75
  category: "command-injection",
55
76
  },
56
77
  {
@@ -66,7 +87,38 @@ export const SCAN_PATTERNS = [
66
87
  name: "Pipe-to-shell execution",
67
88
  severity: "critical",
68
89
  description: "Detects curl/wget output piped directly to a shell interpreter (download-and-execute)",
69
- pattern: /\b(?:curl|wget)\b[^|]*\|\s*(?:(?:\/\w+)*\/)?(?:env\s+)?(?:ba|z|da|k)?sh\b/g,
90
+ // Each scan stops at the next curl/wget, so a pipe-free run costs one
91
+ // pass however many times the command repeats, and padding cannot hide the pipe.
92
+ pattern: /\b(?:curl|wget)\b(?:(?!\b(?:curl|wget)\b)[^|\n])*\|\s*(?:(?:\/\w+)*\/)?(?:env\s+)?(?:ba|z|da|k)?sh\b/g,
93
+ category: "command-injection",
94
+ },
95
+ {
96
+ id: "CI-009",
97
+ name: "Python shell execution",
98
+ severity: "high",
99
+ description: "Detects os.system/os.popen and subprocess calls with shell=True",
100
+ // os.system( is already CI-003 ("system("); only the spaced form is added.
101
+ pattern: /\bos\.popen\s*\(|\bos\.system\s+\(|\bsubprocess\.(?:run|call|Popen|check_output|check_call)\s*\([^)]{0,400}?\bshell\s*=\s*True/g,
102
+ category: "command-injection",
103
+ },
104
+ {
105
+ id: "CI-010",
106
+ name: "PowerShell dynamic execution",
107
+ severity: "high",
108
+ description: "Detects Invoke-Expression/iex, -EncodedCommand payloads and base64 decoding in PowerShell",
109
+ pattern: /\bInvoke-Expression\b|\|\s*iex\b|\biex\s*\(\s*(?:\(|\$|\[|['"]|New-Object\b|Invoke-|i(?:wr|rm)\b|Get-Content\b)|\s-(?:EncodedCommand|enc|ec)\s+[A-Za-z0-9+\/=]{20,}|\[(?:System\.)?Convert\]::FromBase64String/gi,
110
+ category: "command-injection",
111
+ },
112
+ {
113
+ // CLI-only: the PowerShell twin of CI-008. A CI-010 match inside this
114
+ // span is reported as CI-011 instead, so installer docs in fenced code
115
+ // (irm bun.sh/install.ps1 | iex) get the same downgrade as curl | sh.
116
+ // The download target must be literal: a $variable URL stays CI-010.
117
+ id: "CI-011",
118
+ name: "PowerShell download-and-execute",
119
+ severity: "critical",
120
+ description: "Detects irm/iwr output piped to iex, or iex of a downloaded script (PowerShell installer one-liners)",
121
+ pattern: /\b(?:irm|iwr|Invoke-RestMethod|Invoke-WebRequest)\b[^|\n$]{1,400}\|\s*(?:iex|Invoke-Expression)\b|\b(?:iex|Invoke-Expression)\s*\(?\s*\(\s*(?:(?:irm|iwr|Invoke-RestMethod|Invoke-WebRequest)\b[^()\n$]{1,400}|New-Object\s+(?:System\.)?Net\.WebClient\s*\)\s*\.DownloadString\s*\(\s*['"]https?:\/\/[^()\n$'"]{1,400}['"]\s*)\)/gi,
70
122
  category: "command-injection",
71
123
  },
72
124
  // --- Data Exfiltration (8-12) --------------------------------------------
@@ -99,7 +151,10 @@ export const SCAN_PATTERNS = [
99
151
  name: "DNS exfiltration pattern",
100
152
  severity: "medium",
101
153
  description: "Detects encoding data into DNS lookups",
102
- pattern: /dns\.(?:resolve|lookup)\s*\(.*(?:\+|concat|\$\{)/g,
154
+ // Scans like this one stop at the next call of the same function, so a
155
+ // repeated prefix cannot make a long line quadratic, and no length bound
156
+ // lets padding hide the rest of the call.
157
+ pattern: /dns\.(?:resolve|lookup)\s*\((?:(?!dns\.(?:resolve|lookup)\b)[^\n])*?(?:\+|concat|\$\{)/g,
103
158
  category: "data-exfiltration",
104
159
  },
105
160
  {
@@ -107,7 +162,7 @@ export const SCAN_PATTERNS = [
107
162
  name: "Base64 encode and send",
108
163
  severity: "medium",
109
164
  description: "Detects base64 encoding followed by network send (exfil pattern)",
110
- pattern: /btoa\s*\(|Buffer\.from\([^)]*\)\.toString\s*\(\s*['"]base64['"]/g,
165
+ pattern: /btoa\s*\(|Buffer\.from\((?:(?!Buffer\.from\()[^)\n])*\)\.toString\s*\(\s*['"]base64['"]/g,
111
166
  category: "data-exfiltration",
112
167
  },
113
168
  // --- Privilege Escalation (13-17) ----------------------------------------
@@ -157,7 +212,7 @@ export const SCAN_PATTERNS = [
157
212
  name: "Read .env file",
158
213
  severity: "critical",
159
214
  description: "Detects reading .env files which contain secrets",
160
- pattern: /readFile(?:Sync)?\s*\([^)]*\.env\b/g,
215
+ pattern: /readFile(?:Sync)?\s*\((?:(?!readFile)[^)\n])*?\.env\b/g,
161
216
  category: "credential-theft",
162
217
  },
163
218
  {
@@ -189,7 +244,7 @@ export const SCAN_PATTERNS = [
189
244
  name: "Secrets in environment",
190
245
  severity: "high",
191
246
  description: "Detects broad process.env access patterns",
192
- pattern: /process\.env\[(?:[^'"\]]*\+|[a-zA-Z_]\w*\])/g,
247
+ pattern: /process\.env\[(?:(?:(?!process\.env\[)[^'"\]\n])*\+|[a-zA-Z_]\w*\])/g,
193
248
  category: "credential-theft",
194
249
  },
195
250
  {
@@ -247,15 +302,15 @@ export const SCAN_PATTERNS = [
247
302
  name: "Write to system paths",
248
303
  severity: "critical",
249
304
  description: "Detects writing to system-critical paths",
250
- pattern: /writeFile(?:Sync)?\s*\([^)]*(?:\/etc\/|\/usr\/|\/var\/|\/System\/|C:\\Windows)/g,
305
+ pattern: /writeFile(?:Sync)?\s*\((?:(?!writeFile)[^)\n])*?(?:\/etc\/|\/usr\/|\/var\/|\/System\/|C:\\Windows)/g,
251
306
  category: "filesystem-access",
252
307
  },
253
308
  {
254
309
  id: "FS-003",
255
310
  name: "Path traversal",
256
311
  severity: "high",
257
- description: "Detects directory traversal attempts",
258
- pattern: /\.\.[/\\]\.\.[/\\]|\.\.(?:[/\\]){2,}/g,
312
+ description: "Detects directory traversal in filesystem API call arguments",
313
+ pattern: new RegExp(String.raw `${FS_CALL}(?:(?!${FS_CALL})[^)\n])*\.\.[/\\]\.\.[/\\]`, "g"),
259
314
  category: "filesystem-access",
260
315
  },
261
316
  {
@@ -280,7 +335,10 @@ export const SCAN_PATTERNS = [
280
335
  name: "Reverse shell pattern",
281
336
  severity: "critical",
282
337
  description: "Detects reverse shell connection patterns",
283
- pattern: /(?:\/dev\/tcp\/|nc\s+-[a-z]*e|ncat\s|bash\s+-i\s+>&|mkfifo|0<&\d+)/g,
338
+ // A flag or shell pipe after the host only counts when netcat is the
339
+ // command itself, and the argument run stops at the first | ; or &, so
340
+ // `nc -z db 5432 && psql -c ...` and prose about `wc -c` stay clean.
341
+ pattern: new RegExp(String.raw `(?:\/dev\/tcp\/|\b(?:nc|netcat)\s+-[a-z]*e|\bncat\b\s+-|${NETCAT_COMMAND}[^\n|;&]{0,160}?\s(?:-e|-c|--exec|--sh-exec)\b|${NETCAT_COMMAND}[^\n|;&]{0,160}\|\s*(?:(?:\/\w+)*\/)?(?:ba|z|da|k)?sh\b|bash\s+-i\s+>&|mkfifo\s+\S|0<&\d+)`, "g"),
284
342
  category: "network-access",
285
343
  },
286
344
  {
@@ -291,6 +349,23 @@ export const SCAN_PATTERNS = [
291
349
  pattern: /(?:http|https):\/\/\$\{|(?:http|https):\/\/['"]\s*\+/g,
292
350
  category: "network-access",
293
351
  },
352
+ {
353
+ id: "NA-004",
354
+ name: "Fake prerequisite download",
355
+ severity: "high",
356
+ description: "Detects instructions to download and run an external binary or script as a setup step (ClawHavoc-style)",
357
+ pattern: new RegExp(String.raw `\b(?:before (?:using|running|installing)|prerequisites?|required?|first)\b[^.\n]{0,120}?\b(?:download|install|run|execute)\b[^\n]{0,160}?(?:https?:\/\/(?!${OFFICIAL_INSTALLER_URL_SOURCE_NO_DOT_SEGMENTS})[^\/\s)'"\x60]{1,253}\/[^\s)'"\x60]{0,400}?\.(?:zip|exe|dmg|pkg|msi|sh|ps1|bat|cmd|command|app|scr)\b|\b(?:glot\.io|pastebin\.com|rentry\.co|paste\.ee|transfer\.sh|hastebin\.com)\/)`, "gi"),
358
+ category: "network-access",
359
+ },
360
+ {
361
+ id: "NA-005",
362
+ name: "Password-protected archive",
363
+ severity: "high",
364
+ description: "Detects password-protected archives, a common way to hide payloads from scanners",
365
+ // No /i flag: unzip -P is the password but -p pipes to stdout.
366
+ pattern: /\b(?:[Zz][Ii][Pp]|[Aa][Rr][Cc][Hh][Ii][Vv][Ee]|[Rr][Aa][Rr]|7[Zz])\b[^\n]{0,80}?\b[Pp][Aa][Ss][Ss](?:[Ww][Oo][Rr][Dd]|[Cc][Oo][Dd][Ee])\s*(?:[Ii][Ss]\s*)?[:=]\s*\S+|\bunzip\b[^|&;\n]{0,60}?\s-[a-zA-Z]*P\s*\S+|\b(?:7z|7za|7zz|unrar)\s+[ex]\b[^|&;\n]{0,60}?\s-[pP]\S+/g,
367
+ category: "network-access",
368
+ },
294
369
  // --- Code Execution (35-37) ----------------------------------------------
295
370
  {
296
371
  id: "CE-001",
@@ -316,15 +391,21 @@ export const SCAN_PATTERNS = [
316
391
  pattern: /import\s*\(\s*(?:`[^`]*\$\{|[a-zA-Z_]\w*\s*[\+)])/g,
317
392
  category: "code-execution",
318
393
  },
394
+ {
395
+ id: "CE-004",
396
+ name: "Python dynamic execution or deserialization",
397
+ severity: "high",
398
+ description: "Detects pickle/marshal loads and exec() of decoded or compiled code",
399
+ pattern: /\b(?:pickle|cPickle|_pickle|marshal|dill)\.loads?\s*\(|\bexec\s*\(\s*(?:compile|base64|codecs|zlib|bytes\.fromhex|marshal|__import__)\b|\b__import__\s*\(\s*['"](?:os|subprocess|socket|pty)['"]\s*\)/g,
400
+ category: "code-execution",
401
+ },
319
402
  // --- DCI Block Abuse (38-51) -----------------------------------------------
320
- // DCI blocks are shell commands in SKILL.md executed via ! prefix.
321
- // These patterns detect malicious use within DCI contexts.
322
403
  {
323
404
  id: "DCI-001",
324
405
  name: "DCI credential file read",
325
406
  severity: "critical",
326
407
  description: "DCI block reads credential files (~/.ssh/, ~/.aws/, .env)",
327
- pattern: /^\s*!\s*`[^`]*(?:~\/\.ssh\/|~\/\.aws\/|\.env\b|\.gnupg\/)/gm,
408
+ pattern: /^[^\S\n]*![^\S\n]*`[^`\n]*(?:~\/\.ssh\/|~\/\.aws\/|\.env\b|\.gnupg\/)/gm,
328
409
  category: "dci-abuse",
329
410
  },
330
411
  {
@@ -332,7 +413,7 @@ export const SCAN_PATTERNS = [
332
413
  name: "DCI network exfiltration",
333
414
  severity: "critical",
334
415
  description: "DCI block uses curl/wget for network access",
335
- pattern: /^\s*!\s*`[^`]*\b(?:curl|wget)\b/gm,
416
+ pattern: /^[^\S\n]*![^\S\n]*`[^`\n]*\b(?:curl|wget)\b/gm,
336
417
  category: "dci-abuse",
337
418
  },
338
419
  {
@@ -340,7 +421,9 @@ export const SCAN_PATTERNS = [
340
421
  name: "DCI fetch/nc network call",
341
422
  severity: "critical",
342
423
  description: "DCI block uses fetch or netcat for network access",
343
- pattern: /^\s*!\s*`[^`]*\b(?:fetch|nc|ncat|netcat)\b/gm,
424
+ // `git fetch` and other package-manager fetch subcommands are not network
425
+ // calls the skill makes. The lookbehind runs only where `fetch` matched.
426
+ pattern: /^[^\S\n]*![^\S\n]*`[^`\n]*(?:\bfetch\b(?<!\b(?:git|npm|pnpm|yarn)\s+fetch)|\b(?:nc|ncat|netcat)\b)/gm,
344
427
  category: "dci-abuse",
345
428
  },
346
429
  {
@@ -348,7 +431,9 @@ export const SCAN_PATTERNS = [
348
431
  name: "DCI agent config write",
349
432
  severity: "critical",
350
433
  description: "DCI block writes to agent config files (CLAUDE.md, AGENTS.md, .claude/)",
351
- pattern: /^\s*!\s*`[^`]*(?:>\s*.*(?:CLAUDE\.md|AGENTS\.md|\.claude\/|\.specweave\/))/gm,
434
+ // The prefix cannot pass a `>`, so only the first redirect is tried: a
435
+ // later target also follows it, and the line is scanned once.
436
+ pattern: /^[^\S\n]*![^\S\n]*`[^`>\n]*>[^`\n]*(?:CLAUDE\.md|AGENTS\.md|\.claude\/|\.specweave\/)/gm,
352
437
  category: "dci-abuse",
353
438
  },
354
439
  {
@@ -356,7 +441,9 @@ export const SCAN_PATTERNS = [
356
441
  name: "DCI agent config modify",
357
442
  severity: "critical",
358
443
  description: "DCI block modifies agent config via tee/sed/echo append",
359
- pattern: /^\s*!\s*`[^`]*(?:tee|sed\s+-i|echo\s+.*>>)\s*.*(?:CLAUDE\.md|AGENTS\.md|\.claude\/)/gm,
444
+ // Each tee/sed -i scan stops at the next one, each echo scan at the next
445
+ // echo, and each `>>` scan at the next `>>`: linear with no length bound.
446
+ pattern: /^[^\S\n]*![^\S\n]*`[^`\n]*?(?:(?:\btee\b|\bsed\s+-i)(?:(?!\btee\b|\bsed\s+-i)[^`\n])*|\becho\s(?:(?!\becho\s)[^`\n])*?>>(?:(?!>>)[^`\n])*)(?:CLAUDE\.md|AGENTS\.md|\.claude\/)/gm,
360
447
  category: "dci-abuse",
361
448
  },
362
449
  {
@@ -364,7 +451,7 @@ export const SCAN_PATTERNS = [
364
451
  name: "DCI base64 decode",
365
452
  severity: "critical",
366
453
  description: "DCI block contains base64 decoding (obfuscation)",
367
- pattern: /^\s*!\s*`[^`]*\b(?:base64\s+(?:-[dD]|--decode)|atob\s*\()/gm,
454
+ pattern: /^[^\S\n]*![^\S\n]*`[^`\n]*\b(?:base64\s+(?:-[dD]|--decode)|atob\s*\()/gm,
368
455
  category: "dci-abuse",
369
456
  },
370
457
  {
@@ -372,7 +459,7 @@ export const SCAN_PATTERNS = [
372
459
  name: "DCI hex escape obfuscation",
373
460
  severity: "critical",
374
461
  description: "DCI block contains hex escape sequences (obfuscation)",
375
- pattern: /^\s*!\s*`[^`]*\\x[0-9a-fA-F]{2}(?:\\x[0-9a-fA-F]{2}){3,}/gm,
462
+ pattern: /^[^\S\n]*![^\S\n]*`[^`\n]*\\x[0-9a-fA-F]{2}(?:\\x[0-9a-fA-F]{2}){3,}/gm,
376
463
  category: "dci-abuse",
377
464
  },
378
465
  {
@@ -380,7 +467,7 @@ export const SCAN_PATTERNS = [
380
467
  name: "DCI eval execution",
381
468
  severity: "critical",
382
469
  description: "DCI block uses eval for code execution",
383
- pattern: /^\s*!\s*`[^`]*\beval\b/gm,
470
+ pattern: /^[^\S\n]*![^\S\n]*`[^`\n]*\beval\b/gm,
384
471
  category: "dci-abuse",
385
472
  },
386
473
  {
@@ -388,7 +475,9 @@ export const SCAN_PATTERNS = [
388
475
  name: "DCI download and execute",
389
476
  severity: "critical",
390
477
  description: "DCI block pipes downloaded content to shell (download-and-execute)",
391
- pattern: /^\s*!\s*`[^`]*\b(?:curl|wget)\b[^`]*\|\s*(?:ba|z|da|k)?sh\b/gm,
478
+ // The prefix stops at the first curl/wget; a later shell pipe also
479
+ // follows it, so one pass decides the line.
480
+ pattern: /^[^\S\n]*![^\S\n]*`(?:(?!\b(?:curl|wget)\b)[^`\n])*\b(?:curl|wget)\b[^`\n]*\|\s*(?:ba|z|da|k)?sh\b/gm,
392
481
  category: "dci-abuse",
393
482
  },
394
483
  {
@@ -396,7 +485,7 @@ export const SCAN_PATTERNS = [
396
485
  name: "DCI reverse shell",
397
486
  severity: "critical",
398
487
  description: "DCI block establishes a reverse shell connection",
399
- pattern: /^\s*!\s*`[^`]*(?:\/dev\/tcp\/|bash\s+-i\s+>&|mkfifo|nc\s+-[a-z]*e)/gm,
488
+ pattern: /^[^\S\n]*![^\S\n]*`[^`\n]*(?:\/dev\/tcp\/|bash\s+-i\s+>&|mkfifo|nc\s+-[a-z]*e)/gm,
400
489
  category: "dci-abuse",
401
490
  },
402
491
  {
@@ -404,7 +493,7 @@ export const SCAN_PATTERNS = [
404
493
  name: "DCI sudo escalation",
405
494
  severity: "critical",
406
495
  description: "DCI block uses sudo for privilege escalation",
407
- pattern: /^\s*!\s*`[^`]*\bsudo\b/gm,
496
+ pattern: /^[^\S\n]*![^\S\n]*`[^`\n]*\bsudo\b/gm,
408
497
  category: "dci-abuse",
409
498
  },
410
499
  {
@@ -412,7 +501,7 @@ export const SCAN_PATTERNS = [
412
501
  name: "DCI rm destructive command",
413
502
  severity: "critical",
414
503
  description: "DCI block executes destructive rm -rf command",
415
- pattern: /^\s*!\s*`[^`]*\brm\s+-[a-zA-Z]*r[a-zA-Z]*f/gm,
504
+ pattern: /^[^\S\n]*![^\S\n]*`[^`\n]*\brm\s+-[a-zA-Z]*r[a-zA-Z]*f/gm,
416
505
  category: "dci-abuse",
417
506
  },
418
507
  {
@@ -420,7 +509,9 @@ export const SCAN_PATTERNS = [
420
509
  name: "DCI home dir exfiltration",
421
510
  severity: "critical",
422
511
  description: "DCI block reads from home directory sensitive paths",
423
- pattern: /^\s*!\s*`[^`]*(?:cat|less|head|tail|strings)\s+[^`]*(?:~\/\.|\/home\/[^`]*\.)/gm,
512
+ // Independent lookaheads with no length bound, so padding cannot push the
513
+ // path out of reach; each /home/ scan stops at the next /home/.
514
+ pattern: /^[^\S\n]*![^\S\n]*`(?=[^`\n]*\b(?:cat|less|head|tail|strings)\s)(?=[^`\n]*(?:~\/\.|\/home\/(?:(?!\/home\/)[^`\s])*\.))/gm,
424
515
  category: "dci-abuse",
425
516
  },
426
517
  {
@@ -428,7 +519,7 @@ export const SCAN_PATTERNS = [
428
519
  name: "DCI data pipe to network",
429
520
  severity: "critical",
430
521
  description: "DCI block pipes local data to a network command",
431
- pattern: /^\s*!\s*`[^`]*(?:cat|tar|zip)\s+[^|`]*\|\s*(?:curl|wget|nc)\b/gm,
522
+ pattern: /^[^\S\n]*![^\S\n]*`(?=[^`\n]*\b(?:cat|tar|zip)\s)(?=[^`\n]*\|\s*(?:curl|wget|nc)\b)/gm,
432
523
  category: "dci-abuse",
433
524
  },
434
525
  ];
@@ -437,7 +528,7 @@ export const SCAN_PATTERNS = [
437
528
  // Suppress DCI-abuse findings when the line matches this pattern AND
438
529
  // no malicious DCI pattern also matches (prevents appended-command bypass).
439
530
  const SAFE_DCI_PATTERNS = [
440
- /^\s*!\s*`for\s+d\s+in\s+\.specweave\/skill-memories/,
531
+ /^[^\S\n]*![^\S\n]*`for\s+d\s+in\s+\.specweave\/skill-memories/,
441
532
  ];
442
533
  /** All malicious DCI patterns extracted for the two-pass safe check */
443
534
  const MALICIOUS_DCI_PATTERNS = SCAN_PATTERNS
@@ -547,71 +638,46 @@ function computeFencedCodeBlockLines(lines) {
547
638
  }
548
639
  return fenced;
549
640
  }
550
- // ---- HTML comment line detection -------------------------------------------
551
- // Pre-compute which lines are inside HTML comments (<!-- ... -->).
552
- // Findings inside HTML comments are suppressed entirely.
553
- function computeHtmlCommentLines(lines) {
554
- const commented = new Set();
555
- let inComment = false;
556
- for (let i = 0; i < lines.length; i++) {
557
- const line = lines[i];
558
- if (inComment) {
559
- if (line.includes("-->")) {
560
- // Closing line — don't mark it (content may follow -->)
561
- inComment = false;
562
- }
563
- else {
564
- // Interior line — fully inside comment
565
- commented.add(i);
566
- }
567
- }
568
- else if (line.includes("<!--") && !line.includes("-->")) {
569
- // Multi-line comment starts — don't mark opening line
570
- // (content may precede <!-- or follow on same line)
571
- inComment = true;
572
- }
573
- // Single-line <!-- ... --> comments are NOT marked to prevent
574
- // bypass via: <!-- --> eval("malicious")
575
- }
576
- return commented;
577
- }
578
641
  // ---- Documentation-safe patterns -------------------------------------------
579
642
  // These patterns commonly fire on installation instructions and code examples
580
643
  // inside fenced code blocks. Downgrade to "info" when in documentation context.
581
- const DOCUMENTATION_SAFE_PATTERNS = new Set(["PE-001", "PE-002", "PE-003", "FS-001", "CI-008", "CT-002", "CT-004", "NA-001", "PI-003"]);
582
- // ---- Markdown-link safe context for FS-003 ---------------------------------
583
- /**
584
- * Returns true if the match at `matchIndex` falls inside a markdown link
585
- * target, i.e. between `](` and `)`. Standard relative links like
586
- * `[text](../../tools/file.md)` are not path-traversal attacks.
587
- */
588
- function isInsideMarkdownLink(line, matchIndex) {
589
- const before = line.slice(0, matchIndex);
590
- const linkOpen = before.lastIndexOf("](");
591
- if (linkOpen === -1)
592
- return false;
593
- // Ensure the link isn't already closed before our match
594
- if (before.slice(linkOpen + 2).includes(")"))
595
- return false;
596
- // Ensure there's a closing ) after the match
597
- return line.indexOf(")", matchIndex) !== -1;
644
+ // NA-004/NA-005 are deliberately absent: fake-prerequisite and password-archive
645
+ // lures are written as prose, so documentation context must not hide them.
646
+ const DOCUMENTATION_SAFE_PATTERNS = new Set([
647
+ "PE-001", "PE-002", "PE-003", "FS-001", "CI-008", "CI-009", "CI-010", "CI-011",
648
+ "CE-004", "CT-002", "CT-004", "NA-001", "PI-003",
649
+ ]);
650
+ // CI-010 forms that run hidden or computed code keep their severity even in a
651
+ // fenced example: an -EncodedCommand payload, base64 decoded on a line that
652
+ // also executes, and Invoke-Expression/iex of a variable or a download.
653
+ const CI010_ENCODED_RE = /^\s-(?:EncodedCommand|enc|ec)\s/i;
654
+ const CI010_EXEC_SINK_RE = /\b(?:iex|Invoke-Expression|Invoke-Command|Start-Process|powershell|pwsh)\b|\[ScriptBlock\]::Create|\.Invoke\s*\(|&\s*\(/i;
655
+ const CI010_COMPUTED_RE = /\$[\w{(:]|DownloadString|FromBase64String|GetString|\b(?:irm|iwr|Invoke-RestMethod|Invoke-WebRequest)\b/i;
656
+ function ci010KeepsSeverity(match, line) {
657
+ if (CI010_ENCODED_RE.test(match))
658
+ return true;
659
+ if (/FromBase64String/i.test(match))
660
+ return CI010_EXEC_SINK_RE.test(line);
661
+ return CI010_COMPUTED_RE.test(line);
598
662
  }
599
663
  // ---- Markdown inline code detection ----------------------------------------
600
664
  // Detect if a regex match falls inside markdown inline code (backticks).
601
665
  // Inline code in SKILL.md is referential — it names functions/commands, not
602
666
  // invokes them. Example: `eval()` in "Watch for: `eval()`, `exec()`".
603
667
  /**
604
- * Returns true if the match at [matchStart, matchEnd) falls inside a
605
- * markdown inline code span (single or double backtick delimiters).
606
- * Fenced code block markers (```) are ignored — handled separately.
668
+ * The [contentStart, contentEnd) ranges of markdown inline code spans (single
669
+ * or double backtick delimiters) on a line, in order. Fenced code block
670
+ * markers (```) are ignored — handled separately. Computed once per line so
671
+ * many matches on one long line stay linear.
607
672
  */
608
- function isInsideInlineCode(line, matchStart, matchEnd) {
673
+ function inlineCodeSpans(line) {
674
+ const spans = [];
609
675
  let i = 0;
610
676
  while (i < line.length) {
611
- if (line[i] !== "`") {
612
- i++;
613
- continue;
614
- }
677
+ const tick = line.indexOf("`", i);
678
+ if (tick < 0)
679
+ break;
680
+ i = tick;
615
681
  // Count opening backticks
616
682
  let openLen = 0;
617
683
  while (i + openLen < line.length && line[i + openLen] === "`")
@@ -629,12 +695,91 @@ function isInsideInlineCode(line, matchStart, matchEnd) {
629
695
  i = contentStart;
630
696
  continue;
631
697
  }
632
- // Check if match falls within this inline code span
633
- if (matchStart >= contentStart && matchEnd <= closeIdx)
634
- return true;
698
+ spans.push([contentStart, closeIdx]);
635
699
  i = closeIdx + openLen;
636
700
  }
637
- return false;
701
+ return spans;
702
+ }
703
+ // ---- Finding context ---------------------------------------------------------
704
+ // Each context line is cut to a window around the match column, so a match on
705
+ // a huge line costs O(window), not O(line).
706
+ const CONTEXT_WINDOW = 300;
707
+ function contextWindow(text, column) {
708
+ if (text.length <= CONTEXT_WINDOW)
709
+ return text;
710
+ let start = Math.max(0, Math.min(column - CONTEXT_WINDOW / 2, text.length - CONTEXT_WINDOW));
711
+ let end = start + CONTEXT_WINDOW;
712
+ // Never split a surrogate pair.
713
+ const high = (c) => c >= 0xd800 && c <= 0xdbff;
714
+ if (start > 0 && high(text.charCodeAt(start - 1)))
715
+ start++;
716
+ if (end < text.length && high(text.charCodeAt(end - 1)))
717
+ end--;
718
+ return text.slice(start, end);
719
+ }
720
+ // ---- CI-010 vs CI-011 -------------------------------------------------------
721
+ // CI-011 (PowerShell download-and-execute) is a more specific form of CI-010.
722
+ // A CI-010 match inside a CI-011 span is dropped so the installer one-liner is
723
+ // reported once, under the id that gets the documentation-context downgrade.
724
+ /** Longest rescanned-payload match kept on a finding. */
725
+ const MAX_PAYLOAD_MATCH = 200;
726
+ const INSTALLER_SOURCE = SCAN_PATTERNS.find((p) => p.id === "CI-011").pattern;
727
+ function installerSpans(text) {
728
+ const spans = [];
729
+ const re = new RegExp(INSTALLER_SOURCE.source, INSTALLER_SOURCE.flags);
730
+ let m;
731
+ while ((m = re.exec(text)) !== null) {
732
+ if (m[0].length === 0) {
733
+ re.lastIndex++;
734
+ continue;
735
+ }
736
+ spans.push([m.index, m.index + m[0].length]);
737
+ }
738
+ return spans;
739
+ }
740
+ /** True if [start, end) lies inside one of the sorted, non-overlapping spans. */
741
+ function insideSpan(spans, start, end) {
742
+ let lo = 0;
743
+ let hi = spans.length - 1;
744
+ while (lo <= hi) {
745
+ const mid = (lo + hi) >> 1;
746
+ if (spans[mid][0] <= start)
747
+ lo = mid + 1;
748
+ else
749
+ hi = mid - 1;
750
+ }
751
+ return hi >= 0 && end <= spans[hi][1];
752
+ }
753
+ // ---- Finding caps -------------------------------------------------------------
754
+ // A 2MB file of one repeated match must not become hundreds of thousands of
755
+ // findings. Past the caps a finding folds into the latest one of the same
756
+ // pattern and severity; one is still listed when no such finding exists, so
757
+ // a new severity is never lost.
758
+ const MAX_FINDINGS_PER_PATTERN = 100;
759
+ const MAX_FINDINGS = 2000;
760
+ // Decoded payloads are rescanned up to these limits; the rest are reported
761
+ // as one HC-010 marker so the judge knows they were not rescanned.
762
+ const MAX_RESCAN_PAYLOADS = 50;
763
+ const MAX_RESCAN_CHARS = 64 * 1024;
764
+ const UNSCANNED_RULE = HIDDEN_CONTENT_RULES.find((r) => r.id === "HC-010");
765
+ /** Adds findings to `out` under the caps; `add` returns the finding that holds the new one. */
766
+ function cappedFindings(out) {
767
+ const listed = new Map();
768
+ const latest = new Map();
769
+ return (finding) => {
770
+ const key = `${finding.patternId}:${finding.severity}`;
771
+ const count = listed.get(finding.patternId) ?? 0;
772
+ const holder = latest.get(key);
773
+ if (holder && (count >= MAX_FINDINGS_PER_PATTERN || out.length >= MAX_FINDINGS)) {
774
+ holder.occurrences = (holder.occurrences ?? 1) + (finding.occurrences ?? 1);
775
+ holder.foldedFindings = (holder.foldedFindings ?? 0) + 1 + (finding.foldedFindings ?? 0);
776
+ return holder;
777
+ }
778
+ out.push(finding);
779
+ listed.set(finding.patternId, count + 1);
780
+ latest.set(key, finding);
781
+ return finding;
782
+ };
638
783
  }
639
784
  // ---- Scanner function -----------------------------------------------------
640
785
  /**
@@ -643,67 +788,162 @@ function isInsideInlineCode(line, matchStart, matchEnd) {
643
788
  export function scanContent(content) {
644
789
  const lines = content.split("\n");
645
790
  const fencedLines = computeFencedCodeBlockLines(lines);
646
- const commentLines = computeHtmlCommentLines(lines);
647
791
  const findings = [];
792
+ const add = cappedFindings(findings);
793
+ const installerSpanCache = new Map();
794
+ const inlineCodeCache = new Map();
795
+ const safeDciCache = new Map();
796
+ const rmSeverityCache = new Map();
648
797
  for (const pattern of SCAN_PATTERNS) {
649
798
  // Reset the regex lastIndex for each pattern (they use /g flag)
650
799
  const regex = new RegExp(pattern.pattern.source, pattern.pattern.flags);
651
800
  for (let lineIdx = 0; lineIdx < lines.length; lineIdx++) {
652
801
  const line = lines[lineIdx];
653
802
  let match;
803
+ // One finding per severity for this pattern on this line; further
804
+ // matches only raise its occurrence count.
805
+ let folded = null;
654
806
  while ((match = regex.exec(line)) !== null) {
655
- // Suppress DCI-abuse findings for known-safe DCI patterns
656
- if (pattern.category === "dci-abuse" && isSafeDciBlock(line)) {
807
+ // Guard against zero-length matches looping forever on a /g regex.
808
+ if (match[0].length === 0) {
809
+ regex.lastIndex++;
657
810
  continue;
658
811
  }
659
- // Suppress all findings inside HTML comments
660
- if (commentLines.has(lineIdx)) {
661
- continue;
812
+ // Suppress DCI-abuse findings for known-safe DCI patterns
813
+ if (pattern.category === "dci-abuse") {
814
+ let safe = safeDciCache.get(lineIdx);
815
+ if (safe === undefined)
816
+ safeDciCache.set(lineIdx, (safe = isSafeDciBlock(line)));
817
+ if (safe)
818
+ continue;
662
819
  }
663
- // Suppress FS-003 path traversal inside markdown links
664
- if (pattern.id === "FS-003" && isInsideMarkdownLink(line, match.index)) {
665
- continue;
820
+ // HTML comments are NOT suppressed: they render invisibly to a human
821
+ // reviewer but an agent reads them verbatim, so they are where hidden
822
+ // instructions go. hidden-content.ts adds HC-005 for directive bodies.
823
+ if (pattern.id === "CI-010") {
824
+ let spans = installerSpanCache.get(lineIdx);
825
+ if (!spans)
826
+ installerSpanCache.set(lineIdx, (spans = installerSpans(line)));
827
+ if (insideSpan(spans, match.index, match.index + match[0].length))
828
+ continue;
666
829
  }
667
- // Build context: up to 1 line before and after
668
- const contextLines = [];
669
- if (lineIdx > 0)
670
- contextLines.push(lines[lineIdx - 1]);
671
- contextLines.push(line);
672
- if (lineIdx < lines.length - 1)
673
- contextLines.push(lines[lineIdx + 1]);
674
830
  // Downgrade FS-001 severity based on context:
675
831
  // safe targets → info, system paths → keep high, other → low
676
832
  // Only for `rm` commands — rimraf() keeps base severity ("high")
677
833
  let severity = pattern.severity;
678
- if (pattern.id === "FS-001" && /\brm\s+-/.test(line)) {
679
- if (isRmTargetingSafePaths(line)) {
680
- severity = "info";
681
- }
682
- else if (!isRmTargetingSystemPaths(line)) {
683
- severity = "low";
834
+ if (pattern.id === "FS-001") {
835
+ let rmSeverity = rmSeverityCache.get(lineIdx);
836
+ if (rmSeverity === undefined) {
837
+ rmSeverity = !/\brm\s+-/.test(line)
838
+ ? null
839
+ : isRmTargetingSafePaths(line)
840
+ ? "info"
841
+ : isRmTargetingSystemPaths(line)
842
+ ? null
843
+ : "low";
844
+ rmSeverityCache.set(lineIdx, rmSeverity);
684
845
  }
846
+ if (rmSeverity)
847
+ severity = rmSeverity;
685
848
  }
686
849
  // Downgrade documentation-safe patterns inside fenced code blocks
687
- if (DOCUMENTATION_SAFE_PATTERNS.has(pattern.id) && fencedLines.has(lineIdx)) {
850
+ if (DOCUMENTATION_SAFE_PATTERNS.has(pattern.id) &&
851
+ fencedLines.has(lineIdx) &&
852
+ !(pattern.id === "CI-010" && ci010KeepsSeverity(match[0], line))) {
688
853
  severity = "info";
689
854
  }
690
855
  // Downgrade non-DCI patterns inside markdown inline code to info.
691
856
  // Inline code references (e.g., `eval()`) are educational, not invocations.
692
- if (pattern.category !== "dci-abuse" && isInsideInlineCode(line, match.index, match.index + match[0].length)) {
693
- severity = "info";
857
+ if (pattern.category !== "dci-abuse") {
858
+ let spans = inlineCodeCache.get(lineIdx);
859
+ if (!spans)
860
+ inlineCodeCache.set(lineIdx, (spans = inlineCodeSpans(line)));
861
+ if (insideSpan(spans, match.index, match.index + match[0].length))
862
+ severity = "info";
694
863
  }
695
- findings.push({
864
+ const prior = folded?.get(severity);
865
+ if (prior) {
866
+ prior.occurrences = (prior.occurrences ?? 1) + 1;
867
+ continue;
868
+ }
869
+ // Build context: up to 1 line before and after, each windowed
870
+ // around the match column.
871
+ const contextLines = [];
872
+ if (lineIdx > 0)
873
+ contextLines.push(contextWindow(lines[lineIdx - 1], match.index));
874
+ contextLines.push(contextWindow(line, match.index));
875
+ if (lineIdx < lines.length - 1)
876
+ contextLines.push(contextWindow(lines[lineIdx + 1], match.index));
877
+ const finding = {
696
878
  patternId: pattern.id,
697
879
  patternName: pattern.name,
698
880
  severity,
881
+ ...(severity !== pattern.severity ? { downgradedFrom: pattern.severity } : {}),
699
882
  category: pattern.category,
700
883
  match: match[0],
701
884
  lineNumber: lineIdx + 1,
702
885
  context: contextLines.join("\n"),
703
- });
886
+ };
887
+ // Later matches on this line go to whichever finding now holds it.
888
+ (folded ??= new Map()).set(severity, add(finding));
889
+ }
890
+ }
891
+ }
892
+ // Hidden channels: invisible Unicode, comment directives, encoded text.
893
+ // Decoded payloads are rescanned at full severity: hidden text gets no
894
+ // documentation-context downgrade.
895
+ const hidden = scanHiddenContent(content);
896
+ const extra = [...hidden.findings];
897
+ let budget = MAX_RESCAN_CHARS;
898
+ for (const [index, payload] of hidden.payloads.entries()) {
899
+ if (index >= MAX_RESCAN_PAYLOADS || budget <= 0) {
900
+ const skipped = hidden.payloads.length - index;
901
+ extra.push({
902
+ patternId: UNSCANNED_RULE.id,
903
+ patternName: UNSCANNED_RULE.name,
904
+ severity: UNSCANNED_RULE.severity,
905
+ category: "prompt-injection",
906
+ match: `[vskill: ${skipped} decoded payload${skipped === 1 ? "" : "s"} not rescanned]`,
907
+ lineNumber: payload.lineNumber,
908
+ context: "",
909
+ });
910
+ break;
911
+ }
912
+ // The whole decoded text is rescanned (within the budget), not the
913
+ // display copy.
914
+ const text = payload.text.length > budget ? payload.text.slice(0, budget) : payload.text;
915
+ budget -= text.length;
916
+ const spans = installerSpans(text);
917
+ for (const pattern of SCAN_PATTERNS) {
918
+ const regex = new RegExp(pattern.pattern.source, pattern.pattern.flags);
919
+ let match;
920
+ let first = null;
921
+ while ((match = regex.exec(text)) !== null) {
922
+ if (match[0].length === 0) {
923
+ regex.lastIndex++;
924
+ continue;
925
+ }
926
+ if (pattern.id === "CI-010" && insideSpan(spans, match.index, match.index + match[0].length))
927
+ continue;
928
+ if (first) {
929
+ first.occurrences = (first.occurrences ?? 1) + 1;
930
+ continue;
931
+ }
932
+ first = {
933
+ patternId: pattern.id,
934
+ patternName: pattern.name,
935
+ severity: pattern.severity,
936
+ category: pattern.category,
937
+ match: `[hidden ${payload.kind}] ${match[0].slice(0, MAX_PAYLOAD_MATCH)}`,
938
+ lineNumber: payload.lineNumber,
939
+ context: contextWindow(text, match.index),
940
+ };
941
+ extra.push(first);
704
942
  }
705
943
  }
706
944
  }
945
+ for (const finding of extra)
946
+ add(finding);
707
947
  return findings;
708
948
  }
709
949
  //# sourceMappingURL=patterns.js.map