penthera 1.7.0 → 1.8.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 (163) hide show
  1. package/CHANGELOG.md +315 -0
  2. package/README.md +139 -76
  3. package/SECURITY.md +8 -6
  4. package/docs/examples/pr-dependency-review.yml +1 -1
  5. package/lib/adaptive-probes.js +3 -3
  6. package/lib/advisories/evidence.js +248 -0
  7. package/lib/advisories/index.js +228 -0
  8. package/lib/advisories/products.js +83 -0
  9. package/lib/advisories/versions.js +65 -0
  10. package/lib/agents/hidden-content.js +122 -11
  11. package/lib/agents/index.js +26 -6
  12. package/lib/agents/lock.js +33 -5
  13. package/lib/agents/rules.js +33 -4
  14. package/lib/compliance/index.js +4 -4
  15. package/lib/crawler.js +12 -15
  16. package/lib/deps/capabilities.js +21 -5
  17. package/lib/deps/findings.js +309 -0
  18. package/lib/deps/index.js +13 -272
  19. package/lib/deps/iocs.js +74 -0
  20. package/lib/deps/lockfile.js +3 -8
  21. package/lib/deps/online.js +8 -3
  22. package/lib/deps/popular-packages.json.gz +0 -0
  23. package/lib/deps/popular-pypi.json.gz +0 -0
  24. package/lib/deps/python/capabilities.js +16 -3
  25. package/lib/deps/python/index.js +5 -1
  26. package/lib/deps/python/lockfiles.js +11 -5
  27. package/lib/deps/python/source.js +75 -26
  28. package/lib/deps/python/toml.js +15 -3
  29. package/lib/deps/source.js +28 -9
  30. package/lib/deps/typosquat.js +30 -5
  31. package/lib/fix/apply.js +253 -34
  32. package/lib/fix/index.js +7 -4
  33. package/lib/fix/model.js +25 -5
  34. package/lib/fix/util.js +6 -2
  35. package/lib/fuzzer.js +3 -3
  36. package/lib/git.js +65 -0
  37. package/lib/hook/index.js +157 -73
  38. package/lib/hook/policy.js +10 -4
  39. package/lib/install/index.js +4 -7
  40. package/lib/machine.js +0 -13
  41. package/lib/memory/store.js +11 -4
  42. package/lib/nuclei-loader.js +0 -25
  43. package/lib/owasp-wstg.js +4 -0
  44. package/lib/plugins.js +1 -1
  45. package/lib/provenance.js +1 -0
  46. package/lib/recon.js +12 -31
  47. package/lib/references.json +224 -0
  48. package/lib/retirejs.js +4 -4
  49. package/lib/rules/advisories/advisories.json +186 -0
  50. package/lib/rules/advisories/eol.json +413 -0
  51. package/lib/rules/advisories/pack.json +20 -0
  52. package/lib/rules/advisories/pack.json.sig +6 -0
  53. package/lib/rules/allowed_signers +13 -0
  54. package/lib/rules/core/agent-exec.json +29 -0
  55. package/lib/rules/core/agent-instructions.json +17 -0
  56. package/lib/rules/core/pack.json +30 -0
  57. package/lib/rules/core/pack.json.sig +6 -0
  58. package/lib/rules/core/secrets.json +161 -0
  59. package/lib/rules/core/templates.json +52 -0
  60. package/lib/rules/index.js +107 -0
  61. package/lib/rules/ioc-2025-2026/files.json +43 -0
  62. package/lib/rules/ioc-2025-2026/hosts.json +35 -0
  63. package/lib/rules/ioc-2025-2026/pack.json +25 -0
  64. package/lib/rules/ioc-2025-2026/pack.json.sig +6 -0
  65. package/lib/rules/ioc-2025-2026/packages.json +1048 -0
  66. package/lib/rules/pack.js +452 -0
  67. package/lib/rules/regex-guard.js +81 -9
  68. package/lib/rules/sshsig.js +205 -0
  69. package/lib/templates.js +24 -20
  70. package/lib/tmp.js +21 -0
  71. package/lib/whitebox/frameworks.js +1 -1
  72. package/lib/whitebox/secrets.js +117 -41
  73. package/package.json +12 -4
  74. package/scripts/rules/build-pack.mjs +80 -0
  75. package/scripts/rules/check-bundled.mjs +30 -0
  76. package/scripts/rules/sign-pack.sh +32 -0
  77. package/skills/penthera/SKILL.md +16 -15
  78. package/src/agent/git.js +147 -14
  79. package/src/agent/loop.js +165 -46
  80. package/src/cli/agent.js +11 -5
  81. package/src/cli/deps.js +0 -2
  82. package/src/cli/detect.js +14 -7
  83. package/src/cli/fix.js +26 -8
  84. package/src/cli/hook.js +2 -1
  85. package/src/cli/onboarding.js +14 -6
  86. package/src/cli/report-outputs.js +1 -0
  87. package/src/cli/rules.js +144 -0
  88. package/src/cli/ui.js +43 -10
  89. package/src/cli.js +24 -1
  90. package/src/report/evidence.js +105 -21
  91. package/src/report/footer.js +1 -1
  92. package/src/report/md-escape.js +8 -0
  93. package/src/scan-repo.js +6 -0
  94. package/src/scan-url.js +11 -3
  95. package/src/ui/api/data.js +39 -6
  96. package/src/ui/api/routes.js +44 -11
  97. package/src/ui/api/runs.js +32 -7
  98. package/src/ui/security.js +20 -23
  99. package/src/ui/server.js +52 -27
  100. package/src/ui/static/app.css +583 -213
  101. package/src/ui/static/favicon.svg +1 -1
  102. package/src/ui/static/fonts/DepartureMono-LICENSE.txt +93 -0
  103. package/src/ui/static/fonts/DepartureMono-Regular.woff2 +0 -0
  104. package/src/ui/static/index.html +5 -4
  105. package/src/ui/static/js/api.js +114 -6
  106. package/src/ui/static/js/app.js +81 -42
  107. package/src/ui/static/js/center.js +246 -133
  108. package/src/ui/static/js/charts.js +99 -18
  109. package/src/ui/static/js/copy.js +68 -9
  110. package/src/ui/static/js/dialog.js +152 -0
  111. package/src/ui/static/js/left.js +23 -60
  112. package/src/ui/static/js/menubar.js +170 -0
  113. package/src/ui/static/js/onboarding.js +104 -0
  114. package/src/ui/static/js/panels.js +186 -0
  115. package/src/ui/static/js/right.js +69 -188
  116. package/src/ui/static/js/state.js +36 -5
  117. package/src/ui/static/logo.svg +10 -0
  118. package/src/ui/static/mark.svg +3 -0
  119. package/src/utils/http.js +162 -13
  120. package/docs/METHODOLOGY.md +0 -375
  121. package/docs/commands/README.md +0 -24
  122. package/docs/commands/action.md +0 -21
  123. package/docs/commands/agents.md +0 -38
  124. package/docs/commands/deps.md +0 -115
  125. package/docs/commands/fix.md +0 -53
  126. package/docs/commands/hook.md +0 -69
  127. package/docs/commands/init.md +0 -114
  128. package/docs/commands/install.md +0 -53
  129. package/docs/commands/scan.md +0 -100
  130. package/docs/commands/skill.md +0 -58
  131. package/docs/compare/README.md +0 -25
  132. package/docs/compare/github.md +0 -60
  133. package/docs/compare/semgrep.md +0 -54
  134. package/docs/compare/snyk.md +0 -62
  135. package/docs/compare/socket.md +0 -78
  136. package/docs/compare/zap.md +0 -59
  137. package/docs/compliance-cra.md +0 -141
  138. package/docs/compliance-nis2.md +0 -168
  139. package/docs/dashboard.md +0 -187
  140. package/docs/evidence.md +0 -162
  141. package/docs/funding/due-diligence-checklist.md +0 -79
  142. package/docs/funding/nlnet-ngi-zero-draft.md +0 -273
  143. package/docs/funding/partner-one-pager.md +0 -84
  144. package/docs/funding/sponsor-tiers.md +0 -81
  145. package/docs/index.md +0 -81
  146. package/docs/internal-audit.md +0 -83
  147. package/docs/local-loop.md +0 -276
  148. package/docs/marketing.md +0 -151
  149. package/docs/owasp-wstg-coverage.md +0 -79
  150. package/docs/plan.md +0 -102
  151. package/docs/privacy.md +0 -38
  152. package/docs/references.bib +0 -559
  153. package/docs/references.md +0 -119
  154. package/docs/release-plan.md +0 -228
  155. package/docs/research/socket-positioning-notes.md +0 -183
  156. package/docs/use-cases/README.md +0 -14
  157. package/docs/use-cases/agencies.md +0 -71
  158. package/docs/use-cases/ai-coding-teams.md +0 -81
  159. package/docs/use-cases/eu-sme-compliance.md +0 -68
  160. package/docs/use-cases/open-source-maintainers.md +0 -75
  161. package/docs/use-cases/solo-founder.md +0 -65
  162. package/lib/deps/popular-packages.json +0 -1
  163. package/lib/deps/popular-pypi.json +0 -1
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Version strings as runtimes and servers print them, and how they compare.
3
+ *
4
+ * An upstream version is `major.minor[.patch][letters]`: `8.3.12`, `2.4.62`,
5
+ * `1.1.1w` (OpenSSL 1.x used letters for patch releases). Anything after it
6
+ * (`-1ubuntu2.19`, `+deb12u1`, `-fpm-alpine`) is a suffix. On a banner a suffix
7
+ * means a distribution build, whose security fixes are backported without the
8
+ * upstream number changing, so the number alone cannot say whether the fix is
9
+ * in. That case is never reported as affected; see lib/advisories/index.js.
10
+ */
11
+
12
+ const VERSION = /^(\d{1,4})(?:\.(\d{1,4})(?:\.(\d{1,6}))?)?([a-z]{1,2})?(.*)$/;
13
+
14
+ /**
15
+ * Parse a version string.
16
+ * @param {string} s
17
+ * `20`, `v20` and `8.3` parse too, with the missing parts null.
18
+ * @returns {{ major: number, minor: number|null, patch: number|null, letters: string,
19
+ * suffix: string, upstream: string, raw: string } | null}
20
+ */
21
+ export function parseVersion(s) {
22
+ if (typeof s !== "string") return null;
23
+ const m = VERSION.exec(s.trim().replace(/^v(?=\d)/, ""));
24
+ if (!m) return null;
25
+ const [, major, minor, patch, letters = "", suffix = ""] = m;
26
+ // "8.3.12abc" is not a version; letters only follow a full x.y.z.
27
+ if (letters && patch === undefined) return null;
28
+ // A suffix starts with a separator: "20-alpine", "8.1.2-1ubuntu2", not "8x".
29
+ if (suffix && !/^[-+~_ ]/.test(suffix)) return null;
30
+ const parts = [major, minor, patch].filter((x) => x !== undefined).map(Number);
31
+ return {
32
+ major: Number(major),
33
+ minor: minor === undefined ? null : Number(minor),
34
+ patch: patch === undefined ? null : Number(patch),
35
+ letters,
36
+ suffix,
37
+ upstream: parts.join(".") + letters,
38
+ raw: s.trim(),
39
+ };
40
+ }
41
+
42
+ /** Compare two parsed versions (or strings). A missing patch sorts as 0. */
43
+ export function compareVersions(a, b) {
44
+ const x = typeof a === "string" ? parseVersion(a) : a;
45
+ const y = typeof b === "string" ? parseVersion(b) : b;
46
+ if (!x || !y) throw new Error(`not a version: ${!x ? a : b}`);
47
+ if (x.major !== y.major) return x.major < y.major ? -1 : 1;
48
+ const mx = x.minor ?? 0;
49
+ const my = y.minor ?? 0;
50
+ if (mx !== my) return mx < my ? -1 : 1;
51
+ const px = x.patch ?? 0;
52
+ const py = y.patch ?? 0;
53
+ if (px !== py) return px < py ? -1 : 1;
54
+ if (x.letters !== y.letters) {
55
+ // OpenSSL: 1.1.1 < 1.1.1a < 1.1.1z < 1.1.1za
56
+ if (x.letters.length !== y.letters.length) return x.letters.length < y.letters.length ? -1 : 1;
57
+ return x.letters < y.letters ? -1 : 1;
58
+ }
59
+ return 0;
60
+ }
61
+
62
+ /** Compare branches like "8.3" and "8.10", "20" and "22", "1.1.1" and "3.0". */
63
+ export function compareBranches(a, b) {
64
+ return compareVersions(a, b);
65
+ }
@@ -22,6 +22,7 @@
22
22
  * Each detector returns hits `{ rule, severity, title, detail, line, count }`.
23
23
  * Callers turn hits into findings.
24
24
  */
25
+ import { rulesOf, rulesGeneration, packTest, packMatch } from "../rules/index.js";
25
26
 
26
27
  // ── Invisible characters ──────────────────────────────────────────────────
27
28
 
@@ -200,7 +201,7 @@ function detectWhitespaceInflation(text, opts) {
200
201
 
201
202
  // ── Instructions in hidden places ─────────────────────────────────────────
202
203
 
203
- const INSTRUCTION_PATTERNS = [
204
+ export const INSTRUCTION_PATTERNS = [
204
205
  /\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
206
  /\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
207
  /\bdo(?:n'?t| not) (?:tell|inform|mention|reveal|show|alert|ask|notify)\b/i,
@@ -208,15 +209,17 @@ const INSTRUCTION_PATTERNS = [
208
209
  /\b(?:system prompt|developer message|new instructions?)\b/i,
209
210
  /\b(?:run|execute|exec|invoke)\b[^.\n]{0,40}\b(?:command|script|bash|shell|curl|wget|powershell)\b/i,
210
211
  /\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
+ // Credential paths: lib/rules/core/agent-instructions.json.
213
+ ...rulesOf("agent-instruction", { pack: "core" }).map((r) => r.pattern),
212
214
  ];
215
+ const packInstructions = () => rulesOf("agent-instruction", { exceptPack: "core" });
213
216
 
214
217
  // Text addressed to an automated reviewer. Legitimate skills have no reason to
215
218
  // talk to a security scanner.
216
219
  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
220
 
218
221
  export function looksLikeInstruction(s) {
219
- return INSTRUCTION_PATTERNS.some((re) => re.test(s));
222
+ return INSTRUCTION_PATTERNS.some((re) => re.test(s)) || packInstructions().some((r) => packTest(r, s));
220
223
  }
221
224
 
222
225
  function detectHiddenInstructions(text) {
@@ -250,28 +253,136 @@ function detectHiddenInstructions(text) {
250
253
  // ── Code execution patterns ───────────────────────────────────────────────
251
254
 
252
255
  const SHELL = String.raw`(?:sudo\s+)?(?:env\s+\S*\s*)?(?:ba|z|da|k|fi|c|tc)?sh\b`;
256
+ const INTERPRETER = String.raw`(?:sudo\s+)?(?:python[0-9.]*|node|perl|ruby|php|deno|bun)\b`;
257
+ const PS_DOWNLOAD = String.raw`\b(?:iwr|irm|Invoke-WebRequest|Invoke-RestMethod|DownloadString)\b`;
258
+
259
+ // The patterns of the form `<source> ... | <sink>` are matched by the helpers
260
+ // below rather than by `re` itself (kept as the definition). As one regex
261
+ // they restart the scan at every <source> on a line, which is quadratic on a
262
+ // line full of them; the helpers return the same match in linear time.
263
+
264
+ function matchAt(text, start, end) {
265
+ const m = [text.slice(start, end)];
266
+ m.index = start;
267
+ return m;
268
+ }
269
+
270
+ /** `source[^\n|]*\|\s*sink`: the source sits in the pipeline stage just before the pipe. */
271
+ function pipedFrom(source, sink) {
272
+ const pipe = new RegExp(String.raw`\|\s*${sink}`, "gi");
273
+ return (text) => {
274
+ for (const m of text.matchAll(pipe)) {
275
+ const start = m.index === 0 ? 0 : Math.max(text.lastIndexOf("|", m.index - 1), text.lastIndexOf("\n", m.index - 1)) + 1;
276
+ const s = source.exec(text.slice(start, m.index));
277
+ if (s) return matchAt(text, start + s.index, m.index + m[0].length);
278
+ }
279
+ return null;
280
+ };
281
+ }
282
+
283
+ /**
284
+ * `first[^\n]*second`: leftmost `first`, extended to the last `second` that
285
+ * starts on its line. A `second` that begins with `\|` may run past the line
286
+ * end (`\|\s*sh` spans a newline), so it is tried at each pipe on the line.
287
+ */
288
+ function thenOnLine(first, second) {
289
+ const firstG = new RegExp(first, "gi");
290
+ const secondG = new RegExp(second, "gi");
291
+ const secondY = new RegExp(second, "iy");
292
+ const atPipe = second.startsWith("\\|");
293
+ const lastOnLine = (text, from, eol) => {
294
+ let last = null;
295
+ if (atPipe) {
296
+ for (let p = text.indexOf("|", from); p >= 0 && p < eol; p = text.indexOf("|", p + 1)) {
297
+ secondY.lastIndex = p;
298
+ const b = secondY.exec(text);
299
+ if (b) last = b;
300
+ }
301
+ } else {
302
+ for (const b of text.slice(from, eol).matchAll(secondG)) last = { 0: b[0], index: from + b.index };
303
+ }
304
+ return last;
305
+ };
306
+ return (text) => {
307
+ let skipTo = 0;
308
+ for (const a of text.matchAll(firstG)) {
309
+ if (a.index < skipTo) continue;
310
+ const aEnd = a.index + a[0].length;
311
+ let eol = text.indexOf("\n", aEnd);
312
+ if (eol < 0) eol = text.length;
313
+ // Only the first `first` on a line needs checking: any `second` after a
314
+ // later one is also after this one.
315
+ skipTo = eol;
316
+ const last = lastOnLine(text, aEnd, eol);
317
+ if (last) return matchAt(text, a.index, last.index + last[0].length);
318
+ }
319
+ return null;
320
+ };
321
+ }
322
+
323
+ function earliest(...finders) {
324
+ return (text) => {
325
+ let best = null;
326
+ for (const f of finders) {
327
+ const m = f(text);
328
+ if (m && (!best || m.index < best.index)) best = m;
329
+ }
330
+ return best;
331
+ };
332
+ }
333
+
253
334
  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") },
335
+ {
336
+ rule: "curl-pipe-shell", label: "download piped to a shell",
337
+ re: new RegExp(String.raw`\b(?:curl|wget|fetch|iwr)\b[^\n|]*\|\s*${SHELL}`, "i"),
338
+ match: pipedFrom(/\b(?:curl|wget|fetch|iwr)\b/i, SHELL),
339
+ },
340
+ {
341
+ rule: "curl-pipe-interpreter", label: "download piped to an interpreter",
342
+ re: /\b(?:curl|wget)\b[^\n|]*\|\s*(?:sudo\s+)?(?:python[0-9.]*|node|perl|ruby|php|deno|bun)\b/i,
343
+ match: pipedFrom(/\b(?:curl|wget)\b/i, INTERPRETER),
344
+ },
345
+ // Process and command substitution: lib/rules/core/agent-exec.json.
346
+ ...rulesOf("agent-exec", { pack: "core" }).map((r) => ({ rule: r.id, label: r.label, re: r.pattern })),
347
+ {
348
+ rule: "base64-pipe-shell", label: "decoded base64 piped to a shell",
349
+ re: new RegExp(String.raw`base64\s+(?:-d|--decode|-D)\b[^\n]*\|\s*${SHELL}`, "i"),
350
+ match: thenOnLine(String.raw`base64\s+(?:-d|--decode|-D)\b`, String.raw`\|\s*${SHELL}`),
351
+ },
259
352
  { rule: "shell-eval", label: "eval of command output", re: /\beval\s+["']?\$\(/ },
260
353
  { 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
354
  { 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 },
355
+ {
356
+ rule: "powershell-iex", label: "PowerShell Invoke-Expression of a download",
357
+ 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,
358
+ match: earliest(
359
+ thenOnLine(String.raw`\b(?:iex|Invoke-Expression)\b`, PS_DOWNLOAD),
360
+ thenOnLine(PS_DOWNLOAD, String.raw`\|\s*(?:iex|Invoke-Expression)\b`),
361
+ ),
362
+ },
263
363
  ];
264
364
 
265
365
  /** Find remote-code-execution patterns in a string (a file, a hook command). */
266
366
  export function findExecPatterns(text) {
267
367
  const out = [];
268
- for (const p of EXEC_PATTERNS) {
269
- const m = text.match(p.re);
368
+ for (const p of execPatterns()) {
369
+ const m = p.pack ? packMatch(p.pack, text) : p.match ? p.match(text) : text.match(p.re);
270
370
  if (m) out.push({ rule: p.rule, label: p.label, match: m[0], index: m.index });
271
371
  }
272
372
  return out;
273
373
  }
274
374
 
375
+ // Built-ins, then rules from other packs (bundled or --rules).
376
+ let _exec = null;
377
+ let _execGen = -1;
378
+ function execPatterns() {
379
+ if (_execGen !== rulesGeneration()) {
380
+ _exec = [...EXEC_PATTERNS, ...rulesOf("agent-exec", { exceptPack: "core" }).map((r) => ({ rule: r.id, label: r.label, re: r.pattern, pack: r }))];
381
+ _execGen = rulesGeneration();
382
+ }
383
+ return _exec;
384
+ }
385
+
275
386
  function detectExec(text) {
276
387
  return findExecPatterns(text).map((m) => ({
277
388
  rule: m.rule,
@@ -157,6 +157,26 @@ export function gradeFindings(findings) {
157
157
 
158
158
  // ── Scan ──────────────────────────────────────────────────────────────────
159
159
 
160
+ /**
161
+ * Run `check` on each item; one file that throws (hostile nesting, a parser
162
+ * edge case) is reported and skipped instead of aborting the whole audit.
163
+ */
164
+ function eachChecked(items, hits, fileOf, check) {
165
+ for (const item of items) {
166
+ try {
167
+ check(item);
168
+ } catch (e) {
169
+ hits.push({
170
+ rule: "config-unparsed",
171
+ severity: "medium",
172
+ title: "File could not be checked",
173
+ detail: `${e?.message || e}. Review it by hand.`,
174
+ url: fileOf(item),
175
+ });
176
+ }
177
+ }
178
+ }
179
+
160
180
  /**
161
181
  * Audit agent configuration.
162
182
  * @param {string} root
@@ -171,24 +191,24 @@ export async function scanAgents(root, opts = {}) {
171
191
  const hits = [];
172
192
 
173
193
  progress(`Scanning ${inv.skills.length} skill(s) for hidden content...`);
174
- for (const s of inv.skills) {
194
+ eachChecked(inv.skills, hits, (s) => inv.display(s.skillFile), (s) => {
175
195
  scanSkillFolder(s, inv, hits);
176
196
  hits.push(...checkAllowedTools(inv.display(s.skillFile), s.frontmatter, `Skill "${s.name}"`));
177
- }
197
+ });
178
198
 
179
199
  progress("Scanning instruction and rule files...");
180
- for (const f of inv.instructionFiles) {
200
+ eachChecked(inv.instructionFiles, hits, (f) => f.display, (f) => {
181
201
  scanTextFile(f.path, f.display, hits);
182
202
  if (/\.mdc?$/i.test(f.path)) {
183
203
  let text = "";
184
204
  try { text = readFileSync(f.path, "utf-8"); } catch { /* unreadable */ }
185
205
  hits.push(...checkAllowedTools(f.display, parseFrontmatter(text), "Command"));
186
206
  }
187
- }
207
+ });
188
208
 
189
209
  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));
210
+ eachChecked(inv.mcpServers, hits, (m) => m.file, (m) => hits.push(...checkMcpServer(m)));
211
+ eachChecked(inv.hooks, hits, (h) => h.file, (h) => hits.push(...checkHook(h)));
192
212
  hits.push(...checkPermissions(inv));
193
213
  hits.push(...checkMarketplaces(inv));
194
214
  for (const e of inv.errors) {
@@ -18,13 +18,41 @@ function sha256(data) {
18
18
  return createHash("sha256").update(data).digest("hex");
19
19
  }
20
20
 
21
- /** Stable JSON: object keys sorted at every level. */
21
+ /**
22
+ * Stable JSON: object keys sorted at every level.
23
+ *
24
+ * Iterative (an explicit stack, not recursion) so a config nested hundreds of
25
+ * thousands of levels deep hashes instead of overflowing the call stack.
26
+ */
22
27
  export function canonicalJson(value) {
23
- if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
24
- if (value && typeof value === "object") {
25
- return `{${Object.keys(value).sort().map((k) => `${JSON.stringify(k)}:${canonicalJson(value[k])}`).join(",")}}`;
28
+ const out = [];
29
+ // Pending work, popped from the end: a string is emitted as is, a
30
+ // { v } is a value still to serialise.
31
+ const stack = [{ v: value }];
32
+ while (stack.length) {
33
+ const item = stack.pop();
34
+ if (typeof item === "string") { out.push(item); continue; }
35
+ const v = item.v;
36
+ if (Array.isArray(v)) {
37
+ out.push("[");
38
+ stack.push("]");
39
+ for (let i = v.length - 1; i >= 0; i--) {
40
+ stack.push({ v: v[i] });
41
+ if (i > 0) stack.push(",");
42
+ }
43
+ } else if (v && typeof v === "object") {
44
+ const keys = Object.keys(v).sort();
45
+ out.push("{");
46
+ stack.push("}");
47
+ for (let i = keys.length - 1; i >= 0; i--) {
48
+ stack.push({ v: v[keys[i]] });
49
+ stack.push(`${i > 0 ? "," : ""}${JSON.stringify(keys[i])}:`);
50
+ }
51
+ } else {
52
+ out.push(JSON.stringify(v ?? null));
53
+ }
26
54
  }
27
- return JSON.stringify(value ?? null);
55
+ return out.join("");
28
56
  }
29
57
 
30
58
  /** Hash a folder: every file's path and content hash, plus symlink targets. */
@@ -52,8 +52,24 @@ export function npmSpecPinned(spec) {
52
52
  /** True if a Python spec (`pkg==1.2.3`, `pkg@1.2.3`) pins an exact version. */
53
53
  export function pySpecPinned(spec) {
54
54
  if (/^(?:\.{0,2}\/|file:|git\+|https?:)/.test(spec)) return true;
55
- const m = spec.match(/(?:==|@)([^,;\s]+)$/);
56
- return !!m && /^\d+(?:\.\d+)*(?:[-.+]?[A-Za-z0-9.]+)?$/.test(m[1]) && !/[<>~^*]/.test(spec);
55
+ const version = pinnedVersion(spec);
56
+ // Same language as /^\d+(?:\.\d+)*(?:[-.+]?[A-Za-z0-9.]+)?$/, without its quadratic overlap.
57
+ return version != null && /^(?:\d[A-Za-z0-9.]*|\d+(?:\.\d+)*[-+][A-Za-z0-9.]+)$/.test(version) && !/[<>~^*]/.test(spec);
58
+ }
59
+
60
+ /**
61
+ * The text after the first `==` or `@` in the spec's last run of
62
+ * non-separator characters: what `/(?:==|@)([^,;\s]+)$/` captures, found in
63
+ * linear time (that regex rescans the tail from every `@`).
64
+ */
65
+ function pinnedVersion(spec) {
66
+ let start = spec.length;
67
+ while (start > 0 && !/[,;\s]/.test(spec[start - 1])) start--;
68
+ const tail = spec.slice(start);
69
+ const at = tail.search(/==|@/);
70
+ if (at < 0) return null;
71
+ const version = tail.slice(at + (tail[at] === "@" ? 1 : 2));
72
+ return version || null;
57
73
  }
58
74
 
59
75
  const RUNNERS = [
@@ -260,9 +276,22 @@ export function checkAllowedTools(display, frontmatter, label) {
260
276
 
261
277
  // ── Hooks ─────────────────────────────────────────────────────────────────
262
278
 
263
- const AUTO_APPROVE = /["']?(?:permissionDecision|behavior)["']?\s*\\?["']?\s*:\s*\\?["']?\s*allow\b|["']?decision["']?\s*\\?["']?\s*:\s*\\?["']?\s*approve\b/i;
279
+ // Whitespace, an optional (escaped) quote, whitespace. Written so the two
280
+ // whitespace runs cannot split one run of spaces between them, which made the
281
+ // former `\s*\\?["']?\s*` quadratic on long whitespace.
282
+ const QSEP = String.raw`\s*(?:\\?["']\s*|\\\s*)?`;
283
+ const AUTO_APPROVE = new RegExp(String.raw`["']?(?:permissionDecision|behavior)["']?${QSEP}:${QSEP}allow\b|["']?decision["']?${QSEP}:${QSEP}approve\b`, "i");
264
284
  const PIPE_TO_SHELL = /\|\s*(?:sudo\s+)?(?:ba|z|da|k)?sh\b(?:\s|$|;|&|\))/;
265
285
 
286
+ /** The line holding the first pipe into a shell, for a finding's detail. */
287
+ function pipeToShellLine(text) {
288
+ const m = /\|\s*(?:sudo\s+)?(?:ba|z|da|k)?sh\b/.exec(text);
289
+ if (!m) return null;
290
+ const start = text.lastIndexOf("\n", m.index) + 1;
291
+ const end = text.indexOf("\n", m.index + m[0].length);
292
+ return text.slice(start, end < 0 ? text.length : end);
293
+ }
294
+
266
295
  function hookScript(hook, command) {
267
296
  const env = {
268
297
  CLAUDE_PROJECT_DIR: hook.baseDir,
@@ -306,7 +335,7 @@ export function checkHook(h) {
306
335
  rule: "hook-pipe-shell",
307
336
  severity: "high",
308
337
  title: `${where} hook pipes into a shell`,
309
- detail: `Hook ${s.label} pipes data into \`sh\`/\`bash\`: \`${preview(s.text.match(/[^\n]*\|\s*(?:sudo\s+)?(?:ba|z|da|k)?sh\b[^\n]*/)?.[0] || s.text, 120)}\`. Whatever produces that input controls the commands.`,
338
+ detail: `Hook ${s.label} pipes data into \`sh\`/\`bash\`: \`${preview(pipeToShellLine(s.text) || s.text, 120)}\`. Whatever produces that input controls the commands.`,
310
339
  url: h.file,
311
340
  });
312
341
  break;
@@ -32,13 +32,13 @@ const NIS2_MEASURES = [
32
32
  probes: [], categories: [],
33
33
  note: "Organisational, outside a scanner's reach." },
34
34
  { id: "d", title: "Supply-chain security", level: "partial",
35
- probes: ["retirejs", "secret-scanning"],
36
- categories: ["cve", "secrets", "js-vulnerability"],
37
- note: "JS dependency CVEs (Retire.js), leaked credentials (secret scan), a CycloneDX SBOM (--sbom), and passive supplier rating (--suppliers)." },
35
+ probes: ["retirejs", "secret-scanning", "runtime-advisories"],
36
+ categories: ["cve", "secrets", "js-vulnerability", "runtime-advisory"],
37
+ note: "JS dependency CVEs (Retire.js), runtime and server advisories (PHP, nginx, Apache, Node.js, Python, OpenSSL), leaked credentials (secret scan), a CycloneDX SBOM (--sbom), and passive supplier rating (--suppliers)." },
38
38
  { id: "e", title: "Secure acquisition, development & maintenance; vulnerability handling", level: "strong",
39
39
  probes: ["reachability", "endpoint-discovery", "openapi", "sensitive-files", "cors",
40
40
  "security-headers", "email-dns", "sqli", "ssti", "ssrf", "xss", "cmdi", "open-redirect",
41
- "api-fuzzing", "risky-patterns"],
41
+ "api-fuzzing", "risky-patterns", "runtime-advisories"],
42
42
  categories: ["*"],
43
43
  note: "The core scan: black- and white-box testing mapped to OWASP WSTG, with SARIF and baseline regression." },
44
44
  { id: "f", title: "Assessing the effectiveness of measures", level: "evidence",
package/lib/crawler.js CHANGED
@@ -10,7 +10,7 @@
10
10
  * - ffuf, wordlist-based endpoint brute-forcing
11
11
  */
12
12
 
13
- import { safeFetch } from "../src/utils/http.js";
13
+ import { safeFetch, readBody } from "../src/utils/http.js";
14
14
 
15
15
  const DEFAULT_TIMEOUT = 8_000;
16
16
 
@@ -101,7 +101,7 @@ export async function discoverEndpoints(baseUrl, opts = {}) {
101
101
  // which is often absent on chunked responses. This matches the unit the
102
102
  // calibration baseline uses (text.length) so the noise filter in
103
103
  // autoCalibrate compares like with like instead of header-vs-body.
104
- const body = await res.text().catch(() => "");
104
+ const body = await readBody(res).catch(() => "");
105
105
  return {
106
106
  path,
107
107
  status: res.status,
@@ -145,6 +145,11 @@ export async function fingerprint(baseUrl) {
145
145
  if (!res) return tech;
146
146
 
147
147
  tech.headers = Object.fromEntries(res.headers.entries());
148
+ // Cookie names only (never values): PHPSESSID says PHP even when the version is hidden.
149
+ tech.cookieNames = (res.headers.getSetCookie?.() || [])
150
+ .map((c) => c.split("=")[0].trim())
151
+ .filter((n) => /^[\w.-]{1,64}$/.test(n))
152
+ .slice(0, 20);
148
153
 
149
154
  // Server detection
150
155
  const server = res.headers.get("server") || "";
@@ -167,13 +172,16 @@ export async function fingerprint(baseUrl) {
167
172
 
168
173
  // Next.js detection from HTML
169
174
  try {
170
- const html = await res.text();
175
+ const html = await readBody(res);
171
176
  if (html.includes("/_next/static") || html.includes("__NEXT_DATA__")) {
172
177
  tech.framework = "Next.js";
173
178
  }
174
179
  if (html.includes("__nuxt")) tech.framework = "Nuxt";
175
180
  if (html.includes("ng-version")) tech.framework = "Angular";
176
181
  if (html.includes("data-reactroot") || html.includes("__react")) tech.framework = "React";
182
+ // First same-site link to a .php page, as evidence that PHP serves the site.
183
+ const php = /\shref=["']((?:\/|\.\/|(?![a-z][a-z0-9+.-]*:))[^"'\s>]{0,200}?\.php(?:[?#][^"'\s>]{0,100})?)["']/i.exec(html);
184
+ tech.phpLink = php ? php[1] : null;
177
185
  } catch { /* ignore */ }
178
186
 
179
187
  // Security header audit
@@ -208,7 +216,7 @@ export async function autoCalibrate(baseUrl) {
208
216
  ]) {
209
217
  const res = await safeFetch(`${baseUrl}${garbage}`, { timeout: DEFAULT_TIMEOUT, probe: "endpoint-discovery" });
210
218
  if (res) {
211
- const text = await res.text().catch(() => "");
219
+ const text = await readBody(res).catch(() => "");
212
220
  baselines.push({
213
221
  status: res.status,
214
222
  size: text.length,
@@ -234,14 +242,3 @@ export async function autoCalibrate(baseUrl) {
234
242
  };
235
243
  }
236
244
 
237
- /**
238
- * Compare crawled endpoints with filesystem-discovered routes.
239
- * Returns "shadow" endpoints, routes that exist at runtime but aren't in source.
240
- */
241
- export function findShadowEndpoints(crawledPaths, filesystemRoutes) {
242
- const knownPaths = new Set(filesystemRoutes.map(r => r.url));
243
- return crawledPaths.filter(ep => {
244
- const normalized = ep.path.replace(/\/$/, "");
245
- return !knownPaths.has(normalized) && ep.status !== 404;
246
- });
247
- }
@@ -79,7 +79,10 @@ const SEV_RANK = { critical: 4, high: 3, medium: 2, low: 1, info: 0 };
79
79
  export const maxSeverity = (sevs) => sevs.reduce((m, s) => (SEV_RANK[s] > SEV_RANK[m] ? s : m), "info");
80
80
 
81
81
  const JS_EXT = /\.(c|m)?jsx?$/i;
82
- const MAX_JS_BYTES = 8 * 1024 * 1024;
82
+ // Large enough for real bundles (typescript.js and editor workers are 9-13 MB),
83
+ // and the same as the tarball reader's per-file cap (source.js MAX_FILE_BYTES).
84
+ // A file over it is not read, and the package is then unscanned, never clean.
85
+ const MAX_JS_BYTES = 64 * 1024 * 1024;
83
86
  const MAX_EVIDENCE = 5;
84
87
  const EXFIL_WINDOW = 40; // lines
85
88
  const NEAR_WINDOW = 30; // lines
@@ -451,11 +454,13 @@ export function nodeScriptTargets(cmd) {
451
454
 
452
455
  /**
453
456
  * Scan a package through a source (see source.js).
454
- * @param {{ files(): string[], read(rel): Buffer, size(rel): number }} source
455
- * @param {{ prefix?: string }} opts prefix is prepended to evidence paths (e.g. node_modules/foo/)
456
- * @returns {{ name, version, capabilities, installScripts, exfil, filesScanned, skipped, unscanned }}
457
+ * @param {{ files(): string[], read(rel): Buffer, size(rel): number, anomaly?: string, truncated?: boolean }} source
458
+ * @param {{ prefix?: string, iocs?: ReturnType<import("./iocs.js").loadIocs> }} opts prefix is
459
+ * prepended to evidence paths (e.g. node_modules/foo/); iocs are rule-pack indicators to look for
460
+ * @returns {{ name, version, capabilities, installScripts, exfil, filesScanned, skipped, unscanned, iocHits }}
457
461
  * unscanned: why the package could not be fully read (archive anomaly, no
458
- * package.json), else null. Callers must not treat such a package as clean.
462
+ * package.json, a code file too large to scan, more files than the source
463
+ * lists), else null. Callers must not treat such a package as clean.
459
464
  */
460
465
  export function scanPackage(source, opts = {}) {
461
466
  const prefix = opts.prefix || "";
@@ -474,6 +479,8 @@ export function scanPackage(source, opts = {}) {
474
479
  pkg = {};
475
480
  unscanned = unscanned || "package.json is missing or not valid JSON, so its install scripts are unknown";
476
481
  }
482
+ const iocs = opts.iocs || null;
483
+ const iocHits = iocs ? [...iocs.scanFiles(files, source, prefix), ...iocs.scanText(pkgText, `${prefix}package.json`)] : [];
477
484
 
478
485
  const installScripts = {};
479
486
  for (const hook of INSTALL_HOOKS) {
@@ -507,12 +514,20 @@ export function scanPackage(source, opts = {}) {
507
514
  let src;
508
515
  try { src = source.read(f).toString("utf-8"); } catch { continue; }
509
516
  filesScanned++;
517
+ if (iocs) iocHits.push(...iocs.scanText(src, `${prefix}${f}`));
510
518
  const r = scanSource(src, `${prefix}${f}`);
511
519
  fileCaps[f] = Object.keys(r.caps);
512
520
  if (r.aiConfigProject) aiConfigProject[f] = r.aiConfigProject;
513
521
  mergeCaps(caps, r.caps);
514
522
  if (r.exfil) exfil.push(r.exfil);
515
523
  }
524
+ // Code that was never read is not clean code: fail closed.
525
+ if (source.truncated) {
526
+ unscanned = unscanned || `the package has more than ${source.maxFiles} files, so some were never read`;
527
+ }
528
+ if (skipped.length) {
529
+ unscanned = unscanned || `${skipped.length} code file(s) over ${MAX_JS_BYTES / 1024 / 1024} MB were not scanned (${skipped.slice(0, 3).join(", ")}${skipped.length > 3 ? ", ..." : ""})`;
530
+ }
516
531
 
517
532
  // Install scripts that are themselves suspicious: shell download pipes, or a
518
533
  // `node file.js` hook whose file reaches the network, decodes payloads, etc.
@@ -556,5 +571,6 @@ export function scanPackage(source, opts = {}) {
556
571
  filesScanned,
557
572
  skipped,
558
573
  unscanned,
574
+ iocHits,
559
575
  };
560
576
  }