diff --git a/common/changes/@snowplow/browser-plugin-bot-detection/bot-detection-plugin_2026-03-17-17-11.json b/common/changes/@snowplow/browser-plugin-bot-detection/bot-detection-plugin_2026-03-17-17-11.json new file mode 100644 index 000000000..4ac37d3ca --- /dev/null +++ b/common/changes/@snowplow/browser-plugin-bot-detection/bot-detection-plugin_2026-03-17-17-11.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "comment": "Add browser-plugin-bot-detection plugin wrapping FingerprintJS BotD for client-side bot detection", + "type": "minor", + "packageName": "@snowplow/browser-plugin-bot-detection" + } + ], + "packageName": "@snowplow/browser-plugin-bot-detection", + "email": "nick.stanch@snowplowanalytics.com" +} \ No newline at end of file diff --git a/common/changes/@snowplow/browser-tracker-core/issue-1464-replace-sha1-buffer-polyfill_2026-03-29-16-18.json b/common/changes/@snowplow/browser-tracker-core/issue-1464-replace-sha1-buffer-polyfill_2026-03-29-16-18.json new file mode 100644 index 000000000..6ec66b2ac --- /dev/null +++ b/common/changes/@snowplow/browser-tracker-core/issue-1464-replace-sha1-buffer-polyfill_2026-03-29-16-18.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "comment": "Replace SHA1 buffer polyfill with lightweight implementation", + "type": "none", + "packageName": "@snowplow/browser-tracker-core" + } + ], + "packageName": "@snowplow/browser-tracker-core" +} \ No newline at end of file diff --git a/common/config/rush/browser-approved-packages.json b/common/config/rush/browser-approved-packages.json index 091e52eb9..f157b3ec4 100644 --- a/common/config/rush/browser-approved-packages.json +++ b/common/config/rush/browser-approved-packages.json @@ -6,6 +6,10 @@ "name": "@ampproject/rollup-plugin-closure-compiler", "allowedCategories": [ "libraries", "plugins", "trackers" ] }, + { + "name": "@fingerprintjs/botd", + "allowedCategories": [ "plugins" ] + }, { "name": "@react-native-async-storage/async-storage", "allowedCategories": [ "trackers" ] @@ -42,6 +46,10 @@ "name": "@snowplow/browser-plugin-ad-tracking", "allowedCategories": [ "trackers" ] }, + { + "name": "@snowplow/browser-plugin-bot-detection", + "allowedCategories": [ "trackers" ] + }, { "name": "@snowplow/browser-plugin-browser-features", "allowedCategories": [ "trackers" ] @@ -214,10 +222,6 @@ "name": "@types/react", "allowedCategories": [ "trackers" ] }, - { - "name": "@types/sha1", - "allowedCategories": [ "libraries" ] - }, { "name": "@types/sinon", "allowedCategories": [ "trackers" ] @@ -422,10 +426,6 @@ "name": "saucelabs", "allowedCategories": [ "trackers" ] }, - { - "name": "sha1", - "allowedCategories": [ "libraries" ] - }, { "name": "sinon", "allowedCategories": [ "trackers" ] diff --git a/common/config/rush/pnpm-lock.yaml b/common/config/rush/pnpm-lock.yaml index c6895c636..44f6cc7f6 100644 --- a/common/config/rush/pnpm-lock.yaml +++ b/common/config/rush/pnpm-lock.yaml @@ -15,9 +15,6 @@ importers: '@snowplow/tracker-core': specifier: workspace:* version: link:../tracker-core - sha1: - specifier: ^1.1.1 - version: 1.1.1 tslib: specifier: ^2.3.1 version: 2.8.1 @@ -43,9 +40,6 @@ importers: '@types/jsdom': specifier: ~16.2.14 version: 16.2.15 - '@types/sha1': - specifier: ~1.1.3 - version: 1.1.5 '@types/uuid': specifier: ^10.0.0 version: 10.0.0 @@ -235,6 +229,79 @@ importers: specifier: ~4.6.2 version: 4.6.4 + ../../plugins/browser-plugin-bot-detection: + dependencies: + '@fingerprintjs/botd': + specifier: 2.0.0 + version: 2.0.0 + '@snowplow/browser-tracker-core': + specifier: workspace:* + version: link:../../libraries/browser-tracker-core + '@snowplow/tracker-core': + specifier: workspace:* + version: link:../../libraries/tracker-core + tslib: + specifier: ^2.3.1 + version: 2.8.1 + devDependencies: + '@ampproject/rollup-plugin-closure-compiler': + specifier: ~0.27.0 + version: 0.27.0(rollup@2.70.2) + '@rollup/plugin-commonjs': + specifier: ~21.0.2 + version: 21.0.3(rollup@2.70.2) + '@rollup/plugin-node-resolve': + specifier: ~13.1.3 + version: 13.1.3(rollup@2.70.2) + '@types/jest': + specifier: ~28.1.1 + version: 28.1.8 + '@types/jsdom': + specifier: ~16.2.14 + version: 16.2.15 + '@typescript-eslint/eslint-plugin': + specifier: ~5.15.0 + version: 5.15.0(@typescript-eslint/parser@5.15.0(eslint@8.11.0)(typescript@4.6.4))(eslint@8.11.0)(typescript@4.6.4) + '@typescript-eslint/parser': + specifier: ~5.15.0 + version: 5.15.0(eslint@8.11.0)(typescript@4.6.4) + eslint: + specifier: ~8.11.0 + version: 8.11.0 + jest: + specifier: ~28.1.3 + version: 28.1.3(@types/node@20.17.10)(ts-node@10.9.2(@types/node@20.17.10)(typescript@4.6.4)) + jest-environment-jsdom: + specifier: ~28.1.3 + version: 28.1.3 + jest-environment-jsdom-global: + specifier: ~4.0.0 + version: 4.0.0(jest-environment-jsdom@28.1.3) + jest-standard-reporter: + specifier: ~2.0.0 + version: 2.0.0 + rollup: + specifier: ~2.70.1 + version: 2.70.2 + rollup-plugin-cleanup: + specifier: ~3.2.1 + version: 3.2.1(rollup@2.70.2) + rollup-plugin-license: + specifier: ~2.6.1 + version: 2.6.1(rollup@2.70.2) + rollup-plugin-terser: + specifier: ~7.0.2 + version: 7.0.2(rollup@2.70.2) + rollup-plugin-ts: + specifier: ~2.0.5 + version: 2.0.7(@babel/core@7.26.0)(@babel/plugin-transform-runtime@7.25.9(@babel/core@7.26.0))(@babel/preset-env@7.26.0(@babel/core@7.26.0))(@babel/runtime@7.26.0)(rollup@2.70.2)(typescript@4.6.4) + ts-jest: + specifier: ~28.0.8 + version: 28.0.8(@babel/core@7.26.0)(@jest/types@28.1.3)(babel-jest@28.1.3(@babel/core@7.26.0))(jest@28.1.3(@types/node@20.17.10)(ts-node@10.9.2(@types/node@20.17.10)(typescript@4.6.4)))(typescript@4.6.4) + typescript: + specifier: ~4.6.2 + version: 4.6.4 + ../../plugins/browser-plugin-button-click-tracking: dependencies: '@snowplow/browser-tracker-core': @@ -3428,6 +3495,9 @@ packages: resolution: {integrity: sha512-XXrH9Uarn0stsyldqDYq8r++mROmWRI1xKMXa640Bb//SY1+ECYX6VzT6Lcx5frD0V30XieqJ0oX9I2Xj5aoMA==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + '@fingerprintjs/botd@2.0.0': + resolution: {integrity: sha512-yhuz23NKEcBDTHmGz/ULrXlGnbHenO+xZmVwuBkuqHUkqvaZ5TAA0kAgcRy4Wyo5dIBdkIf57UXX8/c9UlMLJg==} + '@gar/promisify@1.1.3': resolution: {integrity: sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw==} @@ -3985,9 +4055,6 @@ packages: '@types/semver@7.5.8': resolution: {integrity: sha512-I8EUhyrgfLrcTkzV3TSsGyl1tSuPrEDzr0yd5m90UgNxQkyDXULk3b6MlQqTCpZpNtWe1K0hzclnZkTcLBe2UQ==} - '@types/sha1@1.1.5': - resolution: {integrity: sha512-eE1PzjW7u2VfxI+bTsvjzjBfpwqvxSpgfUmnRNVY+PJU1NBsdGZlaO/qnVnPKHzzpgIl9YyBIxvrgBvt1mzt2A==} - '@types/split2@4.2.3': resolution: {integrity: sha512-59OXIlfUsi2k++H6CHgUQKEb2HKRokUA39HY1i1dS8/AIcqVjtAAFdf8u+HxTWK/4FUHMJQlKSZ4I6irCBJ1Zw==} @@ -4703,9 +4770,6 @@ packages: chardet@0.7.0: resolution: {integrity: sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==} - charenc@0.0.2: - resolution: {integrity: sha512-yrLQ/yVUFXkzg7EDQsPieE/53+0RlaWTs+wBrvW36cyilJ2SaDWfl4Yj7MtLTXleV9uEKefbAGUPv2/iWSooRA==} - chokidar@3.6.0: resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} engines: {node: '>= 8.10.0'} @@ -4990,9 +5054,6 @@ packages: resolution: {integrity: sha512-mpjkSErNO6vioL/Cde2aF4UBysPFEMyn+1AN1t7Oc4yqvzSRWe8iBte4P8BHyjo64OmC+ZBxwjIqmpSpIWiQ7Q==} engines: {node: '>=10.0.0'} - crypt@0.0.2: - resolution: {integrity: sha512-mCxBlsHFYh9C+HVpiEacem8FEBnMXgU9gy4zmNC+SXAZNB/1idgp/aulFJ4FgCi7GPEVbfyng092GqL2k2rmow==} - css-shorthand-properties@1.1.2: resolution: {integrity: sha512-C2AugXIpRGQTxaCW0N7n5jD/p5irUmCrwl03TrnMFBHDbdq44CFWR2zO7rK9xPN4Eo3pUxC4vQzQgbIpzrD1PQ==} @@ -8190,9 +8251,6 @@ packages: setprototypeof@1.2.0: resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} - sha1@1.1.1: - resolution: {integrity: sha512-dZBS6OrMjtgVkopB1Gmo4RQCDKiZsqcpAQpkV/aaj+FCrCg8r4I4qMkDPQjBgLIxlmu9k4nUbWq6ohXahOneYA==} - shallow-clone@3.0.1: resolution: {integrity: sha512-/6KqX+GVUdqPuPPd2LxDDxzX6CAbjJehAAOKlNpqqUpAqPM6HeL8f+o3a+JsyGjn2lv0WY8UsTgUJjU9Ok55NA==} engines: {node: '>=8'} @@ -10140,6 +10198,8 @@ snapshots: transitivePeerDependencies: - supports-color + '@fingerprintjs/botd@2.0.0': {} + '@gar/promisify@1.1.3': {} '@hapi/hoek@9.3.0': {} @@ -11086,10 +11146,6 @@ snapshots: '@types/semver@7.5.8': {} - '@types/sha1@1.1.5': - dependencies: - '@types/node': 14.6.4 - '@types/split2@4.2.3': dependencies: '@types/node': 14.6.4 @@ -12112,8 +12168,6 @@ snapshots: chardet@0.7.0: {} - charenc@0.0.2: {} - chokidar@3.6.0: dependencies: anymatch: 3.1.3 @@ -12414,8 +12468,6 @@ snapshots: dependencies: '@types/node': 16.18.122 - crypt@0.0.2: {} - css-shorthand-properties@1.1.2: {} css-value@0.0.1: {} @@ -16419,11 +16471,6 @@ snapshots: setprototypeof@1.2.0: {} - sha1@1.1.1: - dependencies: - charenc: 0.0.2 - crypt: 0.0.2 - shallow-clone@3.0.1: dependencies: kind-of: 6.0.3 diff --git a/common/config/rush/repo-state.json b/common/config/rush/repo-state.json index f780a410f..407b63331 100644 --- a/common/config/rush/repo-state.json +++ b/common/config/rush/repo-state.json @@ -1,5 +1,5 @@ // DO NOT MODIFY THIS FILE MANUALLY BUT DO COMMIT IT. It is generated and used by Rush. { - "pnpmShrinkwrapHash": "1bbfee8474092dd7a04f769c89f237e4c74bfbb5", + "pnpmShrinkwrapHash": "7bfb995166f0ecc4b03f50797de4b75379120fb5", "preferredVersionsHash": "bf21a9e8fbc5a3846fb05b4fa0859e0917b2202f" } diff --git a/common/config/rush/version-policies.json b/common/config/rush/version-policies.json index 30729f249..c997fd26e 100644 --- a/common/config/rush/version-policies.json +++ b/common/config/rush/version-policies.json @@ -42,6 +42,6 @@ * * Valid values are: "prerelease", "release", "minor", "patch", "major" */ - "nextBump": "patch" + "nextBump": "minor" } ] diff --git a/libraries/browser-tracker-core/package.json b/libraries/browser-tracker-core/package.json index ba000c14a..2efdf3419 100644 --- a/libraries/browser-tracker-core/package.json +++ b/libraries/browser-tracker-core/package.json @@ -23,7 +23,6 @@ }, "dependencies": { "@snowplow/tracker-core": "workspace:*", - "sha1": "^1.1.1", "tslib": "^2.3.1", "uuid": "^10.0.0" }, @@ -33,7 +32,6 @@ "@rollup/plugin-node-resolve": "~13.1.3", "@types/jest": "~28.1.1", "@types/jsdom": "~16.2.14", - "@types/sha1": "~1.1.3", "@types/uuid": "^10.0.0", "@typescript-eslint/eslint-plugin": "~5.15.0", "@typescript-eslint/parser": "~5.15.0", diff --git a/libraries/browser-tracker-core/src/helpers/sha1.ts b/libraries/browser-tracker-core/src/helpers/sha1.ts new file mode 100644 index 000000000..af6a8c5ea --- /dev/null +++ b/libraries/browser-tracker-core/src/helpers/sha1.ts @@ -0,0 +1,98 @@ +/** + * Pure-JS SHA-1 implementation (FIPS 180-4). + * Replaces the `sha1` npm package to avoid pulling in a Node.js `Buffer` + * polyfill (~28 KB) when bundled for the browser. + */ +/** Compute SHA-1 hex digest of a UTF-8 string (FIPS 180-4). */ +export function sha1(message: string): string { + // Encode UTF-8 + const bytes: number[] = []; + for (let i = 0; i < message.length; i++) { + let c = message.charCodeAt(i); + if (c < 0x80) { + bytes.push(c); + } else if (c < 0x800) { + bytes.push(0xc0 | (c >> 6), 0x80 | (c & 0x3f)); + } else if (c >= 0xd800 && c <= 0xdbff && i + 1 < message.length) { + const next = message.charCodeAt(++i); + c = 0x10000 + ((c & 0x3ff) << 10) + (next & 0x3ff); + bytes.push(0xf0 | (c >> 18), 0x80 | ((c >> 12) & 0x3f), 0x80 | ((c >> 6) & 0x3f), 0x80 | (c & 0x3f)); + } else { + bytes.push(0xe0 | (c >> 12), 0x80 | ((c >> 6) & 0x3f), 0x80 | (c & 0x3f)); + } + } + + // Pre-processing: pad to 512-bit blocks + const bitLen = bytes.length * 8; + bytes.push(0x80); + while (bytes.length % 64 !== 56) bytes.push(0); + // Append length as 64-bit big-endian (high 32 bits always 0 for strings < 512 MB) + bytes.push(0, 0, 0, 0); + bytes.push((bitLen >>> 24) & 0xff, (bitLen >>> 16) & 0xff, (bitLen >>> 8) & 0xff, bitLen & 0xff); + + // Initialize hash values + let h0 = 0x67452301; + let h1 = 0xefcdab89; + let h2 = 0x98badcfe; + let h3 = 0x10325476; + let h4 = 0xc3d2e1f0; + + // Process each 512-bit block + const w = new Array(80); + for (let offset = 0; offset < bytes.length; offset += 64) { + for (let i = 0; i < 16; i++) { + w[i] = + (bytes[offset + i * 4] << 24) | + (bytes[offset + i * 4 + 1] << 16) | + (bytes[offset + i * 4 + 2] << 8) | + bytes[offset + i * 4 + 3]; + } + for (let i = 16; i < 80; i++) { + const n = w[i - 3] ^ w[i - 8] ^ w[i - 14] ^ w[i - 16]; + w[i] = (n << 1) | (n >>> 31); + } + + let a = h0, + b = h1, + c = h2, + d = h3, + e = h4; + + for (let i = 0; i < 80; i++) { + let f: number, k: number; + if (i < 20) { + f = (b & c) | (~b & d); + k = 0x5a827999; + } else if (i < 40) { + f = b ^ c ^ d; + k = 0x6ed9eba1; + } else if (i < 60) { + f = (b & c) | (b & d) | (c & d); + k = 0x8f1bbcdc; + } else { + f = b ^ c ^ d; + k = 0xca62c1d6; + } + const temp = (((a << 5) | (a >>> 27)) + f + e + k + w[i]) | 0; + e = d; + d = c; + c = (b << 30) | (b >>> 2); + b = a; + a = temp; + } + + h0 = (h0 + a) | 0; + h1 = (h1 + b) | 0; + h2 = (h2 + c) | 0; + h3 = (h3 + d) | 0; + h4 = (h4 + e) | 0; + } + + // Produce hex digest + let hex = ''; + for (const h of [h0, h1, h2, h3, h4]) { + const part = (h >>> 0).toString(16); + hex += (part.length < 8 ? '00000000'.slice(part.length) : '') + part; + } + return hex; +} diff --git a/libraries/browser-tracker-core/src/tracker/index.ts b/libraries/browser-tracker-core/src/tracker/index.ts index 4bd868d8d..7d4b8cac4 100755 --- a/libraries/browser-tracker-core/src/tracker/index.ts +++ b/libraries/browser-tracker-core/src/tracker/index.ts @@ -7,7 +7,7 @@ import { SelfDescribingJson, LOG, } from '@snowplow/tracker-core'; -import hash from 'sha1'; +import { sha1 as hash } from '../helpers/sha1'; import { v4 as uuid } from 'uuid'; import { decorateQuerystring, diff --git a/libraries/browser-tracker-core/test/helpers/sha1.test.ts b/libraries/browser-tracker-core/test/helpers/sha1.test.ts new file mode 100644 index 000000000..291ee99a5 --- /dev/null +++ b/libraries/browser-tracker-core/test/helpers/sha1.test.ts @@ -0,0 +1,37 @@ +import { sha1 } from '../../src/helpers/sha1'; + +describe('sha1', () => { + // RFC 3174 test vectors + test('empty string', () => { + expect(sha1('')).toBe('da39a3ee5e6b4b0d3255bfef95601890afd80709'); + }); + + test('"abc"', () => { + expect(sha1('abc')).toBe('a9993e364706816aba3e25717850c26c9cd0d89d'); + }); + + test('"The quick brown fox jumps over the lazy dog"', () => { + expect(sha1('The quick brown fox jumps over the lazy dog')).toBe('2fd4e1c67a2d28fced849ee1bb76e7391b93eb12'); + }); + + test('multi-block message', () => { + expect(sha1('abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq')).toBe( + '84983e441c3bd26ebaae4aa1f95129e5e54670f1' + ); + }); + + // Domain hash use case — must match output of sha1 npm package v1.1.1 + test('domain hash: "example.com/"', () => { + expect(sha1('example.com/')).toBe('880970443b82bdca0439e34c62e6c667277c2b39'); + expect(sha1('example.com/').slice(0, 4)).toBe('8809'); + }); + + test('domain hash: "localhost/"', () => { + expect(sha1('localhost/')).toBe('1fffd42e9a20211889ebfae87a84665b392c19a4'); + expect(sha1('localhost/').slice(0, 4)).toBe('1fff'); + }); + + test('returns 40-char lowercase hex string', () => { + expect(sha1('anything')).toMatch(/^[0-9a-f]{40}$/); + }); +}); diff --git a/plugins/browser-plugin-bot-detection/LICENSE b/plugins/browser-plugin-bot-detection/LICENSE new file mode 100644 index 000000000..76f1946ea --- /dev/null +++ b/plugins/browser-plugin-bot-detection/LICENSE @@ -0,0 +1,29 @@ +BSD 3-Clause License + +Copyright (c) 2022 Snowplow Analytics Ltd, 2010 Anthon Pang +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/plugins/browser-plugin-bot-detection/README.md b/plugins/browser-plugin-bot-detection/README.md new file mode 100644 index 000000000..6a30a4f0a --- /dev/null +++ b/plugins/browser-plugin-bot-detection/README.md @@ -0,0 +1,59 @@ +# Snowplow Bot Detection + +[![npm version][npm-image]][npm-url] +[![License][license-image]](LICENSE) + +Browser Plugin to be used with `@snowplow/browser-tracker`. + +Detects bots client-side using [FingerprintJS BotD](https://github.com/fingerprintjs/BotD) and attaches the result as a `client_side_bot_detection` context entity to all tracked events. + +## Maintainer quick start + +Part of the Snowplow JavaScript Tracker monorepo. +Build with [Node.js](https://nodejs.org/en/) (18 - 20) and [Rush](https://rushjs.io/). + +### Setup repository + +```bash +npm install -g @microsoft/rush +git clone https://github.com/snowplow/snowplow-javascript-tracker.git +rush update +``` + +## Package Installation + +With npm: + +```bash +npm install @snowplow/browser-plugin-bot-detection +``` + +## Usage + +Initialize your tracker with the BotDetectionPlugin: + +```js +import { newTracker } from '@snowplow/browser-tracker'; +import { BotDetectionPlugin } from '@snowplow/browser-plugin-bot-detection'; + +newTracker('sp1', '{{collector}}', { plugins: [ BotDetectionPlugin() ] }); +``` + +Once detection completes, a `client_side_bot_detection` context entity will be attached to all subsequent events with the following fields: + +- `bot` (boolean): Whether a bot was detected. +- `kind` (string | null): The type of bot detected (e.g. `"selenium"`, `"phantomjs"`), or `null` if no bot was found. + +## Copyright and license + +Licensed and distributed under the [BSD 3-Clause License](LICENSE) ([An OSI Approved License][osi]). + +Copyright (c) 2022 Snowplow Analytics Ltd, 2010 Anthon Pang. + +All rights reserved. + +[npm-url]: https://www.npmjs.com/package/@snowplow/browser-plugin-bot-detection +[npm-image]: https://img.shields.io/npm/v/@snowplow/browser-plugin-bot-detection +[docs]: https://docs.snowplowanalytics.com/docs/collecting-data/collecting-from-own-applications/javascript-tracker/ +[osi]: https://opensource.org/licenses/BSD-3-Clause +[license-image]: https://img.shields.io/npm/l/@snowplow/browser-plugin-bot-detection diff --git a/plugins/browser-plugin-bot-detection/jest.config.js b/plugins/browser-plugin-bot-detection/jest.config.js new file mode 100644 index 000000000..87d15da9b --- /dev/null +++ b/plugins/browser-plugin-bot-detection/jest.config.js @@ -0,0 +1,6 @@ +module.exports = { + preset: 'ts-jest', + reporters: ['jest-standard-reporter'], + setupFilesAfterEnv: ['../../setupTestGlobals.ts'], + testEnvironment: 'jest-environment-jsdom-global', +}; diff --git a/plugins/browser-plugin-bot-detection/package.json b/plugins/browser-plugin-bot-detection/package.json new file mode 100644 index 000000000..5e218336f --- /dev/null +++ b/plugins/browser-plugin-bot-detection/package.json @@ -0,0 +1,54 @@ +{ + "name": "@snowplow/browser-plugin-bot-detection", + "version": "4.6.9", + "description": "Detects bots client-side using FingerprintJS BotD and attaches the result as a context entity.", + "homepage": "https://github.com/snowplow/snowplow-javascript-tracker", + "bugs": "https://github.com/snowplow/snowplow-javascript-tracker/issues", + "repository": { + "type": "git", + "url": "https://github.com/snowplow/snowplow-javascript-tracker.git" + }, + "license": "BSD-3-Clause", + "author": "Snowplow Analytics Ltd (https://snowplow.io/)", + "sideEffects": false, + "main": "./dist/index.umd.js", + "module": "./dist/index.module.js", + "types": "./dist/index.module.d.ts", + "files": [ + "dist" + ], + "scripts": { + "build": "rollup -c --silent --failAfterWarnings", + "test": "jest" + }, + "dependencies": { + "@fingerprintjs/botd": "2.0.0", + "@snowplow/browser-tracker-core": "workspace:*", + "@snowplow/tracker-core": "workspace:*", + "tslib": "^2.3.1" + }, + "devDependencies": { + "@ampproject/rollup-plugin-closure-compiler": "~0.27.0", + "@rollup/plugin-commonjs": "~21.0.2", + "@rollup/plugin-node-resolve": "~13.1.3", + "@types/jest": "~28.1.1", + "@types/jsdom": "~16.2.14", + "@typescript-eslint/eslint-plugin": "~5.15.0", + "@typescript-eslint/parser": "~5.15.0", + "eslint": "~8.11.0", + "jest": "~28.1.3", + "jest-environment-jsdom": "~28.1.3", + "jest-environment-jsdom-global": "~4.0.0", + "jest-standard-reporter": "~2.0.0", + "rollup": "~2.70.1", + "rollup-plugin-cleanup": "~3.2.1", + "rollup-plugin-license": "~2.6.1", + "rollup-plugin-terser": "~7.0.2", + "rollup-plugin-ts": "~2.0.5", + "ts-jest": "~28.0.8", + "typescript": "~4.6.2" + }, + "peerDependencies": { + "@snowplow/browser-tracker": "~4.6.9" + } +} diff --git a/plugins/browser-plugin-bot-detection/rollup.config.js b/plugins/browser-plugin-bot-detection/rollup.config.js new file mode 100644 index 000000000..fe08aba48 --- /dev/null +++ b/plugins/browser-plugin-bot-detection/rollup.config.js @@ -0,0 +1,37 @@ +import { nodeResolve } from '@rollup/plugin-node-resolve'; +import commonjs from '@rollup/plugin-commonjs'; +import ts from 'rollup-plugin-ts'; // Preferred over @rollup/plugin-typescript as it bundles .d.ts files +import { banner } from '../../banner'; +import compiler from '@ampproject/rollup-plugin-closure-compiler'; +import { terser } from 'rollup-plugin-terser'; +import cleanup from 'rollup-plugin-cleanup'; +import pkg from './package.json'; +import { builtinModules } from 'module'; + +const umdPlugins = [nodeResolve({ browser: true }), commonjs(), ts()]; +const umdName = 'snowplowBotDetection'; + +export default [ + // CommonJS (for Node) and ES module (for bundlers) build. + { + input: './src/index.ts', + plugins: [...umdPlugins, banner()], + treeshake: { moduleSideEffects: ['sha1'] }, + output: [{ file: pkg.main, format: 'umd', sourcemap: true, name: umdName }], + }, + { + input: './src/index.ts', + plugins: [...umdPlugins, compiler(), terser(), cleanup({ comments: 'none' }), banner()], + treeshake: { moduleSideEffects: ['sha1'] }, + output: [{ file: pkg.main.replace('.js', '.min.js'), format: 'umd', sourcemap: true, name: umdName }], + }, + { + input: './src/index.ts', + external: [...builtinModules, ...Object.keys(pkg.dependencies), ...Object.keys(pkg.devDependencies)], + plugins: [ + ts(), // so Rollup can convert TypeScript to JavaScript + banner(), + ], + output: [{ file: pkg.module, format: 'es', sourcemap: true }], + }, +]; diff --git a/plugins/browser-plugin-bot-detection/src/index.ts b/plugins/browser-plugin-bot-detection/src/index.ts new file mode 100644 index 000000000..966c4f356 --- /dev/null +++ b/plugins/browser-plugin-bot-detection/src/index.ts @@ -0,0 +1,32 @@ +import { BrowserPlugin } from '@snowplow/browser-tracker-core'; +import { LOG } from '@snowplow/tracker-core'; +import { load } from '@fingerprintjs/botd'; +import { CLIENT_SIDE_BOT_DETECTION_SCHEMA } from './schemata'; +import { BotDetectionContextData } from './types'; + +export { BotDetectionContextData, BotKind } from './types'; + +let contextData: BotDetectionContextData | undefined; +let detectionStarted = false; + +export function BotDetectionPlugin(): BrowserPlugin { + return { + activateBrowserPlugin: () => { + if (!detectionStarted) { + detectionStarted = true; + load() + .then((detector) => detector.detect()) + .then((result) => { + contextData = result.bot ? { bot: true, kind: result.botKind } : { bot: false, kind: null }; + }) + .catch((err) => LOG.error('BotDetectionPlugin: BotD load/detect failed', err)); + } + }, + contexts: () => { + if (contextData) { + return [{ schema: CLIENT_SIDE_BOT_DETECTION_SCHEMA, data: contextData }]; + } + return []; + }, + }; +} diff --git a/plugins/browser-plugin-bot-detection/src/schemata.ts b/plugins/browser-plugin-bot-detection/src/schemata.ts new file mode 100644 index 000000000..af05d9f6a --- /dev/null +++ b/plugins/browser-plugin-bot-detection/src/schemata.ts @@ -0,0 +1,2 @@ +export const CLIENT_SIDE_BOT_DETECTION_SCHEMA = + 'iglu:com.snowplowanalytics.snowplow/client_side_bot_detection/jsonschema/1-0-0'; diff --git a/plugins/browser-plugin-bot-detection/src/types.ts b/plugins/browser-plugin-bot-detection/src/types.ts new file mode 100644 index 000000000..f374b478d --- /dev/null +++ b/plugins/browser-plugin-bot-detection/src/types.ts @@ -0,0 +1,25 @@ +export type BotKind = + | 'awesomium' + | 'cef' + | 'cefsharp' + | 'coachjs' + | 'electron' + | 'fminer' + | 'geb' + | 'nightmarejs' + | 'phantomas' + | 'phantomjs' + | 'rhino' + | 'selenium' + | 'sequentum' + | 'slimerjs' + | 'webdriverio' + | 'webdriver' + | 'headless_chrome' + | 'unknown'; + +export interface BotDetectionContextData { + [key: string]: unknown; + bot: boolean; + kind: BotKind | null; +} diff --git a/plugins/browser-plugin-bot-detection/test/bot-detection.test.ts b/plugins/browser-plugin-bot-detection/test/bot-detection.test.ts new file mode 100644 index 000000000..7b6885879 --- /dev/null +++ b/plugins/browser-plugin-bot-detection/test/bot-detection.test.ts @@ -0,0 +1,149 @@ +import { buildLinkClick, trackerCore } from '@snowplow/tracker-core'; +import { BrowserTracker } from '@snowplow/browser-tracker-core'; +import { setImmediate } from 'timers'; + +const flushPromises = () => new Promise(setImmediate); + +let mockDetectResult: any = { bot: false }; +let mockLoadReject: Error | null = null; +let mockDetectReject: Error | null = null; + +jest.mock('@fingerprintjs/botd', () => ({ + load: () => { + if (mockLoadReject) { + return Promise.reject(mockLoadReject); + } + return Promise.resolve({ + detect: () => { + if (mockDetectReject) { + return Promise.reject(mockDetectReject); + } + return Promise.resolve(mockDetectResult); + }, + }); + }, +})); + +describe('BotDetectionPlugin', () => { + beforeEach(() => { + jest.resetModules(); + mockDetectResult = { bot: false }; + mockLoadReject = null; + mockDetectReject = null; + }); + + it('attaches bot context when a bot is detected', async () => { + mockDetectResult = { bot: true, botKind: 'selenium' }; + + const { BotDetectionPlugin } = require('../src'); + const plugin = BotDetectionPlugin(); + + const core = trackerCore({ + corePlugins: [plugin], + callback: (payloadBuilder) => { + const json = payloadBuilder.getJson().filter((e) => e.keyIfEncoded === 'cx'); + const contexts = json[0]?.json; + expect(contexts).toEqual({ + schema: 'iglu:com.snowplowanalytics.snowplow/contexts/jsonschema/1-0-0', + data: [ + { + schema: 'iglu:com.snowplowanalytics.snowplow/client_side_bot_detection/jsonschema/1-0-0', + data: { bot: true, kind: 'selenium' }, + }, + ], + }); + }, + }); + + plugin.activateBrowserPlugin?.({ core } as BrowserTracker); + await flushPromises(); + + core.track(buildLinkClick({ targetUrl: 'https://example.com' })); + }); + + it('attaches no-bot context when no bot is detected', async () => { + mockDetectResult = { bot: false }; + + const { BotDetectionPlugin } = require('../src'); + const plugin = BotDetectionPlugin(); + + const core = trackerCore({ + corePlugins: [plugin], + callback: (payloadBuilder) => { + const json = payloadBuilder.getJson().filter((e) => e.keyIfEncoded === 'cx'); + const contexts = json[0]?.json; + expect(contexts).toEqual({ + schema: 'iglu:com.snowplowanalytics.snowplow/contexts/jsonschema/1-0-0', + data: [ + { + schema: 'iglu:com.snowplowanalytics.snowplow/client_side_bot_detection/jsonschema/1-0-0', + data: { bot: false, kind: null }, + }, + ], + }); + }, + }); + + plugin.activateBrowserPlugin?.({ core } as BrowserTracker); + await flushPromises(); + + core.track(buildLinkClick({ targetUrl: 'https://example.com' })); + }); + + it('returns empty contexts while detection is pending', () => { + const { BotDetectionPlugin } = require('../src'); + const plugin = BotDetectionPlugin(); + + const core = trackerCore({ + corePlugins: [plugin], + callback: (payloadBuilder) => { + const json = payloadBuilder.getJson().filter((e) => e.keyIfEncoded === 'cx'); + expect(json).toHaveLength(0); + }, + }); + + plugin.activateBrowserPlugin?.({ core } as BrowserTracker); + // Do NOT await — detection is still pending + core.track(buildLinkClick({ targetUrl: 'https://example.com' })); + }); + + it('returns empty contexts when load() fails', async () => { + mockLoadReject = new Error('load failed'); + + const { BotDetectionPlugin } = require('../src'); + const plugin = BotDetectionPlugin(); + + const core = trackerCore({ + corePlugins: [plugin], + callback: (payloadBuilder) => { + const json = payloadBuilder.getJson().filter((e) => e.keyIfEncoded === 'cx'); + expect(json).toHaveLength(0); + }, + }); + + plugin.activateBrowserPlugin?.({ core } as BrowserTracker); + await flushPromises(); + + core.track(buildLinkClick({ targetUrl: 'https://example.com' })); + }); + + it('returns empty contexts when detect() fails', async () => { + mockDetectReject = new Error('detect failed'); + + const { BotDetectionPlugin } = require('../src'); + const plugin = BotDetectionPlugin(); + + const core = trackerCore({ + corePlugins: [plugin], + callback: (payloadBuilder) => { + const json = payloadBuilder.getJson().filter((e) => e.keyIfEncoded === 'cx'); + expect(json).toHaveLength(0); + }, + }); + + plugin.activateBrowserPlugin?.({ core } as BrowserTracker); + await flushPromises(); + + core.track(buildLinkClick({ targetUrl: 'https://example.com' })); + }); +}); diff --git a/plugins/browser-plugin-bot-detection/tsconfig.json b/plugins/browser-plugin-bot-detection/tsconfig.json new file mode 100644 index 000000000..4082f16a5 --- /dev/null +++ b/plugins/browser-plugin-bot-detection/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "../../tsconfig.json" +} diff --git a/rush.json b/rush.json index 7f5b3315a..b6ada47fd 100644 --- a/rush.json +++ b/rush.json @@ -463,6 +463,12 @@ "reviewCategory": "plugins", "versionPolicyName": "tracker" }, + { + "packageName": "@snowplow/browser-plugin-bot-detection", + "projectFolder": "plugins/browser-plugin-bot-detection", + "reviewCategory": "plugins", + "versionPolicyName": "tracker" + }, { "packageName": "@snowplow/browser-plugin-site-tracking", "projectFolder": "plugins/browser-plugin-site-tracking",