Use with caution. Address findings before production.
Scanned 9/26/2026, 1:48:18 PMยทCached resultยทFast Scanยท48 rulesยทHow we decide โ
AIVSS Score
Medium
Severity Breakdown
0
critical
2
high
34
medium
0
low
MCP Server Information
Findings
This package receives a C grade with a safety score of 59/100 due to 34 medium-severity issues, primarily centered on server configuration problems (26 findings) alongside concerns with resource exhaustion, ANSI escape injection, and insecure container images. While no critical or high-severity vulnerabilities were detected, the configuration weaknesses and moderate-risk patterns suggest you should review the specific server setup recommendations before deployment. The behavioral mismatches and container security issues warrant attention if this will run in a production environment.
No known CVEs found for this package or its dependencies.
Scan Details
Want deeper analysis?
Fast scan found 36 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 36 findings
36 findings
Tool asserts `readOnlyHint: true` (or `read_only_hint=True`) while the same file contains a destructive sink (fs.unlink / os.remove / DROP TABLE / DELETE FROM / TRUNCATE / `method: "DELETE"`). The annotation is dishonest โ clients will skip confirmation for a handler that in fact mutates state.
Evidence
| 1 | /** |
| 2 | * Firecrawl Monitor tools. |
| 3 | * |
| 4 | * Monitors run recurring scrapes/crawls and diff each result against the last |
| 5 | * retained snapshot. The SDK exposes monitor methods, but its HttpClient |
| 6 | * injects a top-level `origin` field into every POST/PATCH body and |
| 7 | * /v2/monitor rejects that with "Unrecognized key in body". Until the SDK |
| 8 | * strips `origin` for monitor requests, we hit /v2/monitor directly via fetch |
| 9 | * โ same pattern the CLI uses. |
| 10 | */ |
| 11 | |
| 12 | import { z } from 'zod'; |
| 13 | import type { ContentResult |
Remediation
Missing annotation: add `{ annotations: { destructiveHint: true } }` as the 4th argument to `server.tool(...)` (TS) or pass `destructive_hint=True` to the `@mcp.tool(...)` decorator (Python). readOnly-lying: remove the `readOnlyHint: true` / `read_only_hint=True` assertion, or refactor the handler so it no longer calls the destructive sink. Do not suppress with a waiver โ clients rely on these hints for user-facing confirmation.
File mounts an HTTP route that handles MCP `tools/list` (Express / Fastify / FastAPI / Flask) but the route โ and the router it sits behind โ has no auth middleware applied. An anonymous client can enumerate every tool the server exposes, scope the attack surface, and (if `tools/call` shares the route) invoke them. Apply auth at the route or router level: Express `passport.authenticate(...)` / a `requireAuth`-style middleware, FastAPI `Depends(get_current_user)` or `Depends(verify_jwt)`, Flask
Evidence
| 1 | #!/usr/bin/env node |
| 2 | |
| 3 | import { Server } from '@modelcontextprotocol/sdk/server/index.js'; |
| 4 | import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; |
| 5 | import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js'; |
| 6 | import { |
| 7 | Tool, |
| 8 | CallToolRequestSchema, |
| 9 | ListToolsRequestSchema, |
| 10 | } from '@modelcontextprotocol/sdk/types.js'; |
| 11 | import FirecrawlApp, { |
| 12 | type ScrapeOptions, |
| 13 | type MapOptions, |
| 14 | type Document, |
| 15 | } from '@mendable/firecrawl-js'; |
| 16 | |
| 17 | import { StreamableHTTP |
Remediation
Apply auth middleware at the route or router level: - Express / Fastify / Koa: `passport.authenticate(...)`, `requireAuth`, `verifyToken`, or an equivalent JWT middleware applied via `router.use(authMw)` or as a per-route handler. - FastAPI: `Depends(get_current_user)`, `OAuth2PasswordBearer`, `HTTPBearer`, or `verify_jwt` dependency. - Flask: `@login_required`, `@auth_required`, `@jwt_required`, or call `verify_jwt_in_request()` in the handler. Mounting MCP behind a s
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
| 782 | ) { |
| 783 | return; |
| 784 | } |
| 785 | console.log( |
| 786 | '[MCP_SEARCH_AUTH]', |
| 787 | JSON.stringify({ |
| 788 | auth_mode: searchCompanionAuthMode(request, session), |
| 789 | outcome, |
| 790 | profile: 'companion', |
| 791 | // Unique only to this telemetry record; it is not a cross-service |
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
| 1566 | : {}), |
| 1567 | ...(code ? { code } : {}), |
| 1568 | }; |
| 1569 | console.error('[MCP_ACTION]', JSON.stringify(payload)); |
| 1570 | |
| 1571 | const secret = normalizeHeader(process.env.FIRECRAWL_MCP_ACTION_LOG_SECRET); |
| 1572 | const apiUrl = normalizeHeader(process.env.FIRECRAWL_API_URL); |
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.
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
| 491 | const elapsedMs = () => Date.now() - startedAt; |
| 492 | let response: Response; |
| 493 | try { |
| 494 | response = await fetch(getOAuthIntrospectionEndpoint(), { |
| 495 | method: 'POST', |
| 496 | headers: { |
| 497 | 'Content-Type': 'application/x-www-form-urlencoded', |
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
| 2255 | apiKey: string, |
| 2256 | origin?: string |
| 2257 | ): Promise<any> { |
| 2258 | const response = await fetch(`${resolveApiBaseUrl()}${pathName}`, { |
| 2259 | method: 'POST', |
| 2260 | headers: { |
| 2261 | 'Content-Type': 'application/json', |
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
| 2896 | headers['x-firecrawl-keyless-ip'] = session.keylessClientIp; |
| 2897 | headers['x-firecrawl-keyless-secret'] = process.env.KEYLESS_PROXY_SECRET; |
| 2898 | } |
| 2899 | const response = await fetch(`${resolveApiBaseUrl()}${path}`, { |
| 2900 | method: 'POST', |
| 2901 | headers, |
| 2902 | body: JSON.stringify(body), |
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
| 3626 | parameters: z |
| 3627 | .object({ |
| 3628 | scrapeId: z.string().trim().min(1).optional(), |
| 3629 | url: z.string().trim().url().optional(), |
| 3630 | prompt: z.string().trim().min(1).optional(), |
| 3631 | code: z.string().trim().min(1).optional(), |
| 3632 | language: z.enum(['bash', 'python', 'node']).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
| 3017 | ); |
| 3018 | |
| 3019 | const valuableSourceSchema = z.object({ |
| 3020 | url: z.string().url(), |
| 3021 | reason: z.string().max(1000).optional(), |
| 3022 | }); |
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
| 1917 | } |
| 1918 | |
| 1919 | const scrapeParamsSchema = z.object({ |
| 1920 | url: z.string().url(), |
| 1921 | formats: z |
| 1922 | .array( |
| 1923 | z.enum([ |
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
| 3076 | valuableSources: z |
| 3077 | .array( |
| 3078 | z.object({ |
| 3079 | url: z.string().url(), |
| 3080 | reason: z.string().max(1000).optional(), |
| 3081 | }) |
| 3082 | ) |
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
| 2542 | `, |
| 2543 | outputSchema: mapOutputSchema, |
| 2544 | parameters: z.object({ |
| 2545 | url: z.string().url(), |
| 2546 | search: z.string().optional(), |
| 2547 | sitemap: z.enum(['include', 'skip', 'only']).optional(), |
| 2548 | includeSubdomains: z.boolean().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
| 3239 | valuableSources: z.array(valuableSourceSchema).max(50).optional(), |
| 3240 | missingContent: z.array(missingContentSchema).max(50).optional(), |
| 3241 | querySuggestions: z.string().max(2000).optional(), |
| 3242 | url: z.string().url().optional(), |
| 3243 | pageNumbers: z.array(z.number().int().positive()).max(100).optional(), |
| 3244 | metadata: z.record(z.string(), z.unknown()).optional(), |
| 3245 | }).superRefine((value, ctx) => { |
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
| 3377 | Crawl results can be large; use conservative limits when full-site coverage is unnecessary. Webhooks and interactive scrape actions are unavailable in safe mode. Returns the crawl ID, status, and page data. |
| 3378 | `, |
| 3379 | parameters: z.object({ |
| 3380 | url: z.string(), |
| 3381 | prompt: z.string().optional(), |
| 3382 | excludePaths: z.array(z.string()).optional(), |
| 3383 | includePaths: z.array(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
| 469 | `, |
| 470 | outputSchema: deprecatedToolOutputSchema, |
| 471 | parameters: z.object({ |
| 472 | query: z.string().min(1), |
| 473 | k: z.number().int().min(1).max(100).optional(), |
| 474 | }), |
| 475 | canList: () => false, |
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
| 3628 | scrapeId: z.string().trim().min(1).optional(), |
| 3629 | url: z.string().trim().url().optional(), |
| 3630 | prompt: z.string().trim().min(1).optional(), |
| 3631 | code: z.string().trim().min(1).optional(), |
| 3632 | language: z.enum(['bash', 'python', 'node']).optional(), |
| 3633 | timeout: z.number().min(1).max(300).optional(), |
| 3634 | scrapeOptions: scrapeParamsSchema.omit({ url: true }).partial().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
| 2012 | const scrapeToolParamsSchema = scrapeParamsSchema |
| 2013 | .extend({ |
| 2014 | url: z.string().url().optional(), |
| 2015 | timeout: z.number().int().positive().optional().describe("Execution timeout in milliseconds."), |
| 2016 | requestId: z |
| 2017 | .string() |
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
| 1977 | text: z.string().optional(), |
| 1978 | key: z.string().optional(), |
| 1979 | direction: z.enum(['up', 'down']).optional(), |
| 1980 | script: z.string().optional(), |
| 1981 | fullPage: z.boolean().optional(), |
| 1982 | }) |
| 1983 | ) |
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
Package declares an install-time hook (npm postinstall/preinstall/prepare, setup.py cmdclass override, custom setuptools install class, or non-default pyproject build-backend). Anyone installing this package runs the hook. Confirm the hook is necessary and review its contents; prefer shipping a plain library without install-time execution.
Evidence
| 21 | "lint": "eslint src/**/*.ts", |
| 22 | "lint:fix": "eslint src/**/*.ts --fix", |
| 23 | "format": "prettier --write .", |
| 24 | "prepare": "npm run build", |
| 25 | "publish-prod": "npm run build && npm publish", |
| 26 | "publish-beta": "npm run build && npm publish --tag beta" |
| 27 | }, |
Remediation
Prefer libraries that do not require install-time code execution: - Drop `postinstall`/`preinstall`/`prepare` scripts if the work can happen at runtime or build-time instead. - Ship pre-built native binaries rather than compiling via a custom `cmdclass` or `build_ext` override. - For Dockerfiles: replace `RUN curl โฆ | sh` with a pinned download + checksum verification + explicit `RUN` of a named script. - If the hook is unavoidable, document exactly what it does so downstream reviewers
Dockerfile never sets a non-root `USER` directive, so the CMD runs as root by default. Any RCE or library-level vulnerability exploited inside this container gets full privileges (MCP Top-10 R3). Add `USER <non-root>` before CMD / ENTRYPOINT in the final stage โ e.g. `USER 1000`, `USER nobody`, or `USER nonroot` on distroless.
Evidence
| 1 | # Generated by https://smithery.ai. See: https://smithery.ai/docs/config#dockerfile |
| 2 | # Use a Node.js image as the base for building the application |
| 3 | FROM node:22-alpine AS builder |
| 4 | |
| 5 | # Enable pnpm via corepack |
| 6 | RUN corepack enable && corepack prepare pnpm@10.17.1 --activate |
| 7 | |
| 8 | # Set the working directory inside the container |
| 9 | WORKDIR /app |
| 10 | |
| 11 | # Copy package.json and pnpm-lock.yaml to install dependencies |
| 12 | COPY package.json pnpm-lock.yaml ./ |
| 13 | |
| 14 | # Install dependencies (ignoring scripts to prevent running the pr |
Remediation
Create and switch to a non-root user before the CMD / ENTRYPOINT: RUN adduser --system --uid 1000 app USER 1000 Or reuse the base image's shipped non-root account (e.g. `USER nobody`, `USER nonroot` on distroless). Multi-stage builds only need the USER directive in the final stage.
Dockerfile never sets a non-root `USER` directive, so the CMD runs as root by default. Any RCE or library-level vulnerability exploited inside this container gets full privileges (MCP Top-10 R3). Add `USER <non-root>` before CMD / ENTRYPOINT in the final stage โ e.g. `USER 1000`, `USER nobody`, or `USER nonroot` on distroless.
Evidence
| 1 | ## Service image: Node (FastMCP) + NGINX sidecar in one container |
| 2 | |
| 3 | # 1) Build stage |
| 4 | FROM node:22-alpine AS builder |
| 5 | WORKDIR /app |
| 6 | |
| 7 | # Enable pnpm via corepack |
| 8 | RUN corepack enable && corepack prepare pnpm@10.17.1 --activate |
| 9 | |
| 10 | COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ |
| 11 | COPY patches ./patches |
| 12 | # Install dev dependencies for the build, but skip scripts to avoid running |
| 13 | # the package "prepare" script before the source code is copied. |
| 14 | RUN pnpm install --frozen-lockfile --ignore-scripts |
| 15 | |
| 16 | COPY . |
Remediation
Create and switch to a non-root user before the CMD / ENTRYPOINT: RUN adduser --system --uid 1000 app USER 1000 Or reuse the base image's shipped non-root account (e.g. `USER nobody`, `USER nonroot` on distroless). Multi-stage builds only need the USER directive in the final stage.
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
| 17 | steps: |
| 18 | |
| 19 | - name: 'Checkout GitHub Action' |
| 20 | uses: actions/checkout@main |
| 21 | |
| 22 | - name: 'Login to GitHub Container Registry' |
| 23 | uses: docker/login-action@v1 |
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 | password: ${{secrets.GITHUB_TOKEN}} |
| 25 | |
| 26 | - name: 'Set up Docker Buildx' |
| 27 | uses: docker/setup-buildx-action@v1 |
| 28 | |
| 29 | - name: 'Build Service Image' |
| 30 | uses: docker/build-push-action@v2 |
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
| 17 | contents: read |
| 18 | |
| 19 | steps: |
| 20 | - uses: actions/checkout@v5 |
| 21 | |
| 22 | - name: Install pnpm |
| 23 | uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4 |
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 | version: 10 |
| 26 | |
| 27 | - name: Set up Node.js |
| 28 | uses: actions/setup-node@v4 |
| 29 | with: |
| 30 | node-version: "20" |
| 31 | cache: "pnpm" |
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
| 11 | runs-on: ubuntu-latest |
| 12 | |
| 13 | steps: |
| 14 | - uses: actions/checkout@v3 |
| 15 | |
| 16 | - name: Setup pnpm |
| 17 | uses: pnpm/action-setup@v4 |
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
| 27 | uses: docker/setup-buildx-action@v1 |
| 28 | |
| 29 | - name: 'Build Service Image' |
| 30 | uses: docker/build-push-action@v2 |
| 31 | with: |
| 32 | context: . |
| 33 | file: ./Dockerfile.service |
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
| 14 | - uses: actions/checkout@v3 |
| 15 | |
| 16 | - name: Setup pnpm |
| 17 | uses: pnpm/action-setup@v4 |
| 18 | with: |
| 19 | version: 10 |
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
| 27 | password: ${{ secrets.GITHUB_TOKEN }} |
| 28 | |
| 29 | - name: 'Set up Docker Buildx' |
| 30 | uses: docker/setup-buildx-action@v1 |
| 31 | |
| 32 | - name: 'Build Service Image' |
| 33 | uses: docker/build-push-action@v2 |
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
| 20 | uses: actions/checkout@main |
| 21 | |
| 22 | - name: 'Login to GitHub Container Registry' |
| 23 | uses: docker/login-action@v1 |
| 24 | with: |
| 25 | registry: ghcr.io |
| 26 | username: ${{ github.actor }} |
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
| 17 | uses: actions/checkout@main |
| 18 | |
| 19 | - name: 'Login to GitHub Container Registry' |
| 20 | uses: docker/login-action@v1 |
| 21 | with: |
| 22 | registry: ghcr.io |
| 23 | username: ${{github.actor}} |
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
| 14 | runs-on: ubuntu-latest |
| 15 | steps: |
| 16 | - name: 'Checkout GitHub Action' |
| 17 | uses: actions/checkout@main |
| 18 | |
| 19 | - name: 'Login to GitHub Container Registry' |
| 20 | uses: docker/login-action@v1 |
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
| 19 | version: 10 |
| 20 | |
| 21 | - name: Use Node.js |
| 22 | uses: actions/setup-node@v3 |
| 23 | with: |
| 24 | node-version: '22.x' |
| 25 | cache: 'pnpm' |
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
| 30 | uses: docker/setup-buildx-action@v1 |
| 31 | |
| 32 | - name: 'Build Service Image' |
| 33 | uses: docker/build-push-action@v2 |
| 34 | with: |
| 35 | context: . |
| 36 | file: ./Dockerfile.service |
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
MCP server has authentication wired but no invocation log. An authenticated server that never logs is a forensics dead-end: unauthorized actions cannot be detected, attributed, or reconstructed after the fact. Closes the OWASP MCP Top 10:2025 MCP08 (Lack of Audit and Telemetry) gap. Fix: add a structured `logger.info("tool.invoke", ...)` call to every authenticated tool handler with at minimum the tool name, caller identity, and request id. Ship invocation events to a retention sink (CloudWatc
Evidence
| 1 | # Changelog |
| 2 | |
| 3 | ## [Unreleased] |
| 4 | |
| 5 | ### Added |
| 6 | |
| 7 | - `firecrawl_agent` now exposes the optional `effort` (`low`, `medium`, `high`), `maxCredits`, and `strictConstrainToURLs` parameters that `POST /v2/agent` already accepts, and forwards them in the request body. |
| 8 | |
| 9 | ### Changed |
| 10 | |
| 11 | - The search surface (`/v2/mcp-search`) now exposes `firecrawl_find_tools` and `firecrawl_scrape` alongside its six search tools, so agents can execute the Alexandria providers that `firecrawl_search` already returns. Both carry surf |
Remediation
Add a structured invocation log to every authenticated MCP tool handler. A minimum example: Python (FastMCP): import logging logger = logging.getLogger(__name__) @mcp.tool() def my_tool(args, ctx): logger.info( "tool.invoke", extra={ "tool": "my_tool", "user_id": ctx.user_id, "request_id": ctx.request_id, }, ) ... TypeScript (@modelcontextprotocol/sdk): import pi
MCP tool file registers a tool, performs a destructive sink (fs.unlink / shutil.rmtree / DROP TABLE / DELETE FROM / TRUNCATE / UPDATE ... SET / HTTP DELETE|PUT|PATCH / subprocess / exec / spawn), and emits no audit event anywhere in the file. Without an audit event, an investigator cannot answer "who deleted record X on day Y?" โ the irreversible action leaves no trail. Closes the OWASP MCP Top 10:2025 MCP08 (Lack of Audit and Telemetry) gap. Distinct from MCP-201 (no confirmation) and MCP-283
Evidence
| 1 | /** |
| 2 | * Firecrawl Monitor tools. |
| 3 | * |
| 4 | * Monitors run recurring scrapes/crawls and diff each result against the last |
| 5 | * retained snapshot. The SDK exposes monitor methods, but its HttpClient |
| 6 | * injects a top-level `origin` field into every POST/PATCH body and |
| 7 | * /v2/monitor rejects that with "Unrecognized key in body". Until the SDK |
| 8 | * strips `origin` for monitor requests, we hit /v2/monitor directly via fetch |
| 9 | * โ same pattern the CLI uses. |
| 10 | */ |
| 11 | |
| 12 | import { z } from 'zod'; |
| 13 | import type { ContentResult |
Remediation
Add a structured audit-event emit immediately after every destructive sink. Minimum schema: actor, action, target, outcome, request_id. Python: from acme.audit import audit_log @mcp.tool() def delete_record(token: str, record_id: str) -> dict: actor = verify_token(token) db.execute("DELETE FROM records WHERE id = %s", (record_id,)) audit_log( actor=actor.sub, action="delete_record", target=record_id, outcome=