diff --git a/.buildkite/scripts/steps/security/third_party_packages.txt b/.buildkite/scripts/steps/security/third_party_packages.txt index 8655c840a4bcb..38a87f846bb87 100644 --- a/.buildkite/scripts/steps/security/third_party_packages.txt +++ b/.buildkite/scripts/steps/security/third_party_packages.txt @@ -44,6 +44,7 @@ lightningcss tar-fs browserslist react-day-picker +mysql2 jszip @rsdoctor/rspack-plugin @rspack/core diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 80595dcd438f2..62eff115b51a6 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -2623,6 +2623,8 @@ src/platform/packages/shared/kbn-connector-specs/src/auth_types/kubernetes_* @el # Connector Specs src/platform/packages/shared/kbn-connector-specs/src/all_specs.ts src/platform/packages/shared/kbn-connector-specs/src/connector_icons_map.ts +src/platform/packages/shared/kbn-connector-specs/src/lib/clients/** @elastic/workchat-eng +src/platform/packages/shared/kbn-connector-specs/src/lib/generic_db_connector* @elastic/workchat-eng src/platform/packages/shared/kbn-connector-specs/src/lib/mcp/** @elastic/workchat-eng src/platform/packages/shared/kbn-connector-specs/src/specs/** src/platform/packages/shared/kbn-connector-specs/src/specs/abuseipdb/** @elastic/workflows-eng @@ -2664,6 +2666,7 @@ src/platform/packages/shared/kbn-connector-specs/src/specs/kubernetes/** @elasti src/platform/packages/shared/kbn-connector-specs/src/specs/microsoft_teams/** @elastic/workchat-eng src/platform/packages/shared/kbn-connector-specs/src/specs/misp/** @elastic/workflows-eng src/platform/packages/shared/kbn-connector-specs/src/specs/monday_com/** @elastic/workchat-eng +src/platform/packages/shared/kbn-connector-specs/src/specs/mysql/** @elastic/workchat-eng src/platform/packages/shared/kbn-connector-specs/src/specs/notion/** @elastic/workchat-eng src/platform/packages/shared/kbn-connector-specs/src/specs/okta/** @elastic/workflows-eng src/platform/packages/shared/kbn-connector-specs/src/specs/one_drive/** @elastic/workchat-eng diff --git a/docs/reference/connectors-kibana/_snippets/data-context-sources-connectors-list.md b/docs/reference/connectors-kibana/_snippets/data-context-sources-connectors-list.md index 3fac627410dc2..6777a666745ee 100644 --- a/docs/reference/connectors-kibana/_snippets/data-context-sources-connectors-list.md +++ b/docs/reference/connectors-kibana/_snippets/data-context-sources-connectors-list.md @@ -33,6 +33,7 @@ - [Kubernetes](/reference/connectors-kibana/kubernetes-action-type.md): Read and modify resources in a Kubernetes cluster through its REST API. - [Microsoft Teams](/reference/connectors-kibana/microsoft-teams-action-type.md): Search messages and browse teams, channels, and chats in Microsoft Teams. - [Monday.com](/reference/connectors-kibana/monday-com-action-type.md): Search boards, read and create items, post updates, and manage workspaces in Monday.com. +- [MySQL](/reference/connectors-kibana/mysql-action-type.md): Query tables, search rows, explore schema, and execute SQL in a MySQL database. - [New Relic](/reference/connectors-kibana/new-relic-action-type.md): Acknowledge and resolve AI issues, manage muting rules, and run NRQL queries. - [Notion](/reference/connectors-kibana/notion-action-type.md): Explore content and databases in Notion. - [OneDrive](/reference/connectors-kibana/one-drive-action-type.md): Search files and folders, browse drives, read file content, and list files shared with you in Microsoft OneDrive. diff --git a/docs/reference/connectors-kibana/mysql-action-type.md b/docs/reference/connectors-kibana/mysql-action-type.md new file mode 100644 index 0000000000000..5a8dbcf69c63e --- /dev/null +++ b/docs/reference/connectors-kibana/mysql-action-type.md @@ -0,0 +1,133 @@ +--- +navigation_title: "MySQL" +type: reference +description: "Use the MySQL connector to query, explore schema, and execute SQL against a MySQL database." +applies_to: + stack: preview 9.6 + serverless: preview +--- + +# MySQL connector [mysql-action-type] + +The MySQL connector connects directly to a MySQL database so you can search, query, and explore schema from chat conversations. Workflow authors can also run write or DDL statements through **Execute SQL**. + +## Requirements [mysql-requirements] + +The MySQL connector connects directly to MySQL over the native MySQL protocol (default port 3306). Your MySQL server must be network-accessible from your Kibana instance. TLS is required by default. + +To use the MySQL connector, you need: + +1. A MySQL server accessible from your Kibana instance. +2. A MySQL user with access to the databases you want to query. +3. The server hostname, port, database name, and credentials. + +## Get connection details [mysql-api-credentials] + +To configure the connector: + +1. Identify the hostname or IP address of your MySQL server. +2. Create a MySQL user with the appropriate permissions for your use case (see [Database user permissions](#mysql-security)). +3. Note the server port (default: 3306) and the default database name. + +## Create connectors in {{kib}} [define-mysql-ui] + +You can create connectors in **{{stack-manage-app}} > {{connectors-ui}}**. + +### Connector configuration [mysql-connector-configuration] + +MySQL connectors have the following configuration properties: + +Host +: The hostname or IP address of the MySQL server (for example, `mysql.example.com`, `192.168.1.1`, `localhost`). Do not include a protocol prefix. + +Port +: The port number of the MySQL server (default: 3306). + +Database +: The name of the default database to query. + +Username +: The MySQL user to authenticate as. + +Password +: The password for the MySQL user. + +TLS +: Whether to encrypt the connection. **Required** (default) uses Kibana TLS settings. **Disabled** is only for servers that do not support TLS. + + +## Test connectors [mysql-action-configuration] + +You can test connectors as you're creating or editing the connector in {{kib}}. + +## MySQL Connector actions [mysql-connector-actions] + +The MySQL connector has the following actions: + +Query +: Run a read-only SQL query against the MySQL database. + - **sql** (required): The SQL query to run. Include a `LIMIT` clause to bound the result size. Do not include a trailing semicolon. + +List Databases +: List all databases accessible to the authenticated user. + +List Tables +: List all tables in a database. + - **database** (optional): The database name. Uses the configured default if omitted. + +Describe Table +: Describe the schema of a table, including column names, types, and constraints. + - **table** (required): The table name to describe. + - **database** (optional): The database name. Uses the configured default if omitted. + +Search Rows +: Search rows in a table by matching a search term against specified columns. + - **table** (required): The table to search. + - **searchTerm** (required): The text to search for. + - **columns** (required): Array of column names to search in. Use `describeTable` to discover available columns. + - **maxRows** (optional): Maximum number of rows to return (1-1000, default: 100). + - **database** (optional): The database name. Uses the configured default if omitted. + +Execute SQL +: Run any SQL statement against the MySQL database. No restrictions — `INSERT`, `UPDATE`, `DELETE`, `DROP`, and DDL are all permitted. Use only when the workflow explicitly requires a write or destructive operation. Prefer **Query** for read-only access. + - **sql** (required): The SQL statement to execute. + + +## Database user permissions [mysql-security] + +The permissions you grant to the MySQL user determine what the connector can do. Configure them to match your intended use case. + +### Read-only chat use case (recommended) + +For chat conversations, where the goal is to query and explore data, use a dedicated user with only `SELECT` access. This is the recommended configuration and provides the strongest protection against unintended modifications. + +```sql +-- Create a read-only user and grant SELECT on the target databases +CREATE USER 'kibana_reader'@'%' IDENTIFIED BY ''; +GRANT SELECT ON my_database.* TO 'kibana_reader'@'%'; +FLUSH PRIVILEGES; +``` + +The `query` action enforces read-only access at the application level by accepting only `SELECT` and `WITH` statements and by blocking multi-statement input. Use **List Tables** and **Describe Table** for schema discovery. + +::::{note} +The application-level read-only check is not a security guarantee. Prompt injection and other techniques may craft inputs that bypass it. The only reliable protection is granting the database user read-only permissions. Application-level enforcement is a defense-in-depth measure, not a substitute for least-privilege credentials. +:::: + +Using a least-privilege database user adds a second, independent layer of enforcement. Note: the `executeSql` action bypasses these restrictions and can run any statement — do not grant write privileges unless your use case requires them. + +You can further restrict the user to connections from your Kibana host's IP address: + +```sql +CREATE USER 'kibana_reader'@'' IDENTIFIED BY ''; +``` + +### Broader access + +If your use case requires write access or access across multiple databases, grant the appropriate privileges to the MySQL user. Scope permissions as narrowly as possible for your use case. + +```sql +-- Example: grant read/write access to a specific database +GRANT SELECT, INSERT, UPDATE, DELETE ON my_database.* TO 'kibana_user'@'%'; +FLUSH PRIVILEGES; +``` diff --git a/docs/reference/toc.yml b/docs/reference/toc.yml index b3e25ee80381b..187031a15ee16 100644 --- a/docs/reference/toc.yml +++ b/docs/reference/toc.yml @@ -118,6 +118,7 @@ toc: - file: connectors-kibana/microsoft-teams-action-type.md - file: connectors-kibana/monday-com-action-type.md - file: connectors-kibana/misp-action-type.md + - file: connectors-kibana/mysql-action-type.md - file: connectors-kibana/new-relic-action-type.md - file: connectors-kibana/notion-action-type.md - file: connectors-kibana/okta-action-type.md diff --git a/package.json b/package.json index a32f1d5a127fe..fb167a268b233 100644 --- a/package.json +++ b/package.json @@ -1562,6 +1562,7 @@ "monaco-yaml": "5.1.0", "murmurhash": "2.0.1", "mustache": "4.2.0", + "mysql2": "3.24.3", "node-diff3": "3.1.2", "node-fetch": "2.7.0", "node-forge": "1.4.0", diff --git a/packages/kbn-optimizer/src/worker/webpack.config.ts b/packages/kbn-optimizer/src/worker/webpack.config.ts index 5291a6a5bf406..d14d2a5666272 100644 --- a/packages/kbn-optimizer/src/worker/webpack.config.ts +++ b/packages/kbn-optimizer/src/worker/webpack.config.ts @@ -90,6 +90,8 @@ export function getWebpackConfig( } const sharedExternals: Record = { 'node:crypto': 'commonjs crypto', + mysql2: 'commonjs mysql2', + 'mysql2/promise': 'commonjs mysql2/promise', ...UiSharedDepsSrc.externals, }; if (request && request in sharedExternals) { diff --git a/packages/kbn-rspack-optimizer/src/config/externals.test.ts b/packages/kbn-rspack-optimizer/src/config/externals.test.ts index 23c8bff30ecfd..8143d392cf8b8 100644 --- a/packages/kbn-rspack-optimizer/src/config/externals.test.ts +++ b/packages/kbn-rspack-optimizer/src/config/externals.test.ts @@ -14,7 +14,7 @@ import { getExternals, isKeaReactReduxImport } from './externals'; * Rspack-specific externals that are NOT in UiSharedDepsSrc.externals. * Any addition here must be intentional and documented. */ -const RSPACK_ONLY_EXTERNALS = ['node:crypto']; +const RSPACK_ONLY_EXTERNALS = ['node:crypto', 'mysql2', 'mysql2/promise']; describe('externals configuration', () => { const rspackExternals = getExternals(); diff --git a/packages/kbn-rspack-optimizer/src/config/externals.ts b/packages/kbn-rspack-optimizer/src/config/externals.ts index 5365c05b7d5d5..87ba138485820 100644 --- a/packages/kbn-rspack-optimizer/src/config/externals.ts +++ b/packages/kbn-rspack-optimizer/src/config/externals.ts @@ -39,5 +39,8 @@ export function getExternals(): Record { // Node.js built-ins (rspack-specific, for browser compatibility) 'node:crypto': 'commonjs crypto', + // Native MySQL driver — keep out of the browser bundle (mirrors webpack). + mysql2: 'commonjs mysql2', + 'mysql2/promise': 'commonjs mysql2/promise', }; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 21c739ff6d7fc..abd973979f4fe 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4406,6 +4406,9 @@ importers: mustache: specifier: 4.2.0 version: 4.2.0 + mysql2: + specifier: 3.24.3 + version: 3.24.3(@types/node@24.10.13) node-diff3: specifier: 3.1.2 version: 3.1.2 @@ -16129,6 +16132,10 @@ packages: aws-sign2@0.7.0: resolution: {integrity: sha512-08kcGqnYf/YmjoRhfxyu+CLxBjUtHLXLXX/vUfx9l2LYzG3c1m61nrpyFUZI6zeS+Li/wWMMidD9KgrqtGq3mA==} + aws-ssl-profiles@1.1.2: + resolution: {integrity: sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==} + engines: {node: '>= 6.0.0'} + aws4@1.13.2: resolution: {integrity: sha512-lHe62zvbTB5eEABUVi/AwVh0ZKY9rMMDhmm+eeyuuUQbQ3+J+fONVQOZyj+DdrvD4BY33uYniyRJ4UJIaSKAfw==} @@ -18973,6 +18980,9 @@ packages: engines: {node: '>=20.0.0'} hasBin: true + generate-function@2.3.1: + resolution: {integrity: sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==} + generator-function@2.0.1: resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==} engines: {node: '>= 0.4'} @@ -19994,6 +20004,9 @@ packages: is-promise@4.0.0: resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + is-property@1.0.2: + resolution: {integrity: sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==} + is-regex@1.2.1: resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} engines: {node: '>= 0.4'} @@ -20916,6 +20929,10 @@ packages: resolution: {integrity: sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==} engines: {node: '>=12'} + lru.min@1.1.5: + resolution: {integrity: sha512-5J9ysMYUpYIg9RF2vJpy9SinEmSviFSe0GyPpCQ4L5QSkLAgeLXlTAOu2ZwWUU5m+0SBl6gUU1R1ZQB3aKypfA==} + engines: {bun: '>=1.0.0', deno: '>=1.30.0', node: '>=8.0.0'} + luxon@1.28.1: resolution: {integrity: sha512-gYHAa180mKrNIUJCbwpmD0aTu9kV0dREDrwNnuyFAsO1Wt0EVYSZelPnJlbj9HplzXX/YWXHFTL45kvZ53M0pw==} @@ -21492,6 +21509,16 @@ packages: resolution: {integrity: sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==} engines: {node: ^20.17.0 || >=22.9.0} + mysql2@3.24.3: + resolution: {integrity: sha512-OKfWHkMAg9v06neq8FmSyhbxPQKABN9PAW5G9/bDTXzJBO5xXtkKL0V27vju7HQWk9UD4Od5BZsvCtBTB1CPEw==} + engines: {node: '>= 8.0'} + peerDependencies: + '@types/node': 24.10.13 + + named-placeholders@1.1.6: + resolution: {integrity: sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==} + engines: {node: '>=8.0.0'} + nano-css@5.6.2: resolution: {integrity: sha512-+6bHaC8dSDGALM1HJjOHVXpuastdu2xFoZlC77Jh4cg+33Zcgm+Gxd+1xsnpZK14eyHObSp82+ll5y3SX75liw==} peerDependencies: @@ -24170,6 +24197,10 @@ packages: sql-escape-string@1.1.0: resolution: {integrity: sha512-/kqO4pLZSLfV0KsBM2xkVh2S3GbjJJone37d7gYwLyP0c+REh3vnmkhQ7VwNrX76igC0OhJWpTg0ukkdef9vvA==} + sql-escaper@1.5.1: + resolution: {integrity: sha512-4toX5E1fQbBrpfXidaHnF0669nkAdETeIPTs2SUjxxD7RRIs9ICG4gtpmfc68JCEKehsdwLFqBu9VlQqZ1P1gg==} + engines: {bun: '>=1.0.0', deno: '>=2.0.0', node: '>=12.0.0'} + sql-summary@1.0.1: resolution: {integrity: sha512-IpCr2tpnNkP3Jera4ncexsZUp0enJBLr+pHCyTweMUBrbJsTgQeLWx1FXLhoBj/MvcnUQpkgOn2EY8FKOkUzww==} @@ -33718,6 +33749,8 @@ snapshots: aws-sign2@0.7.0: {} + aws-ssl-profiles@1.1.2: {} + aws4@1.13.2: {} axe-core@4.13.0: {} @@ -37178,6 +37211,10 @@ snapshots: transitivePeerDependencies: - supports-color + generate-function@2.3.1: + dependencies: + is-property: 1.0.2 + generator-function@2.0.1: {} gensync@1.0.0-beta.2: {} @@ -38351,6 +38388,8 @@ snapshots: is-promise@4.0.0: {} + is-property@1.0.2: {} + is-regex@1.2.1: dependencies: call-bound: 1.0.4 @@ -39511,6 +39550,8 @@ snapshots: lru-cache@7.18.3: {} + lru.min@1.1.5: {} + luxon@1.28.1: {} lz-string@1.5.0: {} @@ -40381,6 +40422,21 @@ snapshots: mute-stream@3.0.0: {} + mysql2@3.24.3(@types/node@24.10.13): + dependencies: + '@types/node': 24.10.13 + aws-ssl-profiles: 1.1.2 + generate-function: 2.3.1 + iconv-lite: 0.7.3 + long: 5.3.2 + lru.min: 1.1.5 + named-placeholders: 1.1.6 + sql-escaper: 1.5.1 + + named-placeholders@1.1.6: + dependencies: + lru.min: 1.1.5 + nano-css@5.6.2(react-dom@18.2.0(react@18.2.0))(react@18.2.0): dependencies: '@jridgewell/sourcemap-codec': 1.6.0 @@ -43491,6 +43547,8 @@ snapshots: sql-escape-string@1.1.0: {} + sql-escaper@1.5.1: {} + sql-summary@1.0.1: {} sql.js@1.14.2: {} diff --git a/renovate.json b/renovate.json index 1a5b061c81104..4e0e9b76d4e0a 100644 --- a/renovate.json +++ b/renovate.json @@ -2555,6 +2555,15 @@ "enabled": true, "minimumReleaseAge": "14 days" }, + { + "groupName": "mysql2", + "matchDepNames": ["mysql2"], + "reviewers": ["team:response-ops"], + "matchBaseBranches": ["main"], + "addLabels": ["Team:ResponseOps"], + "minimumReleaseAge": "14 days", + "enabled": true + }, { "groupName": "react-day-picker", "matchDepNames": ["react-day-picker"], diff --git a/src/platform/packages/shared/kbn-connector-specs/index.ts b/src/platform/packages/shared/kbn-connector-specs/index.ts index ed8a3bbfd1b14..cb15dab55b077 100644 --- a/src/platform/packages/shared/kbn-connector-specs/index.ts +++ b/src/platform/packages/shared/kbn-connector-specs/index.ts @@ -94,7 +94,6 @@ export { } from './src/auth_mode_by_auth_type_id'; export { getMeta, setMeta, addMeta } from './src/connector_spec_ui'; export type { BaseMetadata } from './src/connector_spec_ui'; -export { clientTypes } from './src/lib/clients'; export type { ClientTypeSpec, BuildContext, diff --git a/src/platform/packages/shared/kbn-connector-specs/server.ts b/src/platform/packages/shared/kbn-connector-specs/server.ts index 9359f9d6a0639..c899a48fb9f53 100644 --- a/src/platform/packages/shared/kbn-connector-specs/server.ts +++ b/src/platform/packages/shared/kbn-connector-specs/server.ts @@ -22,3 +22,5 @@ export const authTypeSpecs = { KubernetesEksAuth, KubernetesGkeAuth, }; + +export { clientTypes } from './src/lib/clients'; diff --git a/src/platform/packages/shared/kbn-connector-specs/src/all_specs.ts b/src/platform/packages/shared/kbn-connector-specs/src/all_specs.ts index 3b3832756fa8a..6e098d1469075 100644 --- a/src/platform/packages/shared/kbn-connector-specs/src/all_specs.ts +++ b/src/platform/packages/shared/kbn-connector-specs/src/all_specs.ts @@ -46,6 +46,7 @@ export * from './specs/google_cloud_storage/google_cloud_storage'; export * from './specs/sharepoint_server/sharepoint_server'; export * from './specs/microsoft_teams/microsoft_teams'; export * from './specs/monday_com/monday_com'; +export * from './specs/mysql/mysql'; export * from './specs/outlook/outlook'; export * from './specs/tavily/tavily'; export * from './specs/pagerduty/pagerduty'; diff --git a/src/platform/packages/shared/kbn-connector-specs/src/connector_icons_map.ts b/src/platform/packages/shared/kbn-connector-specs/src/connector_icons_map.ts index 14806a0b7254a..91e8a2900c752 100644 --- a/src/platform/packages/shared/kbn-connector-specs/src/connector_icons_map.ts +++ b/src/platform/packages/shared/kbn-connector-specs/src/connector_icons_map.ts @@ -132,6 +132,8 @@ export const ConnectorIconsMap: Map< '.slack2', lazy(() => import(/* webpackChunkName: "connectorIconSlack2" */ './specs/slack/icon')), ], + + ['.mysql', lazy(() => import(/* webpackChunkName: "connectorIconMysql" */ './specs/mysql/icon'))], ['.gmail', lazy(() => import(/* webpackChunkName: "connectorIconGmail" */ './specs/gmail/icon'))], [ '.azure-blob', diff --git a/src/platform/packages/shared/kbn-connector-specs/src/connector_spec_ui.ts b/src/platform/packages/shared/kbn-connector-specs/src/connector_spec_ui.ts index 329b516f3b095..e5e30c57d0aef 100644 --- a/src/platform/packages/shared/kbn-connector-specs/src/connector_spec_ui.ts +++ b/src/platform/packages/shared/kbn-connector-specs/src/connector_spec_ui.ts @@ -26,6 +26,7 @@ import { z } from '@kbn/zod/v4'; export enum WidgetType { Text = 'text', + Number = 'number', Password = 'password', Select = 'select', FormFieldset = 'formFieldset', diff --git a/src/platform/packages/shared/kbn-connector-specs/src/lib/clients/index.ts b/src/platform/packages/shared/kbn-connector-specs/src/lib/clients/index.ts index a39039f1e5a9f..0c445d725e74f 100644 --- a/src/platform/packages/shared/kbn-connector-specs/src/lib/clients/index.ts +++ b/src/platform/packages/shared/kbn-connector-specs/src/lib/clients/index.ts @@ -7,7 +7,9 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ +import type { Pool as Mysql2Pool } from 'mysql2/promise'; import type { ClientTypeSpec } from './client_type_spec'; +import { mysqlClientType } from './mysql'; export type { ClientTypeSpec, @@ -17,10 +19,9 @@ export type { CredentialAccessor, } from './client_type_spec'; -// No client types are registered yet. `ClientTypeId` resolves to `never` -// until a client type is added to `ClientRegistry`. -// eslint-disable-next-line @typescript-eslint/no-empty-interface -export interface ClientRegistry {} +export interface ClientRegistry { + mysql: Mysql2Pool; +} export type ClientTypeId = keyof ClientRegistry; @@ -28,4 +29,6 @@ export type ClientTypeSpecs = Readonly<{ [K in ClientTypeId]: ClientTypeSpec; }>; -export const clientTypes: ClientTypeSpecs = {}; +export const clientTypes: ClientTypeSpecs = { + mysql: mysqlClientType, +}; diff --git a/src/platform/packages/shared/kbn-connector-specs/src/lib/clients/mysql.test.ts b/src/platform/packages/shared/kbn-connector-specs/src/lib/clients/mysql.test.ts new file mode 100644 index 0000000000000..d6dbe94168c1b --- /dev/null +++ b/src/platform/packages/shared/kbn-connector-specs/src/lib/clients/mysql.test.ts @@ -0,0 +1,173 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import type { BuildContext } from './client_type_spec'; +import { mysqlClientType } from './mysql'; + +interface MockPool { + end: jest.Mock; +} + +const mockCreatePool = jest.fn(() => ({ + end: jest.fn().mockResolvedValue(undefined), +})); + +jest.mock('mysql2/promise', () => ({ + createPool: (opts: unknown) => mockCreatePool(opts), +})); + +const makeCredential = (username: string, password: string): BuildContext['credential'] => ({ + getAuthHeaders: jest.fn().mockResolvedValue({ + Authorization: `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`, + }), +}); + +const makeNetworkSettings = ( + overrides: Partial = {} +): BuildContext['networkSettings'] => ({ + ensureHostnameAllowed: jest.fn(), + ensureUriAllowed: jest.fn(), + getSslSettings: jest.fn().mockReturnValue({}), + getProxySettings: jest.fn().mockReturnValue(undefined), + getCustomHostSettings: jest.fn().mockReturnValue(undefined), + getResponseSettings: jest.fn().mockReturnValue({ timeout: 60000, maxContentLength: 10485760 }), + ...overrides, +}); + +const makeCtx = (overrides: Partial = {}): BuildContext => ({ + logger: { info: jest.fn(), debug: jest.fn(), error: jest.fn(), warn: jest.fn() } as never, + config: { host: 'db.example.com', port: 3306, database: 'testdb' }, + networkSettings: makeNetworkSettings(), + credential: makeCredential('tester', 'secret'), + ...overrides, +}); + +describe('mysqlClientType', () => { + beforeEach(() => { + mockCreatePool.mockClear(); + }); + + it('has id "mysql"', () => { + expect(mysqlClientType.id).toBe('mysql'); + }); + + describe('build', () => { + it('calls ensureHostnameAllowed before creating the pool', async () => { + const networkSettings = makeNetworkSettings(); + const ctx = makeCtx({ networkSettings }); + + await mysqlClientType.build(ctx); + + expect(networkSettings.ensureHostnameAllowed).toHaveBeenCalledWith('db.example.com'); + expect(mockCreatePool).toHaveBeenCalledTimes(1); + }); + + it('does not create the pool when the host is not allowlisted', async () => { + const networkSettings = makeNetworkSettings({ + ensureHostnameAllowed: jest.fn().mockImplementation(() => { + throw new Error('Host not allowed'); + }), + }); + const ctx = makeCtx({ networkSettings }); + + await expect(mysqlClientType.build(ctx)).rejects.toThrow('Host not allowed'); + expect(mockCreatePool).not.toHaveBeenCalled(); + }); + + it('decodes username and password from the Basic Authorization header', async () => { + const ctx = makeCtx({ credential: makeCredential('alice', 'p@ss:word') }); + + await mysqlClientType.build(ctx); + + expect(mockCreatePool).toHaveBeenCalledWith( + expect.objectContaining({ user: 'alice', password: 'p@ss:word' }) + ); + }); + + it('passes host, port, and database from config to createPool', async () => { + const ctx = makeCtx({ + config: { host: 'mysql.prod', port: 3307, database: 'prod_db' }, + }); + + await mysqlClientType.build(ctx); + + expect(mockCreatePool).toHaveBeenCalledWith( + expect.objectContaining({ host: 'mysql.prod', port: 3307, database: 'prod_db' }) + ); + }); + + it('applies Kibana TLS settings and connect timeout by default', async () => { + const networkSettings = makeNetworkSettings({ + getSslSettings: jest.fn().mockReturnValue({ verificationMode: 'full' }), + getResponseSettings: jest.fn().mockReturnValue({ timeout: 15000, maxContentLength: 1 }), + }); + const ctx = makeCtx({ networkSettings }); + + await mysqlClientType.build(ctx); + + expect(mockCreatePool).toHaveBeenCalledWith( + expect.objectContaining({ + connectTimeout: 15000, + queueLimit: 100, + ssl: expect.objectContaining({ rejectUnauthorized: true, verifyIdentity: true }), + }) + ); + }); + + it('omits ssl when config.ssl is disabled', async () => { + const ctx = makeCtx({ + config: { host: 'db.example.com', port: 3306, database: 'testdb', ssl: 'disabled' }, + }); + + await mysqlClientType.build(ctx); + + const opts = mockCreatePool.mock.calls[0][0] as Record; + expect(opts.ssl).toBeUndefined(); + }); + }); + + describe('terminate', () => { + it('calls pool.end()', async () => { + const pool = await mysqlClientType.build(makeCtx()); + await mysqlClientType.terminate(pool as never); + expect(pool.end).toHaveBeenCalledTimes(1); + }); + }); + + describe('isUserError', () => { + const isUserError = (err: unknown): boolean => { + const fn = mysqlClientType.isUserError; + if (!fn) { + throw new Error('expected mysqlClientType.isUserError'); + } + return fn(err); + }; + + it.each([ + 'ER_ACCESS_DENIED_ERROR', + 'ER_DBACCESS_DENIED_ERROR', + 'ER_BAD_DB_ERROR', + 'ECONNREFUSED', + 'ENOTFOUND', + ])('returns true for %s', (code) => { + const err = Object.assign(new Error('db error'), { code }); + expect(isUserError(err)).toBe(true); + }); + + it('returns false for transient / unknown error codes', () => { + const err = Object.assign(new Error('unknown'), { code: 'ER_LOCK_DEADLOCK' }); + expect(isUserError(err)).toBe(false); + }); + + it('returns false for non-Error values', () => { + expect(isUserError('string error')).toBe(false); + expect(isUserError(null)).toBe(false); + }); + }); +}); diff --git a/src/platform/packages/shared/kbn-connector-specs/src/lib/clients/mysql.ts b/src/platform/packages/shared/kbn-connector-specs/src/lib/clients/mysql.ts new file mode 100644 index 0000000000000..78359db465c65 --- /dev/null +++ b/src/platform/packages/shared/kbn-connector-specs/src/lib/clients/mysql.ts @@ -0,0 +1,94 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { getNodeSSLOptions } from '@kbn/actions-utils'; +import type { Pool as Mysql2Pool, SslOptions } from 'mysql2/promise'; +import type { BuildContext, ClientTypeSpec } from './client_type_spec'; + +// mysql2 error codes that indicate the user supplied bad configuration (not a transient network error). +const USER_ERROR_CODES = new Set([ + 'ER_ACCESS_DENIED_ERROR', // wrong credentials + 'ER_DBACCESS_DENIED_ERROR', // no access to specified database + 'ER_BAD_DB_ERROR', // database does not exist + 'ECONNREFUSED', // wrong host or port + 'ENOTFOUND', // hostname cannot be resolved +]); + +const extractBasicCredentials = async ( + credential: BuildContext['credential'] +): Promise<{ username: string; password: string }> => { + const headers = await credential.getAuthHeaders(); + const authHeader = headers.Authorization ?? headers.authorization ?? ''; + const encoded = authHeader.startsWith('Basic ') ? authHeader.slice(6) : ''; + const decoded = Buffer.from(encoded, 'base64').toString('utf8'); + const colonIdx = decoded.indexOf(':'); + return { + username: colonIdx >= 0 ? decoded.slice(0, colonIdx) : decoded, + password: colonIdx >= 0 ? decoded.slice(colonIdx + 1) : '', + }; +}; + +const toMysqlSslOptions = (ctx: BuildContext): SslOptions => { + const sslSettings = ctx.networkSettings.getSslSettings(); + const verificationMode = sslSettings.verificationMode ?? 'full'; + const nodeSsl = getNodeSSLOptions(ctx.logger, verificationMode, sslSettings); + + const ssl: SslOptions = { + rejectUnauthorized: nodeSsl.rejectUnauthorized ?? true, + // 'full' checks hostname; 'certificate' and 'none' do not. + verifyIdentity: verificationMode === 'full', + }; + if (nodeSsl.ca) ssl.ca = nodeSsl.ca; + if (nodeSsl.cert) ssl.cert = nodeSsl.cert; + if (nodeSsl.key) ssl.key = nodeSsl.key; + if (nodeSsl.passphrase) ssl.passphrase = nodeSsl.passphrase; + return ssl; +}; + +export const mysqlClientType: ClientTypeSpec = { + id: 'mysql', + + async build(ctx: BuildContext): Promise { + const host = ctx.config?.host as string; + const port = ctx.config?.port as number; + const database = ctx.config?.database as string; + const sslMode = (ctx.config?.ssl as 'required' | 'disabled' | undefined) ?? 'required'; + + ctx.networkSettings.ensureHostnameAllowed(host); + + const { username, password } = await extractBasicCredentials(ctx.credential); + const { timeout } = ctx.networkSettings.getResponseSettings(); + + ctx.logger.info(`[mysql] Opening connection pool for ${host}:${port}/${database}`); + const lib = await import('mysql2/promise'); + return lib.createPool({ + host, + port, + database, + user: username, + password, + waitForConnections: true, + connectionLimit: 5, + queueLimit: 100, + connectTimeout: timeout, + disableEval: true, + ...(sslMode === 'required' ? { ssl: toMysqlSslOptions(ctx) } : {}), + }); + }, + + async terminate(pool: Mysql2Pool): Promise { + await pool.end(); + }, + + isUserError(err: unknown): boolean { + if (!(err instanceof Error)) return false; + const code = (err as NodeJS.ErrnoException).code ?? ''; + return USER_ERROR_CODES.has(code); + }, +}; diff --git a/src/platform/packages/shared/kbn-connector-specs/src/lib/generic_db_connector.test.ts b/src/platform/packages/shared/kbn-connector-specs/src/lib/generic_db_connector.test.ts new file mode 100644 index 0000000000000..03d7449756755 --- /dev/null +++ b/src/platform/packages/shared/kbn-connector-specs/src/lib/generic_db_connector.test.ts @@ -0,0 +1,151 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { + assertReadOnly, + BIGQUERY_READ_ONLY_PREFIXES, + escapeLikePattern, + isReadOnlySql, + READ_ONLY_STATEMENT_PREFIXES, + SELECT_OR_WITH_PREFIX, +} from './generic_db_connector'; + +describe('isReadOnlySql', () => { + describe('default prefixes (SELECT, WITH, SHOW, DESCRIBE, DESC, EXPLAIN)', () => { + const allowed: Array<[string, string]> = [ + ['SELECT', 'SELECT * FROM users LIMIT 10'], + ['SHOW', 'SHOW TABLES'], + ['DESCRIBE', 'DESCRIBE users'], + ['DESC', 'DESC users'], + ['EXPLAIN', 'EXPLAIN SELECT * FROM users'], + ['WITH', 'WITH recent AS (SELECT * FROM orders) SELECT * FROM recent'], + ['lowercase select', 'select * from users'], + ['leading whitespace', ' SELECT 1'], + ['line comment', '-- a note\nSELECT 1'], + ['hash comment', '# a note\nSELECT 1'], + ['block comment', '/* a note */ SELECT 1'], + ['mixed comments', '-- one\n/* two */\n select 1'], + ['trailing semicolon', 'SELECT 1;'], + ['trailing semicolon + comment', 'SELECT 1; -- done'], + ]; + + it.each(allowed)('allows %s', (_label, sql) => { + expect(isReadOnlySql(sql)).toBe(true); + }); + }); + + describe('rejected statements', () => { + const rejected: Array<[string, string]> = [ + ['INSERT', 'INSERT INTO users VALUES (1)'], + ['UPDATE', 'UPDATE users SET name = "x"'], + ['DELETE', 'DELETE FROM users'], + ['CREATE', 'CREATE TABLE t(id INT)'], + ['DROP', 'DROP TABLE users'], + ['ALTER', 'ALTER TABLE users ADD COLUMN c INT'], + ['TRUNCATE', 'TRUNCATE TABLE users'], + ['GRANT', 'GRANT SELECT ON users TO reader'], + ['REVOKE', 'REVOKE SELECT ON users FROM reader'], + ['CALL', 'CALL my_procedure()'], + ['MERGE', 'MERGE INTO t USING s ON t.id = s.id WHEN MATCHED THEN UPDATE SET a = 1'], + ['blank', ''], + ['leading whitespace write', ' INSERT INTO users VALUES (1)'], + ['comment-hidden write', '-- looks fine\nINSERT INTO users VALUES (1)'], + ['block-comment-hidden write', '/* SELECT */ DROP TABLE users'], + ['multi-statement', 'SELECT 1; DROP TABLE users'], + ]; + + it.each(rejected)('rejects %s', (_label, sql) => { + expect(isReadOnlySql(sql)).toBe(false); + }); + }); + + it('restricts BigQuery to SELECT, WITH, and EXPLAIN', () => { + expect(isReadOnlySql('EXPLAIN SELECT 1', BIGQUERY_READ_ONLY_PREFIXES)).toBe(true); + expect(isReadOnlySql('SHOW TABLES', BIGQUERY_READ_ONLY_PREFIXES)).toBe(false); + expect(isReadOnlySql('DESCRIBE users', BIGQUERY_READ_ONLY_PREFIXES)).toBe(false); + }); + + it('restricts SELECT/WITH-only callers (MySQL query)', () => { + expect(isReadOnlySql('SELECT 1', SELECT_OR_WITH_PREFIX)).toBe(true); + expect(isReadOnlySql('WITH x AS (SELECT 1) SELECT * FROM x', SELECT_OR_WITH_PREFIX)).toBe(true); + expect(isReadOnlySql('SHOW TABLES', SELECT_OR_WITH_PREFIX)).toBe(false); + expect(isReadOnlySql('DESCRIBE users', SELECT_OR_WITH_PREFIX)).toBe(false); + expect(isReadOnlySql('EXPLAIN SELECT 1', SELECT_OR_WITH_PREFIX)).toBe(false); + }); +}); + +describe('assertReadOnly', () => { + it('allows SELECT and WITH by default', () => { + expect(() => assertReadOnly('SELECT * FROM users')).not.toThrow(); + expect(() => + assertReadOnly('WITH recent AS (SELECT * FROM orders) SELECT * FROM recent') + ).not.toThrow(); + }); + + it('rejects SHOW / DESCRIBE so query does not wrap them in a subquery', () => { + expect(() => assertReadOnly('SHOW TABLES')).toThrow(/read-only/i); + expect(() => assertReadOnly('DESCRIBE users')).toThrow(/read-only/i); + }); + + it('rejects a write statement', () => { + expect(() => assertReadOnly('DROP TABLE users')).toThrow(/read-only/i); + }); + + it('rejects multi-statement input even when the first statement is read-only', () => { + expect(() => assertReadOnly('SELECT 1; DROP TABLE users')).toThrow(/multi-statement/i); + }); + + it('rejects a write smuggled inside a WITH CTE', () => { + expect(() => + assertReadOnly('WITH x AS (INSERT INTO users VALUES (1)) SELECT * FROM x') + ).toThrow(/write operations/i); + }); + + it('rejects a write hidden behind leading comments', () => { + expect(() => assertReadOnly('-- looks fine\nDROP TABLE users')).toThrow(/read-only/i); + }); + + it('rejects MySQL executable comments that would smuggle a write past the guard', () => { + expect(() => assertReadOnly('/*!11111 DELETE FROM users -- */ SELECT 1')).toThrow( + /executable comment/i + ); + expect(() => assertReadOnly('/*! DROP TABLE users */ SELECT 1')).toThrow(/executable comment/i); + }); + + it('can use the broader discovery prefix set', () => { + expect(() => assertReadOnly('SHOW TABLES', READ_ONLY_STATEMENT_PREFIXES)).not.toThrow(); + }); +}); + +describe('escapeLikePattern', () => { + it('escapes percent and underscore wildcards', () => { + expect(escapeLikePattern('50%_off')).toBe('50!%!_off'); + }); + + it('escapes the escape character itself first', () => { + expect(escapeLikePattern('a!b')).toBe('a!!b'); + }); + + it('escapes single quotes by default for text-protocol SQL string literals', () => { + expect(escapeLikePattern("o'brien")).toBe("o''brien"); + }); + + it('leaves single quotes unmodified when escapeSingleQuotes is false', () => { + expect(escapeLikePattern("o'brien", false)).toBe("o'brien"); + }); + + it('leaves plain text untouched', () => { + expect(escapeLikePattern('hello world')).toBe('hello world'); + }); + + it('does not let escaping introduce a fresh wildcard', () => { + const result = escapeLikePattern('_'); + expect(result).toBe('!_'); + }); +}); diff --git a/src/platform/packages/shared/kbn-connector-specs/src/lib/generic_db_connector.ts b/src/platform/packages/shared/kbn-connector-specs/src/lib/generic_db_connector.ts new file mode 100644 index 0000000000000..32007e670a77f --- /dev/null +++ b/src/platform/packages/shared/kbn-connector-specs/src/lib/generic_db_connector.ts @@ -0,0 +1,103 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +/** + * Shared read-only SQL guardrail used by MySQL, Snowflake, and BigQuery. + * + * isReadOnlySql strips leading whitespace/comments, rejects multi-statement + * input (semicolons), and requires an allowlisted leading keyword. This matches + * the original Snowflake and BigQuery prefix-only semantics. + * + * assertReadOnly is the stricter MySQL variant: it additionally rejects + * write/DDL tokens that can hide after a read-only prefix (e.g. WITH ... INSERT) + * and MySQL executable comments (/ *!...*\/) that bypass comment stripping. + * + * The semicolon check is conservative: a semicolon inside a string literal is treated + * as a second statement. Agents can rewrite such queries. + */ + +export const READ_ONLY_STATEMENT_PREFIXES = /^(SELECT|WITH|SHOW|DESCRIBE|DESC|EXPLAIN)\b/i; +export const SELECT_OR_WITH_PREFIX = /^(SELECT|WITH)\b/i; +export const BIGQUERY_READ_ONLY_PREFIXES = /^(SELECT|WITH|EXPLAIN)\b/i; + +// Write / DDL that can hide after a read-only prefix (WITH CTE, SELECT INTO, EXPLAIN UPDATE). +const WRITE_PATTERN = + /\b(INSERT\s+INTO|REPLACE\s+INTO|UPDATE\s+|DELETE\s+|MERGE\s+INTO|CREATE\s+|DROP\s+|ALTER\s+|TRUNCATE\s+|GRANT\s+|REVOKE\s+|CALL\s+|LOAD\s+DATA|INTO\s+(OUTFILE|DUMPFILE))/i; + +// MySQL executable comments (/*!...*/): MySQL server parses their contents as code, +// but stripLeadingCommentsAndWhitespace would strip them as inert text first, letting +// a write/DDL slip past both the prefix check and WRITE_PATTERN. Reject outright. +const MYSQL_EXECUTABLE_COMMENT = /\/\*!/; + +export const stripLeadingCommentsAndWhitespace = (sql: string): string => { + let remaining = sql; + while (true) { + const before = remaining; + remaining = remaining.replace(/^\s+/, ''); + remaining = remaining.replace(/^--[^\n]*(?:\n|$)/, ''); + remaining = remaining.replace(/^#[^\n]*(?:\n|$)/, ''); + remaining = remaining.replace(/^\/\*[\s\S]*?\*\//, ''); + if (remaining === before) { + return remaining; + } + } +}; + +export const hasTrailingStatement = (sql: string): boolean => { + const semicolonIndex = sql.indexOf(';'); + if (semicolonIndex === -1) { + return false; + } + const trailing = stripLeadingCommentsAndWhitespace(sql.slice(semicolonIndex + 1)); + return trailing.length > 0; +}; + +export const isReadOnlySql = ( + sql: string, + allowedPrefixes: RegExp = READ_ONLY_STATEMENT_PREFIXES +): boolean => { + if (hasTrailingStatement(sql)) return false; + const head = stripLeadingCommentsAndWhitespace(sql); + return allowedPrefixes.test(head); +}; + +export const assertReadOnly = ( + sql: string, + allowedPrefixes: RegExp = SELECT_OR_WITH_PREFIX +): void => { + if (MYSQL_EXECUTABLE_COMMENT.test(sql)) { + throw new Error('MySQL executable comments (/*! ... */) are not permitted'); + } + if (hasTrailingStatement(sql)) { + throw new Error('Multi-statement SQL is not permitted'); + } + const head = stripLeadingCommentsAndWhitespace(sql); + if (!allowedPrefixes.test(head)) { + throw new Error( + 'Only read-only SQL statements are permitted (SELECT, WITH). Use listTables or describeTable for schema discovery, or executeSql for writes.' + ); + } + if (WRITE_PATTERN.test(head)) { + throw new Error('Write operations are not permitted'); + } +}; + +// LIKE escape character is '!'. Safe regardless of NO_BACKSLASH_ESCAPES. +// escapeSingleQuotes: true (default) for text-protocol drivers that embed the pattern +// in a SQL string literal; false for prepared-statement drivers (binary protocol). +export const escapeLikePattern = (value: string, escapeSingleQuotes = true): string => { + let result = value + .replace(/!/g, '!!') // escape char itself first + .replace(/%/g, '!%') // literal percent + .replace(/_/g, '!_'); // literal underscore + if (escapeSingleQuotes) { + result = result.replace(/'/g, "''"); + } + return result; +}; diff --git a/src/platform/packages/shared/kbn-connector-specs/src/specs/bigquery/bigquery.ts b/src/platform/packages/shared/kbn-connector-specs/src/specs/bigquery/bigquery.ts index c8da3c06b0592..4098a590997f6 100644 --- a/src/platform/packages/shared/kbn-connector-specs/src/specs/bigquery/bigquery.ts +++ b/src/platform/packages/shared/kbn-connector-specs/src/specs/bigquery/bigquery.ts @@ -10,6 +10,7 @@ import { i18n } from '@kbn/i18n'; import { z, lazySchema } from '@kbn/zod/v4'; import type { ActionContext, ConnectorSpec } from '../../connector_spec'; +import { BIGQUERY_READ_ONLY_PREFIXES, isReadOnlySql } from '../../lib/generic_db_connector'; import type { ExecuteQueryInput, GetQueryResultsInput, @@ -28,8 +29,6 @@ const DEFAULT_LOCATION = 'US'; const DEFAULT_MAX_RESULTS = 1000; const BIGQUERY_USER_AGENT = 'Kibana-BigQuery-Connector/1.0'; -const READ_ONLY_QUERY_PREFIXES = /^(SELECT|WITH|EXPLAIN)\b/i; - interface BigQueryJobReference { projectId?: string; jobId?: string; @@ -69,30 +68,6 @@ interface BigQueryQueryResponse { errors?: Array<{ message?: string; reason?: string; location?: string }>; } -const stripLeadingCommentsAndWhitespace = (sql: string): string => { - let remaining = sql; - while (true) { - const before = remaining; - remaining = remaining.replace(/^\s+/, ''); - remaining = remaining.replace(/^--[^\n]*(?:\n|$)/, ''); - remaining = remaining.replace(/^\/\*[\s\S]*?\*\//, ''); - if (remaining === before) return remaining; - } -}; - -const hasTrailingStatement = (sql: string): boolean => { - const semicolonIndex = sql.indexOf(';'); - if (semicolonIndex === -1) return false; - const trailing = stripLeadingCommentsAndWhitespace(sql.slice(semicolonIndex + 1)); - return trailing.length > 0; -}; - -const isReadOnlyQuery = (sql: string): boolean => { - if (hasTrailingStatement(sql)) return false; - const head = stripLeadingCommentsAndWhitespace(sql); - return READ_ONLY_QUERY_PREFIXES.test(head); -}; - const throwBigQueryError = (error: unknown): never => { const err = error as { response?: { @@ -285,7 +260,7 @@ export const BigQuery: ConnectorSpec = { 'Run a read-only GoogleSQL query in BigQuery. Accepts SELECT, WITH (CTE), and EXPLAIN statements only; rejects DML, DDL, scripts, stored procedures, and semicolon-delimited multi-statement submissions before the request is sent. Returns normalized rows as objects plus the BigQuery job reference and pagination token when more rows are available.', input: RunQueryInputSchema, handler: async (ctx, input: RunQueryInput) => { - if (!isReadOnlyQuery(input.query)) { + if (!isReadOnlySql(input.query, BIGQUERY_READ_ONLY_PREFIXES)) { throw new Error( 'runQuery only accepts read-only BigQuery GoogleSQL statements (SELECT, WITH, EXPLAIN) and rejects semicolon-delimited multi-statement submissions. Use executeQuery from a workflow for non-read-only statements.' ); diff --git a/src/platform/packages/shared/kbn-connector-specs/src/specs/mysql/icon/index.tsx b/src/platform/packages/shared/kbn-connector-specs/src/specs/mysql/icon/index.tsx new file mode 100644 index 0000000000000..69398d5ca328a --- /dev/null +++ b/src/platform/packages/shared/kbn-connector-specs/src/specs/mysql/icon/index.tsx @@ -0,0 +1,18 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import React from 'react'; +import { EuiIcon } from '@elastic/eui'; +import type { ConnectorIconProps } from '../../../types'; + +import mysqlIcon from './mysql.svg'; + +export default (props: ConnectorIconProps) => { + return ; +}; diff --git a/src/platform/packages/shared/kbn-connector-specs/src/specs/mysql/icon/mysql.svg b/src/platform/packages/shared/kbn-connector-specs/src/specs/mysql/icon/mysql.svg new file mode 100644 index 0000000000000..b1b26c6f36fd3 --- /dev/null +++ b/src/platform/packages/shared/kbn-connector-specs/src/specs/mysql/icon/mysql.svg @@ -0,0 +1 @@ + diff --git a/src/platform/packages/shared/kbn-connector-specs/src/specs/mysql/mysql.test.ts b/src/platform/packages/shared/kbn-connector-specs/src/specs/mysql/mysql.test.ts new file mode 100644 index 0000000000000..a764f645c1d6a --- /dev/null +++ b/src/platform/packages/shared/kbn-connector-specs/src/specs/mysql/mysql.test.ts @@ -0,0 +1,367 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import type { ActionContext, ConnectorSpec } from '../../connector_spec'; + +interface MockPool { + query: jest.Mock; + execute: jest.Mock; + end: jest.Mock; +} + +const createMockPool = (): MockPool => ({ + query: jest.fn().mockResolvedValue([[]]), + execute: jest.fn().mockResolvedValue([[]]), + end: jest.fn().mockResolvedValue(undefined), +}); + +const makeConfig = (overrides: Record = {}) => ({ + host: 'db.example.com', + port: 3306, + database: 'testdb', + ...overrides, +}); + +const makeContext = ( + config: Record, + pool: MockPool = createMockPool() +): ActionContext => + ({ + config, + log: { info: jest.fn(), debug: jest.fn(), error: jest.fn() }, + getClient: jest.fn().mockResolvedValue(pool), + } as unknown as ActionContext); + +// Convenience wrapper when pool behaviour needs to be inspected in the test. +const makeContextWithPool = ( + config: Record = makeConfig() +): { ctx: ActionContext; pool: MockPool } => { + const pool = createMockPool(); + return { ctx: makeContext(config, pool), pool }; +}; + +describe('MysqlConnector', () => { + let MysqlConnector: ConnectorSpec; + let configSchema: NonNullable; + + beforeEach(() => { + ({ MysqlConnector } = require('./mysql')); + if (!MysqlConnector.schema) { + throw new Error('MySQL spec is missing a config schema'); + } + configSchema = MysqlConnector.schema; + }); + + describe('metadata', () => { + it('has the correct connector id and display name', () => { + expect(MysqlConnector.metadata.id).toBe('.mysql'); + expect(MysqlConnector.metadata.displayName).toBe('MySQL'); + }); + + it('uses basic auth so credentials are encrypted, not stored in schema', () => { + expect(MysqlConnector.auth?.types).toEqual(['basic']); + expect(configSchema.shape).not.toHaveProperty('username'); + expect(configSchema.shape).not.toHaveProperty('password'); + }); + + it('is marked as technical preview', () => { + expect(MysqlConnector.metadata.isTechnicalPreview).toBe(true); + }); + + it('supports agentBuilder feature (workflows added in a follow-up)', () => { + expect(MysqlConnector.metadata.supportedFeatureIds).toContain('agentBuilder'); + expect(MysqlConnector.metadata.supportedFeatureIds).not.toContain('workflows'); + }); + }); + + describe('schema', () => { + it('accepts a fully populated config', () => { + const result = configSchema.safeParse(makeConfig()); + expect(result.success).toBe(true); + }); + + it('accepts a numeric port', () => { + const result = configSchema.parse(makeConfig({ port: 3306 })); + expect(result.port).toBe(3306); + }); + + it('defaults port to 3306 when omitted', () => { + const config = makeConfig(); + delete (config as Record).port; + const result = configSchema.parse(config); + expect(result.port).toBe(3306); + }); + + it('rejects an out-of-range port', () => { + expect(configSchema.safeParse(makeConfig({ port: 99999 })).success).toBe(false); + }); + + it('rejects a host with a protocol prefix', () => { + expect(configSchema.safeParse(makeConfig({ host: 'mysql://db.example.com' })).success).toBe( + false + ); + }); + + it.each(['host', 'database'])('rejects a config missing %s', (field) => { + const config = makeConfig(); + delete (config as Record)[field]; + expect(configSchema.safeParse(config).success).toBe(false); + }); + + it('defaults TLS to required', () => { + const result = configSchema.parse(makeConfig()); + expect(result.ssl).toBe('required'); + }); + }); + + describe('tool exposure and scope', () => { + it('marks read actions as agent-facing tools and executeSql as workflow-only', () => { + const toolActions = ['query', 'listDatabases', 'listTables', 'describeTable', 'searchRows']; + for (const name of toolActions) { + expect(MysqlConnector.actions[name].isTool).toBe(true); + } + expect(MysqlConnector.actions.executeSql.isTool).toBe(false); + }); + + it('marks all discovery/query actions as read scope', () => { + const readActions = ['query', 'listDatabases', 'listTables', 'describeTable', 'searchRows']; + for (const name of readActions) { + expect(MysqlConnector.actions[name].scope).toBe('read'); + } + }); + + it('marks executeSql as destroy scope', () => { + expect(MysqlConnector.actions.executeSql.scope).toBe('destroy'); + }); + }); + + describe('query action', () => { + it('passes SQL to the pool as-is', async () => { + const { ctx, pool } = makeContextWithPool(); + + await MysqlConnector.actions.query.handler(ctx, { + sql: 'SELECT id, name FROM users ORDER BY name LIMIT 20', + }); + + expect(pool.query).toHaveBeenCalledWith('SELECT id, name FROM users ORDER BY name LIMIT 20'); + }); + + it('rejects a write statement before leasing a connection', async () => { + const { ctx } = makeContextWithPool(); + + await expect( + MysqlConnector.actions.query.handler(ctx, { sql: 'DROP TABLE users' }) + ).rejects.toThrow(/read-only/i); + expect(ctx.getClient).not.toHaveBeenCalled(); + }); + + it('rejects multi-statement input before leasing a connection', async () => { + const { ctx } = makeContextWithPool(); + + await expect( + MysqlConnector.actions.query.handler(ctx, { sql: 'SELECT 1; DROP TABLE users' }) + ).rejects.toThrow(/multi-statement/i); + expect(ctx.getClient).not.toHaveBeenCalled(); + }); + + it('rejects SHOW / DESCRIBE so they are not wrapped in a subquery', async () => { + const { ctx } = makeContextWithPool(); + + await expect( + MysqlConnector.actions.query.handler(ctx, { sql: 'SHOW TABLES' }) + ).rejects.toThrow(/read-only/i); + await expect( + MysqlConnector.actions.query.handler(ctx, { sql: 'DESCRIBE users' }) + ).rejects.toThrow(/read-only/i); + expect(ctx.getClient).not.toHaveBeenCalled(); + }); + + it('rejects a write hidden behind a leading comment', async () => { + const { ctx } = makeContextWithPool(); + + await expect( + MysqlConnector.actions.query.handler(ctx, { sql: '-- looks fine\nDROP TABLE users' }) + ).rejects.toThrow(/read-only/i); + expect(ctx.getClient).not.toHaveBeenCalled(); + }); + }); + + describe('listDatabases action', () => { + it('runs SHOW DATABASES', async () => { + const { ctx, pool } = makeContextWithPool(); + + await MysqlConnector.actions.listDatabases.handler(ctx, {}); + + expect(pool.query).toHaveBeenCalledWith('SHOW DATABASES'); + }); + }); + + describe('listTables action', () => { + it('quotes the target database and defaults to the configured database', async () => { + const { ctx, pool } = makeContextWithPool(makeConfig({ database: 'my_db' })); + + await MysqlConnector.actions.listTables.handler(ctx, {}); + + expect(pool.query).toHaveBeenCalledWith('SHOW TABLES FROM `my_db`'); + }); + + it('uses the explicitly provided database over the configured default', async () => { + const { ctx, pool } = makeContextWithPool(makeConfig({ database: 'default_db' })); + + await MysqlConnector.actions.listTables.handler(ctx, { database: 'other_db' }); + + expect(pool.query).toHaveBeenCalledWith('SHOW TABLES FROM `other_db`'); + }); + + it('throws when no database is provided or configured', async () => { + const { ctx } = makeContextWithPool(makeConfig({ database: undefined })); + + await expect(MysqlConnector.actions.listTables.handler(ctx, {})).rejects.toThrow( + /no database specified/i + ); + }); + + it('throws when the provided database is empty', async () => { + const { ctx } = makeContextWithPool(); + + await expect( + MysqlConnector.actions.listTables.handler(ctx, { database: '' }) + ).rejects.toThrow(/no database specified/i); + }); + }); + + describe('describeTable action', () => { + it('quotes both the database and table name', async () => { + const { ctx, pool } = makeContextWithPool(makeConfig({ database: 'my_db' })); + + await MysqlConnector.actions.describeTable.handler(ctx, { table: 'orders' }); + + expect(pool.query).toHaveBeenCalledWith('DESCRIBE `my_db`.`orders`'); + }); + + it('escapes backticks embedded in an identifier', async () => { + const { ctx, pool } = makeContextWithPool(makeConfig({ database: 'my_db' })); + + await MysqlConnector.actions.describeTable.handler(ctx, { table: 'weird`table' }); + + expect(pool.query).toHaveBeenCalledWith('DESCRIBE `my_db`.`weird``table`'); + }); + }); + + describe('searchRows action', () => { + it('builds an OR-joined LIKE clause across the requested columns', async () => { + const { ctx, pool } = makeContextWithPool(makeConfig({ database: 'my_db' })); + + await MysqlConnector.actions.searchRows.handler(ctx, { + table: 'users', + searchTerm: 'jane', + columns: ['name', 'email'], + }); + + expect(pool.execute).toHaveBeenCalledWith( + "SELECT * FROM `my_db`.`users` WHERE LOWER(`name`) LIKE ? ESCAPE '!' OR LOWER(`email`) LIKE ? ESCAPE '!' LIMIT 100", + ['%jane%', '%jane%'] + ); + }); + + it('lowercases the search term so LIKE matching is case-insensitive', async () => { + const { ctx, pool } = makeContextWithPool(makeConfig({ database: 'my_db' })); + + await MysqlConnector.actions.searchRows.handler(ctx, { + table: 'users', + searchTerm: 'Jane', + columns: ['name'], + }); + + const [, params] = pool.execute.mock.calls[0]; + expect(params[0]).toBe('%jane%'); + }); + + it('escapes SQL LIKE wildcards in the search term', async () => { + const { ctx, pool } = makeContextWithPool(makeConfig({ database: 'my_db' })); + + await MysqlConnector.actions.searchRows.handler(ctx, { + table: 'users', + searchTerm: '50%_off', + columns: ['notes'], + }); + + const [, params] = pool.execute.mock.calls[0]; + expect(params[0]).toBe('%50!%!_off%'); + }); + + it('passes single quotes in the search term unmodified — the driver handles them', async () => { + const { ctx, pool } = makeContextWithPool(makeConfig({ database: 'my_db' })); + + await MysqlConnector.actions.searchRows.handler(ctx, { + table: 'users', + searchTerm: "o'brien", + columns: ['name'], + }); + + const [, params] = pool.execute.mock.calls[0]; + expect(params[0]).toBe("%o'brien%"); + }); + + it('respects a custom maxRows', async () => { + const { ctx, pool } = makeContextWithPool(makeConfig({ database: 'my_db' })); + + await MysqlConnector.actions.searchRows.handler(ctx, { + table: 'users', + searchTerm: 'jane', + columns: ['name'], + maxRows: 25, + }); + + const [sql] = pool.execute.mock.calls[0]; + expect(sql).toContain('LIMIT 25'); + }); + }); + + describe('executeSql action', () => { + it('runs the SQL directly without any read-only guard', async () => { + const { ctx, pool } = makeContextWithPool(); + + await MysqlConnector.actions.executeSql.handler(ctx, { + sql: 'DROP TABLE users', + }); + + expect(pool.query).toHaveBeenCalledWith('DROP TABLE users'); + }); + + it('runs INSERT statements', async () => { + const { ctx, pool } = makeContextWithPool(); + + await MysqlConnector.actions.executeSql.handler(ctx, { + sql: "INSERT INTO users (name) VALUES ('alice')", + }); + + expect(pool.query).toHaveBeenCalledWith("INSERT INTO users (name) VALUES ('alice')"); + }); + }); + + describe('test handler', () => { + it('returns a success message when the connection works', async () => { + const { ctx, pool } = makeContextWithPool(); + + const result = await MysqlConnector.test?.handler(ctx); + + expect(pool.query).toHaveBeenCalledWith('SELECT 1'); + expect(result?.message).toMatch(/connected/i); + }); + + it('throws when the connection fails', async () => { + const pool = createMockPool(); + pool.query.mockRejectedValue(new Error('connect ECONNREFUSED')); + const ctx = makeContext(makeConfig(), pool); + + await expect(MysqlConnector.test?.handler(ctx)).rejects.toThrow('connect ECONNREFUSED'); + }); + }); +}); diff --git a/src/platform/packages/shared/kbn-connector-specs/src/specs/mysql/mysql.ts b/src/platform/packages/shared/kbn-connector-specs/src/specs/mysql/mysql.ts new file mode 100644 index 0000000000000..51bd6802af446 --- /dev/null +++ b/src/platform/packages/shared/kbn-connector-specs/src/specs/mysql/mysql.ts @@ -0,0 +1,299 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +/** + * MySQL Connector + * + * Connects directly to a MySQL database over the native MySQL wire protocol + * (via `mysql2`) rather than HTTP. The connection pool is managed by the + * framework's client lease pool via `ctx.getClient('mysql')`, which handles + * lifecycle (eviction on connector update/delete, TTL-based eviction) and + * enforces `xpack.actions.allowedHosts` before the first connection. + * + * Username and password are declared under `auth: { types: ['basic'] }` + * rather than `schema` so they are encrypted at rest. The `MysqlClientTypeSpec` + * in `lib/clients/mysql.ts` decodes them from the Authorization header that + * the framework's credential accessor produces. + * + * `query` accepts SELECT/WITH only (enforced by `assertReadOnly`) and runs the + * statement as-is; include a `LIMIT` in the SQL to bound the result size. + * Use `listDatabases`, `listTables`, and `describeTable` for SHOW/DESCRIBE. + * `executeSql` is unrestricted and carries `scope: 'destroy'`. + */ +import { i18n } from '@kbn/i18n'; +import { z, lazySchema } from '@kbn/zod/v4'; +import type { ActionContext, ConnectorSpec } from '../../connector_spec'; +import { assertReadOnly, escapeLikePattern } from '../../lib/generic_db_connector'; +import { + type DescribeTableInput, + DescribeTableInputSchema, + type ExecuteSqlInput, + ExecuteSqlInputSchema, + ListDatabasesInputSchema, + type ListTablesInput, + ListTablesInputSchema, + type QueryInput, + QueryInputSchema, + type SearchRowsInput, + SearchRowsInputSchema, +} from './types'; + +const DEFAULT_MAX_ROWS = 100; +const MAX_MAX_ROWS = 1000; + +const resolveMaxRows = (maxRows?: number): number => { + if (maxRows === undefined) { + return DEFAULT_MAX_ROWS; + } + if (!Number.isInteger(maxRows) || maxRows < 1 || maxRows > MAX_MAX_ROWS) { + throw new Error(`maxRows must be an integer between 1 and ${MAX_MAX_ROWS}`); + } + return maxRows; +}; + +const runSql = async ( + ctx: ActionContext, + sql: string, + params?: readonly string[] +): Promise => { + const pool = await ctx.getClient('mysql'); + const [rows] = params ? await pool.execute(sql, [...params]) : await pool.query(sql); + return rows; +}; + +export const MysqlConnector: ConnectorSpec = { + metadata: { + id: '.mysql', + displayName: 'MySQL', + description: i18n.translate('core.kibanaConnectorSpecs.mysql.metadata.description', { + defaultMessage: + 'Query tables, search rows, explore schema, and execute SQL in a MySQL database', + }), + minimumLicense: 'enterprise', + isTechnicalPreview: true, + supportedFeatureIds: ['agentBuilder'], + }, + + auth: { + types: ['basic'], + }, + + schema: lazySchema(() => + z.object({ + host: z + .string() + .min(1) + .max(253) + .refine((value) => !/^[a-z][a-z0-9+.-]*:\/\//i.test(value), { + message: 'Host must be a hostname or IP address, without a protocol prefix', + }) + .describe( + i18n.translate('core.kibanaConnectorSpecs.mysql.config.host.description', { + defaultMessage: 'The MySQL server hostname or IP address', + }) + ) + .meta({ + widget: 'text', + label: i18n.translate('core.kibanaConnectorSpecs.mysql.config.host.label', { + defaultMessage: 'Host', + }), + placeholder: 'mysql.example.com', + helpText: i18n.translate('core.kibanaConnectorSpecs.mysql.config.host.helpText', { + defaultMessage: 'The hostname or IP address of the MySQL server (no protocol prefix).', + }), + }), + port: z + .number() + .int() + .min(1) + .max(65535) + .default(3306) + .describe( + i18n.translate('core.kibanaConnectorSpecs.mysql.config.port.description', { + defaultMessage: 'The MySQL server port', + }) + ) + .meta({ + widget: 'number', + label: i18n.translate('core.kibanaConnectorSpecs.mysql.config.port.label', { + defaultMessage: 'Port', + }), + placeholder: '3306', + helpText: i18n.translate('core.kibanaConnectorSpecs.mysql.config.port.helpText', { + defaultMessage: 'The port number of the MySQL server (default: 3306)', + }), + }), + database: z + .string() + .min(1) + .max(64) + .describe( + i18n.translate('core.kibanaConnectorSpecs.mysql.config.database.description', { + defaultMessage: 'The default database to connect to', + }) + ) + .meta({ + widget: 'text', + label: i18n.translate('core.kibanaConnectorSpecs.mysql.config.database.label', { + defaultMessage: 'Database', + }), + placeholder: 'my_database', + helpText: i18n.translate('core.kibanaConnectorSpecs.mysql.config.database.helpText', { + defaultMessage: 'The name of the default database to query', + }), + }), + ssl: z + .enum(['required', 'disabled']) + .default('required') + .describe( + i18n.translate('core.kibanaConnectorSpecs.mysql.config.ssl.description', { + defaultMessage: 'Whether to use TLS when connecting to MySQL', + }) + ) + .meta({ + widget: 'select', + label: i18n.translate('core.kibanaConnectorSpecs.mysql.config.ssl.label', { + defaultMessage: 'TLS', + }), + helpText: i18n.translate('core.kibanaConnectorSpecs.mysql.config.ssl.helpText', { + defaultMessage: + 'Required (default) encrypts the connection using Kibana TLS settings. Disable only for servers that do not support TLS.', + }), + }), + }) + ), + + actions: { + query: { + isTool: true, + scope: 'read', + description: + 'Execute a read-only SQL SELECT query against the MySQL database. Only SELECT and WITH statements are permitted; SHOW, DESCRIBE, INSERT, UPDATE, DELETE, and DDL are blocked. Include a LIMIT clause to bound results (e.g. LIMIT 100). Do not include a trailing semicolon. Use listTables first to discover available tables, and describeTable to inspect column names before writing queries. Prefer WHERE clauses and explicit column lists to keep result size manageable.', + input: QueryInputSchema, + handler: async (ctx, input: QueryInput) => { + assertReadOnly(input.sql); + return runSql(ctx, input.sql); + }, + }, + + listDatabases: { + isTool: true, + scope: 'read', + description: + 'List all databases available on the connected MySQL server. Use this first to discover what databases are accessible before querying tables.', + input: ListDatabasesInputSchema, + handler: async (ctx) => runSql(ctx, 'SHOW DATABASES'), + }, + + listTables: { + isTool: true, + scope: 'read', + description: + 'List all tables in a MySQL database. Specify database to target a specific database, or omit to use the configured default. Use describeTable to inspect column names and types before querying.', + input: ListTablesInputSchema, + handler: async (ctx, input: ListTablesInput) => { + const db = resolveDatabase(input.database, ctx); + return runSql(ctx, `SHOW TABLES FROM ${quoteIdentifier(db)}`); + }, + }, + + describeTable: { + isTool: true, + scope: 'read', + description: + 'Describe the structure of a MySQL table — returns column names, data types, nullability, and default values. Use this before query or searchRows to discover available columns and build correct queries.', + input: DescribeTableInputSchema, + handler: async (ctx, input: DescribeTableInput) => { + const db = resolveDatabase(input.database, ctx); + return runSql(ctx, `DESCRIBE ${quoteIdentifier(db)}.${quoteIdentifier(input.table)}`); + }, + }, + + searchRows: { + isTool: true, + scope: 'read', + description: + 'Search for rows in a MySQL table by matching a text value against one or more columns using LIKE pattern matching (case-insensitive partial match via LOWER). Returns up to maxRows results (default 100). Use describeTable first to discover searchable column names. Prefer query (SQL SELECT) for structured filtering; use searchRows for broad text discovery across known columns.', + input: SearchRowsInputSchema, + handler: async (ctx, input: SearchRowsInput) => { + const db = resolveDatabase(input.database, ctx); + const likeParam = `%${escapeLikePattern(input.searchTerm.toLowerCase(), false)}%`; + const whereClause = input.columns + .map((col) => `LOWER(${quoteIdentifier(col)}) LIKE ? ESCAPE '!'`) + .join(' OR '); + const maxRows = resolveMaxRows(input.maxRows); + const sql = + `SELECT * FROM ${quoteIdentifier(db)}.${quoteIdentifier(input.table)}` + + ` WHERE ${whereClause} LIMIT ${maxRows}`; + return runSql( + ctx, + sql, + input.columns.map(() => likeParam) + ); + }, + }, + + executeSql: { + isTool: false, + scope: 'destroy', + description: + 'Execute any SQL statement against the MySQL database. No restrictions — INSERT, UPDATE, DELETE, DROP, and DDL are all permitted. Use only when the workflow explicitly requires a write or destructive operation. Prefer query for read-only access.', + input: ExecuteSqlInputSchema, + handler: async (ctx, input: ExecuteSqlInput) => runSql(ctx, input.sql), + }, + }, + + test: { + description: i18n.translate('core.kibanaConnectorSpecs.mysql.test.description', { + defaultMessage: 'Verifies MySQL connection by running a lightweight query', + }), + enabled: true, + handler: async (ctx) => { + await runSql(ctx, 'SELECT 1'); + return { message: 'Successfully connected to MySQL' }; + }, + }, + + skill: [ + '## MySQL Connector', + '', + 'Access to a MySQL database. Read actions (`query`, `searchRows`, `listDatabases`, `listTables`, `describeTable`) are read-only and safe to call freely. `executeSql` is unrestricted — use it only when a write or destructive operation is explicitly required.', + '', + '### Discovery pattern (schema unknown)', + '1. `listDatabases` — see what databases are accessible.', + '2. `listTables` — list tables in a database (defaults to the configured database if omitted).', + '3. `describeTable` — inspect column names, types, and nullability before writing a query.', + '4. `query` or `searchRows` — read the data.', + '', + '### Choosing between `query`, `searchRows`, and `executeSql`', + '- Prefer `query` for structured filtering, joins, aggregation, or anything expressible as a SELECT.', + '- Prefer `searchRows` for broad, unstructured text lookups across a known set of columns.', + '- Use `executeSql` only for writes or DDL that the workflow explicitly requires (INSERT, UPDATE, DELETE, CREATE, DROP, etc.).', + '- Use `listDatabases`, `listTables`, and `describeTable` for schema exploration. Do not send SHOW or DESCRIBE to `query`.', + '', + '### Gotchas', + '- `query` only allows SELECT and WITH — multi-statement and write SQL are rejected; use `executeSql` for writes.', + '- Include a `LIMIT` clause in `query` SQL (e.g. `LIMIT 100`) — the query runs as-is, so an unbounded SELECT can return a very large result set.', + '- `searchRows` caps results at `maxRows` (default 100, max 1000) — narrow with additional columns or a more specific search term rather than relying on a large `maxRows`.', + '- Database and table names are case-sensitive on case-sensitive filesystems (the common case on Linux). Use the exact casing returned by `listDatabases` / `listTables`.', + '- TLS is required by default. Set TLS to disabled only when the MySQL server does not support TLS.', + ].join('\n'), +}; + +const quoteIdentifier = (identifier: string): string => `\`${identifier.replace(/`/g, '``')}\``; + +const resolveDatabase = (inputDb: string | undefined, ctx: ActionContext): string => { + const db = inputDb ?? (ctx.config?.database as string | undefined); + if (!db) { + throw new Error( + 'No database specified and no default database is configured for this connector' + ); + } + return db; +}; diff --git a/src/platform/packages/shared/kbn-connector-specs/src/specs/mysql/types.ts b/src/platform/packages/shared/kbn-connector-specs/src/specs/mysql/types.ts new file mode 100644 index 0000000000000..713c536366d77 --- /dev/null +++ b/src/platform/packages/shared/kbn-connector-specs/src/specs/mysql/types.ts @@ -0,0 +1,114 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { z, lazySchema } from '@kbn/zod/v4'; + +// ============================================================================= +// Action input schemas & inferred types +// ============================================================================= + +export const QueryInputSchema = lazySchema(() => + z.object({ + sql: z + .string() + .min(1) + .max(10000) + .describe( + 'Read-only SQL SELECT or WITH query to execute. Include a LIMIT clause to bound results (e.g. SELECT id, name FROM users WHERE status = "active" LIMIT 100). Do not include a trailing semicolon.' + ), + }) +); +export type QueryInput = z.infer; + +export const ListDatabasesInputSchema = lazySchema(() => z.object({})); +export const ListTablesInputSchema = lazySchema(() => + z.object({ + database: z + .string() + .min(1) + .max(64) + .optional() + .describe( + 'Database name to list tables from. Uses the configured default database if omitted.' + ), + }) +); +export type ListTablesInput = z.infer; + +export const DescribeTableInputSchema = lazySchema(() => + z.object({ + table: z + .string() + .min(1) + .max(64) + .describe('Name of the table to describe (e.g. "users", "orders")'), + database: z + .string() + .min(1) + .max(64) + .optional() + .describe( + 'Database name containing the table. Uses the configured default database if omitted.' + ), + }) +); +export type DescribeTableInput = z.infer; + +export const SearchRowsInputSchema = lazySchema(() => + z.object({ + table: z + .string() + .min(1) + .max(64) + .describe('Name of the table to search (e.g. "users", "products")'), + searchTerm: z + .string() + .min(1) + .max(500) + .describe( + 'Text to search for using SQL LIKE pattern matching. Matches rows where any of the specified columns contain this text (case-insensitive, partial match).' + ), + columns: z + .array(z.string().min(1).max(64)) + .min(1) + .max(50) + .describe( + 'Column names to search in (e.g. ["name", "email", "notes"]). At least one column is required. Use describeTable to discover available columns.' + ), + maxRows: z + .number() + .int() + .min(1) + .max(1000) + .optional() + .describe('Maximum number of rows to return (1-1000, default: 100)'), + database: z + .string() + .min(1) + .max(64) + .optional() + .describe( + 'Database name containing the table. Uses the configured default database if omitted.' + ), + }) +); +export type SearchRowsInput = z.infer; + +export const ExecuteSqlInputSchema = lazySchema(() => + z.object({ + sql: z + .string() + .min(1) + .max(10000) + .describe( + 'SQL statement to execute. Any statement type is permitted (SELECT, INSERT, UPDATE, DELETE, CREATE, DROP, etc.). Use with caution — this action is unrestricted.' + ), + }) +); +export type ExecuteSqlInput = z.infer; diff --git a/src/platform/packages/shared/kbn-connector-specs/src/specs/snowflake/snowflake.ts b/src/platform/packages/shared/kbn-connector-specs/src/specs/snowflake/snowflake.ts index a68281bd3ad3f..3e58868fa2e7b 100644 --- a/src/platform/packages/shared/kbn-connector-specs/src/specs/snowflake/snowflake.ts +++ b/src/platform/packages/shared/kbn-connector-specs/src/specs/snowflake/snowflake.ts @@ -38,6 +38,7 @@ import { i18n } from '@kbn/i18n'; import { z, lazySchema } from '@kbn/zod/v4'; import type { ActionContext, ConnectorSpec } from '../../connector_spec'; import { normalizeUrl } from '../../connector_utils'; +import { isReadOnlySql, READ_ONLY_STATEMENT_PREFIXES } from '../../lib/generic_db_connector'; import type { ExecuteStatementInput, RunQueryInput, @@ -85,49 +86,6 @@ const buildListParams = (input: { return params; }; -// --------------------------------------------------------------------------- -// Read-only SQL guardrail for `runQuery` -// -// Strips leading whitespace + SQL comments (line `-- ...` and block `/* ... */`) -// and matches the first remaining token against an allowlist of read-only -// statement keywords. Multi-statement submissions are rejected. -// --------------------------------------------------------------------------- - -const READ_ONLY_STATEMENT_PREFIXES = /^(SELECT|WITH|SHOW|DESCRIBE|DESC|EXPLAIN)\b/i; - -const stripLeadingCommentsAndWhitespace = (sql: string): string => { - let remaining = sql; - // Repeatedly strip whitespace, line comments, and block comments from the start - // until nothing matches. - while (true) { - const before = remaining; - remaining = remaining.replace(/^\s+/, ''); - remaining = remaining.replace(/^--[^\n]*(?:\n|$)/, ''); - remaining = remaining.replace(/^\/\*[\s\S]*?\*\//, ''); - if (remaining === before) return remaining; - } -}; - -const hasTrailingStatement = (sql: string): boolean => { - // Detect semicolon-delimited multi-statement submissions. Anything after the - // first `;` that isn't whitespace or a comment counts as a second statement. - // - // Note: this is a conservative textual check — it does not parse string - // literals, so a query containing `;` inside a quoted string will be - // rejected. That is acceptable for a read-only guardrail; agents can - // rewrite such queries to avoid embedded semicolons. - const semicolonIndex = sql.indexOf(';'); - if (semicolonIndex === -1) return false; - const trailing = stripLeadingCommentsAndWhitespace(sql.slice(semicolonIndex + 1)); - return trailing.length > 0; -}; - -const isReadOnlyStatement = (sql: string): boolean => { - if (hasTrailingStatement(sql)) return false; - const head = stripLeadingCommentsAndWhitespace(sql); - return READ_ONLY_STATEMENT_PREFIXES.test(head); -}; - // --------------------------------------------------------------------------- // Shared request builder for runQuery + executeStatement // --------------------------------------------------------------------------- @@ -368,7 +326,7 @@ export const Snowflake: ConnectorSpec = { 'Run a read-only SQL query asynchronously in Snowflake. Accepts SELECT, WITH (CTE), SHOW, DESCRIBE / DESC, and EXPLAIN only. Write operations (INSERT, UPDATE, DELETE, MERGE), DDL (CREATE, ALTER, DROP, TRUNCATE), privilege changes (GRANT, REVOKE), stored procedure calls (CALL), and session state changes (USE, SET) are rejected before the request is sent. Returns a statement handle — use getStatementStatus to retrieve results, or cancelStatement to abort. Supports bind variables and session-scoped context (warehouse, database, schema, role). Single-statement only; multi-statement submissions are rejected. For write or DDL operations, ask the user to invoke executeStatement from a workflow.', input: RunQueryInputSchema, handler: async (ctx, input: RunQueryInput) => { - if (!isReadOnlyStatement(input.statement)) { + if (!isReadOnlySql(input.statement, READ_ONLY_STATEMENT_PREFIXES)) { throw new Error( 'runQuery only accepts read-only SQL statements (SELECT, WITH, SHOW, DESCRIBE, DESC, EXPLAIN) and rejects semicolon-delimited multi-statement submissions. ' + 'For write (INSERT / UPDATE / DELETE / MERGE), DDL (CREATE / ALTER / DROP / TRUNCATE), privilege, procedure, or session-state statements, use the executeStatement action from a workflow.' diff --git a/x-pack/platform/packages/shared/response-ops/form-generator/src/field_builder.tsx b/x-pack/platform/packages/shared/response-ops/form-generator/src/field_builder.tsx index 8355004d62021..851f5d52aa3dd 100644 --- a/x-pack/platform/packages/shared/response-ops/form-generator/src/field_builder.tsx +++ b/x-pack/platform/packages/shared/response-ops/form-generator/src/field_builder.tsx @@ -6,8 +6,7 @@ */ import React from 'react'; -import type { z } from '@kbn/zod/v4'; -import { ZodError } from '@kbn/zod/v4'; +import { z, ZodError } from '@kbn/zod/v4'; import type { ValidationFunc } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; import { i18n } from '@kbn/i18n'; import { EuiText } from '@elastic/eui'; @@ -72,8 +71,15 @@ export const getFieldFromSchema = ({ return undefined; } + if (schema instanceof z.ZodNumber && value === '') { + return undefined; + } + try { - schema.parse(value); + // Coerce non-empty strings to numbers before validating numeric schemas. + const coerced = + schema instanceof z.ZodNumber && typeof value === 'string' ? Number(value) : value; + schema.parse(coerced); return undefined; } catch (error) { if (!(error instanceof ZodError)) { diff --git a/x-pack/platform/packages/shared/response-ops/form-generator/src/widgets/components/number_widget.tsx b/x-pack/platform/packages/shared/response-ops/form-generator/src/widgets/components/number_widget.tsx new file mode 100644 index 0000000000000..9334bd9141a2e --- /dev/null +++ b/x-pack/platform/packages/shared/response-ops/form-generator/src/widgets/components/number_widget.tsx @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import type { EuiFieldNumberProps } from '@elastic/eui'; +import { UseField } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; +import type { z } from '@kbn/zod/v4'; +import { NumericField as FormNumericField } from '@kbn/es-ui-shared-plugin/static/forms/components'; +import type { BaseWidgetProps } from '../types'; + +type NumberWidgetProps = BaseWidgetProps; + +const numberSerializer = (v: unknown) => { + if (typeof v === 'string') { + if (v === '') return undefined; + const n = Number(v); + if (!Number.isNaN(n)) return n; + } + return v; +}; + +export const NumberWidget: React.FC = ({ path, fieldProps, fieldConfig }) => ( + +); diff --git a/x-pack/platform/packages/shared/response-ops/form-generator/src/widgets/registry.test.ts b/x-pack/platform/packages/shared/response-ops/form-generator/src/widgets/registry.test.ts index d8f5a7271b01c..93ff7b8fab2d1 100644 --- a/x-pack/platform/packages/shared/response-ops/form-generator/src/widgets/registry.test.ts +++ b/x-pack/platform/packages/shared/response-ops/form-generator/src/widgets/registry.test.ts @@ -10,6 +10,7 @@ import { getWidgetComponent } from './registry'; import { WidgetType } from './types'; import { getMeta, addMeta } from '../schema_connector_metadata'; import { TextWidget } from './components/text_widget'; +import { NumberWidget } from './components/number_widget'; import { PasswordWidget } from './components/password_widget'; import { HiddenWidget } from './components/hidden_widget'; @@ -88,15 +89,15 @@ describe('Widget Registry', () => { }); }); - describe('getDefaultWidgetForSchema - unknown schema types', () => { - it('should throw error for unknown schema type without explicit widget', () => { + describe('getDefaultWidgetForSchema - number schemas', () => { + it('should return NumberWidget for z.number() without explicit widget', () => { const schema = z.number(); - - expect(() => getWidgetComponent(schema)).toThrow( - /No widget found for schema type: ZodNumber/ - ); + const component = getWidgetComponent(schema); + expect(component).toBe(NumberWidget); }); + }); + describe('getDefaultWidgetForSchema - unsupported schema types', () => { it('should throw error for z.boolean() without explicit widget', () => { const schema = z.boolean(); diff --git a/x-pack/platform/packages/shared/response-ops/form-generator/src/widgets/registry.ts b/x-pack/platform/packages/shared/response-ops/form-generator/src/widgets/registry.ts index 039d5356201a8..b03afa87a595c 100644 --- a/x-pack/platform/packages/shared/response-ops/form-generator/src/widgets/registry.ts +++ b/x-pack/platform/packages/shared/response-ops/form-generator/src/widgets/registry.ts @@ -11,6 +11,7 @@ import { WidgetType } from './types'; import { getMeta as defaultGetMeta, setMeta as defaultSetMeta } from '../schema_connector_metadata'; import type { GetMetaFn, SetMetaFn } from '../meta_types'; import { TextWidget } from './components/text_widget'; +import { NumberWidget } from './components/number_widget'; import { SelectWidget } from './components/select_widget'; import { PasswordWidget } from './components/password_widget'; import { DiscriminatedUnionWidget } from './components/discriminated_union_widget'; @@ -20,6 +21,7 @@ import { FileUploadWidget } from './components/file_upload_widget'; const WIDGET_REGISTRY = { [WidgetType.Text]: TextWidget, + [WidgetType.Number]: NumberWidget, [WidgetType.Password]: PasswordWidget, [WidgetType.Select]: SelectWidget, [WidgetType.FormFieldset]: DiscriminatedUnionWidget, @@ -43,6 +45,8 @@ const getDefaultWidgetForSchema = (schema: z.ZodType, { getMeta, setMeta }: Meta return WidgetType.Password; } return WidgetType.Text; + } else if (schema instanceof z.ZodNumber) { + return WidgetType.Number; } else if (schema instanceof z.ZodEnum) { return WidgetType.Select; } else if (schema instanceof z.ZodDiscriminatedUnion) { diff --git a/x-pack/platform/packages/shared/response-ops/form-generator/src/widgets/types.ts b/x-pack/platform/packages/shared/response-ops/form-generator/src/widgets/types.ts index c05baedec0f97..e7c59cc886643 100644 --- a/x-pack/platform/packages/shared/response-ops/form-generator/src/widgets/types.ts +++ b/x-pack/platform/packages/shared/response-ops/form-generator/src/widgets/types.ts @@ -11,6 +11,7 @@ import type { FormConfig, ResolvedMetaFunctions } from '../form'; export enum WidgetType { Text = 'text', + Number = 'number', Password = 'password', Select = 'select', FormFieldset = 'formFieldset', diff --git a/x-pack/platform/plugins/shared/actions/server/lib/single_file_connectors/generate_executor_function.ts b/x-pack/platform/plugins/shared/actions/server/lib/single_file_connectors/generate_executor_function.ts index 5120af262e6c6..579fe4e9b93d9 100644 --- a/x-pack/platform/plugins/shared/actions/server/lib/single_file_connectors/generate_executor_function.ts +++ b/x-pack/platform/plugins/shared/actions/server/lib/single_file_connectors/generate_executor_function.ts @@ -12,8 +12,8 @@ import { getConnectorActionErrorMeta, getFinitePositiveNumber, getHeaderValue, - clientTypes as defaultClientTypes, } from '@kbn/connector-specs'; +import { clientTypes as defaultClientTypes } from '@kbn/connector-specs/server'; import type { ActionContext, ClientTypeSpec,