penthera 1.2.0 → 1.4.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 (88) hide show
  1. package/CHANGELOG.md +212 -0
  2. package/README.md +379 -39
  3. package/docs/METHODOLOGY.md +321 -0
  4. package/docs/evidence.md +158 -0
  5. package/docs/examples/github-actions-deps.yml +105 -0
  6. package/docs/examples/github-actions-scan.yml +1 -1
  7. package/docs/examples/pr-dependency-review.yml +56 -0
  8. package/docs/funding/due-diligence-checklist.md +79 -0
  9. package/docs/funding/nlnet-ngi-zero-draft.md +273 -0
  10. package/docs/funding/partner-one-pager.md +84 -0
  11. package/docs/funding/sponsor-tiers.md +81 -0
  12. package/docs/index.md +12 -1
  13. package/docs/local-loop.md +276 -0
  14. package/docs/references.bib +539 -0
  15. package/docs/references.md +77 -0
  16. package/docs/release-plan.md +228 -0
  17. package/lib/adaptive-probes.js +8 -5
  18. package/lib/agents/discover.js +384 -0
  19. package/lib/agents/hidden-content.js +387 -0
  20. package/lib/agents/index.js +286 -0
  21. package/lib/agents/lock.js +115 -0
  22. package/lib/agents/owasp.js +83 -0
  23. package/lib/agents/rules.js +376 -0
  24. package/lib/crawler.js +5 -15
  25. package/lib/data/retire-jsrepository.json.gz +0 -0
  26. package/lib/data/retire-jsrepository.meta.json +7 -0
  27. package/lib/deps/capabilities.js +523 -0
  28. package/lib/deps/diff.js +84 -0
  29. package/lib/deps/index.js +416 -0
  30. package/lib/deps/lockfile.js +106 -0
  31. package/lib/deps/online.js +132 -0
  32. package/lib/deps/popular-packages.json +1 -0
  33. package/lib/deps/report.js +173 -0
  34. package/lib/deps/source.js +171 -0
  35. package/lib/deps/typosquat.js +113 -0
  36. package/lib/fix/apply.js +95 -0
  37. package/lib/fix/diff.js +199 -0
  38. package/lib/fix/index.js +129 -0
  39. package/lib/fix/model.js +108 -0
  40. package/lib/fix/recipes/cookies.js +130 -0
  41. package/lib/fix/recipes/cors.js +116 -0
  42. package/lib/fix/recipes/headers.js +250 -0
  43. package/lib/fix/recipes/rate-limit.js +211 -0
  44. package/lib/fix/recipes/secrets.js +110 -0
  45. package/lib/fix/util.js +231 -0
  46. package/lib/fuzzer.js +14 -8
  47. package/lib/injections.js +19 -15
  48. package/lib/install/index.js +271 -0
  49. package/lib/install/policy.js +117 -0
  50. package/lib/memory/history.js +52 -0
  51. package/lib/memory/index.js +111 -0
  52. package/lib/memory/repo-suppress.js +64 -0
  53. package/lib/memory/store.js +325 -0
  54. package/lib/memory/views.js +198 -0
  55. package/lib/params.js +4 -12
  56. package/lib/provenance.js +2 -0
  57. package/lib/recon.js +12 -20
  58. package/lib/references.js +31 -0
  59. package/lib/references.json +374 -0
  60. package/lib/remediation/index.js +28 -0
  61. package/lib/retirejs.js +50 -22
  62. package/lib/templates.js +7 -10
  63. package/lib/whitebox/frameworks.js +1 -1
  64. package/package.json +16 -4
  65. package/skills/penthera/SKILL.md +103 -22
  66. package/src/agent/git.js +87 -0
  67. package/src/agent/loop.js +519 -0
  68. package/src/agent/tests.js +48 -0
  69. package/src/cli/agent.js +183 -0
  70. package/src/cli/deps.js +84 -0
  71. package/src/cli/evidence.js +67 -0
  72. package/src/cli/fix.js +191 -0
  73. package/src/cli/install.js +225 -0
  74. package/src/cli/memory.js +146 -0
  75. package/src/cli/merge-results.js +2 -0
  76. package/src/cli/report-outputs.js +72 -0
  77. package/src/cli/run-scan.js +2 -0
  78. package/src/cli.js +152 -6
  79. package/src/report/audience.js +270 -0
  80. package/src/report/evidence.js +253 -0
  81. package/src/report/markdown.js +65 -2
  82. package/src/report/model.js +212 -0
  83. package/src/report/template.js +175 -0
  84. package/src/report/timeline.js +145 -0
  85. package/src/reporter.js +109 -5
  86. package/src/scan-url.js +31 -7
  87. package/src/utils/http.js +252 -11
  88. package/src/utils/url.js +23 -0
@@ -0,0 +1,115 @@
1
+ /**
2
+ * penthera.lock: SHA-256 pins for skills, MCP server definitions, hooks and
3
+ * installed plugins. A skill or server that changes after you reviewed it (a
4
+ * "rug pull") shows up as a changed entry on the next run.
5
+ *
6
+ * The file is deterministic (sorted keys, no timestamps) so it diffs cleanly
7
+ * in git.
8
+ */
9
+ import { createHash } from "node:crypto";
10
+ import { readFileSync, writeFileSync, readlinkSync, existsSync } from "node:fs";
11
+ import { join, relative, sep } from "node:path";
12
+ import { walk } from "./discover.js";
13
+
14
+ export const LOCK_FILENAME = "penthera.lock";
15
+ const LOCK_VERSION = 1;
16
+
17
+ function sha256(data) {
18
+ return createHash("sha256").update(data).digest("hex");
19
+ }
20
+
21
+ /** Stable JSON: object keys sorted at every level. */
22
+ 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(",")}}`;
26
+ }
27
+ return JSON.stringify(value ?? null);
28
+ }
29
+
30
+ /** Hash a folder: every file's path and content hash, plus symlink targets. */
31
+ export function hashDir(dir) {
32
+ const { files, symlinks } = walk(dir, { maxDepth: 12, maxFiles: 20000 });
33
+ const lines = [];
34
+ for (const f of files) {
35
+ let content;
36
+ try { content = readFileSync(f); } catch { content = Buffer.alloc(0); }
37
+ lines.push(`${relative(dir, f).split(sep).join("/")}\0${sha256(content)}`);
38
+ }
39
+ for (const l of symlinks) {
40
+ let target = "";
41
+ try { target = readlinkSync(l); } catch { /* broken */ }
42
+ lines.push(`${relative(dir, l).split(sep).join("/")}\0link:${target}`);
43
+ }
44
+ lines.sort();
45
+ return sha256(lines.join("\n"));
46
+ }
47
+
48
+ function scopeOf(display) {
49
+ return display.startsWith("~/") || display.startsWith("/") ? "home" : "repo";
50
+ }
51
+
52
+ /** Compute the current lock entries for an inventory. */
53
+ export function computeLockEntries(inv) {
54
+ const entries = {};
55
+ for (const s of inv.skills) {
56
+ entries[`skill:${s.display}`] = { kind: "skill", scope: scopeOf(s.display), sha256: hashDir(s.dir) };
57
+ }
58
+ for (const m of inv.mcpServers) {
59
+ entries[`mcp:${m.file}#${m.name}`] = { kind: "mcp", scope: scopeOf(m.file), sha256: sha256(canonicalJson(m.def)) };
60
+ }
61
+ for (const h of inv.hooks) {
62
+ entries[h.key] = { kind: "hook", scope: scopeOf(h.file), sha256: sha256(canonicalJson({ event: h.event, matcher: h.matcher, hook: h.hook })) };
63
+ }
64
+ for (const p of inv.plugins) {
65
+ entries[`plugin:${p.id}`] = { kind: "plugin", scope: "home", sha256: sha256(canonicalJson({ version: p.version, gitCommitSha: p.gitCommitSha })) };
66
+ }
67
+ return entries;
68
+ }
69
+
70
+ export function serializeLock(entries) {
71
+ const sorted = {};
72
+ for (const k of Object.keys(entries).sort()) sorted[k] = entries[k];
73
+ return JSON.stringify({
74
+ lockfileVersion: LOCK_VERSION,
75
+ generator: "penthera agents --pin",
76
+ note: "SHA-256 pins for agent skills, MCP servers, hooks and plugins. Review changes before re-pinning.",
77
+ entries: sorted,
78
+ }, null, 2) + "\n";
79
+ }
80
+
81
+ export function lockPath(root) {
82
+ return join(root, LOCK_FILENAME);
83
+ }
84
+
85
+ /** Read a lock file. Returns null when absent; throws on a corrupt file. */
86
+ export function readLock(path) {
87
+ if (!existsSync(path)) return null;
88
+ const json = JSON.parse(readFileSync(path, "utf-8"));
89
+ if (!json || typeof json.entries !== "object") throw new Error("missing `entries`");
90
+ return json;
91
+ }
92
+
93
+ export function writeLock(path, entries) {
94
+ writeFileSync(path, serializeLock(entries));
95
+ }
96
+
97
+ /**
98
+ * Diff a lock against current entries. Entries for a scope that wasn't scanned
99
+ * this run (home, without --home) are not reported as removed.
100
+ */
101
+ export function diffLock(lock, current, { scopes = ["repo"] } = {}) {
102
+ const pinned = lock?.entries || {};
103
+ const changed = [];
104
+ const added = [];
105
+ const removed = [];
106
+ for (const [key, cur] of Object.entries(current)) {
107
+ const old = pinned[key];
108
+ if (!old) added.push({ key, ...cur });
109
+ else if (old.sha256 !== cur.sha256) changed.push({ key, ...cur, previous: old.sha256 });
110
+ }
111
+ for (const [key, old] of Object.entries(pinned)) {
112
+ if (!current[key] && scopes.includes(old.scope || "repo")) removed.push({ key, ...old });
113
+ }
114
+ return { changed, added, removed };
115
+ }
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Map agent-config rules to the OWASP MCP Top 10 (2025) and the OWASP Top 10
3
+ * for Agentic Applications (2026, "ASI") so findings line up with the
4
+ * frameworks people already report against.
5
+ */
6
+
7
+ export const OWASP_NAMES = {
8
+ MCP01: "MCP01:2025 Token Mismanagement & Secret Exposure",
9
+ MCP02: "MCP02:2025 Privilege Escalation via Scope Creep",
10
+ MCP03: "MCP03:2025 Tool Poisoning",
11
+ MCP04: "MCP04:2025 Software Supply Chain Attacks & Dependency Tampering",
12
+ MCP05: "MCP05:2025 Command Injection & Execution",
13
+ MCP07: "MCP07:2025 Insufficient Authentication & Authorization",
14
+ MCP09: "MCP09:2025 Shadow MCP Servers",
15
+ ASI01: "ASI01 Agent Goal Hijack",
16
+ ASI02: "ASI02 Tool Misuse & Exploitation",
17
+ ASI03: "ASI03 Identity & Privilege Abuse",
18
+ ASI04: "ASI04 Agentic Supply Chain Vulnerabilities",
19
+ ASI05: "ASI05 Unexpected Code Execution",
20
+ ASI06: "ASI06 Memory & Context Poisoning",
21
+ ASI07: "ASI07 Insecure Inter-Agent Communication",
22
+ ASI09: "ASI09 Human-Agent Trust Exploitation",
23
+ };
24
+
25
+ const HIDDEN = ["MCP03", "ASI01"];
26
+ const EXEC = ["MCP05", "ASI05"];
27
+ const SUPPLY = ["MCP04", "ASI04"];
28
+
29
+ export const RULE_OWASP = {
30
+ // Hidden content
31
+ "unicode-tags": HIDDEN,
32
+ "variation-selectors": HIDDEN,
33
+ "bidi-controls": HIDDEN,
34
+ "zero-width": HIDDEN,
35
+ "whitespace-vertical": HIDDEN,
36
+ "whitespace-horizontal": HIDDEN,
37
+ "html-comment-instructions": HIDDEN,
38
+ "scanner-directed": HIDDEN,
39
+ "base64-blob": ["MCP03", "ASI04"],
40
+ "embedded-binary": SUPPLY,
41
+ "unknown-binary": SUPPLY,
42
+ "symlink-escape": ["MCP03", "ASI04"],
43
+ "file-too-large": ["MCP03", "ASI04"],
44
+ // Code execution
45
+ "curl-pipe-shell": EXEC,
46
+ "curl-pipe-interpreter": EXEC,
47
+ "shell-process-substitution": EXEC,
48
+ "shell-command-substitution": EXEC,
49
+ "base64-pipe-shell": EXEC,
50
+ "shell-eval": EXEC,
51
+ "js-eval-remote": EXEC,
52
+ "py-exec-remote": EXEC,
53
+ "powershell-iex": EXEC,
54
+ // MCP servers
55
+ "mcp-unpinned": SUPPLY,
56
+ "mcp-inline-secret": ["MCP01", "ASI03"],
57
+ "mcp-plain-http": ["MCP07", "ASI07"],
58
+ "mcp-exec": EXEC,
59
+ // Hooks and permissions
60
+ "hook-exec": EXEC,
61
+ "hook-pipe-shell": EXEC,
62
+ "hook-auto-approve": ["MCP02", "ASI09"],
63
+ "permission-wildcard": ["MCP02", "ASI03"],
64
+ "permission-bypass-mode": ["MCP02", "ASI09"],
65
+ "permission-credential-read": ["MCP01", "ASI03"],
66
+ // Plugins
67
+ "marketplace-unpinned": SUPPLY,
68
+ "plugin-source-unpinned": SUPPLY,
69
+ // Lock file
70
+ "lock-changed": ["MCP03", "ASI04"],
71
+ "lock-new": ["MCP09", "ASI04"],
72
+ "lock-removed": ["MCP09"],
73
+ "lock-missing": ["MCP09"],
74
+ "config-unparsed": [],
75
+ };
76
+
77
+ export function owaspFor(rule) {
78
+ return RULE_OWASP[rule] || [];
79
+ }
80
+
81
+ export function owaspLabel(ids) {
82
+ return ids.map((id) => OWASP_NAMES[id] || id).join(" · ");
83
+ }
@@ -0,0 +1,376 @@
1
+ /**
2
+ * Risky-configuration rules for MCP servers, hooks, permissions, skills'
3
+ * allowed-tools, and plugin marketplaces. Pure functions over the inventory
4
+ * from discover.js; each returns `{ rule, severity, title, detail, url }` hits.
5
+ */
6
+ import { basename, isAbsolute, resolve } from "node:path";
7
+ import { findSecretsInText } from "../whitebox/secrets.js";
8
+ import { findExecPatterns, preview } from "./hidden-content.js";
9
+ import { readText, isFile } from "./discover.js";
10
+
11
+ // ── Helpers ───────────────────────────────────────────────────────────────
12
+
13
+ function mask(s) {
14
+ const v = String(s);
15
+ return v.length <= 8 ? "***" : v.slice(0, 4) + "…" + v.slice(-4);
16
+ }
17
+
18
+ /** Split a command line on whitespace, honouring simple quotes. */
19
+ export function splitCommand(s) {
20
+ return (String(s).match(/"[^"]*"|'[^']*'|\S+/g) || []).map((t) => t.replace(/^(["'])(.*)\1$/, "$2"));
21
+ }
22
+
23
+ function commandTokens(def) {
24
+ const cmd = def.command ? splitCommand(def.command) : [];
25
+ const args = Array.isArray(def.args) ? def.args.map(String) : [];
26
+ return [...cmd, ...args];
27
+ }
28
+
29
+ const LOCAL_HOST = /^(?:localhost|127(?:\.\d+){3}|\[?::1\]?|0\.0\.0\.0|[\w-]+\.localhost|host\.docker\.internal)$/i;
30
+
31
+ function isPlainRemoteHttp(u) {
32
+ if (typeof u !== "string" || !/^(?:http|ws):\/\//i.test(u)) return false;
33
+ try {
34
+ return !LOCAL_HOST.test(new URL(u.replace(/^ws:/i, "http:")).hostname);
35
+ } catch {
36
+ return false;
37
+ }
38
+ }
39
+
40
+ // ── MCP: unpinned package runners ─────────────────────────────────────────
41
+
42
+ const EXACT_SEMVER = /^v?\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/;
43
+
44
+ /** True if an npm spec (`pkg@1.2.3`, `@scope/pkg@1.2.3`) pins an exact version. */
45
+ export function npmSpecPinned(spec) {
46
+ if (/^(?:\.{0,2}\/|file:|git\+|https?:|[A-Za-z]:\\)/.test(spec)) return true; // local path or explicit URL: not a registry lookup
47
+ const at = spec.startsWith("@") ? spec.indexOf("@", 1) : spec.indexOf("@");
48
+ if (at < 0) return false;
49
+ return EXACT_SEMVER.test(spec.slice(at + 1));
50
+ }
51
+
52
+ /** True if a Python spec (`pkg==1.2.3`, `pkg@1.2.3`) pins an exact version. */
53
+ export function pySpecPinned(spec) {
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);
57
+ }
58
+
59
+ const RUNNERS = [
60
+ { name: "npx", match: (t) => /^npx(?:\.cmd)?$/.test(basename(t[0])), from: 1, eco: "npm", valueOpts: ["-p", "--package", "-c", "--call", "--cache", "--registry"], pkgOpts: ["-p", "--package"], autoYes: (a) => a.includes("-y") || a.includes("--yes") },
61
+ { name: "bunx", match: (t) => /^bunx$/.test(basename(t[0])), from: 1, eco: "npm", valueOpts: ["-p", "--package"], pkgOpts: ["-p", "--package"], autoYes: () => true },
62
+ { name: "pnpm dlx", match: (t) => /^pnpm$/.test(basename(t[0])) && t[1] === "dlx", from: 2, eco: "npm", valueOpts: ["--package"], pkgOpts: ["--package"], autoYes: () => true },
63
+ { name: "yarn dlx", match: (t) => /^yarn$/.test(basename(t[0])) && t[1] === "dlx", from: 2, eco: "npm", valueOpts: ["-p", "--package"], pkgOpts: ["-p", "--package"], autoYes: () => true },
64
+ { name: "uvx", match: (t) => /^uvx$/.test(basename(t[0])), from: 1, eco: "py", valueOpts: ["--from", "--with", "--python", "-p", "--index-url", "--index"], pkgOpts: ["--from"], autoYes: () => true },
65
+ { name: "uv tool run", match: (t) => /^uv$/.test(basename(t[0])) && t[1] === "tool" && t[2] === "run", from: 3, eco: "py", valueOpts: ["--from", "--with", "--python", "-p"], pkgOpts: ["--from"], autoYes: () => true },
66
+ { name: "pipx run", match: (t) => /^pipx$/.test(basename(t[0])) && t[1] === "run", from: 2, eco: "py", valueOpts: ["--spec", "--python", "--index-url"], pkgOpts: ["--spec"], autoYes: () => true },
67
+ ];
68
+
69
+ /** Identify the package an MCP launch command installs on the fly, if any. */
70
+ export function runnerPackage(tokens) {
71
+ const runner = RUNNERS.find((r) => tokens.length && r.match(tokens));
72
+ if (!runner) return null;
73
+ const args = tokens.slice(runner.from);
74
+ let pkg = null;
75
+ for (let i = 0; i < args.length; i++) {
76
+ const a = args[i];
77
+ const [flag, inline] = a.includes("=") ? [a.slice(0, a.indexOf("=")), a.slice(a.indexOf("=") + 1)] : [a, null];
78
+ if (runner.pkgOpts.includes(flag)) { pkg = inline ?? args[i + 1]; break; }
79
+ if (a.startsWith("-")) {
80
+ if (runner.valueOpts.includes(flag) && inline == null) i++;
81
+ continue;
82
+ }
83
+ pkg = a;
84
+ break;
85
+ }
86
+ if (!pkg) return null;
87
+ const pinned = runner.eco === "npm" ? npmSpecPinned(pkg) : pySpecPinned(pkg);
88
+ return { runner: runner.name, pkg, pinned, autoYes: runner.autoYes(args) };
89
+ }
90
+
91
+ // ── MCP rules ─────────────────────────────────────────────────────────────
92
+
93
+ const SECRET_NAME = /(?:token|secret|passw(?:or)?d|api[_-]?key|access[_-]?key|private[_-]?key|client[_-]?secret|auth|credential|bearer|cookie)/i;
94
+ const NOT_A_SECRET = /^(?:\$\{?[\w:-]+\}?|\$\w+|%\w+%|<[^>]+>|\{\{[^}]+\}\}|env:.*|op:\/\/.*|your[-_ ].*|xxx+.*|changeme|placeholder|\*+|true|false|none|null|\d{1,6})$/i;
95
+
96
+ function inlineSecretHits(kind, k, v) {
97
+ const value = String(v ?? "");
98
+ if (!value || NOT_A_SECRET.test(value.trim())) return [];
99
+ const hits = findSecretsInText(`${k}="${value}"`).map((h) => ({ name: h.name, severity: h.severity, match: h.match }));
100
+ if (hits.length) return hits;
101
+ // Bearer tokens in headers, and long literals under a secret-looking name.
102
+ const bearer = value.match(/^(?:Bearer|Basic|Token)\s+(\S{12,})$/i);
103
+ if (bearer && !NOT_A_SECRET.test(bearer[1])) return [{ name: `${kind} credential`, severity: "high", match: bearer[1] }];
104
+ if (SECRET_NAME.test(k) && value.length >= 12 && !/\s/.test(value)) {
105
+ return [{ name: "Credential-like value", severity: "high", match: value }];
106
+ }
107
+ return [];
108
+ }
109
+
110
+ export function checkMcpServer(server) {
111
+ const { name, def, file } = server;
112
+ const hits = [];
113
+ const tokens = commandTokens(def);
114
+
115
+ const run = runnerPackage(tokens);
116
+ if (run && !run.pinned) {
117
+ hits.push({
118
+ rule: "mcp-unpinned",
119
+ severity: run.autoYes ? "medium" : "low",
120
+ title: `MCP server "${name}" runs an unpinned package via ${run.runner}`,
121
+ detail: `\`${preview([...tokens].join(" "), 120)}\` installs \`${run.pkg}\` from the registry at launch${run.autoYes ? " without a prompt" : ""}, at whatever version is current. A compromised or hijacked release runs on your machine with the agent's access. Pin an exact version (\`${run.pkg.replace(/@[^@/]*$/, "")}@x.y.z\`${run.runner.includes("uv") || run.runner.includes("pipx") ? " or `pkg==x.y.z`" : ""}).`,
122
+ url: file,
123
+ });
124
+ }
125
+
126
+ const exec = findExecPatterns(tokens.join(" "));
127
+ if (exec.length) {
128
+ hits.push({
129
+ rule: "mcp-exec",
130
+ severity: "critical",
131
+ title: `MCP server "${name}" launches by downloading and running code`,
132
+ detail: `Launch command contains a ${exec[0].label}: \`${preview(exec[0].match, 120)}\`.`,
133
+ url: file,
134
+ });
135
+ }
136
+
137
+ const secretSources = [];
138
+ if (def.env && typeof def.env === "object") for (const [k, v] of Object.entries(def.env)) secretSources.push(["env", k, v]);
139
+ if (def.headers && typeof def.headers === "object") for (const [k, v] of Object.entries(def.headers)) secretSources.push(["header", k, v]);
140
+ tokens.forEach((t, i) => {
141
+ const flag = tokens[i - 1] || "";
142
+ if (/^--?[\w-]*(?:key|token|secret|password)/i.test(flag)) secretSources.push(["arg", flag.replace(/^-+/, ""), t]);
143
+ else if (t.includes("=")) secretSources.push(["arg", t.slice(0, t.indexOf("=")).replace(/^-+/, ""), t.slice(t.indexOf("=") + 1)]);
144
+ else secretSources.push(["arg", "arg", t]);
145
+ });
146
+ const url = def.url || def.serverUrl || def.endpoint;
147
+ if (typeof url === "string") {
148
+ try {
149
+ const u = new URL(url);
150
+ for (const [k, v] of u.searchParams) secretSources.push(["url", k, v]);
151
+ } catch { /* not a URL */ }
152
+ }
153
+ const reported = new Set();
154
+ for (const [kind, k, v] of secretSources) {
155
+ for (const h of inlineSecretHits(kind, k, v)) {
156
+ if (reported.has(k)) continue;
157
+ reported.add(k);
158
+ hits.push({
159
+ rule: "mcp-inline-secret",
160
+ severity: h.severity === "critical" ? "critical" : "high",
161
+ title: `MCP server "${name}" has an inline secret in ${kind} \`${k}\``,
162
+ detail: `${h.name} stored in plain text in the config (\`${mask(h.match)}\`). Config files get synced, shared and committed. Reference an environment variable instead (\`"${k}": "\${${k.toUpperCase().replace(/[^A-Z0-9]+/g, "_")}}"\`) and rotate this one.`,
163
+ url: file,
164
+ });
165
+ }
166
+ }
167
+
168
+ const remote = [url, ...tokens].find(isPlainRemoteHttp);
169
+ if (remote) {
170
+ hits.push({
171
+ rule: "mcp-plain-http",
172
+ severity: "high",
173
+ title: `MCP server "${name}" connects over plain HTTP`,
174
+ detail: `\`${preview(remote, 100)}\` is not TLS. Anyone on the network path can read the tool traffic and inject tool results or descriptions. Use https://.`,
175
+ url: file,
176
+ });
177
+ }
178
+ return hits;
179
+ }
180
+
181
+ // ── Permissions ───────────────────────────────────────────────────────────
182
+
183
+ const SHELL_TOOL = "(?:Bash|Shell|PowerShell|run_terminal_cmd|terminal)";
184
+ const UNRESTRICTED = new RegExp(`^(?:\\*|${SHELL_TOOL}(?:\\(\\s*(?:\\*|:\\*|\\*\\*|\\*:\\*)?\\s*\\))?)$`, "i");
185
+ const DANGEROUS_CMD = new RegExp(`^${SHELL_TOOL}\\(\\s*(sudo|curl|wget|sh|bash|zsh|eval|ssh|scp|nc|ncat|rm|chmod|dd)\\b[^)]*\\)$`, "i");
186
+ const INTERPRETER_CMD = new RegExp(`^${SHELL_TOOL}\\(\\s*(python[0-9.]*|node|npx|bunx|deno|bun|ruby|perl|php|uvx)\\b[^)]*\\)$`, "i");
187
+ const CREDENTIAL_PATH = /\((?:[^)]*?)(?:~\/\.ssh|\.ssh\/|\.aws\/|\.gnupg|\.npmrc|\.netrc|\.env\b|\.docker\/config|id_rsa|id_ed25519|\.config\/gh)/i;
188
+
189
+ /** Classify one permission rule (`Bash(*)`, `Read(~/.ssh/**)`). Null if fine. */
190
+ export function classifyPermission(rule) {
191
+ const r = String(rule).trim();
192
+ if (UNRESTRICTED.test(r)) {
193
+ return { rule: "permission-wildcard", severity: "high", why: "unrestricted shell: the agent can run any command without asking" };
194
+ }
195
+ let m = r.match(DANGEROUS_CMD);
196
+ if (m) return { rule: "permission-wildcard", severity: "medium", why: `pre-approves \`${m[1]}\` with any arguments, enough to download, exfiltrate or delete` };
197
+ m = r.match(INTERPRETER_CMD);
198
+ if (m) return { rule: "permission-wildcard", severity: "low", why: `pre-approves \`${m[1]}\`, which can run arbitrary code` };
199
+ if (/^(?:Read|Edit|Write|MultiEdit)\(/i.test(r) && CREDENTIAL_PATH.test(r)) {
200
+ return { rule: "permission-credential-read", severity: "medium", why: "pre-approves access to credential files" };
201
+ }
202
+ if (/^mcp__\*$|^mcp__[^_]+__\*$/.test(r) || /^mcp__[\w-]+$/.test(r)) {
203
+ return null; // whole-server allows are a normal pattern
204
+ }
205
+ return null;
206
+ }
207
+
208
+ export function checkPermissions(inv) {
209
+ const hits = [];
210
+ for (const p of inv.permissions) {
211
+ if (p.list !== "allow") continue;
212
+ const c = classifyPermission(p.rule);
213
+ if (c) {
214
+ hits.push({
215
+ rule: c.rule,
216
+ severity: c.severity,
217
+ title: `Permission allow-list grants \`${p.rule}\``,
218
+ detail: `\`${p.rule}\` ${c.why}. Narrow it to the exact commands the workflow needs (\`Bash(npm test)\`, \`Bash(git status)\`).`,
219
+ url: p.file,
220
+ });
221
+ }
222
+ }
223
+ for (const s of inv.settings) {
224
+ if (s.defaultMode === "bypassPermissions") {
225
+ hits.push({
226
+ rule: "permission-bypass-mode",
227
+ severity: "high",
228
+ title: "Permissions default to bypassPermissions",
229
+ detail: "`permissions.defaultMode: \"bypassPermissions\"` runs every tool call without asking, including ones a prompt-injected agent makes. Use the default mode and an allow-list.",
230
+ url: s.file,
231
+ });
232
+ }
233
+ }
234
+ return hits;
235
+ }
236
+
237
+ /** Tokenize an `allowed-tools` frontmatter value (string or list). */
238
+ export function parseAllowedTools(value) {
239
+ if (Array.isArray(value)) return value.flatMap(parseAllowedTools);
240
+ if (typeof value !== "string") return [];
241
+ return value.match(/[A-Za-z_*][\w*-]*(?:\([^)]*\))?/g) || [];
242
+ }
243
+
244
+ export function checkAllowedTools(display, frontmatter, label) {
245
+ const hits = [];
246
+ const tools = parseAllowedTools(frontmatter?.["allowed-tools"] ?? frontmatter?.allowedTools);
247
+ for (const t of tools) {
248
+ const c = classifyPermission(t);
249
+ if (!c) continue;
250
+ hits.push({
251
+ rule: c.rule,
252
+ severity: c.severity,
253
+ title: `${label} pre-approves \`${t}\` in allowed-tools`,
254
+ detail: `While this ${label.toLowerCase().startsWith("skill") ? "skill" : "command"} is active, \`${t}\` ${c.why}. Instructions inside it (or injected into it) run with that grant.`,
255
+ url: display,
256
+ });
257
+ }
258
+ return hits;
259
+ }
260
+
261
+ // ── Hooks ─────────────────────────────────────────────────────────────────
262
+
263
+ const AUTO_APPROVE = /["']?(?:permissionDecision|behavior)["']?\s*\\?["']?\s*:\s*\\?["']?\s*allow\b|["']?decision["']?\s*\\?["']?\s*:\s*\\?["']?\s*approve\b/i;
264
+ const PIPE_TO_SHELL = /\|\s*(?:sudo\s+)?(?:ba|z|da|k)?sh\b(?:\s|$|;|&|\))/;
265
+
266
+ function hookScript(hook, command) {
267
+ const env = {
268
+ CLAUDE_PROJECT_DIR: hook.baseDir,
269
+ CLAUDE_PLUGIN_ROOT: hook.baseDir,
270
+ };
271
+ const expanded = command.replace(/\$\{?(CLAUDE_PROJECT_DIR|CLAUDE_PLUGIN_ROOT)\}?/g, (_, k) => env[k] || "");
272
+ for (const tok of splitCommand(expanded)) {
273
+ if (!/[/\\]|\.(?:sh|py|js|mjs|cjs|ts|rb|pl)$/.test(tok)) continue;
274
+ const p = isAbsolute(tok) ? tok : resolve(hook.baseDir || ".", tok);
275
+ if (isFile(p)) {
276
+ const text = readText(p);
277
+ if (typeof text === "string") return { path: p, text };
278
+ }
279
+ }
280
+ return null;
281
+ }
282
+
283
+ export function checkHook(h) {
284
+ const hits = [];
285
+ const command = typeof h.hook.command === "string" ? h.hook.command : "";
286
+ if (!command) return hits;
287
+ const where = `${h.event}${h.matcher ? ` (${h.matcher})` : ""}`;
288
+ const script = hookScript(h, command);
289
+ const sources = [{ label: "command", text: command }];
290
+ if (script) sources.push({ label: `script ${basename(script.path)}`, text: script.text });
291
+
292
+ for (const s of sources) {
293
+ const exec = findExecPatterns(s.text);
294
+ if (exec.length) {
295
+ hits.push({
296
+ rule: "hook-exec",
297
+ severity: "critical",
298
+ title: `${where} hook downloads and runs code`,
299
+ detail: `Hook ${s.label} contains a ${exec[0].label}: \`${preview(exec[0].match, 120)}\`. Hooks run automatically on every matching event, with no prompt.`,
300
+ url: h.file,
301
+ });
302
+ break;
303
+ }
304
+ if (PIPE_TO_SHELL.test(s.text)) {
305
+ hits.push({
306
+ rule: "hook-pipe-shell",
307
+ severity: "high",
308
+ 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.`,
310
+ url: h.file,
311
+ });
312
+ break;
313
+ }
314
+ }
315
+
316
+ const approve = sources.find((s) => AUTO_APPROVE.test(s.text));
317
+ if (approve) {
318
+ const m = String(h.matcher || "");
319
+ const broad = !m || m === "*" || m === ".*" || /\bBash\b|\bShell\b|\bmcp__/i.test(m);
320
+ hits.push({
321
+ rule: "hook-auto-approve",
322
+ severity: broad ? "high" : "medium",
323
+ title: `${where} hook auto-approves tool calls`,
324
+ detail: `Hook ${approve.label} returns an allow/approve decision${broad ? ` for ${m ? `\`${m}\`` : "every tool"}` : ""}, so matching tool calls skip the permission prompt. A prompt-injected agent gets the same pass.`,
325
+ url: h.file,
326
+ });
327
+ }
328
+ return hits;
329
+ }
330
+
331
+ // ── Plugins and marketplaces ──────────────────────────────────────────────
332
+
333
+ const COMMIT = /^[0-9a-f]{40}$/i;
334
+
335
+ /** Describe a marketplace/plugin source and whether it is pinned to a commit. */
336
+ export function sourcePin(source) {
337
+ if (typeof source === "string") {
338
+ if (/^\.{0,2}\//.test(source)) return { local: true };
339
+ if (/^https?:\/\//i.test(source)) return { pinned: false, label: source, http: isPlainRemoteHttp(source) };
340
+ return { pinned: false, label: source };
341
+ }
342
+ if (!source || typeof source !== "object") return { local: true };
343
+ const kind = source.source;
344
+ if (kind === "directory" || kind === "file" || kind === "local") return { local: true };
345
+ const label = source.repo || source.url || source.path || JSON.stringify(source);
346
+ const pinned = COMMIT.test(source.sha || "") || COMMIT.test(source.ref || "") || COMMIT.test(source.commit || "");
347
+ return { pinned, label, http: isPlainRemoteHttp(source.url), firstParty: /^anthropics\//i.test(source.repo || "") };
348
+ }
349
+
350
+ export function checkMarketplaces(inv) {
351
+ const hits = [];
352
+ for (const m of inv.marketplaces) {
353
+ const s = sourcePin(m.source);
354
+ if (s.local || s.pinned) continue;
355
+ hits.push({
356
+ rule: "marketplace-unpinned",
357
+ severity: s.http ? "high" : s.firstParty ? "low" : "medium",
358
+ title: `Plugin marketplace "${m.name}" is not pinned to a commit`,
359
+ detail: `Source \`${preview(s.label, 100)}\` tracks a moving branch${s.http ? " over plain HTTP" : ""}. Whoever controls it can change the skills, hooks and MCP servers you install on the next update. Pin \`sha\` (or \`ref\`) to a reviewed commit.`,
360
+ url: m.file,
361
+ });
362
+ }
363
+ for (const p of inv.pluginSources) {
364
+ const s = sourcePin(p.source);
365
+ if (s.local || s.pinned) continue;
366
+ hits.push({
367
+ rule: "plugin-source-unpinned",
368
+ severity: s.http ? "high" : "medium",
369
+ title: `Marketplace plugin "${p.name}" is not pinned to a commit`,
370
+ detail: `Source \`${preview(s.label, 100)}\` has no \`sha\`. Everyone installing from this marketplace gets whatever is on the branch at install time.`,
371
+ url: p.file,
372
+ });
373
+ }
374
+ return hits;
375
+ }
376
+
package/lib/crawler.js CHANGED
@@ -10,19 +10,9 @@
10
10
  * - ffuf, wordlist-based endpoint brute-forcing
11
11
  */
12
12
 
13
- const DEFAULT_TIMEOUT = 8_000;
13
+ import { safeFetch } from "../src/utils/http.js";
14
14
 
15
- async function safeFetch(url, opts = {}) {
16
- const controller = new AbortController();
17
- const timeout = setTimeout(() => controller.abort(), opts.timeout || DEFAULT_TIMEOUT);
18
- try {
19
- return await fetch(url, { ...opts, signal: controller.signal, redirect: "manual" });
20
- } catch {
21
- return null;
22
- } finally {
23
- clearTimeout(timeout);
24
- }
25
- }
15
+ const DEFAULT_TIMEOUT = 8_000;
26
16
 
27
17
  /**
28
18
  * Discover endpoints by brute-forcing common API paths.
@@ -105,7 +95,7 @@ export async function discoverEndpoints(baseUrl, opts = {}) {
105
95
  const batch = wordlist.slice(i, i + concurrency);
106
96
  const results = await Promise.allSettled(
107
97
  batch.map(async (path) => {
108
- const res = await safeFetch(`${baseUrl}${path}`, { timeout });
98
+ const res = await safeFetch(`${baseUrl}${path}`, { timeout, probe: "endpoint-discovery" });
109
99
  if (!res) return null;
110
100
  // Measure the actual body length rather than the Content-Length header,
111
101
  // which is often absent on chunked responses. This matches the unit the
@@ -151,7 +141,7 @@ export async function fingerprint(baseUrl) {
151
141
  };
152
142
 
153
143
  // Fetch the homepage
154
- const res = await safeFetch(baseUrl);
144
+ const res = await safeFetch(baseUrl, { timeout: DEFAULT_TIMEOUT, probe: "fingerprint" });
155
145
  if (!res) return tech;
156
146
 
157
147
  tech.headers = Object.fromEntries(res.headers.entries());
@@ -216,7 +206,7 @@ export async function autoCalibrate(baseUrl) {
216
206
  `/api/${Date.now()}-nonexistent-calibration-b`,
217
207
  `/api/${Date.now()}-nonexistent-calibration-c`,
218
208
  ]) {
219
- const res = await safeFetch(`${baseUrl}${garbage}`);
209
+ const res = await safeFetch(`${baseUrl}${garbage}`, { timeout: DEFAULT_TIMEOUT, probe: "endpoint-discovery" });
220
210
  if (res) {
221
211
  const text = await res.text().catch(() => "");
222
212
  baselines.push({
@@ -0,0 +1,7 @@
1
+ {
2
+ "source": "https://raw.githubusercontent.com/RetireJS/retire.js/master/repository/jsrepository.json",
3
+ "fetchedAt": "2026-10-19T08:44:25.051Z",
4
+ "sha256": "b026add16583e58cd463300722a36e319eca637ebdd507be79786a46634cf441",
5
+ "bytes": 581646,
6
+ "libraries": 76
7
+ }