Mostly safe โ a couple of notes worth reading.
Scanned 4/30/2026, 7:07:22 PMยทCached resultยทFast Scanยท45 rulesยทHow we decide โ
AIVSS Score
Low
Severity Breakdown
0
critical
0
high
35
medium
16
low
MCP Server Information
Findings
No known CVEs found for this package or its dependencies.
Scan Details
Want deeper analysis?
Fast scan found 51 findings using rule-based analysis. Upgrade for LLM consensus across 5 judges, AI-generated remediation, and cross-file taint analysis.
Building your own MCP server?
Same rules, same LLM judges, same grade. Private scans stay isolated to your account and never appear in the public registry. Required for code your team hasnโt shipped yet.
Showing 1โ30 of 51 findings
51 findings
User-controlled value printed to terminal without ANSI escape sanitization. Malicious input can inject cursor-control sequences, rewrite earlier output, or hide shell commands from the operator.
Evidence
| 1970 | const filePath = join(downloadDir, file.filename); |
| 1971 | writeFileSync(filePath, body); |
| 1972 | file.localPath = filePath; |
| 1973 | console.error(`[perplexity-mcp] Saved: ${filePath} (${body.length} bytes)`); |
| 1974 | } catch (err: any) { |
| 1975 | console.error(`[perplexity-mcp] Download error for ${file.filename}: ${err.message?.slice(0, 100)}`); |
| 1976 | } |
Remediation
Strip C0/C1 control codes before printing user-controlled values. Python: re.sub(r"[\x00-\x08\x0b-\x1f\x7f]", "", s). Prefer a structured logger (json/logfmt) over raw print to stdout.
User-controlled value printed to terminal without ANSI escape sanitization. Malicious input can inject cursor-control sequences, rewrite earlier output, or hide shell commands from the operator.
Evidence
| 161 | // and opaque IDs safe to log; no tokens / bearers / cookies pass through |
| 162 | // this path. Keeping resource context in the trace lets operators correlate |
| 163 | // cache hits with /token exchanges in audit.log when debugging consent UX. |
| 164 | console.error(`[trace] oauth consent cache hit clientId=${client.client_id} redirectUri=${params.redirectUri} resource=${resource ?? "<unbound>"}`); |
| 165 | try { |
| 166 | this.options.onConsentCacheHit?.({ |
| 167 | clientId: client.client_id, |
Remediation
Strip C0/C1 control codes before printing user-controlled values. Python: re.sub(r"[\x00-\x08\x0b-\x1f\x7f]", "", s). Prefer a structured logger (json/logfmt) over raw print to stdout.
User-controlled value printed to terminal without ANSI escape sanitization. Malicious input can inject cursor-control sequences, rewrite earlier output, or hide shell commands from the operator.
Evidence
| 1801 | } |
| 1802 | console.error(`[perplexity-mcp] ASI blocks: ${[...allBlockUsages].join(", ")}`); |
| 1803 | console.error(`[perplexity-mcp] ASI item types: ${[...allItemTypes].join(", ")}`); |
| 1804 | console.error(`[perplexity-mcp] ASI payload types: ${[...allPayloadTypes].join(", ")}`); |
| 1805 | |
| 1806 | if (!finalData) { |
| 1807 | console.error("[perplexity-mcp] ASI: No FINAL event found in reconnect stream."); |
Remediation
Strip C0/C1 control codes before printing user-controlled values. Python: re.sub(r"[\x00-\x08\x0b-\x1f\x7f]", "", s). Prefer a structured logger (json/logfmt) over raw print to stdout.
User-controlled value printed to terminal without ANSI escape sanitization. Malicious input can inject cursor-control sequences, rewrite earlier output, or hide shell commands from the operator.
Evidence
| 274 | const path = rawPath.split("?")[0] ?? rawPath; |
| 275 | const status = res.statusCode; |
| 276 | const hasAuth = typeof req.headers?.authorization === "string"; |
| 277 | console.error(`[trace] http ${req.method} ${path} auth=${hasAuth ? "yes" : "no"} status=${status} dur=${durationMs}ms ip=${ctx.ip ?? "?"} ua=${(ctx.userAgent ?? "").slice(0, 40)}`); |
| 278 | // Only audit admin + /mcp endpoints, not homepage/static. |
| 279 | if (path.startsWith("/daemon") || path.startsWith("/mcp") || path.startsWith("/authoriz |
Remediation
Strip C0/C1 control codes before printing user-controlled values. Python: re.sub(r"[\x00-\x08\x0b-\x1f\x7f]", "", s). Prefer a structured logger (json/logfmt) over raw print to stdout.
User-controlled value printed to terminal without ANSI escape sanitization. Malicious input can inject cursor-control sequences, rewrite earlier output, or hide shell commands from the operator.
Evidence
| 399 | if (!shouldCapture(url)) return; |
| 400 | const reason = request.failure()?.errorText || "unknown"; |
| 401 | const endpoint = extractEndpointName(url); |
| 402 | console.log( |
| 403 | `${C.red}โ FAIL${C.reset} ${request.method()} ${C.dim}${endpoint}${C.reset} ${C.red}${reason}${C.reset}`, |
| 404 | ); |
| 405 | captured.errors.push({ |
| 406 | at: new Date().toISOString(), |
Remediation
Strip C0/C1 control codes before printing user-controlled values. Python: re.sub(r"[\x00-\x08\x0b-\x1f\x7f]", "", s). Prefer a structured logger (json/logfmt) over raw print to stdout.
User-controlled value printed to terminal without ANSI escape sanitization. Malicious input can inject cursor-control sequences, rewrite earlier output, or hide shell commands from the operator.
Evidence
| 382 | const sc = statusColor(status); |
| 383 | const tag = num ? `#${String(num).padStart(3, "0")}` : " "; |
| 384 | const sseTag = sse ? ` ${C.magenta}SSE${C.reset}` : ""; |
| 385 | console.log( |
| 386 | `${C.dim}${tag}${C.reset} ${sc}โ ${status}${C.reset}${sseTag} ${C.dim}${endpoint}${C.reset}` + |
| 387 | (status >= 400 && typeof body === "string" ? `\n ${C.red}${body.slice(0, 200)}${C.reset}` : ""), |
| 388 | ); |
| 389 | |
| 390 | schedulePersist(); |
Remediation
Strip C0/C1 control codes before printing user-controlled values. Python: re.sub(r"[\x00-\x08\x0b-\x1f\x7f]", "", s). Prefer a structured logger (json/logfmt) over raw print to stdout.
User-controlled value printed to terminal without ANSI escape sanitization. Malicious input can inject cursor-control sequences, rewrite earlier output, or hide shell commands from the operator.
Evidence
| 1804 | console.error(`[perplexity-mcp] ASI payload types: ${[...allPayloadTypes].join(", ")}`); |
| 1805 | |
| 1806 | if (!finalData) { |
| 1807 | console.error("[perplexity-mcp] ASI: No FINAL event found in reconnect stream."); |
| 1808 | return { |
| 1809 | answer: `ASI task may still be running. View results at: ${PERPLEXITY_URL}/search/${threadSlug}`, |
| 1810 | sources: [], |
Remediation
Strip C0/C1 control codes before printing user-controlled values. Python: re.sub(r"[\x00-\x08\x0b-\x1f\x7f]", "", s). Prefer a structured logger (json/logfmt) over raw print to stdout.
motion is 1 edit from popular 'emotion' โ possible typosquat
Remediation
Typosquat: verify you meant the popular package. If so, correct the spelling; if you truly intended the less-common name, suppress with an inline waiver. Stale release: check whether the package has a maintained fork or successor. If no patched release exists, vendor the code or migrate to an active alternative before the unmaintained code accrues unfixed CVEs.
Network / IO / subprocess call without an explicit timeout. A malicious or hung upstream (HTTP host, socket peer, child process) can pin threads, exhaust connection/process pools, and make the MCP server unresponsive. Always pass a bounded timeout. v2 extends v1 with subprocess coverage (R03 from the legacy readiness audit).
Evidence
| 535 | try { |
| 536 | const result: any = await page.evaluate(async (u: string) => { |
| 537 | try { |
| 538 | const r = await fetch(u, { credentials: "include" }); |
| 539 | const ct = r.headers.get("content-type") ?? ""; |
| 540 | if (!r.ok || !ct.includes("application/json")) { |
| 541 | return { ok: false, status: r.status, contentType: ct }; |
Remediation
Pass timeout= on every call: - HTTP: `requests.get(url, timeout=5)`, `httpx.get(url, timeout=5.0)` - Node fetch: `AbortSignal.timeout(5000)` - Subprocess: `subprocess.run(["cmd"], timeout=30, check=True)` Pick a value short enough to fail fast and retry.
Network / IO / subprocess call without an explicit timeout. A malicious or hung upstream (HTTP host, socket peer, child process) can pin threads, exhaust connection/process pools, and make the MCP server unresponsive. Always pass a bounded timeout. v2 extends v1 with subprocess coverage (R03 from the legacy readiness audit).
Evidence
| 1698 | try { |
| 1699 | const rawJson: string = await this.page.evaluate( |
| 1700 | async (url: string) => { |
| 1701 | const resp = await fetch(url, { credentials: "include" }); |
| 1702 | return await resp.text(); |
| 1703 | }, |
| 1704 | THREAD_ENDPOINT(threadSlug) |
Remediation
Pass timeout= on every call: - HTTP: `requests.get(url, timeout=5)`, `httpx.get(url, timeout=5.0)` - Node fetch: `AbortSignal.timeout(5000)` - Subprocess: `subprocess.run(["cmd"], timeout=30, check=True)` Pick a value short enough to fail fast and retry.
Network / IO / subprocess call without an explicit timeout. A malicious or hung upstream (HTTP host, socket peer, child process) can pin threads, exhaust connection/process pools, and make the MCP server unresponsive. Always pass a bounded timeout. v2 extends v1 with subprocess coverage (R03 from the legacy readiness audit).
Evidence
| 87 | const email = process.env.PERPLEXITY_TEST_AUTO_LOGIN_EMAIL; |
| 88 | await page.evaluate(async ({ origin, email }) => { |
| 89 | await fetch(`${origin}/login/email`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ email }) }); |
| 90 | await fetch(`${origin}/login/otp`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ email, otp: "123456" }) }); |
| 91 | }, { origin: ORIGIN, email }); |
| 92 | } |
Remediation
Pass timeout= on every call: - HTTP: `requests.get(url, timeout=5)`, `httpx.get(url, timeout=5.0)` - Node fetch: `AbortSignal.timeout(5000)` - Subprocess: `subprocess.run(["cmd"], timeout=30, check=True)` Pick a value short enough to fail fast and retry.
Network / IO / subprocess call without an explicit timeout. A malicious or hung upstream (HTTP host, socket peer, child process) can pin threads, exhaust connection/process pools, and make the MCP server unresponsive. Always pass a bounded timeout. v2 extends v1 with subprocess coverage (R03 from the legacy readiness audit).
Evidence
| 1549 | try { |
| 1550 | const rawJson: string = await this.page.evaluate( |
| 1551 | async (url: string) => { |
| 1552 | const resp = await fetch(url, { credentials: "include" }); |
| 1553 | return await resp.text(); |
| 1554 | }, |
| 1555 | THREAD_ENDPOINT(threadSlug) |
Remediation
Pass timeout= on every call: - HTTP: `requests.get(url, timeout=5)`, `httpx.get(url, timeout=5.0)` - Node fetch: `AbortSignal.timeout(5000)` - Subprocess: `subprocess.run(["cmd"], timeout=30, check=True)` Pick a value short enough to fail fast and retry.
Network / IO / subprocess call without an explicit timeout. A malicious or hung upstream (HTTP host, socket peer, child process) can pin threads, exhaust connection/process pools, and make the MCP server unresponsive. Always pass a bounded timeout. v2 extends v1 with subprocess coverage (R03 from the legacy readiness audit).
Evidence
| 86 | if (process.env.PERPLEXITY_TEST_AUTO_LOGIN_EMAIL) { |
| 87 | const email = process.env.PERPLEXITY_TEST_AUTO_LOGIN_EMAIL; |
| 88 | await page.evaluate(async ({ origin, email }) => { |
| 89 | await fetch(`${origin}/login/email`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ email }) }); |
| 90 | await fetch(`${origin}/login/otp`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ email, otp: "123456" }) }); |
| 91 | }, { origin: ORIGIN, |
Remediation
Pass timeout= on every call: - HTTP: `requests.get(url, timeout=5)`, `httpx.get(url, timeout=5.0)` - Node fetch: `AbortSignal.timeout(5000)` - Subprocess: `subprocess.run(["cmd"], timeout=30, check=True)` Pick a value short enough to fail fast and retry.
Network / IO / subprocess call without an explicit timeout. A malicious or hung upstream (HTTP host, socket peer, child process) can pin threads, exhaust connection/process pools, and make the MCP server unresponsive. Always pass a bounded timeout. v2 extends v1 with subprocess coverage (R03 from the legacy readiness audit).
Evidence
| 49 | - `CloudSyncOptions` gains an optional `getClient` (lazy getter) used by the daemon to defer init until impit-fallback is needed; the `client` (eager) form is preserved for the CLI and other callers that already paid for init. |
| 50 | |
| 51 | ### Fixed |
| 52 | - **Stealth flags pruned** ([client.ts](packages/mcp-server/src/client.ts), [refresh.ts](packages/mcp-server/src/refresh.ts)) โ `--disable-web-security`, `--disable-features=IsolateOrigins,site-per-process`, and `--disable-site-isolation-trials` removed; the rat |
Remediation
Pass timeout= on every call: - HTTP: `requests.get(url, timeout=5)`, `httpx.get(url, timeout=5.0)` - Node fetch: `AbortSignal.timeout(5000)` - Subprocess: `subprocess.run(["cmd"], timeout=30, check=True)` Pick a value short enough to fail fast and retry.
Network / IO / subprocess call without an explicit timeout. A malicious or hung upstream (HTTP host, socket peer, child process) can pin threads, exhaust connection/process pools, and make the MCP server unresponsive. Always pass a bounded timeout. v2 extends v1 with subprocess coverage (R03 from the legacy readiness audit).
Evidence
| 54 | browser.on("disconnected", () => { cfClosed = true; }); |
| 55 | |
| 56 | // Navigate to the login page so the page has a same-origin context for |
| 57 | // subsequent credentialed fetch() calls. Going to ORIGIN's root path can |
| 58 | // land on a marketing page that may not set a usable document origin for |
| 59 | // in-page fetch. Path is env-var-configurable for the mock server tests. |
| 60 | try { await page.goto(`${ORIGIN}${LOGIN_PATH}`, { waitUntil: "domcontentloaded", timeout: 30_000 }); } |
Remediation
Pass timeout= on every call: - HTTP: `requests.get(url, timeout=5)`, `httpx.get(url, timeout=5.0)` - Node fetch: `AbortSignal.timeout(5000)` - Subprocess: `subprocess.run(["cmd"], timeout=30, check=True)` Pick a value short enough to fail fast and retry.
Network / IO / subprocess call without an explicit timeout. A malicious or hung upstream (HTTP host, socket peer, child process) can pin threads, exhaust connection/process pools, and make the MCP server unresponsive. Always pass a bounded timeout. v2 extends v1 with subprocess coverage (R03 from the legacy readiness audit).
Evidence
| 907 | * WHATWG fetch spec blocks a fixed list of "bad ports" (25, 6667, 10080, โฆ). |
| 908 | * When the caller asks for `port: 0` the OS picks an ephemeral port at |
| 909 | * random โ on rare occasions it hands back one of these blocked ports, and |
| 910 | * every subsequent `fetch(daemon.url)` throws `bad port`. That's how the |
| 911 | * OAuth-conformance tests flake in CI. |
| 912 | * |
| 913 | * Retry the listen up to 5 times when port is 0 and the OS assigns a |
Remediation
Pass timeout= on every call: - HTTP: `requests.get(url, timeout=5)`, `httpx.get(url, timeout=5.0)` - Node fetch: `AbortSignal.timeout(5000)` - Subprocess: `subprocess.run(["cmd"], timeout=30, check=True)` Pick a value short enough to fail fast and retry.
Network / IO / subprocess call without an explicit timeout. A malicious or hung upstream (HTTP host, socket peer, child process) can pin threads, exhaust connection/process pools, and make the MCP server unresponsive. Always pass a bounded timeout. v2 extends v1 with subprocess coverage (R03 from the legacy readiness audit).
Evidence
| 1023 | if (this.authenticated) { |
| 1024 | const fetchOk = async (url: string) => { |
| 1025 | try { |
| 1026 | const r = await fetch(url, { credentials: "include" }); |
| 1027 | return r.ok ? await r.json() : null; |
| 1028 | } catch { |
| 1029 | return null; |
Remediation
Pass timeout= on every call: - HTTP: `requests.get(url, timeout=5)`, `httpx.get(url, timeout=5.0)` - Node fetch: `AbortSignal.timeout(5000)` - Subprocess: `subprocess.run(["cmd"], timeout=30, check=True)` Pick a value short enough to fail fast and retry.
Network / IO / subprocess call without an explicit timeout. A malicious or hung upstream (HTTP host, socket peer, child process) can pin threads, exhaust connection/process pools, and make the MCP server unresponsive. Always pass a bounded timeout. v2 extends v1 with subprocess coverage (R03 from the legacy readiness audit).
Evidence
| 63 | const STEALTH_ARGS = [ |
| 64 | "--disable-blink-features=AutomationControlled", |
| 65 | // NOTE: `--disable-web-security` was removed (2026-04-27 public-hardening |
| 66 | // audit). All in-page `fetch()` calls used by the cookie-refresh tier hit |
| 67 | // the same Perplexity origin, so CORS is not a factor; keeping the flag |
| 68 | // would needlessly weaken the browser's same-origin policy. The off-origin |
| 69 | // ASI download path lives in client.ts and now uses APIRequestContext. |
Remediation
Pass timeout= on every call: - HTTP: `requests.get(url, timeout=5)`, `httpx.get(url, timeout=5.0)` - Node fetch: `AbortSignal.timeout(5000)` - Subprocess: `subprocess.run(["cmd"], timeout=30, check=True)` Pick a value short enough to fail fast and retry.
Network / IO / subprocess call without an explicit timeout. A malicious or hung upstream (HTTP host, socket peer, child process) can pin threads, exhaust connection/process pools, and make the MCP server unresponsive. Always pass a bounded timeout. v2 extends v1 with subprocess coverage (R03 from the legacy readiness audit).
Evidence
| 70 | const STEALTH_ARGS = [ |
| 71 | "--disable-blink-features=AutomationControlled", |
| 72 | // NOTE: `--disable-web-security` was removed (2026-04-27 public-hardening |
| 73 | // audit). All in-page `fetch()` calls in this file are same-origin |
| 74 | // (perplexity.ai) โ the only off-origin downloader (`downloadASIFiles`) |
| 75 | // now uses Playwright's `APIRequestContext` (`context.request.get`) which |
| 76 | // runs outside the page context and is not subject to CORS. Re-adding this |
Remediation
Pass timeout= on every call: - HTTP: `requests.get(url, timeout=5)`, `httpx.get(url, timeout=5.0)` - Node fetch: `AbortSignal.timeout(5000)` - Subprocess: `subprocess.run(["cmd"], timeout=30, check=True)` Pick a value short enough to fail fast and retry.
Network / IO / subprocess call without an explicit timeout. A malicious or hung upstream (HTTP host, socket peer, child process) can pin threads, exhaust connection/process pools, and make the MCP server unresponsive. Always pass a bounded timeout. v2 extends v1 with subprocess coverage (R03 from the legacy readiness audit).
Evidence
| 1112 | try { |
| 1113 | return await this.page!.evaluate( |
| 1114 | async (u: string) => { |
| 1115 | const r = await fetch(u, { credentials: "include" }); |
| 1116 | if (!r.ok) return null; |
| 1117 | return r.json(); |
| 1118 | }, |
Remediation
Pass timeout= on every call: - HTTP: `requests.get(url, timeout=5)`, `httpx.get(url, timeout=5.0)` - Node fetch: `AbortSignal.timeout(5000)` - Subprocess: `subprocess.run(["cmd"], timeout=30, check=True)` Pick a value short enough to fail fast and retry.
Network / IO / subprocess call without an explicit timeout. A malicious or hung upstream (HTTP host, socket peer, child process) can pin threads, exhaust connection/process pools, and make the MCP server unresponsive. Always pass a bounded timeout. v2 extends v1 with subprocess coverage (R03 from the legacy readiness audit).
Evidence
| 301 | // canonicalizes headers from {object} โ [[k,v]] tuples |
| 302 | // before forwarding to the native binding. THIS is what |
| 303 | // package.json["main"] points at. |
| 304 | // index.js โ the raw NAPI binding loader; calling .fetch() with |
| 305 | // a plain object on this throws "Given napi value is |
| 306 | // not an array on RequestInit.headers" because the |
| 307 | // binding-side check only accept |
Remediation
Pass timeout= on every call: - HTTP: `requests.get(url, timeout=5)`, `httpx.get(url, timeout=5.0)` - Node fetch: `AbortSignal.timeout(5000)` - Subprocess: `subprocess.run(["cmd"], timeout=30, check=True)` Pick a value short enough to fail fast and retry.
Network / IO / subprocess call without an explicit timeout. A malicious or hung upstream (HTTP host, socket peer, child process) can pin threads, exhaust connection/process pools, and make the MCP server unresponsive. Always pass a bounded timeout. v2 extends v1 with subprocess coverage (R03 from the legacy readiness audit).
Evidence
| 1928 | * Download files generated by ASI tasks. |
| 1929 | * |
| 1930 | * Uses Playwright's `APIRequestContext` (`context.request.get`) instead of |
| 1931 | * an in-page `fetch()`. ASI assets typically live on off-origin CDN buckets |
| 1932 | * (e.g. `pplx-res.cloudinary.com`, GCS, S3); fetching them from inside the |
| 1933 | * Perplexity origin would trip CORS unless the browser was launched with |
| 1934 | * `--disable-web-security` โ which we no longer do (see STEALTH_ARGS note). |
Remediation
Pass timeout= on every call: - HTTP: `requests.get(url, timeout=5)`, `httpx.get(url, timeout=5.0)` - Node fetch: `AbortSignal.timeout(5000)` - Subprocess: `subprocess.run(["cmd"], timeout=30, check=True)` Pick a value short enough to fail fast and retry.
Network / IO / subprocess call without an explicit timeout. A malicious or hung upstream (HTTP host, socket peer, child process) can pin threads, exhaust connection/process pools, and make the MCP server unresponsive. Always pass a bounded timeout. v2 extends v1 with subprocess coverage (R03 from the legacy readiness audit).
Evidence
| 1005 | // Check auth while headed |
| 1006 | const authData: any = await page.evaluate(async (url: string) => { |
| 1007 | try { |
| 1008 | const r = await fetch(url, { credentials: "include" }); |
| 1009 | return await r.json(); |
| 1010 | } catch { |
| 1011 | return null; |
Remediation
Pass timeout= on every call: - HTTP: `requests.get(url, timeout=5)`, `httpx.get(url, timeout=5.0)` - Node fetch: `AbortSignal.timeout(5000)` - Subprocess: `subprocess.run(["cmd"], timeout=30, check=True)` Pick a value short enough to fail fast and retry.
Network / IO / subprocess call without an explicit timeout. A malicious or hung upstream (HTTP host, socket peer, child process) can pin threads, exhaust connection/process pools, and make the MCP server unresponsive. Always pass a bounded timeout. v2 extends v1 with subprocess coverage (R03 from the legacy readiness audit).
Evidence
| 2403 | const rawJson: string = await this.page.evaluate( |
| 2404 | async (url: string) => { |
| 2405 | const response = await fetch(url, { credentials: "include" }); |
| 2406 | return response.text(); |
| 2407 | }, |
| 2408 | THREAD_ENDPOINT(threadSlug), |
Remediation
Pass timeout= on every call: - HTTP: `requests.get(url, timeout=5)`, `httpx.get(url, timeout=5.0)` - Node fetch: `AbortSignal.timeout(5000)` - Subprocess: `subprocess.run(["cmd"], timeout=30, check=True)` Pick a value short enough to fail fast and retry.
Network / IO / subprocess call without an explicit timeout. A malicious or hung upstream (HTTP host, socket peer, child process) can pin threads, exhaust connection/process pools, and make the MCP server unresponsive. Always pass a bounded timeout. v2 extends v1 with subprocess coverage (R03 from the legacy readiness audit).
Evidence
| 952 | } |
| 953 | |
| 954 | // Unlucky ephemeral assignment: close and retry. Final attempt returns |
| 955 | // the blocked port anyway so callers that can't fetch() will see the |
| 956 | // real problem rather than silently hanging. |
| 957 | await new Promise<void>((resolve) => server.close(() => resolve())); |
| 958 | } |
Remediation
Pass timeout= on every call: - HTTP: `requests.get(url, timeout=5)`, `httpx.get(url, timeout=5.0)` - Node fetch: `AbortSignal.timeout(5000)` - Subprocess: `subprocess.run(["cmd"], timeout=30, check=True)` Pick a value short enough to fail fast and retry.
Network / IO / subprocess call without an explicit timeout. A malicious or hung upstream (HTTP host, socket peer, child process) can pin threads, exhaust connection/process pools, and make the MCP server unresponsive. Always pass a bounded timeout. v2 extends v1 with subprocess coverage (R03 from the legacy readiness audit).
Evidence
| 1083 | if (!this.page) return; |
| 1084 | try { |
| 1085 | const data = await this.page.evaluate(async (url: string) => { |
| 1086 | const r = await fetch(url, { credentials: "include" }); |
| 1087 | return r.json(); |
| 1088 | }, AUTH_SESSION_ENDPOINT); |
Remediation
Pass timeout= on every call: - HTTP: `requests.get(url, timeout=5)`, `httpx.get(url, timeout=5.0)` - Node fetch: `AbortSignal.timeout(5000)` - Subprocess: `subprocess.run(["cmd"], timeout=30, check=True)` Pick a value short enough to fail fast and retry.
MCP tool input schema exposes an unconstrained string/any field with a risky name (command/query/sql/code/script/url/path/expr/ eval). Any caller can pass arbitrary values, which typically widens the tool's blast radius well beyond its intent. Narrow the schema with `.enum()`, `.regex()`, `.max()`, `Literal[...]`, Pydantic `Field(max_length=..., pattern=...)`, or a JSON Schema `enum` / `pattern` / `maxLength`.
Evidence
| 244 | title: "Perplexity Search", |
| 245 | description: "Search the web using Perplexity AI with automatic anonymous or authenticated defaults.", |
| 246 | inputSchema: { |
| 247 | query: z.string().describe("The search query or question to ask."), |
| 248 | sources: z.array(z.enum(["web", "scholar", "social"])).optional(), |
| 249 | language: z.string().optional(), |
| 250 | }, |
Remediation
Shape the schema to the tool's actual intent: - Zod: chain `.enum([...])`, `.regex(/.../)`, or `.max(n)`; prefer `z.enum([...])` or `z.literal(...)` when the value set is small. - Pydantic: use `Literal["a", "b"]` or `Field(max_length=..., pattern=r"...")`. - JSON Schema: add `"enum"`, `"pattern"`, or `"maxLength"` to the property. An overbroad schema is an "overpowered tool" โ the model has nothing to prevent it from calling the tool with input far beyond what the tool's prose contract
MCP tool input schema exposes an unconstrained string/any field with a risky name (command/query/sql/code/script/url/path/expr/ eval). Any caller can pass arbitrary values, which typically widens the tool's blast radius well beyond its intent. Narrow the schema with `.enum()`, `.regex()`, `.max()`, `Literal[...]`, Pydantic `Field(max_length=..., pattern=...)`, or a JSON Schema `enum` / `pattern` / `maxLength`.
Evidence
| 299 | title: "Perplexity Reason", |
| 300 | description: "Use a reasoning model for multi-step analysis and explanation.", |
| 301 | inputSchema: { |
| 302 | query: z.string(), |
| 303 | sources: z.array(z.enum(["web", "scholar", "social"])).optional(), |
| 304 | language: z.string().optional(), |
| 305 | model: z.string().optional(), |
Remediation
Shape the schema to the tool's actual intent: - Zod: chain `.enum([...])`, `.regex(/.../)`, or `.max(n)`; prefer `z.enum([...])` or `z.literal(...)` when the value set is small. - Pydantic: use `Literal["a", "b"]` or `Field(max_length=..., pattern=r"...")`. - JSON Schema: add `"enum"`, `"pattern"`, or `"maxLength"` to the property. An overbroad schema is an "overpowered tool" โ the model has nothing to prevent it from calling the tool with input far beyond what the tool's prose contract
MCP tool input schema exposes an unconstrained string/any field with a risky name (command/query/sql/code/script/url/path/expr/ eval). Any caller can pass arbitrary values, which typically widens the tool's blast radius well beyond its intent. Narrow the schema with `.enum()`, `.regex()`, `.max()`, `Literal[...]`, Pydantic `Field(max_length=..., pattern=...)`, or a JSON Schema `enum` / `pattern` / `maxLength`.
Evidence
| 526 | title: "Perplexity Compute", |
| 527 | description: "Run a task using Perplexity Computer mode (ASI).", |
| 528 | inputSchema: { |
| 529 | query: z.string(), |
| 530 | model: z.string().optional(), |
| 531 | language: z.string().optional(), |
| 532 | }, |
Remediation
Shape the schema to the tool's actual intent: - Zod: chain `.enum([...])`, `.regex(/.../)`, or `.max(n)`; prefer `z.enum([...])` or `z.literal(...)` when the value set is small. - Pydantic: use `Literal["a", "b"]` or `Field(max_length=..., pattern=r"...")`. - JSON Schema: add `"enum"`, `"pattern"`, or `"maxLength"` to the property. An overbroad schema is an "overpowered tool" โ the model has nothing to prevent it from calling the tool with input far beyond what the tool's prose contract
MCP tool input schema exposes an unconstrained string/any field with a risky name (command/query/sql/code/script/url/path/expr/ eval). Any caller can pass arbitrary values, which typically widens the tool's blast radius well beyond its intent. Narrow the schema with `.enum()`, `.regex()`, `.max()`, `Literal[...]`, Pydantic `Field(max_length=..., pattern=...)`, or a JSON Schema `enum` / `pattern` / `maxLength`.
Evidence
| 420 | title: "Perplexity Ask", |
| 421 | description: "Query Perplexity with explicit control over model, mode, and follow-up context.", |
| 422 | inputSchema: { |
| 423 | query: z.string(), |
| 424 | model: z.string().optional(), |
| 425 | mode: z.enum(["concise", "copilot"]).optional(), |
| 426 | sources: z.array(z.enum(["web", "scholar", "social"])).optional(), |
Remediation
Shape the schema to the tool's actual intent: - Zod: chain `.enum([...])`, `.regex(/.../)`, or `.max(n)`; prefer `z.enum([...])` or `z.literal(...)` when the value set is small. - Pydantic: use `Literal["a", "b"]` or `Field(max_length=..., pattern=r"...")`. - JSON Schema: add `"enum"`, `"pattern"`, or `"maxLength"` to the property. An overbroad schema is an "overpowered tool" โ the model has nothing to prevent it from calling the tool with input far beyond what the tool's prose contract
MCP tool input schema exposes an unconstrained string/any field with a risky name (command/query/sql/code/script/url/path/expr/ eval). Any caller can pass arbitrary values, which typically widens the tool's blast radius well beyond its intent. Narrow the schema with `.enum()`, `.regex()`, `.max()`, `Literal[...]`, Pydantic `Field(max_length=..., pattern=...)`, or a JSON Schema `enum` / `pattern` / `maxLength`.
Evidence
| 359 | title: "Perplexity Research", |
| 360 | description: "Run a deep research task with the long-form research model.", |
| 361 | inputSchema: { |
| 362 | query: z.string(), |
| 363 | sources: z.array(z.enum(["web", "scholar", "social"])).optional(), |
| 364 | language: z.string().optional(), |
| 365 | }, |
Remediation
Shape the schema to the tool's actual intent: - Zod: chain `.enum([...])`, `.regex(/.../)`, or `.max(n)`; prefer `z.enum([...])` or `z.literal(...)` when the value set is small. - Pydantic: use `Literal["a", "b"]` or `Field(max_length=..., pattern=r"...")`. - JSON Schema: add `"enum"`, `"pattern"`, or `"maxLength"` to the property. An overbroad schema is an "overpowered tool" โ the model has nothing to prevent it from calling the tool with input far beyond what the tool's prose contract
GitHub Actions `uses:` reference is not pinned to a 40-character commit SHA. Tags (`@v4`) and branches (`@main`) are mutable โ a compromised maintainer or a tag rewrite can substitute malicious code into your CI pipeline silently. Pin to a SHA: `uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab`. For readability, include the version as a trailing comment: `# v4.1.1`. Tools like `pinact` / `ratchet` automate this. Allowed unpinned forms (excluded by the rule): - Local actions `.
Evidence
| 36 | shell: bash |
| 37 | steps: |
| 38 | # โโ Setup โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ |
| 39 | - uses: actions/checkout@v4 |
| 40 | with: |
| 41 | fetch-depth: 0 |
| 42 | token: ${{ secrets.GITHUB_TOKEN }} |
Remediation
Pin every `uses:` to a 40-character commit SHA. Trailing comment with the version helps reviewers: `uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab # v4.1.1` Automate the migration with `pinact` (https://github.com/suzuki-shunsuke/pinact) or `ratchet` (https://github.com/sethvargo/ratchet). Add a `pinact run --check` pre-commit hook so future PRs stay pinned. Re-pin when the action releases a new version โ Dependabot can do this automatically with `version-update-strategy: inc
GitHub Actions `uses:` reference is not pinned to a 40-character commit SHA. Tags (`@v4`) and branches (`@main`) are mutable โ a compromised maintainer or a tag rewrite can substitute malicious code into your CI pipeline silently. Pin to a SHA: `uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab`. For readability, include the version as a trailing comment: `# v4.1.1`. Tools like `pinact` / `ratchet` automate this. Allowed unpinned forms (excluded by the rule): - Local actions `.
Evidence
| 24 | run: |
| 25 | shell: bash |
| 26 | steps: |
| 27 | - uses: actions/checkout@v4 |
| 28 | - uses: actions/setup-node@v4 |
| 29 | with: |
| 30 | node-version: ${{ matrix.node-version }} |
Remediation
Pin every `uses:` to a 40-character commit SHA. Trailing comment with the version helps reviewers: `uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab # v4.1.1` Automate the migration with `pinact` (https://github.com/suzuki-shunsuke/pinact) or `ratchet` (https://github.com/sethvargo/ratchet). Add a `pinact run --check` pre-commit hook so future PRs stay pinned. Re-pin when the action releases a new version โ Dependabot can do this automatically with `version-update-strategy: inc
GitHub Actions `uses:` reference is not pinned to a 40-character commit SHA. Tags (`@v4`) and branches (`@main`) are mutable โ a compromised maintainer or a tag rewrite can substitute malicious code into your CI pipeline silently. Pin to a SHA: `uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab`. For readability, include the version as a trailing comment: `# v4.1.1`. Tools like `pinact` / `ratchet` automate this. Allowed unpinned forms (excluded by the rule): - Local actions `.
Evidence
| 46 | git config user.name "github-actions[bot]" |
| 47 | git config user.email "41898282+github-actions[bot]@users.noreply.github.com" |
| 48 | |
| 49 | - uses: actions/setup-node@v4 |
| 50 | with: |
| 51 | node-version: "22" |
| 52 | cache: npm |
Remediation
Pin every `uses:` to a 40-character commit SHA. Trailing comment with the version helps reviewers: `uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab # v4.1.1` Automate the migration with `pinact` (https://github.com/suzuki-shunsuke/pinact) or `ratchet` (https://github.com/sethvargo/ratchet). Add a `pinact run --check` pre-commit hook so future PRs stay pinned. Re-pin when the action releases a new version โ Dependabot can do this automatically with `version-update-strategy: inc
GitHub Actions `uses:` reference is not pinned to a 40-character commit SHA. Tags (`@v4`) and branches (`@main`) are mutable โ a compromised maintainer or a tag rewrite can substitute malicious code into your CI pipeline silently. Pin to a SHA: `uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab`. For readability, include the version as a trailing comment: `# v4.1.1`. Tools like `pinact` / `ratchet` automate this. Allowed unpinned forms (excluded by the rule): - Local actions `.
Evidence
| 25 | shell: bash |
| 26 | steps: |
| 27 | - uses: actions/checkout@v4 |
| 28 | - uses: actions/setup-node@v4 |
| 29 | with: |
| 30 | node-version: ${{ matrix.node-version }} |
| 31 | cache: npm |
Remediation
Pin every `uses:` to a 40-character commit SHA. Trailing comment with the version helps reviewers: `uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab # v4.1.1` Automate the migration with `pinact` (https://github.com/suzuki-shunsuke/pinact) or `ratchet` (https://github.com/sethvargo/ratchet). Add a `pinact run --check` pre-commit hook so future PRs stay pinned. Re-pin when the action releases a new version โ Dependabot can do this automatically with `version-update-strategy: inc
Silent error swallowing detected. An except clause that does pass or ... discards the exception with no log, no metric, and no trace. This blinds incident response and hides real failures.
Evidence
| 205 | } |
| 206 | try { |
| 207 | writeFileSync(summaryPath, lines.join("\n")); |
| 208 | } catch {} |
| 209 | } |
| 210 | |
| 211 | // โโโ Main โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ |
Remediation
Log the exception at minimum (`logger.exception(e)`), emit a metric, or re-raise if the error is not recoverable. If you genuinely want to ignore an exception, say so with a comment.
Silent error swallowing detected. An except clause that does pass or ... discards the exception with no log, no metric, and no trace. This blinds incident response and hides real failures.
Evidence
| 9 | let gitSha = "unknown"; |
| 10 | try { |
| 11 | gitSha = execFileSync("git", ["rev-parse", "--short", "HEAD"], { encoding: "utf-8" }).trim(); |
| 12 | } catch {} |
| 13 | |
| 14 | const outDir = join(__dirname, "../dist/mcp"); |
| 15 | mkdirSync(outDir, { recursive: true }); |
Remediation
Log the exception at minimum (`logger.exception(e)`), emit a metric, or re-raise if the error is not recoverable. If you genuinely want to ignore an exception, say so with a comment.
Silent error swallowing detected. An except clause that does pass or ... discards the exception with no log, no metric, and no trace. This blinds incident response and hides real failures.
Evidence
| 456 | console.log(`${C.green}โ${C.reset} Summary: ${C.dim}${summaryPath}${C.reset}`); |
| 457 | console.log(`${C.bold}${C.green}โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ${C.reset}`); |
| 458 | |
| 459 | try { await context.close(); } catch {} |
| 460 | process.exit(0); |
| 461 | }; |
Remediation
Log the exception at minimum (`logger.exception(e)`), emit a metric, or re-raise if the error is not recoverable. If you genuinely want to ignore an exception, say so with a comment.
Silent error swallowing detected. An except clause that does pass or ... discards the exception with no log, no metric, and no trace. This blinds incident response and hides real failures.
Evidence
| 30 | if (metaUrl) { |
| 31 | try { |
| 32 | candidates.push(fileURLToPath(new URL("../../package.json", metaUrl))); |
| 33 | } catch {} |
| 34 | try { |
| 35 | candidates.push(fileURLToPath(new URL("../package.json", metaUrl))); |
| 36 | } catch {} |
Remediation
Log the exception at minimum (`logger.exception(e)`), emit a metric, or re-raise if the error is not recoverable. If you genuinely want to ignore an exception, say so with a comment.
Silent error swallowing detected. An except clause that does pass or ... discards the exception with no log, no metric, and no trace. This blinds incident response and hides real failures.
Evidence
| 169 | * the same resolution rules without duplicating the candidate ladder. |
| 170 | */ |
| 171 | export function resolveNodePath(): string { |
| 172 | const log = (msg: string) => { try { console.error(`[resolveNodePath] ${msg}`); } catch {} }; |
| 173 | |
| 174 | log(`process.execPath = ${process.execPath}`); |
| 175 | log(`process.platform = ${process.platform}`); |
Remediation
Log the exception at minimum (`logger.exception(e)`), emit a metric, or re-raise if the error is not recoverable. If you genuinely want to ignore an exception, say so with a comment.
Silent error swallowing detected. An except clause that does pass or ... discards the exception with no log, no metric, and no trace. This blinds incident response and hides real failures.
Evidence
| 488 | console.log(`${C.magenta} WS ${arrow}${C.reset} ${C.dim}${data}${C.reset}`); |
| 489 | } |
| 490 | schedulePersist(); |
| 491 | } catch {} |
| 492 | }; |
| 493 | |
| 494 | ws.on("framesent", (f) => record("sent", f)); |
Remediation
Log the exception at minimum (`logger.exception(e)`), emit a metric, or re-raise if the error is not recoverable. If you genuinely want to ignore an exception, say so with a comment.
Silent error swallowing detected. An except clause that does pass or ... discards the exception with no log, no metric, and no trace. This blinds incident response and hides real failures.
Evidence
| 82 | try { |
| 83 | await page.goto(`${ORIGIN}${LOGIN_PATH}`, { waitUntil: "domcontentloaded", timeout: 30_000 }); |
| 84 | } catch {} |
| 85 | if (!localOrigin) await minimizePageWindow(page); |
| 86 | |
| 87 | const ready = await waitForLoginReady(page); |
Remediation
Log the exception at minimum (`logger.exception(e)`), emit a metric, or re-raise if the error is not recoverable. If you genuinely want to ignore an exception, say so with a comment.
Silent error swallowing detected. An except clause that does pass or ... discards the exception with no log, no metric, and no trace. This blinds incident response and hides real failures.
Evidence
| 2 | if (process.env.PERPLEXITY_DEBUG !== "1") return; |
| 3 | if (category === "http" && process.env.PERPLEXITY_DEBUG_VERBOSE !== "1") return; |
| 4 | const entry = { ts: new Date().toISOString(), source: "mcp" as const, category, event, data, ...(error ? { error: String(error) } : {}) }; |
| 5 | try { process.stderr.write("[DEBUG]" + JSON.stringify(entry) + "\n"); } catch {} |
| 6 | } |
| 7 | |
| 8 | export function traceToolHandler<TArgs, TResult>(toolName: string, handler: (args: TArgs) => Promise<TResult>): (args: TArgs) => Promise<TRe |
Remediation
Log the exception at minimum (`logger.exception(e)`), emit a metric, or re-raise if the error is not recoverable. If you genuinely want to ignore an exception, say so with a comment.
Silent error swallowing detected. An except clause that does pass or ... discards the exception with no log, no metric, and no trace. This blinds incident response and hides real failures.
Evidence
| 33 | } catch {} |
| 34 | try { |
| 35 | candidates.push(fileURLToPath(new URL("../package.json", metaUrl))); |
| 36 | } catch {} |
| 37 | } |
| 38 | return candidates; |
| 39 | } |
Remediation
Log the exception at minimum (`logger.exception(e)`), emit a metric, or re-raise if the error is not recoverable. If you genuinely want to ignore an exception, say so with a comment.
Silent error swallowing detected. An except clause that does pass or ... discards the exception with no log, no metric, and no trace. This blinds incident response and hides real failures.
Evidence
| 90 | const paths = getProfilePaths(PROFILE); |
| 91 | if (!existsSync(paths.dir)) mkdirSync(paths.dir, { recursive: true }); |
| 92 | writeFileSync(paths.modelsCache, JSON.stringify(metadata.cache, null, 2)); |
| 93 | } catch {} |
| 94 | } |
| 95 | } finally { |
| 96 | await browser.close().catch(() => {}); |
Remediation
Log the exception at minimum (`logger.exception(e)`), emit a metric, or re-raise if the error is not recoverable. If you genuinely want to ignore an exception, say so with a comment.
Silent error swallowing detected. An except clause that does pass or ... discards the exception with no log, no metric, and no trace. This blinds incident response and hides real failures.
Evidence
| 29 | try { |
| 30 | if (contentType.includes("json")) json = await response.json(); |
| 31 | else text = (await response.text()).slice(0, 500); |
| 32 | } catch {} |
| 33 | return { |
| 34 | ok: response.ok, |
| 35 | status: response.status, |
Remediation
Log the exception at minimum (`logger.exception(e)`), emit a metric, or re-raise if the error is not recoverable. If you genuinely want to ignore an exception, say so with a comment.
Silent error swallowing detected. An except clause that does pass or ... discards the exception with no log, no metric, and no trace. This blinds incident response and hides real failures.
Evidence
| 20 | return { |
| 21 | dispose() { |
| 22 | if (timer) { clearTimeout(timer); timer = null; } |
| 23 | try { w.close(); } catch {} |
| 24 | }, |
| 25 | }; |
| 26 | } |
Remediation
Log the exception at minimum (`logger.exception(e)`), emit a metric, or re-raise if the error is not recoverable. If you genuinely want to ignore an exception, say so with a comment.
Silent error swallowing detected. An except clause that does pass or ... discards the exception with no log, no metric, and no trace. This blinds incident response and hides real failures.
Evidence
| 364 | let parsed = body; |
| 365 | if (typeof body === "string" && body && !body.startsWith("[")) { |
| 366 | try { parsed = JSON.parse(body); } catch {} |
| 367 | } |
| 368 | |
| 369 | captured.responses.push({ |
Remediation
Log the exception at minimum (`logger.exception(e)`), emit a metric, or re-raise if the error is not recoverable. If you genuinely want to ignore an exception, say so with a comment.
Silent error swallowing detected. An except clause that does pass or ... discards the exception with no log, no metric, and no trace. This blinds incident response and hides real failures.
Evidence
| 410 | reason, |
| 411 | }); |
| 412 | schedulePersist(); |
| 413 | } catch {} |
| 414 | }); |
| 415 | |
| 416 | // โโโ Navigate the initial page โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ |
Remediation
Log the exception at minimum (`logger.exception(e)`), emit a metric, or re-raise if the error is not recoverable. If you genuinely want to ignore an exception, say so with a comment.
Silent error swallowing detected. An except clause that does pass or ... discards the exception with no log, no metric, and no trace. This blinds incident response and hides real failures.
Evidence
| 14 | if (!String(filename).endsWith(".reinit")) return; |
| 15 | if (!existsSync(target)) return; |
| 16 | if (timer) clearTimeout(timer); |
| 17 | timer = setTimeout(() => { timer = null; try { callback(); } catch {} }, debounceMs); |
| 18 | }); |
| 19 | |
| 20 | return { |
Remediation
Log the exception at minimum (`logger.exception(e)`), emit a metric, or re-raise if the error is not recoverable. If you genuinely want to ignore an exception, say so with a comment.
Silent error swallowing detected. An except clause that does pass or ... discards the exception with no log, no metric, and no trace. This blinds incident response and hides real failures.
Evidence
| 60 | version = pkg.version; |
| 61 | break; |
| 62 | } |
| 63 | } catch {} |
| 64 | } |
| 65 | results.push({ |
| 66 | category: CATEGORY, |
Remediation
Log the exception at minimum (`logger.exception(e)`), emit a metric, or re-raise if the error is not recoverable. If you genuinely want to ignore an exception, say so with a comment.