{"repo_url":"https://github.com/GLips/Figma-Context-MCP","overall_status":"completed","scanned_at":"2026-10-22T05:19:12.164Z","scan_summary":{"total_components":1,"worst_case_severity":"CRITICAL","repo_trust_score":{"score":27,"tier":"critical","capability_band":"moderate","score_confidence":"medium","score_interval":{"low":14,"high":40},"factors":["ATR-M0003 HIGH","ATR-M0005 CRITICAL","ATR-M0009 HIGH","ATR-M0010 CRITICAL","ATR-M0011 CRITICAL","ATR-C0004 MEDIUM","ATR-C0008 HIGH"]}},"components":[{"artifact_version":"2.0","component":{"component_type":"mcp-server","component_name":"mcp-server","component_path":".","metadata":{"transports":["stdio","streamable_http"]}},"scan_provenance":{"scanned_at":"2026-10-22T05:19:12.164Z","resolved_ref":"c083d65c7e002923e7cb98f4e3bdafb105e90f6d","resolved_ver":"0.13.2"},"repo_context":{"repo_url":"https://github.com/GLips/Figma-Context-MCP","languages":["TypeScript","JavaScript"],"transports_detected":[],"deployment_artifacts":[],"provenance":{"publisher":"GLips","publisher_verified":false,"signed_build":true,"dependency_cve_count":88,"maintenance_active":false,"declared_policy":false}},"component_findings":[{"rule_id":"ATR-M0001","rule_version":"1.2.0","rule_name":"Tool Definition Integrity & Versioning (ETDI-style)","finding":"SECURE","severity":"HIGH","realized_severity":null,"confidence":"medium","rationale":"The two tools (get_figma_data, download_figma_images) are statically registered in src/mcp/index.ts at server-start time from code compiled into the published npm package; there is no runtime fetch/hot-reload of tool manifests from a remote source, no dynamic tool-definition update path, and no mechanism for a server operator to push a tool-schema change without a new package version. Tool definitions are therefore immutable per released version, which satisfies the rule's core update-integrity concern: npm OIDC 'trusted publishing' in .github/workflows/release.yml provides registry-verifiable provenance of a given release artifact, but this is a publish-time attestation, not a runtime tool-definition signature-verification or client-side version-pinning mechanism, and should not be described as such.","vulnerability_details":null,"fix_guidance":"Consider adding MCP tool-manifest signing/attestation (e.g. sigstore) in addition to npm provenance for defense in depth.","why_this_matters":"Prevents rug-pull tool poisoning.","context_note":null,"within_declared_contract":true,"transport_gated":false,"mitigation_present":true,"evidence":[{"path":"src/mcp/index.ts","start_line":75,"end_line":121,"snippet":"function registerTools(\n  server: McpServer,\n  figmaService: FigmaService,\n  options: RegisterToolsOptions,\n): void {\n  server.registerTool(\n    getFigmaDataTool.name,\n    {\n      title: \"Get Figma Data\",\n      description: getFigmaDataTool.description,\n      inputSchema: getFigmaDataTool.parametersSchema,\n      annotations: { readOnlyHint: true },\n    },\n    (params: GetFigmaDataParams, extra: ToolExtra) =>\n      getFigmaDataTool.handler(...)\n        params,\n        figmaService,\n    server.registerTool(downloadFigmaImagesTool.name, ... );\n        options.transport,\n        options.authMode,"},{"path":".github/workflows/release.yml","start_line":8,"end_line":16,"snippet":"permissions:\n  contents: write\n  pull-requests: write\n  id-token: write # Required for npm OIDC trusted publishing\n\njobs:\n  release-please:\n    if: ${{ github.repository_owner == 'GLips' }}\n    runs-on: ubuntu-latest"}],"affected_tools":null,"component_name":"mcp-server"},{"rule_id":"ATR-M0002","rule_version":"1.1.0","rule_name":"Unrestricted Tool Permissions (Overprivileged Tools)","finding":"SECURE","severity":"HIGH","realized_severity":null,"confidence":"high","rationale":"Only two tools are exposed. get_figma_data is read-only (annotations.readOnlyHint=true) and only calls the Figma REST API -- it has no filesystem or exec capability. download_figma_images can write files, with writes confined by resolveLocalPath()/isWithin() (src/utils/local-path.ts) to lexically resolve within a single configured base directory (imageDir), and filenames constrained by a strict regex to end in .png/.svg/.gif. This containment is lexical only: local-path.ts does not resolve symlinks or junctions, and common.ts's createWriteStream follows an existing destination symlink if one is present. No capability in these tools to create such a symlink was identified, so this is a hardening caveat on an otherwise appropriately-scoped tool set rather than a demonstrated overprivileged-tool exposure; neither tool can execute OS commands, access secrets beyond the Figma credential it already needs for its stated purpose, or perform admin/IAM actions.","vulnerability_details":null,"fix_guidance":"No action required; continue bounding filesystem writes to a single configurable root.","why_this_matters":"Limits blast radius and privilege escalation.","context_note":null,"within_declared_contract":true,"transport_gated":false,"mitigation_present":true,"evidence":[{"path":"src/utils/local-path.ts","start_line":43,"end_line":74,"snippet":"export function resolveLocalPath(\n  rawPath: string,\n  baseDir: string,\n  pathImpl: PathImpl = path,\n): ResolveLocalPathResult {\n  ...\n  const base = pathImpl.resolve(baseDir);\n  const candidate = pathImpl.isAbsolute(normalized)\n    ? pathImpl.resolve(normalized)\n    : pathImpl.resolve(base, normalized);\n\n  if (!isWithin(base, candidate, pathImpl)) {\n    return { ok: false, reason: \"outside_image_dir\" };\n  }\n\n  return { ok: true, resolvedPath: candidate };\n}"},{"path":"src/mcp/tools/download-figma-images-tool.ts","start_line":42,"end_line":47,"snippet":"fileName: z\n  .string()\n  .regex(\n    /^[a-zA-Z0-9_.-]+\\.(png|svg|gif)$/,\n    \"File names must contain only letters, numbers, underscores, dots, or hyphens, and end with .png, .svg, or .gif.\",\n  )"}],"affected_tools":null,"component_name":"mcp-server"},{"rule_id":"ATR-M0003","rule_version":"1.3.1","rule_name":"CORS / Origin / Host Validation (DNS Rebinding Defense)","finding":"VULNERABLE","severity":"HIGH","realized_severity":"HIGH","confidence":"medium","rationale":"The HTTP app is built via the MCP SDK's createMcpExpressApp({ host }), which for the server's default host value (127.0.0.1) automatically installs Host-header validation middleware restricting accepted Host headers to 'localhost'/'127.0.0.1'/'[::1]' -- a real code-level DNS-rebinding defense for that default configuration. Host is operator-configurable via FRAMELINK_HOST/--host, and when set to a non-loopback value, createMcpExpressApp receives no explicit `allowedHosts` option and the SDK does not apply any Host allowlist for that configuration (it only logs a console warning); this codebase adds no compensating Origin-header validation of its own before dispatch. In that supported non-loopback configuration, neither tool is a pure function of caller input: a request that omits the X-Figma-Token/Authorization headers inherits the server's own configured Figma credential, and the tools fetch and return that credential's design data. A same-origin DNS-rebound request reaching the listener in this configuration can therefore reach tool dispatch and obtain ambient-credentialed data without the SDK's loopback-only Host defense applying. The default loopback configuration remains mitigated by the SDK's automatic Host validation; the supported non-loopback configuration is not.","vulnerability_details":null,"fix_guidance":"When FRAMELINK_HOST/--host is configured to a non-loopback value, explicitly pass an `allowedHosts` option (or implement Origin-header validation) to createMcpExpressApp so the DNS-rebinding defense continues to apply outside the SDK's automatic loopback-only default, rather than relying on the operator to self-restrict or on the console warning alone.","why_this_matters":"Prevents DNS rebinding/browser hijack of local or remote MCP.","context_note":"[capability_by_design|relief:none|ineligible:within_declared_contract] within_declared_contract is ineligible for relief on ATR-M0003 because the rule declares that signal orthogonal to its risk (discount_ineligible_signals); the reported behaviour is within the tool's declared capability, and that does not reduce this finding's severity. No context relief applies; realized severity equals HIGH.","within_declared_contract":null,"transport_gated":null,"mitigation_present":null,"evidence":[{"path":"src/config.ts","start_line":150,"end_line":151,"snippet":"  const port = resolve(flags.port, envInt(\"FRAMELINK_PORT\", \"PORT\"), 3333);\n  const host = resolve(flags.host, envStr(\"FRAMELINK_HOST\"), \"127.0.0.1\");"},{"path":"src/server.ts","start_line":139,"end_line":145,"snippet":"  const app = createMcpExpressApp({ host });\n\n  const handlePost = async (req: Request, res: Response) => {\n    Logger.log(\"Received StreamableHTTP request\");\n    const requestKey = getRequestApiKey(req);\n    const requestBearerToken = getRequestBearerToken(req);\n    const auth = resolveRequestAuth(baseAuth, requestKey, requestBearerToken);"},{"path":"src/server.ts","start_line":173,"end_line":180,"snippet":"  // Serving StreamableHTTP at /sse lets existing client configs keep working —\n  // modern MCP clients probe with a POST before falling back to SSE.\n  for (const path of [\"/mcp\", \"/sse\"]) {\n    app.post(path, handlePost);\n    app.get(path, handleMethodNotAllowed);\n    app.delete(path, handleMethodNotAllowed);\n  }\n"},{"path":"src/server.ts","start_line":231,"end_line":233,"snippet":"\n  return baseAuth;\n}"},{"path":"src/mcp/tools/get-figma-data-tool.ts","start_line":92,"end_line":97,"snippet":"    Logger.log(`Successfully extracted data: ${result.metrics.simplifiedNodeCount} nodes`);\n    Logger.log(\"Sending result to client\");\n\n    return {\n      content: [{ type: \"text\" as const, text: result.formatted }],\n    };"},{"path":"src/services/figma.ts","start_line":79,"end_line":87,"snippet":"    try {\n      Logger.log(`Calling ${this.baseUrl}${endpoint}`);\n      const headers = this.getAuthHeaders();\n\n      return await fetchJSON<T & { status?: number }>(`${this.baseUrl}${endpoint}`, {\n        headers,\n        redactFromResponseBody: [this.apiKey, this.oauthToken],\n      });\n    } catch (error) {"}],"affected_tools":null,"component_name":"mcp-server"},{"rule_id":"ATR-M0004","rule_version":"1.2.0","rule_name":"Binding to 0.0.0.0 Without Auth/TLS","finding":"SECURE","severity":"HIGH","realized_severity":null,"confidence":"medium","rationale":"The server's resolved default bind address is 127.0.0.1 (loopback), set via resolve(flags.host, envStr('FRAMELINK_HOST'), '127.0.0.1') (src/config.ts:151) -- an operator must take a deliberate additional step (CLI flag or env var) to expose the HTTP listener on 0.0.0.0/all-interfaces. The underlying SDK helper (createMcpExpressApp) additionally emits an explicit console warning when host is '0.0.0.0'/'::' recommending allowedHosts or authentication. No auth/TLS is enforced if an operator does override to 0.0.0.0 (tracked separately under ATR-M0009/ATR-M0010), but the rule's own default-safe negative indicator ('bind 127.0.0.1 by default') is satisfied by this codebase's actual default.","vulnerability_details":null,"fix_guidance":"Consider refusing to bind non-loopback hosts unless an explicit acknowledgement flag is passed, given no TLS/auth is enforced in that configuration.","why_this_matters":"LAN/Internet exposure enables remote exploitation.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":true,"evidence":[{"path":"src/config.ts","start_line":150,"end_line":151,"snippet":"const port = resolve(flags.port, envInt(\"FRAMELINK_PORT\", \"PORT\"), 3333);\n  const host = resolve(flags.host, envStr(\"FRAMELINK_HOST\"), \"127.0.0.1\");"}],"affected_tools":null,"component_name":"mcp-server"},{"rule_id":"ATR-M0005","rule_version":"1.4.0","rule_name":"OAuth 2.1 + PKCE Correctness (MCP Authorization)","finding":"VULNERABLE","severity":"CRITICAL","realized_severity":"CRITICAL","confidence":"high","rationale":"Remote StreamableHTTP transport is registered and reachable; the server's default listener host is 127.0.0.1 (loopback), so it is not publicly exposed by default, but the host is operator-configurable (FRAMELINK_HOST/--host) and a non-loopback bind remains a fully supported deployment mode. No OAuth 2.1/PKCE/authorization-server-metadata exists anywhere in the codebase; the HTTP transport instead accepts a raw Figma Personal Access Token or OAuth bearer token as the caller's credential and forwards it to the upstream Figma API -- this is upstream-credential forwarding, not MCP resource-server token validation, and does not satisfy the rule's PKCE/authorization-server requirements for a remote transport. Per the rule's own applicability guidance, a remote-capable transport lacking OAuth 2.1/PKCE support is the VULNERABLE condition regardless of the operator's chosen default bind address.","vulnerability_details":null,"fix_guidance":"Implement OAuth 2.1 with PKCE for the HTTP/StreamableHTTP transport per the MCP Authorization spec: publish protected-resource metadata, require PKCE + exact redirect-URI match, scope tokens narrowly, and avoid treating a raw Figma PAT/bearer token as the MCP session credential.","why_this_matters":"Misconfig enables token theft and confused-deputy attacks.","context_note":"[capability_by_design|relief:none|ineligible:within_declared_contract] within_declared_contract is ineligible for relief on ATR-M0005 because the rule declares that signal orthogonal to its risk (discount_ineligible_signals); the reported behaviour is within the tool's declared capability, and that does not reduce this finding's severity. No context relief applies; realized severity equals CRITICAL.","within_declared_contract":false,"transport_gated":false,"mitigation_present":false,"evidence":[{"path":"src/server.ts","start_line":129,"end_line":166,"snippet":"export async function startHttpServer(\n  host: string,\n  port: number,\n  baseAuth: FigmaAuthOptions,\n  serverOptions: Omit<CreateServerOptions, \"transport\">,\n): Promise<Server> {\n  ...\n  const handlePost = async (req: Request, res: Response) => {\n    Logger.log(\"Received StreamableHTTP request\");\n    const requestKey = getRequestApiKey(req);\n    const requestBearerToken = getRequestBearerToken(req);\n    const auth = resolveRequestAuth(baseAuth, requestKey, requestBearerToken);\n    ...\n      const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });\n      const mcpServer = createServer(auth, { ...serverOptions, transport: \"http\" });\n      ...\n      await mcpServer.connect(transport);\n      await transport.handleRequest(req, res, req.body);"},{"path":"src/server.ts","start_line":235,"end_line":246,"snippet":"function getRequestApiKey(req: Request): string | undefined {\n  const value = req.headers[\"x-figma-token\"];\n  if (Array.isArray(value)) return value[0]?.trim() || undefined;\n  return value?.trim() || undefined;\n}\n\nfunction getRequestBearerToken(req: Request): string | undefined {\n  const value = req.headers.authorization;\n  const header = Array.isArray(value) ? value[0] : value;\n  const match = header?.match(/^Bearer\\s+(.+)$/i);\n  return match?.[1]?.trim() || undefined;\n}"},{"path":"src/bin.ts","start_line":54,"end_line":64,"snippet":"stdio: {\n      type: Boolean,\n      description: \"Run in stdio transport mode for MCP clients\",\n    },\n    noTelemetry: {\n      type: Boolean,\n      description: \"Disable usage telemetry (telemetry is on by default)\",\n    },\n  },\n  commands: [fetchCommand],\n});"},{"path":"src/services/figma.ts","start_line":38,"end_line":52,"snippet":"private getAuthHeaders(): Record<string, string> {\n    if (this.useOAuth) {\n      Logger.log(\"Using OAuth Bearer token for authentication\");\n      return { Authorization: `Bearer ${this.oauthToken}` };\n    }\n    if (!this.apiKey) {\n      throw new Error(\n        \"Figma API authentication is required. ...\",\n      );\n    }\n    Logger.log(\"Using Personal Access Token for authentication\");\n    return { \"X-Figma-Token\": this.apiKey };\n  }"}],"affected_tools":null,"component_name":"mcp-server"},{"rule_id":"ATR-M0006","rule_version":"1.2.0","rule_name":"Tool Name Collision / Spoofing","finding":"SECURE","severity":"MEDIUM","realized_severity":null,"confidence":"medium","rationale":"The server exposes exactly two tools, `get_figma_data` and `download_figma_images` (src/mcp/index.ts:81,102). Neither name collides with, or is a homograph of, a well-known tool from a widely-deployed MCP server (e.g. filesystem `read_file`/`write_file`, shell `execute_command`) or common client built-ins. Names are domain-specific and self-descriptive, reducing shadowing risk even without an explicit vendor prefix.","vulnerability_details":null,"fix_guidance":"Optional: prefix tool names (e.g. figma_get_data) for extra disambiguation in multi-server environments.","why_this_matters":"Prevents malicious tool shadowing.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":true,"evidence":[{"path":"src/mcp/index.ts","start_line":80,"end_line":102,"snippet":"server.registerTool(\n    getFigmaDataTool.name,\n    { ... },\n    ...\n  );\n\n  if (!options.skipImageDownloads) {\n    server.registerTool(\n      downloadFigmaImagesTool.name,\n      { ... },"}],"affected_tools":null,"component_name":"mcp-server"},{"rule_id":"ATR-M0007","rule_version":"1.2.0","rule_name":"Prompt/Metadata Injection in Tool Descriptions","finding":"SECURE","severity":"MEDIUM","realized_severity":null,"confidence":"high","rationale":"Both tool descriptions are static, developer-authored strings with no control characters, no Unicode bidi overrides, and no embedded directives aimed at the model (e.g. 'ignore previous instructions', data-exfiltration instructions). get_figma_data's description is a fixed literal; download_figma_images's getDescription() only interpolates the server's own configured imageDir path (a local filesystem path the operator configured, not attacker-controlled remote content) into an otherwise neutral sentence.","vulnerability_details":null,"fix_guidance":"No action required.","why_this_matters":"Prevents covert model manipulation.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":true,"evidence":[{"path":"src/mcp/tools/get-figma-data-tool.ts","start_line":109,"end_line":113,"snippet":"export const getFigmaDataTool = {\n  name: \"get_figma_data\",\n  description:\n    \"Get comprehensive Figma file data including layout, content, visuals, and component information\","},{"path":"src/mcp/tools/download-figma-images-tool.ts","start_line":205,"end_line":208,"snippet":"function getDescription(imageDir?: string) {\n  const baseDir = imageDir ?? process.cwd();\n  return `Download SVG and PNG images used in a Figma file based on the IDs of image or icon nodes. Images will be saved relative to the server's image directory: ${baseDir}`;\n}"}],"affected_tools":null,"component_name":"mcp-server"},{"rule_id":"ATR-M0008","rule_version":"1.2.0","rule_name":"CORS Misuse: Wildcard with Credentials","finding":"SECURE","severity":"HIGH","realized_severity":null,"confidence":"high","rationale":"No CORS middleware (no 'cors' package, no hand-rolled Access-Control-Allow-Origin/Access-Control-Allow-Credentials header-setting code) exists anywhere in the repository -- confirmed by exhaustive grep for 'cors'/'Access-Control'/'Origin' across src/, which returned zero hits on any header-emitting code (only unrelated prose comments using the English word 'original'). Because the server never sets Access-Control-Allow-Origin: * (nor any other CORS header), browsers apply the default same-origin policy and cannot read cross-origin responses regardless of credentials -- the specific wildcard+credentials misconfiguration this rule checks for is structurally absent.","vulnerability_details":null,"fix_guidance":"No action required; if CORS is added in future for a browser-based client, ensure an explicit per-env origin allowlist is used together with Access-Control-Allow-Credentials rather than a wildcard.","why_this_matters":"Prevents credentialed cross-origin abuse.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":true,"evidence":[{"path":"src/server.ts","start_line":129,"end_line":193,"snippet":"export async function startHttpServer(\n  host: string,\n  port: number,\n  baseAuth: FigmaAuthOptions,\n  serverOptions: Omit<CreateServerOptions, \"transport\">,\n): Promise<Server> {\n  ...\n  const app = createMcpExpressApp({ host });\n  const handlePost = async (req: Request, res: Response) => { ... };\n  const handleMethodNotAllowed = (_req: Request, res: Response) => {\n    res.status(405).set(\"Allow\", \"POST\").send(\"Method Not Allowed\");\n  };\n  for (const path of [\"/mcp\", \"/sse\"]) {\n    app.post(path, handlePost);\n    app.get(path, handleMethodNotAllowed);\n    app.delete(path, handleMethodNotAllowed);\n  }\n"}],"affected_tools":null,"component_name":"mcp-server"},{"rule_id":"ATR-M0009","rule_version":"1.3.0","rule_name":"Transport Security: TLS Everywhere (Remote)","finding":"VULNERABLE","severity":"HIGH","realized_severity":"HIGH","confidence":"high","rationale":"startHttpServer() listens with plain http via Express's app.listen; there is no https.createServer, no cert/key configuration, and no HSTS anywhere in the repository. Credentials (Figma PAT / OAuth bearer token) travel in request headers to this endpoint on every call, so any deployment reachable beyond loopback (host/port are operator-configurable) transmits them in cleartext absent an externally-added TLS layer that this codebase neither provides nor documents as mandatory.","vulnerability_details":null,"fix_guidance":"Document and/or enforce running the HTTP transport only behind a TLS-terminating reverse proxy for any non-loopback deployment; consider refusing non-loopback binds without an explicit '--insecure-http-ok' style acknowledgement.","why_this_matters":"MitM can steal tokens and tamper responses.","context_note":"[capability_by_design|relief:none|ineligible:within_declared_contract] [readers_disagree:mitigation_present] within_declared_contract is ineligible for relief on ATR-M0009 because the rule declares that signal orthogonal to its risk (discount_ineligible_signals); the reported behaviour is within the tool's declared capability, and that does not reduce this finding's severity. The adjudication reported mitigation_present but the scan did not; relief (-40%) requires both readers, so none is applied for that signal. No context relief applies; realized severity equals HIGH.","within_declared_contract":false,"transport_gated":false,"mitigation_present":false,"evidence":[{"path":"src/server.ts","start_line":194,"end_line":208,"snippet":"return new Promise((resolve, reject) => {\n    const server = app.listen(port, host, () => {\n      Logger.log(`HTTP server listening on port ${port}`);\n      Logger.log(`StreamableHTTP endpoint available at http://${host}:${port}/mcp`);\n      Logger.log(\n        `StreamableHTTP endpoint available at http://${host}:${port}/sse (backward compat)`,\n      );\n      resolve(server);\n    });\n    server.once(\"error\", (err) => {\n      httpServer = null;\n      reject(err);\n    });\n    httpServer = server;\n  });"},{"path":"src/config.ts","start_line":150,"end_line":151,"snippet":"const port = resolve(flags.port, envInt(\"FRAMELINK_PORT\", \"PORT\"), 3333);\n  const host = resolve(flags.host, envStr(\"FRAMELINK_HOST\"), \"127.0.0.1\");"},{"path":"src/server.ts","start_line":235,"end_line":246,"snippet":"function getRequestApiKey(req: Request): string | undefined {\n  const value = req.headers[\"x-figma-token\"];\n  if (Array.isArray(value)) return value[0]?.trim() || undefined;\n  return value?.trim() || undefined;\n}\n\nfunction getRequestBearerToken(req: Request): string | undefined {\n  const value = req.headers.authorization;\n  const header = Array.isArray(value) ? value[0] : value;\n  const match = header?.match(/^Bearer\\s+(.+)$/i);\n  return match?.[1]?.trim() || undefined;\n}"}],"affected_tools":null,"component_name":"mcp-server"},{"rule_id":"ATR-M0010","rule_version":"1.4.0","rule_name":"No Authentication or Authorization Checks (General)","finding":"VULNERABLE","severity":"CRITICAL","realized_severity":"CRITICAL","confidence":"high","rationale":"No authn/authz gate sits between the HTTP listener and MCP tool dispatch. requireGlobalCredentials (the only credential-presence check in the codebase) is invoked only for stdio mode; HTTP mode explicitly skips it. In HTTP mode, any caller that reaches the listener and omits X-Figma-Token/Authorization is silently served using the server's own globally-configured Figma credential (shared ambient identity) and can invoke both tools -- one of which writes files to the host disk and both of which spend the operator's Figma API credential -- with no credential of its own.","vulnerability_details":null,"fix_guidance":"Require every HTTP request to present its own MCP-level credential before dispatching to any tool; do not fall back to a shared, globally-configured Figma credential for callers who present nothing.","why_this_matters":"Prevents unauthorized tool use and data access.","context_note":"[capability_by_design|relief:none|ineligible:within_declared_contract] [readers_disagree:mitigation_present] within_declared_contract is ineligible for relief on ATR-M0010 because the rule declares that signal orthogonal to its risk (discount_ineligible_signals); the reported behaviour is within the tool's declared capability, and that does not reduce this finding's severity. The adjudication reported mitigation_present but the scan did not; relief (-40%) requires both readers, so none is applied for that signal. No context relief applies; realized severity equals CRITICAL.","within_declared_contract":false,"transport_gated":false,"mitigation_present":false,"evidence":[{"path":"src/server.ts","start_line":141,"end_line":153,"snippet":"const handlePost = async (req: Request, res: Response) => {\n    Logger.log(\"Received StreamableHTTP request\");\n    const requestKey = getRequestApiKey(req);\n    const requestBearerToken = getRequestBearerToken(req);\n    const auth = resolveRequestAuth(baseAuth, requestKey, requestBearerToken);\n    const requestSecrets = [requestKey, requestBearerToken].filter(\n      (secret): secret is string => !!secret,\n    );\n    await telemetry.withRequestSecrets(requestSecrets, async () => {\n      const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });\n      const mcpServer = createServer(auth, { ...serverOptions, transport: \"http\" });"},{"path":"src/server.ts","start_line":211,"end_line":233,"snippet":"function resolveRequestAuth(\n  baseAuth: FigmaAuthOptions,\n  requestKey: string | undefined,\n  requestBearerToken: string | undefined,\n): FigmaAuthOptions {\n  if (requestKey) {\n    return { figmaApiKey: requestKey, figmaOAuthToken: \"\", useOAuth: false };\n  }\n  if (requestBearerToken) {\n    return { figmaApiKey: \"\", figmaOAuthToken: requestBearerToken, useOAuth: true };\n  }\n  return baseAuth;\n}"},{"path":"src/config.ts","start_line":125,"end_line":137,"snippet":"/**\n * Fail fast when global credentials are required but missing. HTTP mode skips\n * this check so it can accept per-request `X-Figma-Token` headers; ...\n */\nexport function requireGlobalCredentials(auth: FigmaAuthOptions): void {\n  if (auth.figmaApiKey || auth.figmaOAuthToken) return;\n  throw new UsageError(\n    \"Either FIGMA_API_KEY or FIGMA_OAUTH_TOKEN is required (via CLI argument or .env file)\",\n  );\n}"},{"path":"src/mcp/tools/download-figma-images-tool.ts","start_line":94,"end_line":112,"snippet":"async function downloadFigmaImages(\n  params: DownloadImagesParams,\n  figmaService: FigmaService,\n  imageDir: string | undefined,\n  transport: Transport,\n  authMode: AuthMode,\n  clientInfo: ClientInfo | undefined,\n  extra: ToolExtra,\n) {\n  try {\n    const { fileKey, nodes, localPath, pngScale } = parametersSchema.parse(params);\n    const baseDir = imageDir ?? process.cwd();\n    const resolution = resolveLocalPath(localPath, baseDir);"}],"affected_tools":null,"component_name":"mcp-server"},{"rule_id":"ATR-M0011","rule_version":"1.2.0","rule_name":"OAuth Proxy Per-Client Consent (Confused Deputy Defense)","finding":"VULNERABLE","severity":"CRITICAL","realized_severity":"CRITICAL","confidence":"high","rationale":"This server is not an OAuth proxy: it does not forward an MCP-client's authorization request to a distinct upstream IdP on that client's behalf, has no dynamic MCP-client-registration endpoint, and mints no 'bridge' authorization codes. However, in HTTP mode, a request that omits the X-Figma-Token/Authorization headers falls back to the server's own globally-configured Figma credential (resolveRequestAuth returns baseAuth, src/server.ts) rather than being rejected. get_figma_data takes a caller-supplied fileKey, fetches that file under the server's ambient Figma credential via FigmaService, and returns the resulting design content to the caller -- so any caller that can reach the HTTP listener without presenting its own credential can read Figma files authorized to the server's own token, selecting which file to read via the fileKey parameter, without the caller having been authorized against that target itself. This is a general confused-deputy condition (ambient-credential use for a caller-selected target without caller-specific authorization) rather than the OAuth-proxy bridge-code scenario the rule's title emphasizes, but the rule's broader clause covering ambient-credential misuse for caller-selected targets applies. Exposure is conditional on global Figma credentials being configured and the HTTP listener being reachable by an untrusted caller; the default loopback bind and the option to supply a caller-specific token both reduce, but do not eliminate, this exposure.","vulnerability_details":null,"fix_guidance":"Require every HTTP request to present and use its own caller-supplied Figma credential for tool calls that access caller-selected targets; do not fall back to a shared, globally-configured Figma credential when a caller presents no credential of its own, or otherwise gate such a fallback behind an explicit operator opt-in with reduced trust applied to the resulting ambient-credential session.","why_this_matters":"Shared upstream client + dynamic MCP-client registration + no per-client consent enables attacker-registered MCP clients to receive bridge codes carrying victim upstream tokens — the documented MCP Confused Deputy attack.","context_note":"[capability_by_design|relief:none|ineligible:within_declared_contract] within_declared_contract is ineligible for relief on ATR-M0011 because the rule declares that signal orthogonal to its risk (discount_ineligible_signals); the reported behaviour is within the tool's declared capability, and that does not reduce this finding's severity. No context relief applies; realized severity equals CRITICAL.","within_declared_contract":null,"transport_gated":null,"mitigation_present":null,"evidence":[{"path":"src/server.ts","start_line":141,"end_line":145,"snippet":"  const handlePost = async (req: Request, res: Response) => {\n    Logger.log(\"Received StreamableHTTP request\");\n    const requestKey = getRequestApiKey(req);\n    const requestBearerToken = getRequestBearerToken(req);\n    const auth = resolveRequestAuth(baseAuth, requestKey, requestBearerToken);"},{"path":"src/server.ts","start_line":224,"end_line":233,"snippet":"  if (requestBearerToken) {\n    return {\n      figmaApiKey: \"\",\n      figmaOAuthToken: requestBearerToken,\n      useOAuth: true,\n    };\n  }\n\n  return baseAuth;\n}"},{"path":"src/mcp/index.ts","start_line":88,"end_line":93,"snippet":"    (params: GetFigmaDataParams, extra: ToolExtra) =>\n      getFigmaDataTool.handler(\n        params,\n        figmaService,\n        options.outputFormat,\n        options.transport,"},{"path":"src/mcp/tools/get-figma-data-tool.ts","start_line":67,"end_line":68,"snippet":"    const result = await runGetFigmaData(figmaService, { fileKey, nodeId, depth }, outputFormat, {\n      onFetchStart: async () => {"},{"path":"src/services/get-figma-data.ts","start_line":92,"end_line":96,"snippet":"      if (nodeId) {\n        rawResult = await figmaService.getRawNode(fileKey, nodeId, depth);\n      } else {\n        rawResult = await figmaService.getRawFile(fileKey, depth);\n      }"},{"path":"src/services/figma.ts","start_line":309,"end_line":316,"snippet":"  async getRawFile(\n    fileKey: string,\n    depth?: number | null,\n  ): Promise<{ data: GetFileResponse; rawSize: number }> {\n    const endpoint = `/files/${fileKey}${depth ? `?depth=${depth}` : \"\"}`;\n    Logger.log(`Retrieving raw Figma file: ${fileKey} (depth: ${depth ?? \"default\"})`);\n\n    const result = await this.requestWithSize<GetFileResponse>(endpoint);"},{"path":"src/services/figma.ts","start_line":78,"end_line":86,"snippet":"  private async requestWithSize<T>(endpoint: string): Promise<{ data: T; rawSize: number }> {\n    try {\n      Logger.log(`Calling ${this.baseUrl}${endpoint}`);\n      const headers = this.getAuthHeaders();\n\n      return await fetchJSON<T & { status?: number }>(`${this.baseUrl}${endpoint}`, {\n        headers,\n        redactFromResponseBody: [this.apiKey, this.oauthToken],\n      });"},{"path":"src/services/figma.ts","start_line":38,"end_line":52,"snippet":"  private getAuthHeaders(): Record<string, string> {\n    if (this.useOAuth) {\n      Logger.log(\"Using OAuth Bearer token for authentication\");\n      return { Authorization: `Bearer ${this.oauthToken}` };\n    }\n\n    if (!this.apiKey) {\n      throw new Error(\n        \"Figma API authentication is required. Configure FIGMA_API_KEY or FIGMA_OAUTH_TOKEN on the server, or send X-Figma-Token / Authorization: Bearer on the HTTP request.\",\n      );\n    }\n\n    Logger.log(\"Using Personal Access Token for authentication\");\n    return { \"X-Figma-Token\": this.apiKey };\n  }"},{"path":"src/mcp/tools/get-figma-data-tool.ts","start_line":95,"end_line":97,"snippet":"    return {\n      content: [{ type: \"text\" as const, text: result.formatted }],\n    };"}],"affected_tools":null,"component_name":"mcp-server"}],"repo_findings_inherited":[{"rule_id":"ATR-C0001","rule_version":"1.2.0","rule_name":"Unrestricted Network Fetch / SSRF","finding":"SECURE","severity":"HIGH","realized_severity":null,"confidence":"medium","rationale":"All Figma API calls (src/services/figma.ts) target a hardcoded `baseUrl = \"https://api.figma.com/v1\"`; no tool parameter or client input can change the host. Image downloads (src/utils/common.ts -> downloadFigmaImage) fetch `imageUrl` values, but those URLs are not attacker/caller-supplied -- they come exclusively from Figma's own API response (`getImageFillUrls`/`getNodeRenderUrls` in figma.ts), keyed by `imageRef`/`nodeId` lookups against that response. A caller can only select WHICH of Figma's own returned URLs to fetch, not supply an arbitrary host. There is no tool parameter that accepts a raw destination URL.","vulnerability_details":null,"fix_guidance":"No action required; if a future tool ever accepts a raw URL parameter, add a scheme/host allowlist and block RFC1918/metadata ranges.","why_this_matters":"Enables internal pivoting and data exfiltration.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"src/services/figma.ts","start_line":26,"end_line":36,"snippet":"export class FigmaService {\n  private readonly apiKey: string;\n  private readonly oauthToken: string;\n  private readonly useOAuth: boolean;\n  private readonly baseUrl = \"https://api.figma.com/v1\";"},{"path":"src/services/figma.ts","start_line":213,"end_line":216,"snippet":"const fillRef = gifRef ?? imageRef;\n            const imageUrl = fillRef ? fillUrls[fillRef] : undefined;\n            return imageUrl\n              ? downloadAndProcessImage("}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0002","rule_version":"1.3.2","rule_name":"Static Secrets","finding":"SECURE","severity":"HIGH","realized_severity":null,"confidence":"high","rationale":"Gitleaks (v8.30.1) flagged one generic-api-key match: a hardcoded PostHog project key in src/telemetry/client.ts:10. Manual review confirms this is a write-only PostHog ingest key for anonymous telemetry, explicitly documented in an adjacent comment as 'intentionally embedded in the published package -- it's a public ingest key that cannot read data, only send events.' This matches the standard client-side analytics-key pattern (comparable to a Sentry DSN or PostHog public API key), which is designed to be publicly embedded and provides no read access to any account data; it is not a credential whose exposure grants access to a protected resource. Figma credentials (FIGMA_API_KEY/FIGMA_OAUTH_TOKEN) are only ever read from process.env/CLI flags/an uncommitted .env file (src/config.ts resolveAuth), with .env.example shipping only placeholder values ('your_figma_api_key_here').","vulnerability_details":null,"fix_guidance":"No action required for the write-only PostHog key. Continue to never commit real Figma tokens to .env/.env.example.","why_this_matters":"Plaintext secrets are easily exfiltrated.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"src/telemetry/client.ts","start_line":7,"end_line":11},{"path":".env.example","start_line":1,"end_line":4},{"path":"src/telemetry/client.ts","start_line":8,"end_line":8,"vulnerability":{}}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0003","rule_version":"1.3.0","rule_name":"Runtime Command Injection via Process Execution","finding":"SECURE","severity":"CRITICAL","realized_severity":null,"confidence":"high","rationale":"No process-execution sinks (child_process.exec/execSync/spawn, eval, new Function) exist anywhere in shipped application runtime code (src/**, excluding tests and scripts). The only child_process usages in the repository are test-only (`spawn(\"tsx\", ...)` in src/tests/server.test.ts) and a developer/CI tooling script (scripts/scan-hidden-chars.mjs), which imports execSync but only to run a constant `git ls-files` command with no interpolation of external or caller-controlled input. Both usages fall within excluded execution contexts (test_tooling / developer_tooling / ci_cd_workflow) and are not reachable from the published package's runtime entry points (dist/bin.js -> src/bin.ts -> src/server.ts).","vulnerability_details":null,"fix_guidance":"No action required.","why_this_matters":"Unsanitized command construction in shipped runtime code can give a remote attacker arbitrary code execution with the application's privileges.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"src/tests/server.test.ts","start_line":186,"end_line":190,"snippet":"child = spawn(\"tsx\", [\"src/bin.ts\", `--figma-api-key=test-key`, `--port=${TEST_PORT}`], {"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0004","rule_version":"1.2.0","rule_name":"DoS Protections: Timeouts, Concurrency, Streaming Limits","finding":"VULNERABLE","severity":"MEDIUM","realized_severity":"MEDIUM","confidence":"medium","rationale":"Outbound fetches to the Figma API and to per-node/per-fill image URLs (src/utils/fetch-json.ts fetchJSON() and the raw fetch(imageUrl) inside src/utils/common.ts downloadFigmaImage()) carry no AbortController or application-level deadline; whether the underlying HTTP stack applies its own default timeout or body-size limits was not independently verified, so the absence of an explicit repo-level limit alone does not establish that a request can hang indefinitely, but no application-controlled deadline exists either way. The download_figma_images tool's `nodes` array parameter has no `.max()`/length cap in its zod schema, allowing a single call to request an unbounded number of concurrent image downloads via `Promise.all`; streamed image writes proceed without writer backpressure or byte-count budgets, and fill-URL deduplication does not bound the number of distinct rendered-node requests or overall download concurrency.","vulnerability_details":null,"fix_guidance":"Add an AbortSignal-based timeout to both fetchJSON() and the image-download fetch, cap the `nodes` array length in the download_figma_images schema, and add a request body-size limit / concurrency cap in the HTTP server startup path.","why_this_matters":"Resource exhaustion can crash or stall MCP.","context_note":"[capability_by_design|relief:none|ineligible:within_declared_contract] [readers_disagree:mitigation_present] within_declared_contract is ineligible for relief on ATR-C0004 because the rule declares that signal orthogonal to its risk (discount_ineligible_signals); the reported behaviour is within the tool's declared capability, and that does not reduce this finding's severity. The adjudication reported mitigation_present but the scan did not; relief (-40%) requires both readers, so none is applied for that signal. No context relief applies; realized severity equals MEDIUM.","within_declared_contract":false,"transport_gated":false,"mitigation_present":false,"evidence":[{"path":"src/utils/fetch-json.ts","start_line":59,"end_line":66,"snippet":"export async function fetchJSON<T extends { status?: number }>(\n  url: string,\n  options: RequestOptions = {},\n): Promise<{ data: T; rawSize: number }> {\n  const { redactFromResponseBody = [], ...fetchOptions } = options;\n  try {\n    const response = await fetch(url, fetchOptions);"},{"path":"src/mcp/tools/download-figma-images-tool.ts","start_line":73,"end_line":75,"snippet":"    })\n    .array()\n    .describe(\"The nodes to fetch as images\"),"},{"path":"src/services/figma.ts","start_line":229,"end_line":233,"snippet":"if (fillDownloads.length > 0) {\n        downloadPromises.push(Promise.all(fillDownloads));\n      }\n    }\n\n    // Download rendered nodes with processing"},{"path":"src/utils/fetch-json.ts","start_line":59,"end_line":65,"snippet":"export async function fetchJSON<T extends { status?: number }>(\n  url: string,\n  options: RequestOptions = {},\n): Promise<{ data: T; rawSize: number }> {\n  const { redactFromResponseBody = [], ...fetchOptions } = options;\n  try {\n    const response = await fetch(url, fetchOptions);\n"},{"path":"src/mcp/tools/download-figma-images-tool.ts","start_line":21,"end_line":75,"snippet":"  nodes: z\n    .object({\n      nodeId: z.string().regex(...),\n      imageRef: z.string().optional()...,\n      gifRef: z.string().optional()...,\n      fileName: z.string().regex(...),\n      ...\n        )\n        .describe(\"The ID of the Figma image node to fetch, formatted as 1234:5678\"),\n      imageRef: z"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0005","rule_version":"1.2.0","rule_name":"Logging Hygiene (No Secrets/PII/Tokens) & Audit Trail","finding":"SECURE","severity":"MEDIUM","realized_severity":null,"confidence":"high","rationale":"Logger.log/Logger.error calls throughout src/services/figma.ts and src/server.ts never include the raw API key/OAuth token/Authorization header value -- auth-related logs state only the auth MODE ('Using OAuth Bearer token...', 'Using Personal Access Token...'). A dedicated redaction system exists: telemetry/client.ts maintains a global `redactionSecrets` list plus a per-request AsyncLocalStorage-based `withRequestSecrets()` context so that per-request HTTP credentials (X-Figma-Token / Authorization: Bearer, which arrive only at request time and aren't known at startup) are scrubbed from error messages before they reach telemetry capture. fetch-json.ts additionally redacts configured secrets from any captured HTTP response body before attaching it to a thrown error. A dedicated test file (src/tests/telemetry-redaction.test.ts) specifically exercises that a per-request secret embedded in an upstream error message is redacted end-to-end before being captured.","vulnerability_details":null,"fix_guidance":"No action required.","why_this_matters":"Prevents sensitive data leakage; enables forensics.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"src/telemetry/client.ts","start_line":36,"end_line":55,"snippet":"let redactionSecrets: string[] = [];\nconst requestSecrets = new AsyncLocalStorage<readonly string[]>();\nexport function withRequestSecrets<T>(\n  secrets: readonly string[],\n  fn: () => Promise<T>,\n): Promise<T> {\n  const filtered = secrets.filter(Boolean);\n  if (filtered.length === 0) return fn();\n  return requestSecrets.run(filtered, fn);\n}"},{"path":"src/services/figma.ts","start_line":38,"end_line":52,"snippet":"private getAuthHeaders(): Record<string, string> {\n    if (this.useOAuth) {\n      Logger.log(\"Using OAuth Bearer token for authentication\");\n      return { Authorization: `Bearer ${this.oauthToken}` };\n    }\n    ...\n    Logger.log(\"Using Personal Access Token for authentication\");\n    return { \"X-Figma-Token\": this.apiKey };"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0006","rule_version":"1.2.0","rule_name":"Container/Runtime Hardening","finding":"NOT_APPLICABLE","severity":"MEDIUM","realized_severity":null,"confidence":"high","rationale":"The repository ships no container or IaC deployment artifact of any kind -- no Dockerfile, docker-compose, Helm chart, Kubernetes manifest, or Terraform. Enumerating all tracked files and reviewing both GitHub workflows confirms package scripts run Node directly with no containerization/IaC artifact present in the repository; the .gitignore entries exclude dependency and build-output directories but do not themselves establish this absence beyond what the file-inventory review shows. This rule's threat model (Docker USER/capabilities, Helm securityContext, hostNetwork, etc.) does not apply to an architecture with no containerization artifact in the repo.","vulnerability_details":null,"fix_guidance":"If an official container image is ever published, apply non-root USER, read-only rootfs, dropped capabilities, and resource limits.","why_this_matters":"Reduces blast radius of exploits.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":".gitignore","start_line":2,"end_line":0,"snippet":"(absence) -- `find . -iname \"Dockerfile*\" -o -iname \"docker-compose*\"` returns no results anywhere in the repository (excluding node_modules, which is also absent from this checkout)."}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0007","rule_version":"1.3.0","rule_name":"Dependency Risk & Supply Chain","finding":"VULNERABLE","severity":"HIGH","realized_severity":"MEDIUM","confidence":"high","rationale":"Trivy fs (v0.75.0) found 88 vulnerabilities across pnpm-lock.yaml's resolved dependency graph, including 1 CRITICAL (CVE-2026-90711, proxy-addr 2.0.7 IP-spoofing via IPv4-mapped IPv6 trust-subnet bypass) and numerous HIGH-severity CVEs in undici, js-yaml, hono, fast-uri, and path-to-regexp. undici is a direct runtime dependency of this package (per package.json), while proxy-addr and hono are transitive runtime dependencies of @modelcontextprotocol/sdk (pinned exact 1.29.0), which this server imports directly to run its HTTP transport (src/server.ts); these packages therefore sit on the server's live runtime dependency path rather than dev-only tooling, though the SDK import alone does not prove that each individual transitive CVE's vulnerable code path is exercised. Separately, .github/workflows/release.yml installs a third-party binary (`mcp-publisher`) via `curl | tar xz` from a 'latest' GitHub release URL with no checksum or signature verification, and then executes it with GitHub OIDC credentials and registry-publishing access; the owner/release-created gating conditions restrict when this step runs, not the integrity of the downloaded archive. All third-party GitHub Actions are pinned by a mutable major-version tag rather than an immutable commit SHA, further weakening supply-chain integrity even though the project otherwise uses an exact-pinned lockfile for its own npm dependency tree.","vulnerability_details":"A compromised or vulnerable transitive package (proxy-addr CRITICAL IP-spoofing CVE, undici/hono/js-yaml/fast-uri HIGH CVEs) sits on the runtime dependency path of the HTTP server mode via @modelcontextprotocol/sdk. Combined with GitHub Actions pinned by mutable tags and an unverified curl-pipe binary install in the release workflow, the supply chain has multiple weaknesses despite otherwise using an exact-pinned lockfile.","fix_guidance":"Run `pnpm update` / check for newer @modelcontextprotocol/sdk and js-yaml releases that resolve the transitive CVEs (or override via pnpm.overrides); pin GitHub Actions to full commit SHAs instead of major-version tags; add checksum/signature verification to the mcp-publisher download step.","why_this_matters":"Prevents typosquat and malicious update compromise.","context_note":"[capability_by_design|relief:-40%|ineligible:within_declared_contract] within_declared_contract is ineligible for relief on ATR-C0007 because the rule declares that signal orthogonal to its risk (discount_ineligible_signals); the reported behaviour is within the tool's declared capability, and that does not reduce this finding's severity. Realized severity MEDIUM (from HIGH: 40% context relief -- mitigation_present -40%).","within_declared_contract":false,"transport_gated":false,"mitigation_present":true,"evidence":[{"path":"pnpm-lock.yaml","start_line":1509,"end_line":1510,"snippet":"proxy-addr@2.0.7:\n    resolution: {integrity: ...}"},{"path":"pnpm-lock.yaml","start_line":2126,"end_line":2143,"snippet":"'@modelcontextprotocol/sdk@1.29.0(zod@3.25.76)':\n    dependencies:\n      '@hono/node-server': 1.19.10(hono@4.12.5)\n      ...\n      express: 5.2.1\n      express-rate-limit: 8.2.1(express@5.2.1)\n      hono: 4.12.5\n      ..."},{"path":"src/server.ts","start_line":2,"end_line":5,"snippet":"import { StreamableHTTPServerTransport } from \"@modelcontextprotocol/sdk/server/streamableHttp.js\";\nimport { createMcpExpressApp } from \"@modelcontextprotocol/sdk/server/express.js\";\nimport { Server } from \"http\";\nimport { McpServer } from \"@modelcontextprotocol/sdk/server/mcp.js\";"},{"path":".github/workflows/release.yml","start_line":61,"end_line":64,"snippet":"- name: Install mcp-publisher\n        run: |\n          curl -L \"https://github.com/modelcontextprotocol/registry/releases/latest/download/mcp-publisher_linux_amd64.tar.gz\" | tar xz mcp-publisher\n        if: ${{ steps.release.outputs.release_created }}"},{"path":"pnpm-lock.yaml","start_line":0,"end_line":0,"snippet":null,"vulnerability":{"vulnerability_id":"CVE-2026-90711","pkg_name":"proxy-addr","severity":"CRITICAL","installed_version":"2.0.7","fixed_version":"2.0.8"}},{"path":"pnpm-lock.yaml","start_line":0,"end_line":0,"snippet":null,"vulnerability":{"vulnerability_id":"CVE-2026-104850","pkg_name":"@modelcontextprotocol/sdk","severity":"HIGH","installed_version":"1.29.0","fixed_version":"1.31.0"}},{"path":"pnpm-lock.yaml","start_line":0,"end_line":0,"snippet":null,"vulnerability":{"vulnerability_id":"CVE-2026-111767","pkg_name":"@modelcontextprotocol/sdk","severity":"HIGH","installed_version":"1.29.0","fixed_version":"1.32.0"}},{"path":"pnpm-lock.yaml","start_line":0,"end_line":0,"snippet":null,"vulnerability":{"vulnerability_id":"CVE-2026-111768","pkg_name":"@modelcontextprotocol/sdk","severity":"HIGH","installed_version":"1.29.0","fixed_version":"1.32.0"}},{"path":"pnpm-lock.yaml","start_line":0,"end_line":0,"snippet":null,"vulnerability":{"vulnerability_id":"CVE-2026-30827","pkg_name":"express-rate-limit","severity":"HIGH","installed_version":"8.2.1","fixed_version":"8.2.2, 8.1.1, 8.0.2"}},{"path":"pnpm-lock.yaml","start_line":0,"end_line":0,"snippet":null,"vulnerability":{"vulnerability_id":"CVE-2026-13676","pkg_name":"fast-uri","severity":"HIGH","installed_version":"3.1.0","fixed_version":"4.0.1, 3.1.3, 2.4.2"}},{"path":"pnpm-lock.yaml","start_line":0,"end_line":0,"snippet":null,"vulnerability":{"vulnerability_id":"CVE-2026-16221","pkg_name":"fast-uri","severity":"HIGH","installed_version":"3.1.0","fixed_version":"2.4.3, 3.1.4, 4.1.1"}},{"path":"pnpm-lock.yaml","start_line":0,"end_line":0,"snippet":null,"vulnerability":{"vulnerability_id":"CVE-2026-18446","pkg_name":"fast-uri","severity":"HIGH","installed_version":"3.1.0","fixed_version":"2.4.4, 3.1.5, 4.1.2"}},{"path":"pnpm-lock.yaml","start_line":0,"end_line":0,"snippet":null,"vulnerability":{"vulnerability_id":"CVE-2026-6321","pkg_name":"fast-uri","severity":"HIGH","installed_version":"3.1.0","fixed_version":"3.1.1, 2.4.1"}},{"path":"pnpm-lock.yaml","start_line":0,"end_line":0,"snippet":null,"vulnerability":{"vulnerability_id":"CVE-2026-6322","pkg_name":"fast-uri","severity":"HIGH","installed_version":"3.1.0","fixed_version":"3.1.2, 2.4.1"}},{"path":"pnpm-lock.yaml","start_line":0,"end_line":0,"snippet":null,"vulnerability":{"vulnerability_id":"CVE-2026-75975","pkg_name":"fast-uri","severity":"HIGH","installed_version":"3.1.0","fixed_version":"2.4.5, 3.1.6, 4.1.3"}},{"path":"pnpm-lock.yaml","start_line":0,"end_line":0,"snippet":null,"vulnerability":{"vulnerability_id":"CVE-2026-76172","pkg_name":"fast-uri","severity":"HIGH","installed_version":"3.1.0","fixed_version":"2.4.5, 3.1.6, 4.1.3"}},{"path":"pnpm-lock.yaml","start_line":0,"end_line":0,"snippet":null,"vulnerability":{"vulnerability_id":"CVE-2026-84292","pkg_name":"fast-uri","severity":"HIGH","installed_version":"3.1.0","fixed_version":"2.4.6, 3.1.7, 4.1.4"}},{"path":"pnpm-lock.yaml","start_line":0,"end_line":0,"snippet":null,"vulnerability":{"vulnerability_id":"CVE-2026-54290","pkg_name":"hono","severity":"HIGH","installed_version":"4.12.5","fixed_version":"4.12.25"}},{"path":"pnpm-lock.yaml","start_line":0,"end_line":0,"snippet":null,"vulnerability":{"vulnerability_id":"CVE-2026-69192","pkg_name":"ip-address","severity":"HIGH","installed_version":"10.0.1","fixed_version":"10.3.1"}},{"path":"pnpm-lock.yaml","start_line":0,"end_line":0,"snippet":null,"vulnerability":{"vulnerability_id":"CVE-2026-59869","pkg_name":"js-yaml","severity":"HIGH","installed_version":"4.1.1","fixed_version":"3.15.0, 4.3.0"}},{"path":"pnpm-lock.yaml","start_line":0,"end_line":0,"snippet":null,"vulnerability":{"vulnerability_id":"CVE-2026-84375","pkg_name":"js-yaml","severity":"HIGH","installed_version":"4.1.1","fixed_version":"4.3.2, 3.15.2"}},{"path":"pnpm-lock.yaml","start_line":0,"end_line":0,"snippet":null,"vulnerability":{"vulnerability_id":"GHSA-5p4m-2wfm-xmqj","pkg_name":"js-yaml","severity":"HIGH","installed_version":"4.1.1","fixed_version":"4.3.1, 3.15.1"}},{"path":"pnpm-lock.yaml","start_line":0,"end_line":0,"snippet":null,"vulnerability":{"vulnerability_id":"CVE-2026-4926","pkg_name":"path-to-regexp","severity":"HIGH","installed_version":"8.2.0","fixed_version":"8.4.0"}},{"path":"pnpm-lock.yaml","start_line":0,"end_line":0,"snippet":null,"vulnerability":{"vulnerability_id":"CVE-2026-12151","pkg_name":"undici","severity":"HIGH","installed_version":"7.25.0","fixed_version":"6.27.0, 7.28.0, 8.5.0"}},{"path":"pnpm-lock.yaml","start_line":0,"end_line":0,"snippet":null,"vulnerability":{"vulnerability_id":"CVE-2026-13697","pkg_name":"undici","severity":"HIGH","installed_version":"7.25.0","fixed_version":"7.29.0, 8.9.0"}},{"path":"pnpm-lock.yaml","start_line":0,"end_line":0,"snippet":null,"vulnerability":{"vulnerability_id":"CVE-2026-19534","pkg_name":"undici","severity":"HIGH","installed_version":"7.25.0","fixed_version":"6.28.1, 7.29.1, 8.10.2"}},{"path":"pnpm-lock.yaml","start_line":0,"end_line":0,"snippet":null,"vulnerability":{"vulnerability_id":"CVE-2026-6734","pkg_name":"undici","severity":"HIGH","installed_version":"7.25.0","fixed_version":"7.28.0, 8.2.0"}},{"path":"pnpm-lock.yaml","start_line":0,"end_line":0,"snippet":null,"vulnerability":{"vulnerability_id":"CVE-2026-84961","pkg_name":"undici","severity":"HIGH","installed_version":"7.25.0","fixed_version":"7.29.1, 8.10.2"}},{"path":"pnpm-lock.yaml","start_line":0,"end_line":0,"snippet":null,"vulnerability":{"vulnerability_id":"CVE-2026-9697","pkg_name":"undici","severity":"HIGH","installed_version":"7.25.0","fixed_version":"7.28.0, 8.5.0"}},{"path":"pnpm-lock.yaml","start_line":0,"end_line":0,"snippet":null,"vulnerability":{"vulnerability_id":"CVE-2026-39406","pkg_name":"@hono/node-server","severity":"MEDIUM","installed_version":"1.19.10","fixed_version":"1.19.13"}},{"path":"pnpm-lock.yaml","start_line":0,"end_line":0,"snippet":null,"vulnerability":{"vulnerability_id":"GHSA-frvp-7c67-39w9","pkg_name":"@hono/node-server","severity":"MEDIUM","installed_version":"1.19.10","fixed_version":"2.0.5, 1.19.15"}},{"path":"pnpm-lock.yaml","start_line":0,"end_line":0,"snippet":null,"vulnerability":{"vulnerability_id":"CVE-2026-111769","pkg_name":"@modelcontextprotocol/sdk","severity":"MEDIUM","installed_version":"1.29.0","fixed_version":"1.32.0"}},{"path":"pnpm-lock.yaml","start_line":0,"end_line":0,"snippet":null,"vulnerability":{"vulnerability_id":"CVE-2026-111771","pkg_name":"@modelcontextprotocol/sdk","severity":"MEDIUM","installed_version":"1.29.0","fixed_version":"1.32.0"}},{"path":"pnpm-lock.yaml","start_line":0,"end_line":0,"snippet":null,"vulnerability":{"vulnerability_id":"CVE-2026-86472","pkg_name":"fast-uri","severity":"MEDIUM","installed_version":"3.1.0","fixed_version":"2.4.7, 3.1.8, 4.1.5"}},{"path":"pnpm-lock.yaml","start_line":0,"end_line":0,"snippet":null,"vulnerability":{"vulnerability_id":"CVE-2026-31808","pkg_name":"file-type","severity":"MEDIUM","installed_version":"16.5.4","fixed_version":"21.3.1"}},{"path":"pnpm-lock.yaml","start_line":0,"end_line":0,"snippet":null,"vulnerability":{"vulnerability_id":"CVE-2026-111757","pkg_name":"hono","severity":"MEDIUM","installed_version":"4.12.5","fixed_version":"4.13.11"}},{"path":"pnpm-lock.yaml","start_line":0,"end_line":0,"snippet":null,"vulnerability":{"vulnerability_id":"CVE-2026-39407","pkg_name":"hono","severity":"MEDIUM","installed_version":"4.12.5","fixed_version":"4.12.12"}},{"path":"pnpm-lock.yaml","start_line":0,"end_line":0,"snippet":null,"vulnerability":{"vulnerability_id":"CVE-2026-39408","pkg_name":"hono","severity":"MEDIUM","installed_version":"4.12.5","fixed_version":"4.12.12"}},{"path":"pnpm-lock.yaml","start_line":0,"end_line":0,"snippet":null,"vulnerability":{"vulnerability_id":"CVE-2026-39409","pkg_name":"hono","severity":"MEDIUM","installed_version":"4.12.5","fixed_version":"4.12.12"}},{"path":"pnpm-lock.yaml","start_line":0,"end_line":0,"snippet":null,"vulnerability":{"vulnerability_id":"CVE-2026-39410","pkg_name":"hono","severity":"MEDIUM","installed_version":"4.12.5","fixed_version":"4.12.12"}}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0008","rule_version":"1.2.0","rule_name":"Missing or Inadequate Input Validation","finding":"VULNERABLE","severity":"HIGH","realized_severity":"HIGH","confidence":"high","rationale":"Both MCP tools define zod schemas that validate types and filename syntax, but the download_figma_images `nodes` array parameter has no cardinality maximum (`.max()`/length cap). A caller can submit many entries referencing the same `gifRef` under distinct valid filenames, bypassing fill-URL deduplication since deduplication keys on the fill reference while each filename still starts an independent, concurrently-dispatched download. This inadequately bounded input allows a single tool call to drive an unbounded number of concurrent downloads, risking file-descriptor, connection, and disk exhaustion; schema-level type/filename validation and path containment do not bound this fan-out, and Figma's own API throttling does not cap the number of distinct resolved fill URLs a single call can request.","vulnerability_details":null,"fix_guidance":"Add a maximum array-length (`.max()`) to the `nodes` schema in download_figma_images, deduplicate requested downloads by resolved fill/render URL (not only by the stored imageRef/nodeId key) before dispatching, and bound overall concurrent download count independent of the number of distinct requested filenames.","why_this_matters":"Frontline control to prevent multiple classes of vulns.","context_note":"[capability_by_design|relief:none|ineligible:within_declared_contract] within_declared_contract is ineligible for relief on ATR-C0008 because the rule declares that signal orthogonal to its risk (discount_ineligible_signals); the reported behaviour is within the tool's declared capability, and that does not reduce this finding's severity. No context relief applies; realized severity equals HIGH.","within_declared_contract":null,"transport_gated":null,"mitigation_present":null,"evidence":[{"path":"src/mcp/tools/download-figma-images-tool.ts","start_line":73,"end_line":83,"snippet":"    })\n    .array()\n    .describe(\"The nodes to fetch as images\"),\n  pngScale: z\n    .number()\n    .positive()\n    .optional()\n    .default(2)\n    .describe(\n      \"Export scale for PNG images. Optional, defaults to 2 if not specified. Affects PNG images only.\",\n    ),"},{"path":"src/mcp/index.ts","start_line":100,"end_line":112,"snippet":"  if (!options.skipImageDownloads) {\n    server.registerTool(\n      downloadFigmaImagesTool.name,\n      {\n        title: \"Download Figma Images\",\n        description: downloadFigmaImagesTool.getDescription(options.imageDir),\n        inputSchema: downloadFigmaImagesTool.parametersSchema,\n        annotations: { openWorldHint: true },\n      },\n      (params: DownloadImagesParams, extra: ToolExtra) =>\n        downloadFigmaImagesTool.handler(\n          params,\n          figmaService,"},{"path":"src/services/download-figma-images.ts","start_line":112,"end_line":117,"snippet":"      if (node.gifRef) {\n        // GIF fills are always unique downloads (animated, no dedup needed).\n        const downloadIndex = downloadItems.length;\n        downloadItems.push({ ...downloadItem, gifRef: node.gifRef });\n        downloadToRequests.set(downloadIndex, [finalFileName]);\n      } else if (node.imageRef) {"},{"path":"src/services/figma.ts","start_line":214,"end_line":231,"snippet":"            const fillRef = gifRef ?? imageRef;\n            const imageUrl = fillRef ? fillUrls[fillRef] : undefined;\n            return imageUrl\n              ? downloadAndProcessImage(\n                  fileName,\n                  resolvedPath,\n                  imageUrl,\n                  needsCropping,\n                  cropTransform,\n                  requiresImageDimensions,\n                )\n              : null;\n          },\n        )\n        .filter((promise): promise is Promise<ImageProcessingResult> => promise !== null);\n\n      if (fillDownloads.length > 0) {\n        downloadPromises.push(Promise.all(fillDownloads));"},{"path":"src/utils/image-processing.ts","start_line":153,"end_line":155,"snippet":"  // First download the original image\n  const { downloadFigmaImage } = await import(\"./common.js\");\n  const originalPath = await downloadFigmaImage(fileName, localPath, imageUrl);"},{"path":"src/utils/common.ts","start_line":32,"end_line":44,"snippet":"    // Use fetch to download the image\n    const response = await fetch(imageUrl, {\n      method: \"GET\",\n    });\n\n    if (!response.ok) {\n      tagError(new Error(`Failed to download image: ${response.statusText}`), {\n        category: \"image_download\",\n      });\n    }\n\n    // Create write stream\n    const writer = fs.createWriteStream(fullPath);"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0009","rule_version":"1.2.0","rule_name":"Insecure Deserialization","finding":"SECURE","severity":"HIGH","realized_severity":null,"confidence":"high","rationale":"No unsafe deserializers are reachable at runtime. js-yaml is used in runtime code only via yaml.dump() (src/utils/yaml-dump.ts) to serialize outgoing data for LLM consumption with the restrictive JSON_SCHEMA; yaml.load() is called only within the test suite (e.g. integration.test.ts, serialization.test.ts) against the project's own fixture/test output, not against untrusted runtime input, so these test-only call sites do not constitute an exploitable unsafe-deserialization path. The only other parse site reachable at runtime is JSON.parse() on the Figma API's own HTTP response body in src/utils/fetch-json.ts, standard JSON parsing of data from the fixed https://api.figma.com host.","vulnerability_details":null,"fix_guidance":"No action required.","why_this_matters":"Can lead to arbitrary code execution.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"src/utils/yaml-dump.ts","start_line":1,"end_line":13,"snippet":"import yaml from \"js-yaml\";\nexport function dumpYaml(value: unknown): string {\n  return yaml.dump(value, {\n    noRefs: true,\n    lineWidth: -1,\n    noCompatMode: true,\n    schema: yaml.JSON_SCHEMA,\n  });\n}"},{"path":"src/utils/fetch-json.ts","start_line":87,"end_line":90,"snippet":"const text = await response.text();\nconst rawSize = Buffer.byteLength(text, \"utf8\");\nconst data = JSON.parse(text) as T;\nreturn { data, rawSize };"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0010","rule_version":"1.2.0","rule_name":"Lack of Output Encoding/Sanitization","finding":"SECURE","severity":"MEDIUM","realized_severity":null,"confidence":"medium","rationale":"Tool output is plain text/YAML/tree-format content primarily consumed by an LLM client, not rendered into an HTML UI; however, the repository's own fetch CLI command (src/commands/fetch.ts) prints this serialized output to stdout, so the output is not exclusively machine-only. Text content embedded into the server's structured (markdown-flavored) output format is escaped via escapeMarkdown() in src/transformers/text.ts, and JSON.stringify-based serialization (src/utils/serialize-tree.ts and the JSON output path) applies standard JSON-string encoding to tree name/text fields -- relevant, applicable encoding for those output formats, though not a general-purpose HTML or terminal-escape-sequence sanitizer. (Note: the broader concern of unsanitized third-party content reaching the LLM as a prompt-injection vector is separately captured under ATR-C0013, which is VULNERABLE.)","vulnerability_details":null,"fix_guidance":"No action required for structural encoding; see ATR-C0013 for the separate prompt-injection-containment gap.","why_this_matters":"Prevents UI-level injection and operator compromise.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"src/transformers/text.ts","start_line":735,"end_line":739,"snippet":"const MARKDOWN_ESCAPE_CHARS = /[\\\\*_~[\\](){}]/g;\nfunction escapeMarkdown(text: string): string {\n  return text.replace(MARKDOWN_ESCAPE_CHARS, \"\\\\$&\").replace(/[\\n\\u2029]/g, \"\\\\n\");\n}"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0011","rule_version":"1.4.0","rule_name":"Path Traversal & Symlink Bypass (Filesystem Tools)","finding":"UNKNOWN","severity":"HIGH","realized_severity":null,"confidence":"high","rationale":"The lexical containment checks in resolveLocalPath() (src/utils/local-path.ts) and the isWithin()-based check in downloadFigmaImage() (src/utils/common.ts) block '../' sequences and absolute-path escapes before any write occurs, and fileName is independently constrained by a zod regex preventing traversal characters in the filename component. config.ts's resolvePath calls operate on operator-supplied CLI flags/env vars at startup, not remote/tool-caller input. However, neither check resolves symlinks or junctions: a reproduction placing a symlink inside the configured imageDir that points to an outside directory caused fs.createWriteStream() to write a file outside the base directory, demonstrating a conditional bypass of the lexical containment. Whether an untrusted caller can plant or select such a symlink in a real deployment's imageDir -- i.e. directory ownership and symlink-creation capability -- is not established by the repository. Given that lexical path escapes are blocked but symlink-based containment is not enforced and the attacker-capability prerequisite for exploiting it is unproven, neither a categorical SECURE nor a fully substantiated VULNERABLE verdict is supported.","vulnerability_details":null,"fix_guidance":"Consider adding a realpath()-based post-resolution check to defend against symlinked directories under the base (documented as a known gap in local-path.ts).","why_this_matters":"Traversal exposes arbitrary host files; can lead to code execution.","context_note":null,"within_declared_contract":false,"transport_gated":false,"mitigation_present":true,"evidence":[{"path":"src/utils/local-path.ts","start_line":43,"end_line":86,"snippet":"export function resolveLocalPath(\n  rawPath: string,\n  baseDir: string,\n  pathImpl: PathImpl = path,\n): ResolveLocalPathResult {\n  ...\n  if (!isWithin(base, candidate, pathImpl)) {\n    return { ok: false, reason: \"outside_image_dir\" };\n  }\n  return { ok: true, resolvedPath: candidate };\n}\nexport function isWithin(base: string, candidate: string, pathImpl: PathImpl = path): boolean {\n  const rel = pathImpl.relative(base, candidate);\n  return rel === \"\" || (!rel.startsWith(\"..\") && !pathImpl.isAbsolute(rel));\n}"},{"path":"src/mcp/tools/download-figma-images-tool.ts","start_line":104,"end_line":131,"snippet":"const { fileKey, nodes, localPath, pngScale } = parametersSchema.parse(params);\nconst baseDir = imageDir ?? process.cwd();\nconst resolution = resolveLocalPath(localPath, baseDir);\nif (!resolution.ok) {\n  ...\n  return { isError: true, content: [{ type: \"text\" as const, text: details.userMessage }] };\n}\nconst resolvedPath = resolution.resolvedPath;"},{"path":"src/utils/common.ts","start_line":14,"end_line":30,"snippet":"export async function downloadFigmaImage(\n  fileName: string,\n  localPath: string,\n  imageUrl: string,\n): Promise<string> {\n  try {\n    if (!fs.existsSync(localPath)) {\n      fs.mkdirSync(localPath, { recursive: true });\n    }\n    const fullPath = path.resolve(path.join(localPath, fileName));\n    if (!isWithin(localPath, fullPath)) {\n      tagError(new Error(`File path escapes target directory: ${fileName}`), {\n        category: \"invalid_input\",\n      });\n    }"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0012","rule_version":"1.2.0","rule_name":"Denial of Wallet / Cost Controls","finding":"SECURE","severity":"MEDIUM","realized_severity":null,"confidence":"medium","rationale":"The only external, reachable API dependency is the Figma REST API, accessed with the operator's own Figma Personal Access Token or OAuth token; this server does not integrate a separate metered/paid compute or token service billed to the server operator, and no server-side cost quota or billing configuration is present in the code. The 429-handling code (src/services/errors/rate-limit.ts, src/utils/fetch-json.ts RETRYABLE_STATUSES) reports rate-limit errors back to the caller; it is error-surfacing, not a cost-control or quota mechanism in itself. PostHog telemetry uses a write-only, free-tier ingestion key, but the code does not establish PostHog's pricing terms or otherwise guarantee no incremental telemetry cost. The unbounded image-download fan-out described in ATR-C0004 is a genuine availability/resource-exhaustion weakness, but no operator-billable per-call dependency beyond the user's own Figma account is established in this codebase, so denial-of-wallet exposure specifically is not demonstrated.","vulnerability_details":null,"fix_guidance":"Optional hardening: add a server-side per-session/per-hour cap on download_figma_images node counts to further bound API usage against the operator's Figma plan.","why_this_matters":"Prevents cost-based disruption.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"src/services/figma.ts","start_line":87,"end_line":93,"snippet":"} catch (error) {\n      const meta = getErrorMeta(error);\n      if (meta.http_status === 429) {\n        throw new Error(buildRateLimitMessage(error), { cause: error });\n      }"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0013","rule_version":"1.1.0","rule_name":"Tool Output Sanitization & Injection Containment","finding":"VULNERABLE","severity":"HIGH","realized_severity":"MEDIUM","confidence":"medium","rationale":"The get_figma_data tool returns third-party Figma content (text-layer characters, component/instance names, and other potentially attacker-influenceable strings from a Figma file the server's operator may not control) directly as MCP tool text content, with no structural envelope, no untrusted-content annotation on the tool registration, and no neutralization of prompt-injection markers (fake role/system tags, zero-width/BiDi Unicode, control characters). The only transformation applied (escapeMarkdown in src/transformers/text.ts) escapes CommonMark syntax characters and newline characters purely to keep the YAML/tree serialization well-formed; together with the JSON/YAML/tree serialization and MCP content typing, this is structural data-format normalization, not host-enforced instruction containment. src/mcp/index.ts registers both tools with only readOnlyHint/openWorldHint annotations, surfacing no provenance/untrusted-content signal to the host. This establishes a genuine output-boundary weakness; downstream consequences such as host prompt promotion, secret leakage, or further attacker-directed tool chaining are potential impacts of this gap rather than behaviors reproduced here, since this repository does not itself include a downstream LLM host to exercise against.","vulnerability_details":"A malicious or compromised Figma file (e.g. a shared/public file an attacker controls) can embed prompt-injection payloads in text-layer content, layer names, or component names. get_figma_data fetches this data, runs it through the simplification/serialization pipeline, and returns `result.formatted` verbatim as the tool's text content (src/mcp/tools/get-figma-data-tool.ts:95-97) straight into the calling LLM's context, with no envelope, no untrusted-content flag, and no injection-marker sanitization -- only cosmetic markdown-escaping.","fix_guidance":"Wrap tool text output in a host-enforced envelope (e.g. `<tool_output name=\"get_figma_data\" untrusted=\"true\">...</tool_output>`), strip/neutralize Unicode tag characters, zero-width characters, BiDi overrides, and fabricated role markers from Figma-sourced text (layer/component names, text-node characters) before returning it, and annotate the tool with an untrusted-content signal so hosts can apply stricter handling.","why_this_matters":"Tool outputs are a primary prompt-injection vector. Third-party content (web pages, files, API responses) may contain text crafted to override instructions or impersonate system/tool messages. Without a host-enforced envelope, sanitization, and an explicit untrusted-return signal, the model can be coerced into executing attacker-controlled directives, leaking secrets, or chaining further tool calls on behalf of the adversary.","context_note":"[capability_by_design|relief:-40%|ineligible:within_declared_contract] within_declared_contract is ineligible for relief on ATR-C0013 because the rule declares that signal orthogonal to its risk (discount_ineligible_signals); the reported behaviour is within the tool's declared capability, and that does not reduce this finding's severity. Realized severity MEDIUM (from HIGH: 40% context relief -- mitigation_present -40%).","within_declared_contract":false,"transport_gated":false,"mitigation_present":true,"evidence":[{"path":"src/mcp/tools/get-figma-data-tool.ts","start_line":92,"end_line":98,"snippet":"Logger.log(`Successfully extracted data: ${result.metrics.simplifiedNodeCount} nodes`);\n    Logger.log(\"Sending result to client\");\n\n    return {\n      content: [{ type: \"text\" as const, text: result.formatted }],\n    };\n  } catch (error) {"},{"path":"src/transformers/text.ts","start_line":735,"end_line":739,"snippet":"const MARKDOWN_ESCAPE_CHARS = /[\\\\*_~[\\](){}]/g;\n\nfunction escapeMarkdown(text: string): string {\n  return text.replace(MARKDOWN_ESCAPE_CHARS, \"\\\\$&\").replace(/[\\n\\u2029]/g, \"\\\\n\");\n}"},{"path":"src/mcp/index.ts","start_line":80,"end_line":98,"snippet":"server.registerTool(\n    getFigmaDataTool.name,\n    {\n      title: \"Get Figma Data\",\n      description: getFigmaDataTool.description,\n      inputSchema: getFigmaDataTool.parametersSchema,\n      annotations: { readOnlyHint: true },\n    },\n    (params: GetFigmaDataParams, extra: ToolExtra) =>\n      getFigmaDataTool.handler(...)"}],"affected_tools":["get_figma_data","download_figma_images"],"component_name":null},{"rule_id":"ATR-C0014","rule_version":"1.2.0","rule_name":"CI/CD Workflow Script Injection","finding":"SECURE","severity":"HIGH","realized_severity":null,"confidence":"high","rationale":"Only two workflows exist (ci.yml, release.yml) plus a local composite action (.github/actions/setup). ci.yml triggers on pull_request to main and runs fixed lint/test/typecheck commands with no interpolation of any github.event.*/github.head_ref/inputs.* expression into a shell run block. release.yml triggers only on push to main, additionally gated by `github.repository_owner == 'GLips'`, and the only interpolated values are release-please action outputs (steps.release.outputs.major/minor/patch) -- numeric semantic-version components produced by a constrained, trusted action rather than attacker-controlled PR titles, branch names, or issue content. The resulting shell variable is consumed via `jq --arg` (safe parameter binding); the safety of this step rests on the narrow, action-controlled source of the interpolated values and the trusted push-only execution context, not merely on the VERSION assignment being quoted. No pull_request_target, issue_comment, workflow_run, or repository_dispatch triggers exist anywhere in the repo.","vulnerability_details":null,"fix_guidance":"No action required; continue avoiding pull_request_target/issue_comment triggers and direct interpolation of untrusted event fields into run blocks.","why_this_matters":"CI/CD script injection can execute attacker-controlled commands with workflow tokens, repository secrets, trusted-runner access, artifact or cache authority, and release or deployment privileges.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":".github/workflows/release.yml","start_line":55,"end_line":59,"snippet":"- name: Update server.json version\n  run: |\n    VERSION=\"${{ steps.release.outputs.major }}.${{ steps.release.outputs.minor }}.${{ steps.release.outputs.patch }}\"\n    jq --arg v \"$VERSION\" '.version = $v | .packages[0].version = $v' server.json > server.tmp && mv server.tmp server.json\n  if: ${{ steps.release.outputs.release_created }}"},{"path":".github/workflows/ci.yml","start_line":1,"end_line":29,"snippet":"on:\n  pull_request:\n    branches:\n      - main\njobs:\n  ci:\n    ...\n    steps:\n      - uses: actions/checkout@v4\n      - uses: ./.github/actions/setup\n      - name: Scan for hidden characters\n        run: node scripts/scan-hidden-chars.mjs\n      - name: Check formatting\n        run: pnpm prettier --check \"src/**/*.ts\""}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-R0001","rule_version":"1.3.0","rule_name":"Weaponization: Malicious-Purpose Artifact (Offensive Capability by Design)","finding":"SECURE","severity":"CRITICAL","realized_severity":null,"confidence":"high","rationale":"The repository is a legitimate, openly-maintained MCP server (figma-developer-mcp / Framelink) whose declared and actual purpose is to fetch Figma design data and expose it to AI coding agents for implementing UI designs. No offensive/malicious capability (malware, credential theft, exploit generation, evasion) is present in README, SKILL-equivalent descriptions, tool descriptions, or source.","vulnerability_details":null,"fix_guidance":"No action required.","why_this_matters":"An artifact whose purpose is to weaponize the agent -- to produce malware, steal credentials, evade defenses, or run attacks at scale -- is dangerous regardless of how securely it is built; posture controls (auth, input validation, least privilege) cannot make a weapon safe, which is why a confirmed weaponization finding caps the trust verdict to `blocked` rather than being averaged against a good posture score. Catching malicious PURPOSE is a distinct safety control, and it must be conservative because offensive capability is dual-use: legitimate pentest, CTF, defensive, and research work must abstain (UNKNOWN), never block -- unless the artifact itself hides from the people who install, run or audit it.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"package.json","start_line":1,"end_line":6,"snippet":"{\n  \"name\": \"figma-developer-mcp\",\n  \"version\": \"0.13.2\",\n  \"mcpName\": \"io.github.GLips/Figma-Context-MCP\",\n  \"description\": \"Give your coding agent access to your Figma data. Implement designs in any framework in one-shot.\","}],"affected_tools":null,"component_name":null}],"component_tools":[{"tool_name":"download_figma_images","tool_description":"Download SVG and PNG images used in a Figma file based on the IDs of image or icon nodes; writes files under the configured image directory","types":["write","network-broad"],"status":"IMPLEMENTED","component_name":"mcp-server"},{"tool_name":"get_figma_data","tool_description":"Get comprehensive Figma file data including layout, content, visuals, and component information","types":["read-only","untrusted-output"],"status":"IMPLEMENTED","component_name":"mcp-server"}],"summary":{"high_level_risks":["The HTTP/StreamableHTTP transport (default port 3333) has no OAuth 2.1 authorization layer and no application-level authentication/authorization gate: a caller omitting its own X-Figma-Token/Authorization header is silently served using the operator's globally-configured Figma credential, and both exposed tools have host-side effects (file write, outbound network request carrying that credential) (ATR-M0005, ATR-M0010, HIGH).","Transitive runtime dependencies carry 88 known CVEs per Trivy, including 1 CRITICAL (proxy-addr IP-spoofing) and 24 HIGH severity issues across proxy-addr, undici, fast-uri, js-yaml, express-rate-limit, hono, path-to-regexp, @modelcontextprotocol/sdk, and ip-address (ATR-C0007, CRITICAL/HIGH).","Tool output from get_figma_data returns Figma file content (text-layer characters, layer/component names) directly into the calling LLM's context with no structural envelope, no untrusted-content annotation, and no sanitization of prompt-injection markers (fake role tags, zero-width/BiDi Unicode); only cosmetic markdown-escaping is applied (ATR-C0013, HIGH).","The HTTP transport is plaintext-only with no TLS/HTTPS support in-code; credentials traverse the wire in cleartext unless an operator adds an external TLS-terminating proxy, which is not documented as mandatory (ATR-M0009, HIGH).","GitHub Actions are pinned by mutable tags and the release workflow installs a third-party binary via unauthenticated curl|tar with no checksum verification (ATR-C0007, HIGH).","No timeouts on outbound fetches to the Figma API or image hosts, and no upper bound on the number of images a single download_figma_images call can request, creating a resource-exhaustion/hang risk (ATR-C0004, MEDIUM).","Gitleaks flagged a hardcoded PostHog API key in src/telemetry/client.ts; code comments document it as an intentional write-only public ingest key, a common low-risk SDK pattern, but it represents a hardcoded credential nonetheless."],"top_remediations":["Implement OAuth 2.1 + PKCE for the StreamableHTTP transport per the MCP Authorization spec, and require every HTTP request to present and verify its own MCP-level credential before dispatching to any tool; remove the silent fallback to the server's globally-configured Figma credential for unauthenticated callers (ATR-M0005, ATR-M0010).","Upgrade vulnerable third-party dependencies flagged by Trivy (undici, fast-uri, js-yaml, proxy-addr, express-rate-limit, hono, path-to-regexp, @modelcontextprotocol/sdk) to patched versions, and apply pnpm overrides where fixed versions are not yet available.","Wrap get_figma_data/download_figma_images text output in a host-enforced envelope, strip Unicode tag/zero-width/BiDi characters and fabricated role markers from Figma-sourced text, and mark the tools' returned content as untrusted to prevent prompt injection.","Document and/or enforce that the HTTP transport only be run behind a TLS-terminating reverse proxy for any non-loopback deployment (ATR-M0009).","Pin GitHub Actions to full commit SHAs and add checksum/signature verification to the mcp-publisher binary download in release.yml.","Add AbortSignal-based timeouts to all outbound fetch() calls (Figma API and image downloads) and cap the nodes array length in the download_figma_images schema to prevent resource exhaustion.","When binding to a non-loopback host, explicitly pass allowedHosts or add Origin-header validation instead of relying on the SDK's loopback-only automatic DNS-rebinding protection."]},"rollup":{"component_findings_count":{"vulnerable":5,"secure":6,"not_applicable":0},"repo_findings_inherited_count":{"vulnerable":4,"secure":9,"not_applicable":1},"component_tools_count":{"write":1,"network_broad":1,"read_only":1,"untrusted_output":1},"worst_case_severity":"CRITICAL"},"trust_scores":{"repo_level":{"score":27,"tier":"critical","capability_band":"moderate","score_confidence":"medium","score_interval":{"low":14,"high":40},"factors":["ATR-M0003 HIGH","ATR-M0005 CRITICAL","ATR-M0009 HIGH","ATR-M0010 CRITICAL","ATR-M0011 CRITICAL","ATR-C0004 MEDIUM","ATR-C0008 HIGH"]},"component_level":{"score":28,"tier":"critical","capability_band":"moderate","score_confidence":"medium","score_interval":{"low":14,"high":42},"factors":["ATR-M0003 HIGH","ATR-M0005 CRITICAL","ATR-M0009 HIGH","ATR-M0010 CRITICAL","ATR-M0011 CRITICAL"]}}}]}