penthera 1.2.0 → 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 (101) hide show
  1. package/CHANGELOG.md +269 -0
  2. package/README.md +493 -45
  3. package/docs/METHODOLOGY.md +321 -0
  4. package/docs/compliance-cra.md +141 -0
  5. package/docs/compliance-nis2.md +3 -1
  6. package/docs/evidence.md +158 -0
  7. package/docs/examples/github-actions-deps.yml +105 -0
  8. package/docs/examples/github-actions-scan.yml +1 -1
  9. package/docs/examples/pr-dependency-review.yml +56 -0
  10. package/docs/funding/due-diligence-checklist.md +79 -0
  11. package/docs/funding/nlnet-ngi-zero-draft.md +273 -0
  12. package/docs/funding/partner-one-pager.md +84 -0
  13. package/docs/funding/sponsor-tiers.md +81 -0
  14. package/docs/index.md +12 -1
  15. package/docs/local-loop.md +276 -0
  16. package/docs/plan.md +100 -0
  17. package/docs/references.bib +539 -0
  18. package/docs/references.md +77 -0
  19. package/docs/release-plan.md +228 -0
  20. package/lib/adaptive-probes.js +8 -5
  21. package/lib/agents/discover.js +384 -0
  22. package/lib/agents/hidden-content.js +387 -0
  23. package/lib/agents/index.js +286 -0
  24. package/lib/agents/lock.js +115 -0
  25. package/lib/agents/owasp.js +83 -0
  26. package/lib/agents/rules.js +376 -0
  27. package/lib/compliance/cra.js +525 -0
  28. package/lib/crawler.js +5 -15
  29. package/lib/data/retire-jsrepository.json.gz +0 -0
  30. package/lib/data/retire-jsrepository.meta.json +7 -0
  31. package/lib/deps/capabilities.js +523 -0
  32. package/lib/deps/diff.js +84 -0
  33. package/lib/deps/index.js +440 -0
  34. package/lib/deps/license.js +329 -0
  35. package/lib/deps/lockfile.js +109 -0
  36. package/lib/deps/online.js +132 -0
  37. package/lib/deps/popular-packages.json +1 -0
  38. package/lib/deps/report.js +190 -0
  39. package/lib/deps/source.js +171 -0
  40. package/lib/deps/spdx.json +1 -0
  41. package/lib/deps/typosquat.js +113 -0
  42. package/lib/fix/apply.js +95 -0
  43. package/lib/fix/diff.js +199 -0
  44. package/lib/fix/index.js +129 -0
  45. package/lib/fix/model.js +108 -0
  46. package/lib/fix/recipes/cookies.js +130 -0
  47. package/lib/fix/recipes/cors.js +116 -0
  48. package/lib/fix/recipes/headers.js +250 -0
  49. package/lib/fix/recipes/rate-limit.js +211 -0
  50. package/lib/fix/recipes/secrets.js +110 -0
  51. package/lib/fix/util.js +231 -0
  52. package/lib/fuzzer.js +14 -8
  53. package/lib/hook/index.js +312 -0
  54. package/lib/hook/policy.js +62 -0
  55. package/lib/injections.js +19 -15
  56. package/lib/install/index.js +271 -0
  57. package/lib/install/policy.js +125 -0
  58. package/lib/jurisdictions/nl.json +9 -0
  59. package/lib/memory/history.js +52 -0
  60. package/lib/memory/index.js +111 -0
  61. package/lib/memory/repo-suppress.js +64 -0
  62. package/lib/memory/store.js +325 -0
  63. package/lib/memory/views.js +198 -0
  64. package/lib/params.js +4 -12
  65. package/lib/provenance.js +2 -0
  66. package/lib/recon.js +12 -20
  67. package/lib/references.js +31 -0
  68. package/lib/references.json +374 -0
  69. package/lib/remediation/index.js +28 -0
  70. package/lib/retirejs.js +50 -22
  71. package/lib/templates.js +7 -10
  72. package/lib/whitebox/aibom.js +384 -0
  73. package/lib/whitebox/frameworks.js +1 -1
  74. package/lib/whitebox/sbom.js +45 -10
  75. package/lib/whitebox/secrets.js +11 -1
  76. package/package.json +17 -4
  77. package/skills/penthera/SKILL.md +132 -22
  78. package/src/agent/git.js +87 -0
  79. package/src/agent/loop.js +519 -0
  80. package/src/agent/tests.js +48 -0
  81. package/src/cli/agent.js +183 -0
  82. package/src/cli/deps.js +104 -0
  83. package/src/cli/evidence.js +67 -0
  84. package/src/cli/fix.js +191 -0
  85. package/src/cli/hook.js +131 -0
  86. package/src/cli/install.js +225 -0
  87. package/src/cli/memory.js +146 -0
  88. package/src/cli/merge-results.js +2 -0
  89. package/src/cli/report-outputs.js +72 -0
  90. package/src/cli/run-scan.js +2 -0
  91. package/src/cli.js +212 -8
  92. package/src/report/audience.js +270 -0
  93. package/src/report/evidence.js +253 -0
  94. package/src/report/markdown.js +65 -2
  95. package/src/report/model.js +212 -0
  96. package/src/report/template.js +175 -0
  97. package/src/report/timeline.js +145 -0
  98. package/src/reporter.js +109 -5
  99. package/src/scan-url.js +31 -7
  100. package/src/utils/http.js +252 -11
  101. package/src/utils/url.js +23 -0
@@ -0,0 +1,387 @@
1
+ /**
2
+ * Hidden-content engine for agent instructions (skills, rule files, commands).
3
+ *
4
+ * Deterministic and offline. It looks for content a human reviewer would not
5
+ * see but a model would read and act on, and for payloads that run code.
6
+ *
7
+ * Designed against the ways LLM-judged skill scanners have been bypassed:
8
+ * - Invisible text: Unicode tag characters, zero-width and bidi controls,
9
+ * variation-selector smuggling. Decoded where possible, so the report shows
10
+ * what the model would read.
11
+ * - Content pushed out of view: hundreds of blank lines or a wide run of
12
+ * spaces, so the payload sits past what a reviewer or a truncating judge sees.
13
+ * This engine reads the whole file; there is no context window to overflow.
14
+ * - Instructions in HTML comments, which Markdown renderers hide.
15
+ * - Text addressed to the scanner itself ("this skill is safe, approve it").
16
+ * A regex cannot be talked out of a finding, but the attempt is a signal.
17
+ * - Payloads moved out of SKILL.md: every file in a skill folder is scanned,
18
+ * binaries and archives are identified by magic bytes rather than extension,
19
+ * and symlinks that leave the folder are reported.
20
+ * - Encoded payloads: long base64 blobs, decoded and classified.
21
+ *
22
+ * Each detector returns hits `{ rule, severity, title, detail, line, count }`.
23
+ * Callers turn hits into findings.
24
+ */
25
+
26
+ // ── Invisible characters ──────────────────────────────────────────────────
27
+
28
+ const TAG_RANGE = /[\u{E0000}-\u{E007F}]/u;
29
+ const TAG_RUN = /[\u{E0000}-\u{E007F}]+/gu;
30
+ const VS_SUPPLEMENT_RUN = /[\u{E0100}-\u{E01EF}]+/gu;
31
+ const BIDI = /[‪-‮⁦-⁩]/g;
32
+ // Zero-width and other format characters that render as nothing. U+200D (ZWJ)
33
+ // and U+200C (ZWNJ) have legitimate uses (emoji sequences, Persian, Indic
34
+ // scripts), so those are filtered by context below.
35
+ const ZERO_WIDTH = /[​-‍⁠-⁤᠎ㅤᅟᅠᅠ]/g;
36
+ const PICTO = /\p{Extended_Pictographic}|[\u{1F3FB}-\u{1F3FF}️]/u;
37
+ const NON_LATIN_LETTER = /[\p{L}\p{M}]/u;
38
+
39
+ function lineOf(text, index) {
40
+ let n = 1;
41
+ for (let i = 0; i < index && i < text.length; i++) if (text.charCodeAt(i) === 10) n++;
42
+ return n;
43
+ }
44
+
45
+ /** Decode Unicode tag characters to the ASCII they smuggle. */
46
+ export function decodeTagChars(s) {
47
+ let out = "";
48
+ for (const ch of s) {
49
+ const cp = ch.codePointAt(0);
50
+ if (cp >= 0xe0020 && cp <= 0xe007e) out += String.fromCharCode(cp - 0xe0000);
51
+ }
52
+ return out;
53
+ }
54
+
55
+ /** Make a decoded payload safe to print: visible ASCII only, truncated. */
56
+ export function preview(s, max = 80) {
57
+ const clean = String(s).replace(/[^\x20-\x7E]/g, "·").replace(/\s+/g, " ").trim();
58
+ return clean.length > max ? clean.slice(0, max) + "…" : clean;
59
+ }
60
+
61
+ function detectInvisible(text) {
62
+ const hits = [];
63
+
64
+ const tagRuns = [...text.matchAll(TAG_RUN)];
65
+ if (tagRuns.length) {
66
+ const decoded = tagRuns.map((m) => decodeTagChars(m[0])).join(" ").trim();
67
+ const count = tagRuns.reduce((n, m) => n + [...m[0]].length, 0);
68
+ hits.push({
69
+ rule: "unicode-tags",
70
+ severity: "critical",
71
+ title: "Hidden text in Unicode tag characters",
72
+ detail: `${count} invisible tag character(s) (U+E0000–E007F). A model reads them as text; a reviewer sees nothing.` +
73
+ (decoded ? ` Decoded: "${preview(decoded)}"` : ""),
74
+ line: lineOf(text, tagRuns[0].index),
75
+ count,
76
+ });
77
+ }
78
+
79
+ const vsRuns = [...text.matchAll(VS_SUPPLEMENT_RUN)];
80
+ if (vsRuns.length) {
81
+ const count = vsRuns.reduce((n, m) => n + [...m[0]].length, 0);
82
+ hits.push({
83
+ rule: "variation-selectors",
84
+ severity: "high",
85
+ title: "Data hidden in variation selectors",
86
+ detail: `${count} supplementary variation selector(s) (U+E0100–E01EF). These are invisible and can encode arbitrary bytes ("emoji smuggling").`,
87
+ line: lineOf(text, vsRuns[0].index),
88
+ count,
89
+ });
90
+ }
91
+
92
+ const bidi = [...text.matchAll(BIDI)];
93
+ if (bidi.length) {
94
+ hits.push({
95
+ rule: "bidi-controls",
96
+ severity: "high",
97
+ title: "Bidirectional control characters",
98
+ detail: `${bidi.length} bidi override/isolate character(s) (U+202A–202E, U+2066–2069). They reorder how text displays, so what a reviewer reads differs from what the model reads (Trojan Source).`,
99
+ line: lineOf(text, bidi[0].index),
100
+ count: bidi.length,
101
+ });
102
+ }
103
+
104
+ const zw = [];
105
+ for (const m of text.matchAll(ZERO_WIDTH)) {
106
+ const ch = m[0];
107
+ const i = m.index;
108
+ if (ch === "" && i === 0) continue; // byte-order mark
109
+ // A zero-width space at the start of a line before ``` or ~~~ is the usual
110
+ // trick for showing a code fence inside a fenced block.
111
+ if (ch === "​" && (i === 0 || text[i - 1] === "\n") && /^(?:```|~~~)/.test(text.slice(i + 1, i + 4))) continue;
112
+ const prev = text.slice(Math.max(0, i - 2), i);
113
+ const next = text.slice(i + 1, i + 3);
114
+ if (ch === "‍" && PICTO.test(prev) && PICTO.test(next)) continue; // emoji ZWJ sequence
115
+ if (ch === "‌" || ch === "‍") {
116
+ const p = [...prev].pop() || "";
117
+ const n = [...next][0] || "";
118
+ // ZWNJ/ZWJ between letters of a non-Latin script is ordinary orthography.
119
+ if (NON_LATIN_LETTER.test(p) && NON_LATIN_LETTER.test(n) && /[^\x00-ɏ]/.test(p + n)) continue;
120
+ }
121
+ zw.push(i);
122
+ }
123
+ if (zw.length) {
124
+ hits.push({
125
+ rule: "zero-width",
126
+ severity: zw.length >= 3 ? "high" : "medium",
127
+ title: "Zero-width or invisible characters",
128
+ detail: `${zw.length} zero-width/invisible character(s). They can split keywords to dodge filters or carry a hidden binary encoding.`,
129
+ line: lineOf(text, zw[0]),
130
+ count: zw.length,
131
+ });
132
+ }
133
+
134
+ return hits;
135
+ }
136
+
137
+ // ── Whitespace inflation ──────────────────────────────────────────────────
138
+
139
+ export const DEFAULTS = {
140
+ blankLines: 40, // consecutive blank lines before more content
141
+ horizontalRun: 200, // consecutive spaces/tabs on one line before more content
142
+ base64MinLength: 200, // characters in one base64 run
143
+ };
144
+
145
+ // Linear scans, not regexes: a nested whitespace quantifier backtracks
146
+ // quadratically on exactly the input an attacker would write here.
147
+ function detectWhitespaceInflation(text, opts) {
148
+ const hits = [];
149
+ const lines = text.split("\n");
150
+ let run = 0;
151
+ let longestV = 0;
152
+ let firstV = null;
153
+ let countV = 0;
154
+ let longestH = 0;
155
+ let firstH = null;
156
+ let countH = 0;
157
+ for (let i = 0; i < lines.length; i++) {
158
+ const ln = lines[i];
159
+ if (/^[ \t\r]*$/.test(ln)) { run++; continue; }
160
+ if (run >= opts.blankLines) {
161
+ countV++;
162
+ longestV = Math.max(longestV, run);
163
+ if (firstV === null) firstV = i + 1;
164
+ }
165
+ run = 0;
166
+ let ws = 0;
167
+ for (let j = 0; j < ln.length; j++) {
168
+ const ch = ln.charCodeAt(j);
169
+ if (ch === 32 || ch === 9 || ch === 0xa0) { ws++; continue; }
170
+ if (ws >= opts.horizontalRun && ch !== 13) {
171
+ countH++;
172
+ longestH = Math.max(longestH, ws);
173
+ if (firstH === null) firstH = i + 1;
174
+ }
175
+ ws = 0;
176
+ }
177
+ }
178
+ if (countV) {
179
+ hits.push({
180
+ rule: "whitespace-vertical",
181
+ severity: "high",
182
+ title: "Content hidden below a wall of blank lines",
183
+ detail: `${longestV} consecutive blank lines, then more content. This pushes text past where a reviewer stops scrolling, and past what a truncating scanner reads.`,
184
+ line: firstV,
185
+ count: countV,
186
+ });
187
+ }
188
+ if (countH) {
189
+ hits.push({
190
+ rule: "whitespace-horizontal",
191
+ severity: "high",
192
+ title: "Content hidden off-screen to the right",
193
+ detail: `A run of ${longestH} spaces, then more text on the same line. Editors and diff views without wrapping never show it.`,
194
+ line: firstH,
195
+ count: countH,
196
+ });
197
+ }
198
+ return hits;
199
+ }
200
+
201
+ // ── Instructions in hidden places ─────────────────────────────────────────
202
+
203
+ const INSTRUCTION_PATTERNS = [
204
+ /\b(?:ignore|disregard|forget|override)\b[^.\n]{0,40}\b(?:previous|prior|above|earlier|all|any|other)\b[^.\n]{0,20}\b(?:instructions?|rules?|prompts?|guidelines?|directions?)\b/i,
205
+ /\b(?:you|assistant|claude|agent|model|ai|llm)\b[^.\n]{0,30}\b(?:must|should|shall|will|are to|need to|have to)\b/i,
206
+ /\bdo(?:n'?t| not) (?:tell|inform|mention|reveal|show|alert|ask|notify)\b/i,
207
+ /\bwithout (?:asking|telling|informing|confirm\w*|permission|the user)/i,
208
+ /\b(?:system prompt|developer message|new instructions?)\b/i,
209
+ /\b(?:run|execute|exec|invoke)\b[^.\n]{0,40}\b(?:command|script|bash|shell|curl|wget|powershell)\b/i,
210
+ /\b(?:send|upload|post|exfiltrat\w*|transmit|forward|copy)\b[^\n]{0,60}(?:https?:\/\/|\btokens?\b|\bsecrets?\b|\bapi keys?\b|\bcredentials?\b|\.env\b|\bssh\b|\bpasswords?\b)/i,
211
+ /(?:~\/\.ssh|\bid_rsa\b|\bid_ed25519\b|\.aws\/credentials|\.npmrc|\.netrc|\bkeychain\b)/i,
212
+ ];
213
+
214
+ // Text addressed to an automated reviewer. Legitimate skills have no reason to
215
+ // talk to a security scanner.
216
+ const SCANNER_DIRECTED = /\b(?:security |safety |skill |code )?(?:scanners?|reviewers?|auditors?|judges?|classifiers?|analy[sz]ers?|moderators?)\b[^.\n]{0,60}\b(?:is|as|are|mark|classify|report|treat|consider|rate|label)\b[^.\n]{0,30}\b(?:safe|benign|harmless|clean|trusted|approved?|pass(?:ed)?|low[- ]risk|false positive)\b/i;
217
+
218
+ export function looksLikeInstruction(s) {
219
+ return INSTRUCTION_PATTERNS.some((re) => re.test(s));
220
+ }
221
+
222
+ function detectHiddenInstructions(text) {
223
+ const hits = [];
224
+ const comments = [...text.matchAll(/<!--([\s\S]*?)-->/g)];
225
+ const bad = comments.filter((m) => looksLikeInstruction(m[1]));
226
+ if (bad.length) {
227
+ hits.push({
228
+ rule: "html-comment-instructions",
229
+ severity: "high",
230
+ title: "Instructions hidden in an HTML comment",
231
+ detail: `${bad.length} HTML comment(s) contain instructions. Markdown renderers hide comments, the model still reads them. First: "${preview(bad[0][1])}"`,
232
+ line: lineOf(text, bad[0].index),
233
+ count: bad.length,
234
+ });
235
+ }
236
+ const scanner = text.match(SCANNER_DIRECTED);
237
+ if (scanner) {
238
+ hits.push({
239
+ rule: "scanner-directed",
240
+ severity: "high",
241
+ title: "Text addressed to a security scanner",
242
+ detail: `Tries to talk an automated reviewer into a verdict: "${preview(scanner[0])}". This is how LLM-judged scanners get bypassed.`,
243
+ line: lineOf(text, scanner.index),
244
+ count: 1,
245
+ });
246
+ }
247
+ return hits;
248
+ }
249
+
250
+ // ── Code execution patterns ───────────────────────────────────────────────
251
+
252
+ const SHELL = String.raw`(?:sudo\s+)?(?:env\s+\S*\s*)?(?:ba|z|da|k|fi|c|tc)?sh\b`;
253
+ export const EXEC_PATTERNS = [
254
+ { rule: "curl-pipe-shell", label: "download piped to a shell", re: new RegExp(String.raw`\b(?:curl|wget|fetch|iwr)\b[^\n|]*\|\s*${SHELL}`, "i") },
255
+ { rule: "curl-pipe-interpreter", label: "download piped to an interpreter", re: /\b(?:curl|wget)\b[^\n|]*\|\s*(?:sudo\s+)?(?:python[0-9.]*|node|perl|ruby|php|deno|bun)\b/i },
256
+ { rule: "shell-process-substitution", label: "shell runs a download via process substitution", re: /\b(?:(?:ba|z)?sh|source|\.)\s+<\(\s*(?:curl|wget)\b/i },
257
+ { rule: "shell-command-substitution", label: "shell -c runs a download", re: /\b(?:ba|z)?sh\s+-c\s+["']?\$\(\s*(?:curl|wget)\b/i },
258
+ { rule: "base64-pipe-shell", label: "decoded base64 piped to a shell", re: new RegExp(String.raw`base64\s+(?:-d|--decode|-D)\b[^\n]*\|\s*${SHELL}`, "i") },
259
+ { rule: "shell-eval", label: "eval of command output", re: /\beval\s+["']?\$\(/ },
260
+ { rule: "js-eval-remote", label: "eval of fetched or decoded content", re: /\b(?:eval|Function)\s*\(\s*(?:await\s+)?(?:atob|Buffer\.from|fetch|require\(\s*["']child_process|\(await fetch)/ },
261
+ { rule: "py-exec-remote", label: "exec of fetched or decoded content", re: /\bexec\s*\(\s*(?:base64\.b64decode|__import__|compile|urllib|requests\.get|zlib\.decompress|marshal\.loads|bytes\.fromhex)/ },
262
+ { rule: "powershell-iex", label: "PowerShell Invoke-Expression of a download", re: /\b(?:iex|Invoke-Expression)\b[^\n]*\b(?:iwr|irm|Invoke-WebRequest|Invoke-RestMethod|DownloadString)\b|\b(?:iwr|irm|Invoke-WebRequest|Invoke-RestMethod|DownloadString)\b[^\n]*\|\s*(?:iex|Invoke-Expression)\b/i },
263
+ ];
264
+
265
+ /** Find remote-code-execution patterns in a string (a file, a hook command). */
266
+ export function findExecPatterns(text) {
267
+ const out = [];
268
+ for (const p of EXEC_PATTERNS) {
269
+ const m = text.match(p.re);
270
+ if (m) out.push({ rule: p.rule, label: p.label, match: m[0], index: m.index });
271
+ }
272
+ return out;
273
+ }
274
+
275
+ function detectExec(text) {
276
+ return findExecPatterns(text).map((m) => ({
277
+ rule: m.rule,
278
+ severity: "high",
279
+ title: `Remote code execution pattern: ${m.label}`,
280
+ detail: `\`${preview(m.match, 120)}\`. An agent following this runs code nobody reviewed, fetched at run time.`,
281
+ line: lineOf(text, m.index),
282
+ count: 1,
283
+ }));
284
+ }
285
+
286
+ // ── Base64 blobs ──────────────────────────────────────────────────────────
287
+
288
+ const MAGIC = [
289
+ { name: "ELF executable", test: (b) => b[0] === 0x7f && b[1] === 0x45 && b[2] === 0x4c && b[3] === 0x46 },
290
+ { name: "Windows executable", test: (b) => b[0] === 0x4d && b[1] === 0x5a },
291
+ { name: "Mach-O executable", test: (b) => {
292
+ const m = b.readUInt32BE?.(0);
293
+ return [0xfeedface, 0xfeedfacf, 0xcefaedfe, 0xcffaedfe, 0xcafebabe].includes(m);
294
+ } },
295
+ { name: "zip archive", test: (b) => b[0] === 0x50 && b[1] === 0x4b && (b[2] === 0x03 || b[2] === 0x05 || b[2] === 0x07) },
296
+ { name: "gzip archive", test: (b) => b[0] === 0x1f && b[1] === 0x8b },
297
+ { name: "bzip2 archive", test: (b) => b[0] === 0x42 && b[1] === 0x5a && b[2] === 0x68 },
298
+ { name: "xz archive", test: (b) => b[0] === 0xfd && b[1] === 0x37 && b[2] === 0x7a && b[3] === 0x58 },
299
+ { name: "7z archive", test: (b) => b[0] === 0x37 && b[1] === 0x7a && b[2] === 0xbc && b[3] === 0xaf },
300
+ { name: "rar archive", test: (b) => b[0] === 0x52 && b[1] === 0x61 && b[2] === 0x72 && b[3] === 0x21 },
301
+ { name: "WebAssembly module", test: (b) => b[0] === 0x00 && b[1] === 0x61 && b[2] === 0x73 && b[3] === 0x6d },
302
+ // CPython bytecode: a 2-byte version magic followed by \r\n.
303
+ { name: "Python bytecode (.pyc)", test: (b) => b.length >= 16 && b[2] === 0x0d && b[3] === 0x0a && b[1] < 0x10 },
304
+ { name: "tar archive", test: (b) => b.length > 262 && b.toString("latin1", 257, 262) === "ustar" },
305
+ ];
306
+
307
+ /** Name the executable/archive format of a buffer, or null. */
308
+ export function sniffBinary(buf) {
309
+ if (!buf || buf.length < 4) return null;
310
+ const hit = MAGIC.find((m) => m.test(buf));
311
+ return hit ? hit.name : null;
312
+ }
313
+
314
+ function isPrintable(buf) {
315
+ if (!buf.length) return false;
316
+ let ok = 0;
317
+ for (const b of buf) if (b === 9 || b === 10 || b === 13 || (b >= 32 && b < 127)) ok++;
318
+ return ok / buf.length > 0.95;
319
+ }
320
+
321
+ function detectBase64(text, opts) {
322
+ const re = new RegExp(`[A-Za-z0-9+/_-]{${opts.base64MinLength},}={0,2}`, "g");
323
+ const blobs = [];
324
+ for (const m of text.matchAll(re)) {
325
+ const s = m[0];
326
+ // Real base64 mixes cases and digits; hex digests, identifiers and long
327
+ // words do not.
328
+ if (!/[A-Z]/.test(s) || !/[a-z]/.test(s) || !/[0-9]/.test(s)) continue;
329
+ if (/^[0-9a-f]+$/i.test(s)) continue;
330
+ const before = text.slice(Math.max(0, m.index - 40), m.index);
331
+ const image = /data:image\/[a-z+.-]+;base64,$/i.test(before);
332
+ const buf = Buffer.from(s.replace(/-/g, "+").replace(/_/g, "/"), "base64");
333
+ const binary = sniffBinary(buf);
334
+ let decodedText = null;
335
+ if (!binary && isPrintable(buf)) decodedText = buf.toString("utf-8");
336
+ const execInside = decodedText ? findExecPatterns(decodedText) : [];
337
+ const instructionInside = decodedText ? looksLikeInstruction(decodedText) : false;
338
+ blobs.push({ index: m.index, length: s.length, image, binary, decodedText, execInside, instructionInside });
339
+ }
340
+ if (!blobs.length) return [];
341
+
342
+ const worst = blobs.find((b) => b.binary) || blobs.find((b) => b.execInside.length) ||
343
+ blobs.find((b) => b.instructionInside) || blobs.find((b) => !b.image) || blobs[0];
344
+ let severity = "medium";
345
+ let what = "an opaque encoded payload a reviewer cannot read";
346
+ if (worst.binary) { severity = "high"; what = `a ${worst.binary}`; }
347
+ else if (worst.execInside.length) { severity = "high"; what = `a ${worst.execInside[0].label} (\`${preview(worst.execInside[0].match)}\`)`; }
348
+ else if (worst.instructionInside) { severity = "high"; what = `hidden instructions ("${preview(worst.decodedText)}")`; }
349
+ else if (worst.image) { severity = "low"; what = "an inline image"; }
350
+ else if (worst.decodedText) what = `encoded text ("${preview(worst.decodedText, 60)}")`;
351
+
352
+ return [{
353
+ rule: "base64-blob",
354
+ severity,
355
+ title: "Large base64 blob",
356
+ detail: `${blobs.length} base64 run(s) of ${opts.base64MinLength}+ characters; the largest is ${Math.max(...blobs.map((b) => b.length))}. It decodes to ${what}.`,
357
+ line: lineOf(text, worst.index),
358
+ count: blobs.length,
359
+ }];
360
+ }
361
+
362
+ // ── Public API ────────────────────────────────────────────────────────────
363
+
364
+ /**
365
+ * Scan one text file for hidden content.
366
+ * @param {string} text
367
+ * @param {object} [opts] { blankLines, horizontalRun, base64MinLength, markdown }
368
+ */
369
+ export function scanHiddenContent(text, opts = {}) {
370
+ const o = { ...DEFAULTS, ...opts };
371
+ return [
372
+ ...detectInvisible(text),
373
+ ...detectWhitespaceInflation(text, o),
374
+ ...detectHiddenInstructions(text),
375
+ ...detectExec(text),
376
+ ...detectBase64(text, o),
377
+ ];
378
+ }
379
+
380
+ export function hasTagChars(text) {
381
+ return TAG_RANGE.test(text);
382
+ }
383
+
384
+ // Extensions that are binary or archives no matter what the bytes say.
385
+ export const BINARY_EXTENSIONS = /\.(?:pyc|pyo|pyd|so|dylib|dll|exe|bin|jar|class|whl|egg|zip|tar|tgz|gz|bz2|xz|7z|rar|wasm|node|deb|rpm|dmg|pkg|msi|apk)$/i;
386
+ // Media that ships in skills legitimately.
387
+ export const MEDIA_EXTENSIONS = /\.(?:png|jpe?g|gif|webp|ico|bmp|avif|woff2?|ttf|otf|eot|mp3|mp4|wav|ogg|webm|mov|pdf)$/i;
@@ -0,0 +1,286 @@
1
+ /**
2
+ * Penthera, agents mode: an offline, deterministic audit of the AI developer
3
+ * tooling configured in a repo and (with --home) on this machine.
4
+ *
5
+ * No network, no LLM judge, no registry lookups. Every verdict is a rule you
6
+ * can read, so a skill cannot argue its way past it.
7
+ */
8
+ import { readFileSync, realpathSync, statSync } from "node:fs";
9
+ import { relative, sep, extname } from "node:path";
10
+ import { discoverAgentConfig, walk, parseFrontmatter, MAX_FILE_BYTES } from "./discover.js";
11
+ import { scanHiddenContent, sniffBinary, BINARY_EXTENSIONS, MEDIA_EXTENSIONS } from "./hidden-content.js";
12
+ import { checkMcpServer, checkHook, checkPermissions, checkAllowedTools, checkMarketplaces } from "./rules.js";
13
+ import { computeLockEntries, diffLock, readLock, writeLock, lockPath, LOCK_FILENAME } from "./lock.js";
14
+ import { owaspFor, owaspLabel } from "./owasp.js";
15
+
16
+ export { scanHiddenContent } from "./hidden-content.js";
17
+ export { discoverAgentConfig } from "./discover.js";
18
+
19
+ const SOURCE = "agents-scan";
20
+ const CATEGORY = "agent-config";
21
+
22
+ /** Turn a rule hit into a finding in Penthera's standard shape. */
23
+ export function toFinding(hit) {
24
+ const ids = owaspFor(hit.rule);
25
+ const mapping = ids.length ? ` [${owaspLabel(ids)}]` : "";
26
+ return {
27
+ severity: hit.severity,
28
+ title: hit.title,
29
+ description: `${hit.detail}${hit.line ? ` (line ${hit.line})` : ""}${mapping}`,
30
+ url: hit.url,
31
+ category: CATEGORY,
32
+ source: SOURCE,
33
+ templateId: `agents/${hit.rule}`,
34
+ owasp: ids,
35
+ confidence: hit.confidence || (hit.severity === "info" ? "confirmed" : "likely"),
36
+ };
37
+ }
38
+
39
+ // ── Content scanning ──────────────────────────────────────────────────────
40
+
41
+ function scanTextFile(path, display, hits) {
42
+ let buf;
43
+ try {
44
+ const st = statSync(path);
45
+ if (st.size > MAX_FILE_BYTES) {
46
+ hits.push({
47
+ rule: "file-too-large",
48
+ severity: "medium",
49
+ title: "File too large to scan",
50
+ detail: `${(st.size / 1024 / 1024).toFixed(1)} MB, over the ${MAX_FILE_BYTES / 1024 / 1024} MB limit. Padding a file past a scanner's size limit is a known way to hide a payload; review it by hand.`,
51
+ url: display,
52
+ });
53
+ return;
54
+ }
55
+ buf = readFileSync(path);
56
+ } catch {
57
+ return;
58
+ }
59
+ const binary = sniffBinary(buf);
60
+ if (binary) {
61
+ hits.push({
62
+ rule: "embedded-binary",
63
+ severity: "high",
64
+ title: `Embedded ${binary}`,
65
+ detail: `A ${binary} inside agent instructions cannot be reviewed as text, and agents can be told to unpack or run it.`,
66
+ url: display,
67
+ });
68
+ return;
69
+ }
70
+ if (buf.subarray(0, 8192).includes(0)) {
71
+ hits.push({
72
+ rule: "unknown-binary",
73
+ severity: "medium",
74
+ title: "Unrecognised binary file",
75
+ detail: "A binary file of unknown type sits next to agent instructions. Check what it is and why it is there.",
76
+ url: display,
77
+ });
78
+ return;
79
+ }
80
+ for (const h of scanHiddenContent(buf.toString("utf-8"))) hits.push({ ...h, url: display });
81
+ }
82
+
83
+ function scanSkillFolder(skill, inv, hits) {
84
+ const { files, symlinks, truncated } = walk(skill.dir);
85
+ let realDir = skill.dir;
86
+ try { realDir = realpathSync(skill.dir); } catch { /* keep */ }
87
+ for (const l of symlinks) {
88
+ let target = null;
89
+ try { target = realpathSync(l); } catch { /* broken */ }
90
+ if (!target || (target !== realDir && !target.startsWith(realDir + sep))) {
91
+ hits.push({
92
+ rule: "symlink-escape",
93
+ severity: "high",
94
+ title: "Symlink points outside the skill folder",
95
+ detail: `\`${relative(skill.dir, l)}\` resolves to ${target ? `\`${inv.display(target)}\`` : "a missing target"}. Scanners that stay inside the folder never see what the agent reads through it.`,
96
+ url: inv.display(l),
97
+ });
98
+ }
99
+ }
100
+ if (truncated) {
101
+ hits.push({
102
+ rule: "file-too-large",
103
+ severity: "medium",
104
+ title: "Skill folder too large to scan fully",
105
+ detail: "More files than the scanner's limit; the rest were not checked. Review the folder by hand.",
106
+ url: skill.display,
107
+ });
108
+ }
109
+ for (const f of files) {
110
+ const display = inv.display(f);
111
+ if (BINARY_EXTENSIONS.test(f)) {
112
+ let head = Buffer.alloc(0);
113
+ try { head = readFileSync(f).subarray(0, 512); } catch { /* unreadable */ }
114
+ const kind = sniffBinary(head) || `${extname(f)} file`;
115
+ hits.push({
116
+ rule: "embedded-binary",
117
+ severity: "high",
118
+ title: `Embedded ${kind} in skill`,
119
+ detail: `Compiled code and archives in a skill cannot be reviewed as text. A skill can tell the agent to run or unpack them.`,
120
+ url: display,
121
+ });
122
+ continue;
123
+ }
124
+ if (MEDIA_EXTENSIONS.test(f)) {
125
+ // Check that "media" really is media; a renamed archive is a classic dodge.
126
+ let head = Buffer.alloc(0);
127
+ try { head = readFileSync(f).subarray(0, 512); } catch { /* unreadable */ }
128
+ const kind = sniffBinary(head);
129
+ if (kind) {
130
+ hits.push({
131
+ rule: "embedded-binary",
132
+ severity: "high",
133
+ title: `${kind} disguised as ${extname(f)}`,
134
+ detail: `The file extension says media; the bytes say ${kind}.`,
135
+ url: display,
136
+ });
137
+ }
138
+ continue;
139
+ }
140
+ scanTextFile(f, display, hits);
141
+ }
142
+ }
143
+
144
+ // ── Grade ─────────────────────────────────────────────────────────────────
145
+
146
+ const WEIGHT = { critical: 30, high: 15, medium: 5, low: 1, info: 0 };
147
+
148
+ /** A–F grade. Any critical finding is an F; otherwise score bands. */
149
+ export function gradeFindings(findings) {
150
+ let score = 100;
151
+ for (const f of findings) score -= WEIGHT[f.severity] ?? 0;
152
+ score = Math.max(0, score);
153
+ const critical = findings.some((f) => f.severity === "critical");
154
+ const grade = critical ? "F" : score >= 90 ? "A" : score >= 75 ? "B" : score >= 60 ? "C" : score >= 40 ? "D" : "F";
155
+ return { grade, score };
156
+ }
157
+
158
+ // ── Scan ──────────────────────────────────────────────────────────────────
159
+
160
+ /**
161
+ * Audit agent configuration.
162
+ * @param {string} root
163
+ * @param {object} [opts] { home, homeDir, pin, onPhase }
164
+ */
165
+ export async function scanAgents(root, opts = {}) {
166
+ const progress = opts.onPhase || (() => {});
167
+ const start = Date.now();
168
+
169
+ progress("Discovering agent configuration...");
170
+ const inv = discoverAgentConfig(root, { home: opts.home, homeDir: opts.homeDir });
171
+ const hits = [];
172
+
173
+ progress(`Scanning ${inv.skills.length} skill(s) for hidden content...`);
174
+ for (const s of inv.skills) {
175
+ scanSkillFolder(s, inv, hits);
176
+ hits.push(...checkAllowedTools(inv.display(s.skillFile), s.frontmatter, `Skill "${s.name}"`));
177
+ }
178
+
179
+ progress("Scanning instruction and rule files...");
180
+ for (const f of inv.instructionFiles) {
181
+ scanTextFile(f.path, f.display, hits);
182
+ if (/\.mdc?$/i.test(f.path)) {
183
+ let text = "";
184
+ try { text = readFileSync(f.path, "utf-8"); } catch { /* unreadable */ }
185
+ hits.push(...checkAllowedTools(f.display, parseFrontmatter(text), "Command"));
186
+ }
187
+ }
188
+
189
+ progress(`Checking ${inv.mcpServers.length} MCP server(s), ${inv.hooks.length} hook(s), permissions and plugins...`);
190
+ for (const m of inv.mcpServers) hits.push(...checkMcpServer(m));
191
+ for (const h of inv.hooks) hits.push(...checkHook(h));
192
+ hits.push(...checkPermissions(inv));
193
+ hits.push(...checkMarketplaces(inv));
194
+ for (const e of inv.errors) {
195
+ hits.push({ rule: "config-unparsed", severity: "info", title: "Config file could not be parsed", detail: e.message, url: e.file });
196
+ }
197
+
198
+ // Rug-pull pins
199
+ progress("Comparing against penthera.lock...");
200
+ const entries = computeLockEntries(inv);
201
+ const lpath = opts.lockPath || lockPath(inv.root);
202
+ const lockInfo = { path: lpath, status: "missing", changed: 0, added: 0, removed: 0, entries: Object.keys(entries).length };
203
+ if (opts.pin) {
204
+ writeLock(lpath, entries);
205
+ lockInfo.status = "pinned";
206
+ } else {
207
+ let lock = null;
208
+ try {
209
+ lock = readLock(lpath);
210
+ } catch (e) {
211
+ hits.push({ rule: "config-unparsed", severity: "medium", title: `${LOCK_FILENAME} is corrupt`, detail: `${e.message}. Rug-pull detection is off until it is re-pinned.`, url: LOCK_FILENAME });
212
+ lockInfo.status = "corrupt";
213
+ }
214
+ if (lock) {
215
+ const scopes = opts.home ? ["repo", "home"] : ["repo"];
216
+ const d = diffLock(lock, entries, { scopes });
217
+ Object.assign(lockInfo, { status: "checked", changed: d.changed.length, added: d.added.length, removed: d.removed.length });
218
+ for (const c of d.changed) {
219
+ hits.push({
220
+ rule: "lock-changed",
221
+ severity: "high",
222
+ title: `Pinned ${c.kind} changed since ${LOCK_FILENAME}: ${c.key.replace(/^\w+:/, "")}`,
223
+ detail: `Hash was ${c.previous.slice(0, 12)}…, now ${c.sha256.slice(0, 12)}…. A ${c.kind} that changes after review is how a rug pull works. Diff it, then re-pin with \`penthera agents --pin\` if the change is expected.`,
224
+ url: LOCK_FILENAME,
225
+ });
226
+ }
227
+ for (const a of d.added) {
228
+ hits.push({
229
+ rule: "lock-new",
230
+ severity: "medium",
231
+ title: `New ${a.kind} not in ${LOCK_FILENAME}: ${a.key.replace(/^\w+:/, "")}`,
232
+ detail: `Added since the last pin. Review it, then run \`penthera agents --pin\`.`,
233
+ url: LOCK_FILENAME,
234
+ });
235
+ }
236
+ for (const r of d.removed) {
237
+ hits.push({
238
+ rule: "lock-removed",
239
+ severity: "info",
240
+ title: `Pinned ${r.kind} no longer present: ${r.key.replace(/^\w+:/, "")}`,
241
+ detail: "Removed since the last pin.",
242
+ url: LOCK_FILENAME,
243
+ });
244
+ }
245
+ } else if (lockInfo.status === "missing" && Object.keys(entries).length) {
246
+ hits.push({
247
+ rule: "lock-missing",
248
+ severity: "info",
249
+ title: `No ${LOCK_FILENAME}: skills, MCP servers and hooks are not pinned`,
250
+ detail: `Run \`penthera agents --pin\` after reviewing them. Later runs then report anything that changed (rug pulls) or appeared.`,
251
+ url: LOCK_FILENAME,
252
+ });
253
+ }
254
+ }
255
+
256
+ const findings = hits.map(toFinding);
257
+ const { grade, score } = gradeFindings(findings);
258
+
259
+ return {
260
+ target: inv.root,
261
+ mode: "agents",
262
+ local: true,
263
+ reachable: true,
264
+ timestamp: new Date().toISOString(),
265
+ duration: Date.now() - start,
266
+ agents: {
267
+ grade,
268
+ score,
269
+ home: !!opts.home,
270
+ inventory: {
271
+ skills: inv.skills.length,
272
+ mcpServers: inv.mcpServers.length,
273
+ hooks: inv.hooks.length,
274
+ permissions: inv.permissions.length,
275
+ plugins: inv.plugins.length,
276
+ marketplaces: inv.marketplaces.length,
277
+ instructionFiles: inv.instructionFiles.length,
278
+ configFiles: inv.configFiles.map((f) => f.display),
279
+ },
280
+ lock: { ...lockInfo, path: inv.display(lpath) },
281
+ },
282
+ executedProbes: ["agent-config-discovery", "hidden-content", "agent-config-rules", "agent-lock"],
283
+ findings,
284
+ };
285
+ }
286
+