diff --git a/packages/bundler-security/jest.config.js b/packages/bundler-security/jest.config.js new file mode 100644 index 00000000000..7034dca5e2e --- /dev/null +++ b/packages/bundler-security/jest.config.js @@ -0,0 +1,11 @@ +/** + * Jest configuration for web packages. + * Keeping this file next to the package.json file instead of providing configuration + * with `-c ../../jest.config.base` option in package.json scripts + * allows us to run jest tests directly from IDEs. + */ +const { ...baseConfig } = require('../../jest.config.base'); + +module.exports = { + ...baseConfig, +}; diff --git a/packages/bundler-security/package.json b/packages/bundler-security/package.json new file mode 100644 index 00000000000..8bf2ed58c98 --- /dev/null +++ b/packages/bundler-security/package.json @@ -0,0 +1,17 @@ +{ + "name": "@trezor/bundler-security", + "version": "1.0.0", + "private": true, + "license": "See LICENSE.md in repo root", + "sideEffects": false, + "main": "src/index", + "scripts": { + "depcheck": "yarn g:depcheck", + "lint:js": "yarn g:eslint '**/*.{ts,tsx,js}'", + "type-check": "yarn g:tsc --build", + "test:unit": "yarn g:jest" + }, + "dependencies": { + "webpack": ">=5.0.0" + } +} diff --git a/packages/bundler-security/src/WebpackSecurityPlugin.ts b/packages/bundler-security/src/WebpackSecurityPlugin.ts new file mode 100644 index 00000000000..2ba925048fe --- /dev/null +++ b/packages/bundler-security/src/WebpackSecurityPlugin.ts @@ -0,0 +1,36 @@ +/* eslint-disable no-console */ +import webpack from 'webpack'; + +import { checkSecurityViolation } from './metroSecureResolver'; + +export class WebpackSecurityCheckPlugin { + private logError(message: string) { + console.error(`\x1b[1;31m${message}\x1b[0m`); + } + + apply(compiler: webpack.Compiler) { + compiler.hooks.normalModuleFactory.tap('SecurityCheckPlugin', factory => { + factory.hooks.resolve.tap('SecurityCheckPlugin', resolveData => { + const { request, context, contextInfo } = resolveData; + + const { isViolation, normalizedPath } = checkSecurityViolation({ + moduleName: request, + originModulePath: context, + }); + + if (isViolation) { + console.log('\n\n'); + this.logError( + `SECURITY ALERT: A third-party package is trying to import internal Trezor package! ` + + `\nRequesting module: ${request}` + + `\nNormalized path: ${normalizedPath}` + + `\nImmediate importer: ${context}` + + `\nOriginal issuer: ${contextInfo.issuer}`, + ); + // Manually exit othewise webpack will only print error and happily continue building + process.exit(1); + } + }); + }); + } +} diff --git a/packages/bundler-security/src/constants.js b/packages/bundler-security/src/constants.js new file mode 100644 index 00000000000..6a062b64150 --- /dev/null +++ b/packages/bundler-security/src/constants.js @@ -0,0 +1,11 @@ +const TREZOR_PACKAGES_SCOPES = ['@trezor', '@suite-common', '@suite-native']; +const PACKAGES_PATHS = ['/packages/', '/suite-common/', '/suite-native/']; + +const RELATIVE_PATH_REGEX = new RegExp( + `(?:\\.\\./){2,}(?:${PACKAGES_PATHS.map(p => p.slice(1, -1)).join('|')})`, +); + +module.exports = { + TREZOR_PACKAGES_SCOPES, + RELATIVE_PATH_REGEX, +}; diff --git a/packages/bundler-security/src/index.ts b/packages/bundler-security/src/index.ts new file mode 100644 index 00000000000..d033cd4ec4e --- /dev/null +++ b/packages/bundler-security/src/index.ts @@ -0,0 +1 @@ +export { WebpackSecurityCheckPlugin } from './WebpackSecurityPlugin'; diff --git a/packages/bundler-security/src/metroSecureResolver.js b/packages/bundler-security/src/metroSecureResolver.js new file mode 100644 index 00000000000..7155cdc4362 --- /dev/null +++ b/packages/bundler-security/src/metroSecureResolver.js @@ -0,0 +1,56 @@ +// Metro doesn't support TS and ES modules, so this file must be in plain JS and CommonJS +const path = require('path'); + +const { RELATIVE_PATH_REGEX, TREZOR_PACKAGES_SCOPES } = require('./constants'); + +/** + * @typedef {Object} SecurityCheckParams + * @property {string} moduleName + * @property {string} originModulePath + */ + +/** + * @param {SecurityCheckParams} params + * @returns {{isViolation: boolean, normalizedPath: string}} + */ +const checkSecurityViolation = ({ moduleName, originModulePath }) => { + // Normalize path to remove some crazy relative paths like `../../scripts/..packages/connect` etc. + const normalizedPath = path.normalize(moduleName); + + // Check if file is in node_modules + const isNodeModules = originModulePath.includes('node_modules'); + // prevent standarts imports like `from '@trezor/connect'` or `require('@trezor/connect')` etc. + const isScopedPackage = TREZOR_PACKAGES_SCOPES.some(scope => normalizedPath.includes(scope)); + // 3rd party package can import Connect from node_modules using relative path like `../../../packages/connect` + // check tests for more examples + const isRelativePath = RELATIVE_PATH_REGEX.test(normalizedPath); + + return { + isViolation: (isScopedPackage || isRelativePath) && isNodeModules, + normalizedPath, + }; +}; + +/** + * @param {SecurityCheckParams} params + */ +const metroSecureResolver = ({ moduleName, originModulePath }) => { + const { isViolation, normalizedPath } = checkSecurityViolation({ + moduleName, + originModulePath, + }); + + if (isViolation) { + throw new Error( + `SECURITY ALERT: Some 3rd party package is trying to import internal packages or Connect!\n` + + `Module: ${moduleName}\n` + + `Normalized: ${normalizedPath}\n` + + `From file: ${originModulePath}`, + ); + } +}; + +module.exports = { + checkSecurityViolation, + metroSecureResolver, +}; diff --git a/packages/bundler-security/tests/metroSecureResolver.test.ts b/packages/bundler-security/tests/metroSecureResolver.test.ts new file mode 100644 index 00000000000..bb21a920d5b --- /dev/null +++ b/packages/bundler-security/tests/metroSecureResolver.test.ts @@ -0,0 +1,75 @@ +import { checkSecurityViolation } from '../src/metroSecureResolver'; + +describe('checkSecurityViolation', () => { + it('should detect violation when accessing @trezor scoped package from node_modules', () => { + const result = checkSecurityViolation({ + moduleName: '@trezor/connect', + originModulePath: 'path/to/node_modules/some-package/index.js', + }); + + expect(result.isViolation).toBe(true); + }); + + it('should detect violation when using relative path inside node_modules', () => { + const result = checkSecurityViolation({ + moduleName: '../../@trezor/connect/src/utils', + originModulePath: 'path/to/node_modules/external-lib/index.js', + }); + + expect(result.isViolation).toBe(true); + }); + + it('should detect violation when using relative path inside node_modules 2', () => { + const result = checkSecurityViolation({ + moduleName: '../../../node_modules/@trezor/connect/src/utils', + originModulePath: 'path/to/node_modules/external-lib/index.js', + }); + + expect(result.isViolation).toBe(true); + }); + + it('should detect violation when using relative path from node_modules', () => { + const result = checkSecurityViolation({ + moduleName: '../../../packages/connect/src/utils', + originModulePath: 'path/to/node_modules/external-lib/index.js', + }); + + expect(result.isViolation).toBe(true); + }); + + it('should detect violation when using crazy relative path from node_modules', () => { + const result = checkSecurityViolation({ + moduleName: '../../scripts/../packages/connect/src/utils', + originModulePath: 'path/to/node_modules/external-lib/index.js', + }); + + expect(result.isViolation).toBe(true); + }); + + it('should allow @trezor imports from non-node_modules location', () => { + const result = checkSecurityViolation({ + moduleName: '@trezor/connect', + originModulePath: 'path/to/src/components/index.ts', + }); + + expect(result.isViolation).toBe(false); + }); + + it('should allow relative imports from non-node_modules location', () => { + const result = checkSecurityViolation({ + moduleName: '../utils', + originModulePath: 'path/to/src/components/index.ts', + }); + + expect(result.isViolation).toBe(false); + }); + + it('should allow regular node_modules imports', () => { + const result = checkSecurityViolation({ + moduleName: 'react', + originModulePath: 'path/to/node_modules/some-package/index.js', + }); + + expect(result.isViolation).toBe(false); + }); +}); diff --git a/packages/bundler-security/tsconfig.json b/packages/bundler-security/tsconfig.json new file mode 100644 index 00000000000..c7ebe855e21 --- /dev/null +++ b/packages/bundler-security/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { "outDir": "libDev" }, + "references": [] +} diff --git a/packages/connect-iframe/package.json b/packages/connect-iframe/package.json index 71c8d99a94a..866c4fbb3a5 100644 --- a/packages/connect-iframe/package.json +++ b/packages/connect-iframe/package.json @@ -19,6 +19,7 @@ }, "devDependencies": { "@babel/preset-typescript": "^7.24.7", + "@trezor/bundler-security": "workspace:*", "@trezor/env-utils": "workspace:*", "@trezor/eslint": "workspace:*", "babel-loader": "^9.1.3", diff --git a/packages/connect-iframe/tsconfig.json b/packages/connect-iframe/tsconfig.json index 22e8c593575..2c7782968b9 100644 --- a/packages/connect-iframe/tsconfig.json +++ b/packages/connect-iframe/tsconfig.json @@ -9,6 +9,7 @@ { "path": "../connect" }, { "path": "../connect-analytics" }, { "path": "../connect-common" }, + { "path": "../bundler-security" }, { "path": "../env-utils" }, { "path": "../eslint" } ] diff --git a/packages/connect-iframe/webpack/base.webpack.config.ts b/packages/connect-iframe/webpack/base.webpack.config.ts index eae2fe81280..1d920e48aa0 100644 --- a/packages/connect-iframe/webpack/base.webpack.config.ts +++ b/packages/connect-iframe/webpack/base.webpack.config.ts @@ -3,6 +3,8 @@ import CopyWebpackPlugin from 'copy-webpack-plugin'; import TerserPlugin from 'terser-webpack-plugin'; import webpack from 'webpack'; +import { WebpackSecurityCheckPlugin } from '@trezor/bundler-security'; + import { version } from '../package.json'; import { getDistPathForProject } from './utils'; @@ -106,6 +108,7 @@ export const config: webpack.Configuration = { hints: false, }, plugins: [ + new WebpackSecurityCheckPlugin(), new webpack.DefinePlugin({ 'process.env.IS_CODESIGN_BUILD': `"${process.env.IS_CODESIGN_BUILD === 'true'}"`, // to keep it as string "true"/"false" and not boolean }), diff --git a/packages/suite-build/configs/base.webpack.config.ts b/packages/suite-build/configs/base.webpack.config.ts index 294da11b0ac..6c944965f21 100644 --- a/packages/suite-build/configs/base.webpack.config.ts +++ b/packages/suite-build/configs/base.webpack.config.ts @@ -5,6 +5,8 @@ import webpack from 'webpack'; import { BundleAnalyzerPlugin } from 'webpack-bundle-analyzer'; // Get Suite App version from the Suite package.json +import { WebpackSecurityCheckPlugin } from '@trezor/bundler-security'; + import { suiteVersion } from '../../suite/package.json'; import { assetPrefix, @@ -16,7 +18,6 @@ import { } from '../utils/env'; import { getRevision } from '../utils/git'; import { getPathForProject } from '../utils/path'; - const gitRevision = getRevision(); /** @@ -158,6 +159,7 @@ const config: webpack.Configuration = { ], }, plugins: [ + new WebpackSecurityCheckPlugin(), new webpack.ProgressPlugin(), new webpack.DefinePlugin({ 'process.browser': true, diff --git a/packages/suite-build/package.json b/packages/suite-build/package.json index 610244bb682..22400940492 100644 --- a/packages/suite-build/package.json +++ b/packages/suite-build/package.json @@ -23,6 +23,7 @@ }, "dependencies": { "@suite-common/suite-config": "workspace:*", + "@trezor/bundler-security": "workspace:*", "@trezor/suite": "workspace:*", "babel-loader": "^9.1.3", "babel-plugin-styled-components": "^2.1.4", diff --git a/packages/suite-build/tsconfig.json b/packages/suite-build/tsconfig.json index 137b1d04491..7d7be98b748 100644 --- a/packages/suite-build/tsconfig.json +++ b/packages/suite-build/tsconfig.json @@ -6,6 +6,7 @@ { "path": "../../suite-common/suite-config" }, + { "path": "../bundler-security" }, { "path": "../suite" }, { "path": "../eslint" } ] diff --git a/scripts/generatePackage.js b/scripts/generatePackage.js index bfbfa46c001..074a6a2be7e 100644 --- a/scripts/generatePackage.js +++ b/scripts/generatePackage.js @@ -6,12 +6,8 @@ import sortPackageJson from 'sort-package-json'; import templatePackageJsonWeb from './package-template/package.json'; import templatePackageJsonNative from './package-template-native/package.json'; -// todo: calling yarn generate-package failed on not resolving destructuring imports. default imports seem to work. -import import1 from './utils/getPrettierConfig'; -import import2 from './utils/getWorkspacesList'; - -const { getPrettierConfig } = import1; -const { getWorkspacesList } = import2; +import { getPrettierConfig } from './utils/getPrettierConfig'; +import { getWorkspacesList } from './utils/getWorkspacesList'; const scopes = { '@suite-common': { diff --git a/suite-native/app/metro.config.js b/suite-native/app/metro.config.js index 829ba1796b0..53822442435 100644 --- a/suite-native/app/metro.config.js +++ b/suite-native/app/metro.config.js @@ -2,6 +2,9 @@ const { mergeConfig } = require('@react-native/metro-config'); const { getSentryExpoConfig } = require('@sentry/react-native/metro'); const nodejs = require('node-libs-browser'); + +const { metroSecureResolver } = require('@trezor/bundler-security/src/metroSecureResolver'); + // Learn more https://docs.expo.io/guides/customizing-metro const jsonExpoConfig = getSentryExpoConfig(__dirname); @@ -36,7 +39,12 @@ const config = { }, sourceExts, resolveRequest: (context, moduleName, platform) => { - // index 0 refers to suite-native/app node_modules directory + metroSecureResolver({ + moduleName, + originModulePath: context.originModulePath, + }); + + // web3-validator package handling const rootNodeModulesPath = context.nodeModulesPaths[1]; // web3-validator package is by default trying to use non-existing minified index file. This fixes that. diff --git a/suite-native/app/package.json b/suite-native/app/package.json index 67f0373a2f7..67d8252b45b 100644 --- a/suite-native/app/package.json +++ b/suite-native/app/package.json @@ -79,6 +79,7 @@ "@suite-native/theme": "workspace:*", "@suite-native/toasts": "workspace:*", "@suite-native/transactions": "workspace:*", + "@trezor/bundler-security": "workspace:*", "@trezor/connect": "workspace:*", "@trezor/react-native-usb": "workspace:*", "@trezor/styles": "workspace:*", diff --git a/suite-native/app/tsconfig.json b/suite-native/app/tsconfig.json index ab02e7039d6..63f28407300 100644 --- a/suite-native/app/tsconfig.json +++ b/suite-native/app/tsconfig.json @@ -72,6 +72,9 @@ { "path": "../theme" }, { "path": "../toasts" }, { "path": "../transactions" }, + { + "path": "../../packages/bundler-security" + }, { "path": "../../packages/connect" }, { "path": "../../packages/react-native-usb" diff --git a/suite-native/state/tsconfig.json b/suite-native/state/tsconfig.json index 595e8130dfb..d9354e2608e 100644 --- a/suite-native/state/tsconfig.json +++ b/suite-native/state/tsconfig.json @@ -22,9 +22,7 @@ { "path": "../../suite-common/token-definitions" }, - { - "path": "../../suite-common/trading" - }, + { "path": "../../suite-common/trading" }, { "path": "../../suite-common/wallet-core" }, diff --git a/yarn.lock b/yarn.lock index 973ddacb7e1..814a426f390 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9984,6 +9984,7 @@ __metadata: "@suite-native/theme": "workspace:*" "@suite-native/toasts": "workspace:*" "@suite-native/transactions": "workspace:*" + "@trezor/bundler-security": "workspace:*" "@trezor/connect": "workspace:*" "@trezor/connect-mobile": "workspace:^" "@trezor/react-native-usb": "workspace:*" @@ -11611,6 +11612,14 @@ __metadata: languageName: unknown linkType: soft +"@trezor/bundler-security@workspace:*, @trezor/bundler-security@workspace:packages/bundler-security": + version: 0.0.0-use.local + resolution: "@trezor/bundler-security@workspace:packages/bundler-security" + dependencies: + webpack: "npm:>=5.0.0" + languageName: unknown + linkType: soft + "@trezor/coinjoin@workspace:*, @trezor/coinjoin@workspace:packages/coinjoin": version: 0.0.0-use.local resolution: "@trezor/coinjoin@workspace:packages/coinjoin" @@ -11811,6 +11820,7 @@ __metadata: resolution: "@trezor/connect-iframe@workspace:packages/connect-iframe" dependencies: "@babel/preset-typescript": "npm:^7.24.7" + "@trezor/bundler-security": "workspace:*" "@trezor/connect": "workspace:*" "@trezor/connect-analytics": "workspace:*" "@trezor/connect-common": "workspace:*" @@ -12317,6 +12327,7 @@ __metadata: "@pmmmwh/react-refresh-webpack-plugin": "npm:^0.5.11" "@sentry/webpack-plugin": "npm:^2.22.7" "@suite-common/suite-config": "workspace:*" + "@trezor/bundler-security": "workspace:*" "@trezor/eslint": "workspace:*" "@trezor/suite": "workspace:*" "@types/copy-webpack-plugin": "npm:^10.1.0" @@ -38559,7 +38570,7 @@ __metadata: languageName: node linkType: hard -"schema-utils@npm:^3.0.0, schema-utils@npm:^3.1.1, schema-utils@npm:^3.2.0": +"schema-utils@npm:^3.0.0, schema-utils@npm:^3.1.1": version: 3.3.0 resolution: "schema-utils@npm:3.3.0" dependencies: @@ -40822,7 +40833,7 @@ __metadata: languageName: node linkType: hard -"terser-webpack-plugin@npm:^5.3.1, terser-webpack-plugin@npm:^5.3.10, terser-webpack-plugin@npm:^5.3.11": +"terser-webpack-plugin@npm:^5.3.1, terser-webpack-plugin@npm:^5.3.11": version: 5.3.11 resolution: "terser-webpack-plugin@npm:5.3.11" dependencies: @@ -43936,9 +43947,9 @@ __metadata: languageName: node linkType: hard -"webpack@npm:5, webpack@npm:^5, webpack@npm:^5.97.1": - version: 5.97.1 - resolution: "webpack@npm:5.97.1" +"webpack@npm:5, webpack@npm:>=5.0.0, webpack@npm:^5, webpack@npm:^5.97.1": + version: 5.98.0 + resolution: "webpack@npm:5.98.0" dependencies: "@types/eslint-scope": "npm:^3.7.7" "@types/estree": "npm:^1.0.6" @@ -43958,9 +43969,9 @@ __metadata: loader-runner: "npm:^4.2.0" mime-types: "npm:^2.1.27" neo-async: "npm:^2.6.2" - schema-utils: "npm:^3.2.0" + schema-utils: "npm:^4.3.0" tapable: "npm:^2.1.1" - terser-webpack-plugin: "npm:^5.3.10" + terser-webpack-plugin: "npm:^5.3.11" watchpack: "npm:^2.4.1" webpack-sources: "npm:^3.2.3" peerDependenciesMeta: @@ -43968,7 +43979,7 @@ __metadata: optional: true bin: webpack: bin/webpack.js - checksum: 10/665bd3b8c84b20f0b1f250159865e4d3e9b76c682030313d49124d5f8e96357ccdcc799dd9fe0ebf010fdb33dbc59d9863d79676a308e868e360ac98f7c09987 + checksum: 10/eb16a58b3eb02bfb538c7716e28d7f601a03922e975c74007b41ba5926071ae70302d9acae9800fbd7ddd0c66a675b1069fc6ebb88123b87895a52882e2dc06a languageName: node linkType: hard