@skillsmith/core 0.11.7 → 0.12.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 (184) hide show
  1. package/CHANGELOG.md +200 -0
  2. package/README.md +2 -2
  3. package/dist/.tsbuildinfo +1 -1
  4. package/dist/src/api/client.types.d.ts +9 -0
  5. package/dist/src/api/client.types.d.ts.map +1 -1
  6. package/dist/src/api/schemas.d.ts +6 -0
  7. package/dist/src/api/schemas.d.ts.map +1 -1
  8. package/dist/src/api/schemas.js +5 -0
  9. package/dist/src/api/schemas.js.map +1 -1
  10. package/dist/src/api/security-summary.d.ts +10 -1
  11. package/dist/src/api/security-summary.d.ts.map +1 -1
  12. package/dist/src/api/security-summary.js +9 -0
  13. package/dist/src/api/security-summary.js.map +1 -1
  14. package/dist/src/api/security-summary.test.js +79 -1
  15. package/dist/src/api/security-summary.test.js.map +1 -1
  16. package/dist/src/api/types.d.ts +9 -0
  17. package/dist/src/api/types.d.ts.map +1 -1
  18. package/dist/src/exports/services.d.ts +1 -1
  19. package/dist/src/exports/services.d.ts.map +1 -1
  20. package/dist/src/exports/services.js +3 -1
  21. package/dist/src/exports/services.js.map +1 -1
  22. package/dist/src/index.d.ts +2 -2
  23. package/dist/src/index.d.ts.map +1 -1
  24. package/dist/src/index.js +2 -2
  25. package/dist/src/index.js.map +1 -1
  26. package/dist/src/install/agent-config-merge.json-array.d.ts +8 -0
  27. package/dist/src/install/agent-config-merge.json-array.d.ts.map +1 -1
  28. package/dist/src/install/agent-config-merge.json-array.js +22 -2
  29. package/dist/src/install/agent-config-merge.json-array.js.map +1 -1
  30. package/dist/src/install/agent-pack-installer.cursor-hooks.d.ts.map +1 -1
  31. package/dist/src/install/agent-pack-installer.cursor-hooks.js +122 -3
  32. package/dist/src/install/agent-pack-installer.cursor-hooks.js.map +1 -1
  33. package/dist/src/install/agent-pack-installer.cursor-hooks.test.js +146 -1
  34. package/dist/src/install/agent-pack-installer.cursor-hooks.test.js.map +1 -1
  35. package/dist/src/install/agent-pack-installer.harness.d.ts +8 -0
  36. package/dist/src/install/agent-pack-installer.harness.d.ts.map +1 -1
  37. package/dist/src/install/agent-pack-installer.harness.js +8 -1
  38. package/dist/src/install/agent-pack-installer.harness.js.map +1 -1
  39. package/dist/src/scripts/__tests__/scan-imported-skills.test.js +25 -0
  40. package/dist/src/scripts/__tests__/scan-imported-skills.test.js.map +1 -1
  41. package/dist/src/scripts/skill-scanner/trust-scorer.js +1 -1
  42. package/dist/src/scripts/skill-scanner/trust-scorer.js.map +1 -1
  43. package/dist/src/security/scanner/SecurityScanner.archive.d.ts +34 -0
  44. package/dist/src/security/scanner/SecurityScanner.archive.d.ts.map +1 -0
  45. package/dist/src/security/scanner/SecurityScanner.archive.js +218 -0
  46. package/dist/src/security/scanner/SecurityScanner.archive.js.map +1 -0
  47. package/dist/src/security/scanner/SecurityScanner.compound.d.ts +1 -0
  48. package/dist/src/security/scanner/SecurityScanner.compound.d.ts.map +1 -1
  49. package/dist/src/security/scanner/SecurityScanner.compound.js +117 -38
  50. package/dist/src/security/scanner/SecurityScanner.compound.js.map +1 -1
  51. package/dist/src/security/scanner/SecurityScanner.d.ts +41 -4
  52. package/dist/src/security/scanner/SecurityScanner.d.ts.map +1 -1
  53. package/dist/src/security/scanner/SecurityScanner.decoy.d.ts +72 -0
  54. package/dist/src/security/scanner/SecurityScanner.decoy.d.ts.map +1 -0
  55. package/dist/src/security/scanner/SecurityScanner.decoy.js +265 -0
  56. package/dist/src/security/scanner/SecurityScanner.decoy.js.map +1 -0
  57. package/dist/src/security/scanner/SecurityScanner.encoding.d.ts +56 -0
  58. package/dist/src/security/scanner/SecurityScanner.encoding.d.ts.map +1 -0
  59. package/dist/src/security/scanner/SecurityScanner.encoding.js +270 -0
  60. package/dist/src/security/scanner/SecurityScanner.encoding.js.map +1 -0
  61. package/dist/src/security/scanner/SecurityScanner.exec.d.ts +23 -20
  62. package/dist/src/security/scanner/SecurityScanner.exec.d.ts.map +1 -1
  63. package/dist/src/security/scanner/SecurityScanner.exec.js +207 -52
  64. package/dist/src/security/scanner/SecurityScanner.exec.js.map +1 -1
  65. package/dist/src/security/scanner/SecurityScanner.fetch-correlation.d.ts +115 -0
  66. package/dist/src/security/scanner/SecurityScanner.fetch-correlation.d.ts.map +1 -0
  67. package/dist/src/security/scanner/SecurityScanner.fetch-correlation.js +181 -0
  68. package/dist/src/security/scanner/SecurityScanner.fetch-correlation.js.map +1 -0
  69. package/dist/src/security/scanner/SecurityScanner.helpers.d.ts +14 -11
  70. package/dist/src/security/scanner/SecurityScanner.helpers.d.ts.map +1 -1
  71. package/dist/src/security/scanner/SecurityScanner.helpers.js +26 -124
  72. package/dist/src/security/scanner/SecurityScanner.helpers.js.map +1 -1
  73. package/dist/src/security/scanner/SecurityScanner.js +114 -36
  74. package/dist/src/security/scanner/SecurityScanner.js.map +1 -1
  75. package/dist/src/security/scanner/SecurityScanner.paste-host.d.ts +69 -0
  76. package/dist/src/security/scanner/SecurityScanner.paste-host.d.ts.map +1 -0
  77. package/dist/src/security/scanner/SecurityScanner.paste-host.js +342 -0
  78. package/dist/src/security/scanner/SecurityScanner.paste-host.js.map +1 -0
  79. package/dist/src/security/scanner/SecurityScanner.risk-score.d.ts +27 -0
  80. package/dist/src/security/scanner/SecurityScanner.risk-score.d.ts.map +1 -0
  81. package/dist/src/security/scanner/SecurityScanner.risk-score.js +178 -0
  82. package/dist/src/security/scanner/SecurityScanner.risk-score.js.map +1 -0
  83. package/dist/src/security/scanner/SecurityScanner.urls.d.ts +20 -0
  84. package/dist/src/security/scanner/SecurityScanner.urls.d.ts.map +1 -0
  85. package/dist/src/security/scanner/SecurityScanner.urls.js +26 -0
  86. package/dist/src/security/scanner/SecurityScanner.urls.js.map +1 -0
  87. package/dist/src/security/scanner/index.d.ts +2 -2
  88. package/dist/src/security/scanner/index.d.ts.map +1 -1
  89. package/dist/src/security/scanner/index.js +5 -1
  90. package/dist/src/security/scanner/index.js.map +1 -1
  91. package/dist/src/security/scanner/multiline-category-closure.test.js +6 -0
  92. package/dist/src/security/scanner/multiline-category-closure.test.js.map +1 -1
  93. package/dist/src/security/scanner/patterns.d.ts +41 -21
  94. package/dist/src/security/scanner/patterns.d.ts.map +1 -1
  95. package/dist/src/security/scanner/patterns.exec.d.ts +89 -0
  96. package/dist/src/security/scanner/patterns.exec.d.ts.map +1 -0
  97. package/dist/src/security/scanner/patterns.exec.js +115 -0
  98. package/dist/src/security/scanner/patterns.exec.js.map +1 -0
  99. package/dist/src/security/scanner/patterns.js +78 -41
  100. package/dist/src/security/scanner/patterns.js.map +1 -1
  101. package/dist/src/security/scanner/types.d.ts +15 -1
  102. package/dist/src/security/scanner/types.d.ts.map +1 -1
  103. package/dist/src/security/scanner/typosquat.d.ts +10 -0
  104. package/dist/src/security/scanner/typosquat.d.ts.map +1 -1
  105. package/dist/src/security/scanner/typosquat.js +5 -1
  106. package/dist/src/security/scanner/typosquat.js.map +1 -1
  107. package/dist/src/security/scanner/weights.d.ts +12 -3
  108. package/dist/src/security/scanner/weights.d.ts.map +1 -1
  109. package/dist/src/security/scanner/weights.js +50 -1
  110. package/dist/src/security/scanner/weights.js.map +1 -1
  111. package/dist/src/services/agent-pack/prompt-source.js +1 -1
  112. package/dist/src/services/agent-pack/prompt-source.js.map +1 -1
  113. package/dist/src/services/bundled-sibling-scan.d.ts +87 -13
  114. package/dist/src/services/bundled-sibling-scan.d.ts.map +1 -1
  115. package/dist/src/services/bundled-sibling-scan.js +177 -33
  116. package/dist/src/services/bundled-sibling-scan.js.map +1 -1
  117. package/dist/src/services/recommend-guard.d.ts.map +1 -1
  118. package/dist/src/services/recommend-guard.js +8 -1
  119. package/dist/src/services/recommend-guard.js.map +1 -1
  120. package/dist/src/services/recommend-guard.test.js +6 -2
  121. package/dist/src/services/recommend-guard.test.js.map +1 -1
  122. package/dist/src/sync/index.d.ts +1 -0
  123. package/dist/src/sync/index.d.ts.map +1 -1
  124. package/dist/src/sync/index.js +2 -0
  125. package/dist/src/sync/index.js.map +1 -1
  126. package/dist/src/sync/license-status-client.d.ts +56 -0
  127. package/dist/src/sync/license-status-client.d.ts.map +1 -0
  128. package/dist/src/sync/license-status-client.js +118 -0
  129. package/dist/src/sync/license-status-client.js.map +1 -0
  130. package/dist/src/sync/license-status-client.test.d.ts +14 -0
  131. package/dist/src/sync/license-status-client.test.d.ts.map +1 -0
  132. package/dist/src/sync/license-status-client.test.js +136 -0
  133. package/dist/src/sync/license-status-client.test.js.map +1 -0
  134. package/dist/src/types.d.ts +13 -0
  135. package/dist/src/types.d.ts.map +1 -1
  136. package/dist/src/utils/version-check.d.ts +58 -5
  137. package/dist/src/utils/version-check.d.ts.map +1 -1
  138. package/dist/src/utils/version-check.js +73 -2
  139. package/dist/src/utils/version-check.js.map +1 -1
  140. package/dist/src/utils/version-check.test.js +49 -10
  141. package/dist/src/utils/version-check.test.js.map +1 -1
  142. package/dist/tests/SecurityScanner.scoring.test.js +123 -0
  143. package/dist/tests/SecurityScanner.scoring.test.js.map +1 -1
  144. package/dist/tests/security/advisory-severity-guard.test.d.ts +20 -0
  145. package/dist/tests/security/advisory-severity-guard.test.d.ts.map +1 -0
  146. package/dist/tests/security/advisory-severity-guard.test.js +70 -0
  147. package/dist/tests/security/advisory-severity-guard.test.js.map +1 -0
  148. package/dist/tests/security/archive-evasion.test.d.ts +2 -0
  149. package/dist/tests/security/archive-evasion.test.d.ts.map +1 -0
  150. package/dist/tests/security/archive-evasion.test.js +126 -0
  151. package/dist/tests/security/archive-evasion.test.js.map +1 -0
  152. package/dist/tests/security/clawhavoc-e2e.fixtures.d.ts +71 -0
  153. package/dist/tests/security/clawhavoc-e2e.fixtures.d.ts.map +1 -0
  154. package/dist/tests/security/clawhavoc-e2e.fixtures.js +135 -0
  155. package/dist/tests/security/clawhavoc-e2e.fixtures.js.map +1 -0
  156. package/dist/tests/security/co-signal-escalation.test.d.ts +30 -0
  157. package/dist/tests/security/co-signal-escalation.test.d.ts.map +1 -0
  158. package/dist/tests/security/co-signal-escalation.test.js +494 -0
  159. package/dist/tests/security/co-signal-escalation.test.js.map +1 -0
  160. package/dist/tests/security/decoy-misdirection.test.d.ts +2 -0
  161. package/dist/tests/security/decoy-misdirection.test.d.ts.map +1 -0
  162. package/dist/tests/security/decoy-misdirection.test.js +130 -0
  163. package/dist/tests/security/decoy-misdirection.test.js.map +1 -0
  164. package/dist/tests/security/encoded-payload.test.d.ts +2 -0
  165. package/dist/tests/security/encoded-payload.test.d.ts.map +1 -0
  166. package/dist/tests/security/encoded-payload.test.js +199 -0
  167. package/dist/tests/security/encoded-payload.test.js.map +1 -0
  168. package/dist/tests/security/gatekeeper-bypass.test.d.ts +2 -0
  169. package/dist/tests/security/gatekeeper-bypass.test.d.ts.map +1 -0
  170. package/dist/tests/security/gatekeeper-bypass.test.js +167 -0
  171. package/dist/tests/security/gatekeeper-bypass.test.js.map +1 -0
  172. package/dist/tests/security/paste-host-fetch.test.d.ts +2 -0
  173. package/dist/tests/security/paste-host-fetch.test.d.ts.map +1 -0
  174. package/dist/tests/security/paste-host-fetch.test.js +199 -0
  175. package/dist/tests/security/paste-host-fetch.test.js.map +1 -0
  176. package/dist/tests/security/scanner-regression-guard.test.js +5 -0
  177. package/dist/tests/security/scanner-regression-guard.test.js.map +1 -1
  178. package/dist/tests/services/aidefence-feedback.test.js +5 -0
  179. package/dist/tests/services/aidefence-feedback.test.js.map +1 -1
  180. package/dist/tests/services/bundled-sibling-scan.test.js +171 -10
  181. package/dist/tests/services/bundled-sibling-scan.test.js.map +1 -1
  182. package/dist/tests/skill-scanner/allowlist.test.js +5 -0
  183. package/dist/tests/skill-scanner/allowlist.test.js.map +1 -1
  184. package/package.json +1 -1
@@ -0,0 +1,270 @@
1
+ /**
2
+ * Security Scanner — encoded (base64) payload detect-decode-recursively-rescan
3
+ * @module @skillsmith/core/security/scanner/SecurityScanner.encoding
4
+ *
5
+ * SMI-6033 Wave 2 (Gap 2): before this file, `base64 -d | sh` (the DECODE
6
+ * INVOCATION syntax) was already caught by CODE_EXECUTION_PATTERNS, but an
7
+ * inert base64 blob the agent is merely instructed to decode and run itself
8
+ * scored zero.
9
+ *
10
+ * Rather than a parallel "this looks suspicious" heuristic (high FP risk —
11
+ * base64 blobs are extremely common in legitimate skills: image data URIs,
12
+ * git hashes, JWTs), this detector DECODES the candidate and recursively runs
13
+ * the caller's full detector suite against the decoded text, reusing the
14
+ * whole pattern arsenal instead of duplicating it. The escalation this gap
15
+ * achieves is free: a decoded `curl|bash` natively trips `code_execution` at
16
+ * ITS OWN severity, exactly as if the attacker had shipped it undecoded.
17
+ *
18
+ * Depth-1-only, STRUCTURALLY (not by convention). This module never scans
19
+ * itself — `rescan`, the callback the caller supplies, is expected to be the
20
+ * caller's OWN "run every detector" entry point with the encoded-payload
21
+ * detector specifically disabled for that inner call (see
22
+ * `SecurityScanner.ts`'s `runDetectors(content, lineContexts,
23
+ * skipEncodedPayload)`, where the recursive callback always passes `true`).
24
+ * The inner call therefore cannot reach this detector again no matter what
25
+ * the decoded content contains — a base64 blob discovered INSIDE decoded
26
+ * content is never itself decoded.
27
+ *
28
+ * Candidate detection: a contiguous base64-alphabet run,
29
+ * `[A-Za-z0-9+/]{120,}={0,2}`. Deliberately EXCLUDES `-`/`_` (base64url), so
30
+ * a JWT never becomes a candidate at all — that is the intentional mechanism
31
+ * keeping JWTs out, not a separate check. Bounded/ReDoS-safe: a single
32
+ * character-class quantifier has no catastrophic-backtracking surface, so
33
+ * this intentionally does NOT route through `safeRegexTest`'s 10,000-char
34
+ * truncation — a legitimate base64 blob routinely runs past that on one long
35
+ * line, and truncating it would silently defeat detection.
36
+ *
37
+ * The wrapper finding (`encoded_payload`) is advisory-tier ONLY (weight 1.2 /
38
+ * coefficient 0.04 — the sensitive_path/typosquat tier, NOT the 2.0/0.40 tier
39
+ * the other three Wave 2 detectors use, see weights.ts): this detector is
40
+ * pure observability/provenance-marking, not itself a strong signal. Every
41
+ * finding folded in from the decoded content carries a NEW `decodedFrom`
42
+ * field (types.ts) set to the OUTER document line the blob was found on —
43
+ * the same provenance-marker role `filePath` already plays for a
44
+ * sibling-file finding, just for a decoded-blob origin instead of a
45
+ * different file.
46
+ */
47
+ import { analyzeMarkdownContext, isDocumentationContext, isWithinInlineCode, } from './SecurityScanner.helpers.js';
48
+ /**
49
+ * Per-candidate size cap. Mirrors `MAX_SIBLING_CONTENT_BYTES`'s rationale
50
+ * (`scripts/indexer/skill-processor.security.ts`, 256_000) — bound the work a
51
+ * single decode-and-recursively-rescan pass can do so an attacker can't
52
+ * weaponize an oversized base64 blob into a decode/rescan cost blowup. Same
53
+ * order of magnitude; not required to match exactly.
54
+ *
55
+ * SMI-6033 Wave 3 (adversarial-review fix): exceeding this cap used to
56
+ * `continue` with ZERO trace — no decode, no finding, nothing — which handed
57
+ * an attacker a one-line bypass of the entire detector: pad the malicious
58
+ * blob past 200 KB with base64-valid filler and it becomes invisible. The cap
59
+ * itself is a deliberate, correct resource bound and is UNCHANGED (an
60
+ * oversized candidate is still never decoded or rescanned — decoding it is
61
+ * exactly what the cap exists to prevent); what changed is that its
62
+ * existence is now SURFACED as a low/low advisory finding instead of being
63
+ * silently dropped. Same "caps surfaced, not silent" principle as Gap 8's
64
+ * `scan_coverage_incomplete` flag.
65
+ */
66
+ const MAX_ENCODED_CANDIDATE_BYTES = 200_000;
67
+ /**
68
+ * Ceiling on how many oversized-candidate advisories one document can emit
69
+ * (SMI-6033 Wave 3). Deliberately a SEPARATE counter from
70
+ * `MAX_BASE64_CANDIDATES` below, not a share of that budget: an oversized
71
+ * candidate costs no decode work, so charging it to the decode budget would
72
+ * let an attacker spend the real budget on cheap oversized filler and
73
+ * suppress decoding of the genuine candidates that follow. Beyond this
74
+ * ceiling further oversized candidates are dropped, but — unlike before the
75
+ * fix — the pattern is no longer invisible: the first `MAX_OVERSIZED_ADVISORIES`
76
+ * already surface it. Reset fresh on every `scanEncodedPayload` call.
77
+ */
78
+ const MAX_OVERSIZED_ADVISORIES = 8;
79
+ /**
80
+ * Document-wide candidate-COUNT cap (SMI-6033 Gap 2 resource-bound
81
+ * follow-up). Without a ceiling here, an attacker could pad a skill with
82
+ * dozens/hundreds of individually-qualifying base64 blobs to force many
83
+ * expensive decode-and-recursively-rescan passes — a cost/availability
84
+ * concern, not a false-positive concern (each candidate under
85
+ * `MAX_ENCODED_CANDIDATE_BYTES` is still legitimate-shaped on its own).
86
+ * Checked in REGEX-MATCH order, before the decode attempt itself, so the
87
+ * bound applies even to attempts that end up failing (invalid base64,
88
+ * binary noise) — not just to successful decode-and-rescan passes. Once 8
89
+ * candidates in one document have been attempted, every further candidate is
90
+ * silently skipped: no decode attempt, no finding — the same "not processed
91
+ * is silent" convention `tryDecodeBase64ToPlausibleText`'s own failure path
92
+ * already follows. Reset fresh on every `scanEncodedPayload` call; never
93
+ * persisted across calls or across a scan session.
94
+ */
95
+ const MAX_BASE64_CANDIDATES = 8;
96
+ /**
97
+ * Aggregate DECODED-byte cap across ALL candidates in one document (SMI-6033
98
+ * Gap 2 resource-bound follow-up). Mirrors `MAX_SIBLING_CONTENT_BYTES`'s
99
+ * 256_000 convention (`scripts/indexer/skill-processor.security.ts`) — this
100
+ * detector's document-wide analogue of that per-fetch byte budget. IN
101
+ * ADDITION TO the per-candidate `MAX_ENCODED_CANDIDATE_BYTES` cap above: that
102
+ * one bounds a single blob's ENCODED size; this one bounds the running total
103
+ * of DECODED bytes across every candidate successfully decoded so far in the
104
+ * same document scan, so a run of many just-under-the-per-candidate-cap
105
+ * blobs can't sum to an unbounded decode/rescan cost. Once exceeded, every
106
+ * remaining candidate in the document is skipped — including the one whose
107
+ * own decode pushed the running total over the limit — same silent-skip
108
+ * convention as `MAX_BASE64_CANDIDATES` above. Reset fresh on every
109
+ * `scanEncodedPayload` call; never persisted across calls or across a scan
110
+ * session.
111
+ */
112
+ const MAX_DECODED_TOTAL_BYTES = 256_000;
113
+ /** Contiguous base64-alphabet run, >=120 chars, optional 0-2 padding chars. */
114
+ const BASE64_CANDIDATE = /[A-Za-z0-9+/]{120,}={0,2}/g;
115
+ /**
116
+ * How far back (chars) to look, from a candidate's start, for a
117
+ * `data:image/`, `data:font/`, or `data:audio/` prefix. A real data URI's
118
+ * MIME type + `;base64,` separator is well within this window
119
+ * (`data:image/svg+xml;base64,` is 28 chars).
120
+ */
121
+ const DATA_URI_LOOKBACK = 60;
122
+ const DATA_URI_PREFIX = /data:(?:image|font|audio)\//i;
123
+ /** Non-printable / control characters (excluding \t\r\n) — the printable-ratio gate. */
124
+ // eslint-disable-next-line no-control-regex -- Intentional: detecting binary/control-byte noise in decoded content.
125
+ const CONTROL_CHAR = /[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/;
126
+ /** Minimum fraction of non-control characters required to treat decoded bytes as plausible text. */
127
+ const MIN_PRINTABLE_RATIO = 0.9;
128
+ /**
129
+ * Attempt exactly one base64 decode of `candidate`. Returns the decoded text
130
+ * when it is valid UTF-8 with a plausible-text printable-character ratio;
131
+ * `null` on ANY failure (invalid base64 syntax, decoded bytes that are not
132
+ * valid UTF-8, or valid UTF-8 that is mostly control/binary noise) — decode
133
+ * failure is deliberately not itself a finding.
134
+ */
135
+ function tryDecodeBase64ToPlausibleText(candidate) {
136
+ let binary;
137
+ try {
138
+ // atob (Web/Deno/Node-global — no Buffer dependency, keeps this logic
139
+ // parallel to the edge/Deno twin) throws on invalid base64 syntax.
140
+ binary = atob(candidate);
141
+ }
142
+ catch {
143
+ return null;
144
+ }
145
+ if (binary.length === 0)
146
+ return null;
147
+ // Round-trip check: atob is lenient about some malformed/non-canonical
148
+ // input (e.g. missing padding) — re-encoding and comparing (modulo
149
+ // padding) rejects a decode atob accepted but that doesn't reproduce the
150
+ // original candidate, rather than silently scanning a garbled subset.
151
+ if (btoa(binary).replace(/=+$/, '') !== candidate.replace(/=+$/, ''))
152
+ return null;
153
+ const bytes = new Uint8Array(binary.length);
154
+ for (let i = 0; i < binary.length; i++)
155
+ bytes[i] = binary.charCodeAt(i);
156
+ let text;
157
+ try {
158
+ text = new TextDecoder('utf-8', { fatal: true }).decode(bytes);
159
+ }
160
+ catch {
161
+ return null; // not valid UTF-8 -> binary blob, not a text payload
162
+ }
163
+ if (text.length === 0)
164
+ return null;
165
+ let controlCount = 0;
166
+ for (const ch of text) {
167
+ if (CONTROL_CHAR.test(ch))
168
+ controlCount++;
169
+ }
170
+ const printableRatio = (text.length - controlCount) / text.length;
171
+ if (printableRatio < MIN_PRINTABLE_RATIO)
172
+ return null;
173
+ return text;
174
+ }
175
+ export function scanEncodedPayload(content, lineContexts, rescan) {
176
+ const findings = [];
177
+ const lines = content.split('\n');
178
+ const contexts = lineContexts ?? analyzeMarkdownContext(content);
179
+ // Per-document resource bounds (see MAX_BASE64_CANDIDATES /
180
+ // MAX_DECODED_TOTAL_BYTES doc comments above) — fresh per call, never
181
+ // shared across calls or across a scan session.
182
+ let candidatesProcessed = 0;
183
+ let decodedTotalBytes = 0;
184
+ let aggregateBudgetExhausted = false;
185
+ let oversizedAdvisories = 0;
186
+ lines.forEach((line, index) => {
187
+ const lineNumber = index + 1;
188
+ for (const match of line.matchAll(BASE64_CANDIDATE)) {
189
+ const candidate = match[0];
190
+ const start = match.index ?? 0;
191
+ // Data-URI exclusion runs FIRST (SMI-6033 Wave 3 — it used to run after
192
+ // the size check). An embedded image/font/audio data URI routinely runs
193
+ // past MAX_ENCODED_CANDIDATE_BYTES, and it is a known-benign shape, so
194
+ // it must be excluded BEFORE the oversized advisory below can fire on
195
+ // it. Behaviorally identical for every candidate under the cap.
196
+ const before = line.slice(Math.max(0, start - DATA_URI_LOOKBACK), start);
197
+ if (DATA_URI_PREFIX.test(before))
198
+ continue;
199
+ // Oversized candidate: never decoded/rescanned (that is precisely what
200
+ // the cap buys), but no longer silently dropped either — see
201
+ // MAX_ENCODED_CANDIDATE_BYTES's doc comment for the evasion this
202
+ // closes. Emitted at low/low: it is a coverage caveat, not evidence.
203
+ if (candidate.length > MAX_ENCODED_CANDIDATE_BYTES) {
204
+ if (oversizedAdvisories >= MAX_OVERSIZED_ADVISORIES)
205
+ continue;
206
+ oversizedAdvisories++;
207
+ const oversizeCtx = contexts[index];
208
+ const oversizeInInlineCode = oversizeCtx?.isInlineCode && isWithinInlineCode(line, start);
209
+ const oversizeInDocContext = oversizeCtx
210
+ ? isDocumentationContext(oversizeCtx) || oversizeInInlineCode
211
+ : false;
212
+ findings.push({
213
+ type: 'encoded_payload',
214
+ severity: 'low',
215
+ message: `Base64-encoded payload exceeds the ${MAX_ENCODED_CANDIDATE_BYTES}-byte per-candidate cap — oversized candidate, not decoded/rescanned (${candidate.length} chars)`,
216
+ location: line.trim().slice(0, 100),
217
+ lineNumber,
218
+ category: 'encoded_payload',
219
+ inDocumentationContext: oversizeInDocContext,
220
+ confidence: 'low',
221
+ });
222
+ continue;
223
+ }
224
+ // Document-wide candidate-count cap — checked before the decode
225
+ // attempt itself so the bound applies even to attempts that end up
226
+ // failing, not just successful ones. Once the aggregate decoded-byte
227
+ // budget below is exhausted, every remaining candidate is skipped too.
228
+ if (candidatesProcessed >= MAX_BASE64_CANDIDATES || aggregateBudgetExhausted)
229
+ continue;
230
+ candidatesProcessed++;
231
+ const decoded = tryDecodeBase64ToPlausibleText(candidate);
232
+ if (decoded === null)
233
+ continue; // decode failure / binary noise -> not itself a finding
234
+ // Aggregate decoded-byte cap: if THIS candidate would push the
235
+ // document-wide running total over MAX_DECODED_TOTAL_BYTES, skip it
236
+ // (and, via aggregateBudgetExhausted, every candidate after it) rather
237
+ // than folding in its finding/rescan results.
238
+ const decodedByteLength = new TextEncoder().encode(decoded).length;
239
+ if (decodedTotalBytes + decodedByteLength > MAX_DECODED_TOTAL_BYTES) {
240
+ aggregateBudgetExhausted = true;
241
+ continue;
242
+ }
243
+ decodedTotalBytes += decodedByteLength;
244
+ const ctx = contexts[index];
245
+ const inInlineCode = ctx?.isInlineCode && isWithinInlineCode(line, start);
246
+ const inDocContext = ctx ? isDocumentationContext(ctx) || inInlineCode : false;
247
+ findings.push({
248
+ type: 'encoded_payload',
249
+ // Advisory-tier only (weights.ts: 1.2 / 0.04, the sensitive_path/
250
+ // typosquat tier) — see module header for why this is deliberately
251
+ // NOT the 2.0/0.40 tier the other three Wave 2 detectors use.
252
+ severity: inDocContext ? 'low' : 'medium',
253
+ message: `Base64-encoded payload decoded and rescanned (${candidate.length} chars)`,
254
+ location: line.trim().slice(0, 100),
255
+ lineNumber,
256
+ category: 'encoded_payload',
257
+ inDocumentationContext: inDocContext,
258
+ confidence: inDocContext ? 'low' : 'high',
259
+ });
260
+ // Fold in the decoded content's OWN findings, each tagged with
261
+ // decodedFrom so it stays traceable back to the outer blob that
262
+ // produced it (see module header — the filePath-provenance analogue).
263
+ for (const inner of rescan(decoded)) {
264
+ findings.push({ ...inner, decodedFrom: lineNumber });
265
+ }
266
+ }
267
+ });
268
+ return findings;
269
+ }
270
+ //# sourceMappingURL=SecurityScanner.encoding.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"SecurityScanner.encoding.js","sourceRoot":"","sources":["../../../../src/security/scanner/SecurityScanner.encoding.ts"],"names":[],"mappings":"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"}
@@ -7,10 +7,16 @@
7
7
  * Unicode-concealment attacks the prod edge gate currently scores at ~1 point:
8
8
  *
9
9
  * • code_execution — a skill instructing a remote fetch piped into an
10
- * interpreter (curl|bash and friends). Emits ONE medium finding (score 12,
11
- * sub-threshold) on its own; escalated to critical (score 40, quarantines)
12
- * only when it co-occurs with a NON-documentation exfiltration / privilege /
13
- * credential-path / obfuscation signal. The non-doc gate keeps legitimate
10
+ * interpreter (curl|bash and friends), or SMI-6033 Wave 4, Gap 1 — the
11
+ * same instruction written as free-text prose with no shell syntax at all
12
+ * ("download the installer from thisurl.com and run it",
13
+ * `IMPERATIVE_FETCH_EXEC_PROSE` in patterns.exec.ts). Emits ONE medium
14
+ * finding (score 12, sub-threshold) on its own, whichever pattern set
15
+ * fired; escalated to critical (score 40, quarantines) only when it
16
+ * co-occurs with a NON-documentation exfiltration / privilege /
17
+ * credential-path / obfuscation signal, OR — SMI-6033 Wave 4, Gap 6 — with
18
+ * TWO DISTINCT high-confidence advisory-tier signals
19
+ * (`CO_SIGNAL_MIN_SEVERITY` below). The non-doc gate keeps legitimate
14
20
  * security-research skills (which document these techniques inside fenced
15
21
  * examples) below the threshold.
16
22
  *
@@ -40,9 +46,20 @@ import type { LineContext } from './SecurityScanner.helpers.js';
40
46
  */
41
47
  export declare function stripInvisible(s: string): string;
42
48
  /**
43
- * code_execution: remote fetch piped into an interpreter. Single-emission — at
44
- * most one MEDIUM finding per skill (the first match). escalateCodeExecution()
49
+ * code_execution: remote fetch piped into an interpreter (literal shell
50
+ * syntax), OR SMI-6033 Wave 4, Gap 1 — the same instruction expressed as a
51
+ * natural-language install-and-run imperative with no shell syntax at all.
52
+ *
53
+ * Single-emission — at most one MEDIUM finding per skill (the first match, in
54
+ * line order), whichever pattern set produced it. escalateCodeExecution()
45
55
  * promotes it to CRITICAL on co-occurrence.
56
+ *
57
+ * Ordering is deliberately line-major (each line is tested against the literal
58
+ * syntax set first, then the prose set, before moving to the next line), so a
59
+ * document containing only literal-syntax matches produces byte-identical
60
+ * output to the pre-Gap-1 detector — same line, same message, same severity.
61
+ * The message names WHICH set fired so a reviewer can tell a prose-triggered
62
+ * finding from a syntax-triggered one at a glance.
46
63
  */
47
64
  export declare function scanCodeExecution(content: string, lineContexts?: LineContext[]): SecurityFinding[];
48
65
  /**
@@ -52,19 +69,5 @@ export declare function scanCodeExecution(content: string, lineContexts?: LineCo
52
69
  * prompt-leaking detectors.
53
70
  */
54
71
  export declare function scanObfuscatedDirective(content: string): SecurityFinding[];
55
- /**
56
- * Escalate the code_execution finding to CRITICAL when a NON-documentation
57
- * high/critical exfiltration / privilege / credential-path / obfuscation signal
58
- * is also present WITHIN `MAX_CODE_EXECUTION_CO_SIGNAL_LINE_DISTANCE` lines
59
- * (SMI-5880 — previously any distance qualified, so an unrelated finding in a
60
- * different section of a long document escalated an unrelated code_execution
61
- * finding). Mutates the finding in place. Requiring the co-signal to be
62
- * non-documentation keeps legitimate security-research skills (whose examples
63
- * live in fenced blocks) at MEDIUM.
64
- *
65
- * Runs BEFORE the sibling corroboration mechanism in `scan()` — see that
66
- * function's doc comment (SecurityScanner.evidence.ts) for why the ordering is
67
- * safe in both directions.
68
- */
69
72
  export declare function escalateCodeExecution(findings: SecurityFinding[]): void;
70
73
  //# sourceMappingURL=SecurityScanner.exec.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"SecurityScanner.exec.d.ts","sourceRoot":"","sources":["../../../../src/security/scanner/SecurityScanner.exec.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;AAEH,OAAO,KAAK,EAAE,eAAe,EAAuB,MAAM,YAAY,CAAA;AAGtE,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,8BAA8B,CAAA;AA4B/D;;;;;GAKG;AACH,wBAAgB,cAAc,CAAC,CAAC,EAAE,MAAM,GAAG,MAAM,CAEhD;AA0BD;;;;GAIG;AACH,wBAAgB,iBAAiB,CAC/B,OAAO,EAAE,MAAM,EACf,YAAY,CAAC,EAAE,WAAW,EAAE,GAC3B,eAAe,EAAE,CA6BnB;AAED;;;;;GAKG;AACH,wBAAgB,uBAAuB,CAAC,OAAO,EAAE,MAAM,GAAG,eAAe,EAAE,CAuC1E;AA8DD;;;;;;;;;;;;;GAaG;AACH,wBAAgB,qBAAqB,CAAC,QAAQ,EAAE,eAAe,EAAE,GAAG,IAAI,CAiBvE"}
1
+ {"version":3,"file":"SecurityScanner.exec.d.ts","sourceRoot":"","sources":["../../../../src/security/scanner/SecurityScanner.exec.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAqCG;AAEH,OAAO,KAAK,EAAE,eAAe,EAAyC,MAAM,YAAY,CAAA;AAMxF,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,8BAA8B,CAAA;AA4B/D;;;;;GAKG;AACH,wBAAgB,cAAc,CAAC,CAAC,EAAE,MAAM,GAAG,MAAM,CAEhD;AA0BD;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,iBAAiB,CAC/B,OAAO,EAAE,MAAM,EACf,YAAY,CAAC,EAAE,WAAW,EAAE,GAC3B,eAAe,EAAE,CA0CnB;AAED;;;;;GAKG;AACH,wBAAgB,uBAAuB,CAAC,OAAO,EAAE,MAAM,GAAG,eAAe,EAAE,CAuC1E;AAgLD,wBAAgB,qBAAqB,CAAC,QAAQ,EAAE,eAAe,EAAE,GAAG,IAAI,CAiDvE"}