diff --git a/.eslintrc.js b/.eslintrc.js index 9b7b45657..cf86c5980 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -1,6 +1,6 @@ module.exports = { parser: '@typescript-eslint/parser', - plugins: ['lodash'], + plugins: ['lodash', 'local-rules'], extends: [ '@atixlabs/eslint-config/configurations/react', 'plugin:@typescript-eslint/recommended', @@ -33,7 +33,9 @@ module.exports = { '@typescript-eslint/no-explicit-any': ['error'], 'no-console': ['error', { allow: ['warn', 'error', 'info', 'debug'] }], 'lodash/import-scope': ['error', 'method'], - 'promise/avoid-new': 'off' + 'promise/avoid-new': 'off', + // detect + prevent usage of sensitive props from useSecrets being memoized + 'local-rules/prevent-memoization-of-sensitive-values': 'error' }, overrides: [ { diff --git a/eslint-local-rules/index.js b/eslint-local-rules/index.js new file mode 100644 index 000000000..5ee26cc56 --- /dev/null +++ b/eslint-local-rules/index.js @@ -0,0 +1,3 @@ +module.exports = { + 'prevent-memoization-of-sensitive-values': require('./prevent-memoization-of-sensitive-values') +}; diff --git a/eslint-local-rules/prevent-memoization-of-sensitive-values.js b/eslint-local-rules/prevent-memoization-of-sensitive-values.js new file mode 100644 index 000000000..1c4e534b5 --- /dev/null +++ b/eslint-local-rules/prevent-memoization-of-sensitive-values.js @@ -0,0 +1,61 @@ +module.exports = { + meta: { + type: 'problem', + docs: { + description: 'Disallow sensitive values from useSecrets in useCallback or useMemo dependencies', + recommended: false + }, + messages: { + disallowedDependency: "Avoid using '{{value}}' from useSecrets in {{type}} dependencies." + }, + schema: [] // No options needed for this rule + }, + + create(context) { + const sensitiveProperties = ['password', 'passwordConfirmation', 'passwordRepeat']; + + let declaredSecrets = new Set(); + + return { + VariableDeclarator(node) { + // Detect `const { password, passwordConfirmation } = useSecrets();` + if ( + node.init && + node.init.type === 'CallExpression' && + node.init.callee.name === 'useSecrets' && + node.id.type === 'ObjectPattern' + ) { + node.id.properties.forEach((property) => { + if (property.type === 'Property' && sensitiveProperties.includes(property.key.name)) { + declaredSecrets.add(property.key.name); + } + }); + } + }, + + CallExpression(node) { + // Detect calls to `useCallback` or `useMemo` + if (node.callee.name === 'useCallback' || node.callee.name === 'useMemo') { + const dependencies = node.arguments[1]; + + if (dependencies && dependencies.type === 'ArrayExpression') { + dependencies.elements.forEach((element) => { + if (element && element.type === 'Identifier' && declaredSecrets.has(element.name)) { + context.report({ + node: element, + messageId: 'disallowedDependency', + data: { value: element.name, type: node.callee.name } + }); + } + }); + } + } + }, + + 'Program:exit'() { + // Clean up the declaredSecrets set at the end of the program + declaredSecrets.clear(); + } + }; + } +}; diff --git a/package.json b/package.json index 062dad8fc..c7e966d03 100644 --- a/package.json +++ b/package.json @@ -143,6 +143,7 @@ "eslint-plugin-filenames": "^1.3.2", "eslint-plugin-import": "^2.23.4", "eslint-plugin-jest": "^24.4.0", + "eslint-plugin-local-rules": "^3.0.2", "eslint-plugin-lodash": "^7.4.0", "eslint-plugin-prettier": "^4.0.0", "eslint-plugin-promise": "^5.1.0", diff --git a/packages/e2e-tests/src/features/WalletAccountsExtended.feature b/packages/e2e-tests/src/features/WalletAccountsExtended.feature index c01b4c3e5..9e8c19a42 100644 --- a/packages/e2e-tests/src/features/WalletAccountsExtended.feature +++ b/packages/e2e-tests/src/features/WalletAccountsExtended.feature @@ -63,6 +63,7 @@ Feature: Wallet accounts And I click "Confirm" button on the account unlock drawer Then I see "Account #3 activated" toast And I wait for main loader to disappear + And valid password is not in snapshot And I do not see account unlock drawer with all elements in extended mode When I click "Receive" button on page header Then I see "Wallet Address" page in extended mode for account: 3 and wallet "MultiAccActive1" diff --git a/packages/e2e-tests/src/steps/commonSteps.ts b/packages/e2e-tests/src/steps/commonSteps.ts index 3f82f960f..a4a994f11 100755 --- a/packages/e2e-tests/src/steps/commonSteps.ts +++ b/packages/e2e-tests/src/steps/commonSteps.ts @@ -51,6 +51,7 @@ import { import MainLoader from '../elements/MainLoader'; import Modal from '../elements/modal'; import { setCameraAccessPermission } from '../utils/browserPermissionsUtils'; +import { findNeedleInJSONKeyOrValue } from '../utils/textUtils'; Given(/^Lace is ready for test$/, async () => { await MainLoader.waitUntilLoaderDisappears(); @@ -431,3 +432,27 @@ When( await browser.refresh(); } ); + +Then( + /(invalid|valid|N_8J@bne87A) password is not in snapshot/, + async (password: 'invalid' | 'valid' | 'N_8J@bne87A') => { + await browser.cdp('HeapProfiler', 'collectGarbage'); + const snapshot = await browser.takeHeapSnapshot(); + + let needle = ''; + switch (password) { + case 'valid': + needle = String(getTestWallet(TestWalletName.MultiAccActive1).password); + break; + case 'invalid': + needle = 'somePassword'; + break; + case 'N_8J@bne87A': + needle = 'N_8J@bne87A'; + break; + } + const needlesFound = findNeedleInJSONKeyOrValue(snapshot, needle); + + expect(needlesFound.length).toEqual(0); + } +); diff --git a/packages/e2e-tests/src/utils/textUtils.ts b/packages/e2e-tests/src/utils/textUtils.ts index 61666c12a..0c1a942a8 100644 --- a/packages/e2e-tests/src/utils/textUtils.ts +++ b/packages/e2e-tests/src/utils/textUtils.ts @@ -7,3 +7,55 @@ export const generateRandomString = async (length: number): Promise => .randomBytes(Math.ceil(length / 2)) .toString('hex') .slice(0, length); + +export type JSONValue = string | number | boolean | null | undefined | JSONValue[] | { [key: string]: JSONValue }; + +interface Match { + path: string; // Path to the matched key or value + key?: string; // The key where the match occurred + value?: JSONValue; // The value where the match occurred +} + +/** + * Recursively searches for a specific needle in the keys or values of a JSON structure. + * @param data - The JSON structure to search. + * @param searchString - The string to search for. + * @param currentPath - (Internal) The current path in the structure during recursion. + * @returns An array of matches, including the path and matched key/value. + */ +export const findNeedleInJSONKeyOrValue = ( + data: JSONValue, + searchString: string, + currentPath: string[] = [] +): Match[] => { + const matches: Match[] = []; + + const traverse = (value: JSONValue, path: string[]) => { + if (value && typeof value === 'object') { + if (Array.isArray(value)) { + // Handle arrays + value.forEach((item, index) => { + traverse(item, [...path, index.toString()]); + }); + } else { + // Handle objects + Object.entries(value).forEach(([key, val]) => { + const keyLower = key.toLowerCase(); + if (keyLower.includes(searchString)) { + matches.push({ path: [...path, key].join('.'), key }); + } + if (typeof val === 'string' && val.toLowerCase().includes(searchString)) { + matches.push({ + path: [...path, key].join('.'), + value: val + }); + } + traverse(val, [...path, key]); + }); + } + } + }; + + traverse(data, currentPath); + return matches; +}; diff --git a/yarn.lock b/yarn.lock index f83ee8034..79673882a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -35984,6 +35984,13 @@ __metadata: languageName: node linkType: hard +"eslint-plugin-local-rules@npm:^3.0.2": + version: 3.0.2 + resolution: "eslint-plugin-local-rules@npm:3.0.2" + checksum: ad883be0739f022bcd0f49de45354c792bc2ef23ea0030789c3f90b10288b4346ab509fbfd451e87282c0f92393251e51a5a69a2f3ba3865a1a1f780cebef7cb + languageName: node + linkType: hard + "eslint-plugin-lodash@npm:^7.4.0": version: 7.4.0 resolution: "eslint-plugin-lodash@npm:7.4.0" @@ -43927,6 +43934,7 @@ __metadata: eslint-plugin-filenames: ^1.3.2 eslint-plugin-import: ^2.23.4 eslint-plugin-jest: ^24.4.0 + eslint-plugin-local-rules: ^3.0.2 eslint-plugin-lodash: ^7.4.0 eslint-plugin-prettier: ^4.0.0 eslint-plugin-promise: ^5.1.0