diff --git a/library/helpers/satisfiesVersion.test.ts b/library/helpers/satisfiesVersion.test.ts index 76da7cc7e..6156c67a8 100644 --- a/library/helpers/satisfiesVersion.test.ts +++ b/library/helpers/satisfiesVersion.test.ts @@ -36,6 +36,8 @@ t.test("it matches single range", async () => { t.equal(satisfiesVersion("^1.0.0", "0.0.0"), false); t.equal(satisfiesVersion("^1.0.0", "2.0.0"), false); t.equal(satisfiesVersion("^2.0.0", "1.0.0"), false); + t.equal(satisfiesVersion("^1.2.1", "1.3.0"), true); + t.equal(satisfiesVersion("^1.2.1", "1.2.0"), false); }); t.test("it matches multiple ranges", async () => { diff --git a/library/helpers/satisfiesVersion.ts b/library/helpers/satisfiesVersion.ts index ad8b808a3..4be74bda2 100644 --- a/library/helpers/satisfiesVersion.ts +++ b/library/helpers/satisfiesVersion.ts @@ -49,9 +49,19 @@ export function satisfiesVersion(range: string, version: string) { .split(".") .map((p) => parseInt(p, 10)); - if (major === rMajor && minor >= rMinor && patch >= rPatch) { - return true; + if (major !== rMajor) { + continue; + } + + if (minor < rMinor) { + continue; } + + if (minor === rMinor && patch < rPatch) { + continue; + } + + return true; } return false; diff --git a/library/package-lock.json b/library/package-lock.json index 593152104..53a63adb7 100644 --- a/library/package-lock.json +++ b/library/package-lock.json @@ -64,7 +64,8 @@ "mongodb-v5": "npm:mongodb@^5.0.0", "mongodb-v6": "npm:mongodb@^6.0.0", "mysql": "^2.18.1", - "mysql2": "^3.10.0", + "mysql2-v3.10": "npm:mysql2@3.10", + "mysql2-v3.12": "npm:mysql2@3.12", "needle": "^3.3.1", "node-fetch": "^2", "percentile": "^1.6.0", @@ -12026,10 +12027,66 @@ "safe-buffer": "~5.1.0" } }, - "node_modules/mysql2": { - "version": "3.11.4", - "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.11.4.tgz", - "integrity": "sha512-Z2o3tY4Z8EvSRDwknaC40MdZ3+m0sKbpnXrShQLdxPrAvcNli7jLrD2Zd2IzsRMw4eK9Yle500FDmlkIqp+krg==", + "node_modules/mysql2-v3.10": { + "name": "mysql2", + "version": "3.10.3", + "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.10.3.tgz", + "integrity": "sha512-k43gmH9i79rZD4hGPdj7pDuT0UBiFjs4UzXEy1cJrV0QqcSABomoLwvejqdbcXN+Vd7gi999CVM6o9vCPKq29g==", + "dev": true, + "license": "MIT", + "dependencies": { + "aws-ssl-profiles": "^1.1.1", + "denque": "^2.1.0", + "generate-function": "^2.3.1", + "iconv-lite": "^0.6.3", + "long": "^5.2.1", + "lru-cache": "^8.0.0", + "named-placeholders": "^1.1.3", + "seq-queue": "^0.0.5", + "sqlstring": "^2.3.2" + }, + "engines": { + "node": ">= 8.0" + } + }, + "node_modules/mysql2-v3.10/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/mysql2-v3.10/node_modules/lru-cache": { + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-8.0.5.tgz", + "integrity": "sha512-MhWWlVnuab1RG5/zMRRcVGXZLCXrZTgfwMikgzCegsPnG62yDQo5JnqKkrK4jO5iKqDAZGItAqN5CtKBCBWRUA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=16.14" + } + }, + "node_modules/mysql2-v3.10/node_modules/sqlstring": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/sqlstring/-/sqlstring-2.3.3.tgz", + "integrity": "sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mysql2-v3.12": { + "name": "mysql2", + "version": "3.12.0", + "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.12.0.tgz", + "integrity": "sha512-C8fWhVysZoH63tJbX8d10IAoYCyXy4fdRFz2Ihrt9jtPILYynFEKUUzpp1U7qxzDc3tMbotvaBH+sl6bFnGZiw==", "dev": true, "license": "MIT", "dependencies": { @@ -12047,7 +12104,7 @@ "node": ">= 8.0" } }, - "node_modules/mysql2/node_modules/iconv-lite": { + "node_modules/mysql2-v3.12/node_modules/iconv-lite": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", @@ -12060,7 +12117,7 @@ "node": ">=0.10.0" } }, - "node_modules/mysql2/node_modules/sqlstring": { + "node_modules/mysql2-v3.12/node_modules/sqlstring": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/sqlstring/-/sqlstring-2.3.3.tgz", "integrity": "sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==", diff --git a/library/package.json b/library/package.json index 810670a38..1d4caf72b 100644 --- a/library/package.json +++ b/library/package.json @@ -97,7 +97,8 @@ "mongodb-v5": "npm:mongodb@^5.0.0", "mongodb-v6": "npm:mongodb@^6.0.0", "mysql": "^2.18.1", - "mysql2": "^3.10.0", + "mysql2-v3.10": "npm:mysql2@3.10", + "mysql2-v3.12": "npm:mysql2@3.12", "needle": "^3.3.1", "node-fetch": "^2", "percentile": "^1.6.0", diff --git a/library/sinks/MySQL2.test.ts b/library/sinks/MySQL2.test.ts deleted file mode 100644 index b05eb359b..000000000 --- a/library/sinks/MySQL2.test.ts +++ /dev/null @@ -1,114 +0,0 @@ -import * as t from "tap"; -import { runWithContext, type Context } from "../agent/Context"; -import { MySQL2 } from "./MySQL2"; -import { createTestAgent } from "../helpers/createTestAgent"; - -const dangerousContext: Context = { - remoteAddress: "::1", - method: "POST", - url: "http://localhost:4000", - query: {}, - headers: {}, - body: { - myTitle: `-- should be blocked`, - }, - cookies: {}, - routeParams: {}, - source: "express", - route: "/posts/:id", -}; - -const safeContext: Context = { - remoteAddress: "::1", - method: "POST", - url: "http://localhost:4000/", - query: {}, - headers: {}, - body: {}, - cookies: {}, - routeParams: {}, - source: "express", - route: "/posts/:id", -}; - -t.test("it detects SQL injections", async (t) => { - const agent = createTestAgent(); - agent.start([new MySQL2()]); - - const mysql = require("mysql2/promise"); - - const connection = await mysql.createConnection({ - host: "localhost", - user: "root", - password: "mypassword", - database: "catsdb", - port: 27015, - multipleStatements: true, - }); - - try { - await connection.query( - ` - CREATE TABLE IF NOT EXISTS cats ( - petname varchar(255) - ); - ` - ); - await connection.execute("TRUNCATE cats"); - const [rows] = await connection.query("SELECT petname FROM `cats`;"); - t.same(rows, []); - const [moreRows] = await connection.query({ - sql: "SELECT petname FROM `cats`", - }); - t.same(moreRows, []); - - const error = await t.rejects(async () => { - await runWithContext(dangerousContext, () => { - return connection.query("-- should be blocked"); - }); - }); - if (error instanceof Error) { - t.same( - error.message, - "Zen has blocked an SQL injection: mysql2.query(...) originating from body.myTitle" - ); - } - - const error2 = await t.rejects(async () => { - await runWithContext(dangerousContext, () => { - return connection.query({ sql: "-- should be blocked" }); - }); - }); - if (error2 instanceof Error) { - t.same( - error2.message, - "Zen has blocked an SQL injection: mysql2.query(...) originating from body.myTitle" - ); - } - - const undefinedQueryError = await t.rejects(async () => { - await runWithContext(dangerousContext, () => { - return connection.query(undefined); - }); - }); - - if (undefinedQueryError instanceof Error) { - t.same( - undefinedQueryError.message, - "Cannot read properties of undefined (reading 'constructor')" - ); - } - - await runWithContext(safeContext, () => { - return connection.query("-- This is a comment"); - }); - - await runWithContext(safeContext, () => { - return connection.execute("SELECT 1"); - }); - } catch (error: any) { - t.fail(error); - } finally { - await connection.end(); - } -}); diff --git a/library/sinks/MySQL2.tests.ts b/library/sinks/MySQL2.tests.ts new file mode 100644 index 000000000..16c6f2f01 --- /dev/null +++ b/library/sinks/MySQL2.tests.ts @@ -0,0 +1,172 @@ +/* eslint-disable max-lines-per-function */ +import * as t from "tap"; +import { runWithContext, type Context } from "../agent/Context"; +import { MySQL2 } from "./MySQL2"; +import { startTestAgent } from "../helpers/startTestAgent"; + +export function createMySQL2Tests(versionPkgName: string) { + const dangerousContext: Context = { + remoteAddress: "::1", + method: "POST", + url: "http://localhost:4000", + query: {}, + headers: {}, + body: { + myTitle: `-- should be blocked`, + }, + cookies: {}, + routeParams: {}, + source: "express", + route: "/posts/:id", + }; + + const safeContext: Context = { + remoteAddress: "::1", + method: "POST", + url: "http://localhost:4000/", + query: {}, + headers: {}, + body: {}, + cookies: {}, + routeParams: {}, + source: "express", + route: "/posts/:id", + }; + + t.test("it detects SQL injections", async (t) => { + startTestAgent({ + wrappers: [new MySQL2()], + rewrite: { + mysql2: versionPkgName, + }, + }); + + const mysql = require( + `${versionPkgName}/promise` + ) as typeof import("mysql2-v3.12/promise"); + + const connection = await mysql.createConnection({ + host: "localhost", + user: "root", + password: "mypassword", + database: "catsdb", + port: 27015, + multipleStatements: true, + }); + + let connection2: + | ReturnType + | undefined; + + try { + await connection.query( + ` + CREATE TABLE IF NOT EXISTS cats ( + petname varchar(255) + ); + ` + ); + await connection.execute("TRUNCATE cats"); + const [rows] = await connection.query("SELECT petname FROM `cats`;"); + t.same(rows, []); + const [moreRows] = await connection.query({ + sql: "SELECT petname FROM `cats`", + }); + t.same(moreRows, []); + + const error = await t.rejects(async () => { + await runWithContext(dangerousContext, () => { + return connection.query("-- should be blocked"); + }); + }); + if (error instanceof Error) { + t.same( + error.message, + "Zen has blocked an SQL injection: mysql2.query(...) originating from body.myTitle" + ); + } + + const error2 = await t.rejects(async () => { + await runWithContext(dangerousContext, () => { + return connection.query({ sql: "-- should be blocked" }); + }); + }); + if (error2 instanceof Error) { + t.same( + error2.message, + "Zen has blocked an SQL injection: mysql2.query(...) originating from body.myTitle" + ); + } + + const undefinedQueryError = await t.rejects(async () => { + await runWithContext(dangerousContext, () => { + // @ts-expect-error Testing invalid args + return connection.query(undefined); + }); + }); + + if (undefinedQueryError instanceof Error) { + t.same( + undefinedQueryError.message, + "Cannot read properties of undefined (reading 'constructor')" + ); + } + + await runWithContext(safeContext, () => { + return connection.query("-- This is a comment"); + }); + + await runWithContext(safeContext, () => { + return connection.execute("SELECT 1"); + }); + + // !!! Do not move this code up + // Because the connection of mysql2/promises will also be wrapped and possible test failures if only /promise is imported will be hidden + const mysqlCallback = require( + versionPkgName + ) as typeof import("mysql2-v3.12"); + connection2 = mysqlCallback.createConnection({ + host: "localhost", + user: "root", + password: "mypassword", + database: "catsdb", + port: 27015, + multipleStatements: true, + }); + + const error3 = await t.rejects(async () => { + await runWithContext(dangerousContext, () => { + return new Promise((resolve, reject) => { + connection2!.query( + "-- should be blocked", + (error: any, results: any) => { + if (error) { + reject(error); + } else { + resolve(results); + } + } + ); + }); + }); + }); + if (error3 instanceof Error) { + t.same( + error3.message, + "Zen has blocked an SQL injection: mysql2.query(...) originating from body.myTitle" + ); + } + + runWithContext(safeContext, () => { + connection2!.query("-- This is a comment"); + }); + } catch (error: any) { + t.fail(error); + } finally { + await connection.end(); + if (connection2) { + await connection2.end(); + } + } + }); +} diff --git a/library/sinks/MySQL2.ts b/library/sinks/MySQL2.ts index fe0d1757d..4acf93068 100644 --- a/library/sinks/MySQL2.ts +++ b/library/sinks/MySQL2.ts @@ -2,8 +2,10 @@ import { getContext } from "../agent/Context"; import { Hooks } from "../agent/hooks/Hooks"; import { InterceptorResult } from "../agent/hooks/InterceptorResult"; import { wrapExport } from "../agent/hooks/wrapExport"; +import { WrapPackageInfo } from "../agent/hooks/WrapPackageInfo"; import { Wrapper } from "../agent/Wrapper"; import { isPlainObject } from "../helpers/isPlainObject"; +import { isWrapped } from "../helpers/wrap"; import { checkContextForSqlInjection } from "../vulnerabilities/sql-injection/checkContextForSqlInjection"; import { SQLDialect } from "../vulnerabilities/sql-injection/dialects/SQLDialect"; import { SQLDialectMySQL } from "../vulnerabilities/sql-injection/dialects/SQLDialectMySQL"; @@ -49,21 +51,65 @@ export class MySQL2 implements Wrapper { return undefined; } + // This function is copied from the OpenTelemetry MySQL2 instrumentation (Apache 2.0 license) + // https://github.com/open-telemetry/opentelemetry-js-contrib/blob/21e1331a29e06092fb1e460ca99e0c28b1b57ac4/plugins/node/opentelemetry-instrumentation-mysql2/src/utils.ts#L150 + private getConnectionPrototypeToInstrument(connection: any) { + const connectionPrototype = connection.prototype; + const basePrototype = Object.getPrototypeOf(connectionPrototype); + + // mysql2@3.11.5 included a refactoring, where most code was moved out of the `Connection` class and into a shared base + // so we need to instrument that instead, see https://github.com/sidorares/node-mysql2/pull/3081 + // This checks if the functions we're instrumenting are there on the base - we cannot use the presence of a base + // prototype since EventEmitter is the base for mysql2@<=3.11.4 + if ( + typeof basePrototype?.query === "function" && + typeof basePrototype?.execute === "function" + ) { + return basePrototype; + } + + // otherwise instrument the connection directly. + return connectionPrototype; + } + wrap(hooks: Hooks) { - hooks - .addPackage("mysql2") - .withVersion("^3.0.0") - .onRequire((exports, pkgInfo) => { + const wrapConnection = ( + exports: any, + pkgInfo: WrapPackageInfo, + isPromise: boolean + ) => { + const connectionPrototype = this.getConnectionPrototypeToInstrument( + isPromise ? exports.PromiseConnection : exports.Connection + ); + + if (!isWrapped(connectionPrototype.query)) { // Wrap connection.query - wrapExport(exports.Connection.prototype, "query", pkgInfo, { + wrapExport(connectionPrototype, "query", pkgInfo, { inspectArgs: (args, agent) => this.inspectQuery("mysql2.query", args), }); + } + if (!isWrapped(connectionPrototype.execute)) { // Wrap connection.execute - wrapExport(exports.Connection.prototype, "execute", pkgInfo, { + wrapExport(connectionPrototype, "execute", pkgInfo, { inspectArgs: (args, agent) => this.inspectQuery("mysql2.execute", args), }); + } + }; + + const pkg = hooks.addPackage("mysql2"); + // For all versions of mysql2 newer than 3.0.0 + pkg + .withVersion("^3.0.0") + .onRequire((exports, pkgInfo) => wrapConnection(exports, pkgInfo, false)); + + // For all versions of mysql2 newer than / equal 3.11.5 + // Reason: https://github.com/sidorares/node-mysql2/pull/3081 + pkg + .withVersion("^3.11.5") + .onFileRequire("promise.js", (exports, pkgInfo) => { + return wrapConnection(exports, pkgInfo, true); }); } } diff --git a/library/sinks/MySQL2.v3.10.test.ts b/library/sinks/MySQL2.v3.10.test.ts new file mode 100644 index 000000000..eb310ac01 --- /dev/null +++ b/library/sinks/MySQL2.v3.10.test.ts @@ -0,0 +1,3 @@ +import { createMySQL2Tests } from "./MySQL2.tests"; + +createMySQL2Tests("mysql2-v3.10"); diff --git a/library/sinks/MySQL2.v3.12.test.ts b/library/sinks/MySQL2.v3.12.test.ts new file mode 100644 index 000000000..b8a419df2 --- /dev/null +++ b/library/sinks/MySQL2.v3.12.test.ts @@ -0,0 +1,3 @@ +import { createMySQL2Tests } from "./MySQL2.tests"; + +createMySQL2Tests("mysql2-v3.12"); diff --git a/library/tsconfig.build.json b/library/tsconfig.build.json index cbe747874..449b04a64 100644 --- a/library/tsconfig.build.json +++ b/library/tsconfig.build.json @@ -5,5 +5,5 @@ "noEmit": false }, "include": ["**/*.ts"], - "exclude": ["**/*.test.ts"] + "exclude": ["**/*.test.ts", "**/*.tests.ts"] } diff --git a/sample-apps/fastify-mysql2/package-lock.json b/sample-apps/fastify-mysql2/package-lock.json index 255b98100..5daf50d7c 100644 --- a/sample-apps/fastify-mysql2/package-lock.json +++ b/sample-apps/fastify-mysql2/package-lock.json @@ -226,9 +226,9 @@ "license": "MIT" }, "node_modules/fastify": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/fastify/-/fastify-4.28.1.tgz", - "integrity": "sha512-kFWUtpNr4i7t5vY2EJPCN2KgMVpuqfU4NjnJNCgiNB900oiDeYqaNDRcAfeBbOF5hGixixxcKnOU4KN9z6QncQ==", + "version": "4.29.0", + "resolved": "https://registry.npmjs.org/fastify/-/fastify-4.29.0.tgz", + "integrity": "sha512-MaaUHUGcCgC8fXQDsDtioaCcag1fmPJ9j64vAKunqZF4aSub040ZGi/ag8NGE2714yREPOKZuHCfpPzuUD3UQQ==", "funding": [ { "type": "github", @@ -260,9 +260,9 @@ } }, "node_modules/fastq": { - "version": "1.17.1", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.17.1.tgz", - "integrity": "sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w==", + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.18.0.tgz", + "integrity": "sha512-QKHXPW0hD8g4UET03SdOdunzSouc9N4AuHdsX8XNcTsuz+yYFILVNIX4l9yHABMhiEI9Db0JTTIpu0wB+Y1QQw==", "license": "ISC", "dependencies": { "reusify": "^1.0.4" @@ -384,9 +384,9 @@ } }, "node_modules/mysql2": { - "version": "3.11.3", - "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.11.3.tgz", - "integrity": "sha512-Qpu2ADfbKzyLdwC/5d4W7+5Yz7yBzCU05YWt5npWzACST37wJsB23wgOSo00qi043urkiRwXtEvJc9UnuLX/MQ==", + "version": "3.12.0", + "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.12.0.tgz", + "integrity": "sha512-C8fWhVysZoH63tJbX8d10IAoYCyXy4fdRFz2Ihrt9jtPILYynFEKUUzpp1U7qxzDc3tMbotvaBH+sl6bFnGZiw==", "license": "MIT", "dependencies": { "aws-ssl-profiles": "^1.1.1", @@ -425,9 +425,9 @@ } }, "node_modules/pino": { - "version": "9.5.0", - "resolved": "https://registry.npmjs.org/pino/-/pino-9.5.0.tgz", - "integrity": "sha512-xSEmD4pLnV54t0NOUN16yCl7RIB1c5UUOse5HSyEXtBp+FgFQyPeDutc+Q2ZO7/22vImV7VfEjH/1zV2QuqvYw==", + "version": "9.6.0", + "resolved": "https://registry.npmjs.org/pino/-/pino-9.6.0.tgz", + "integrity": "sha512-i85pKRCt4qMjZ1+L7sy2Ag4t1atFcdbEt76+7iRJn1g2BvsnRMGu9p8pivl9fs63M2kF/A0OacFZhTub+m/qMg==", "license": "MIT", "dependencies": { "atomic-sleep": "^1.0.0", @@ -583,9 +583,9 @@ "integrity": "sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q==" }, "node_modules/set-cookie-parser": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.0.tgz", - "integrity": "sha512-lXLOiqpkUumhRdFF3k1osNXCy9akgx/dyPZ5p8qAg9seJzXr5ZrlqZuWIMuY6ejOsVLE6flJ5/h3lsn57fQ/PQ==", + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.1.tgz", + "integrity": "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==", "license": "MIT" }, "node_modules/sonic-boom": {