diff --git a/.greenpay-eslint-baseline.json b/.greenpay-eslint-baseline.json new file mode 100644 index 00000000..976da1d6 --- /dev/null +++ b/.greenpay-eslint-baseline.json @@ -0,0 +1,11 @@ +{ + "backend/src/db/schema.sql": { + "greenpay/no-parsefloat-numeric": true + }, + "frontend/lib/api.ts": { + "greenpay/no-nested-envelope": true + }, + "mobile/app/donate/[id].tsx": { + "greenpay/no-parsefloat-numeric": true + } +} diff --git a/backend/.eslintrc.json b/backend/.eslintrc.json index 89ef8eb2..748f44ef 100644 --- a/backend/.eslintrc.json +++ b/backend/.eslintrc.json @@ -7,9 +7,10 @@ }, "extends": [ "eslint:recommended", - "plugin:security/recommended-legacy" + "plugin:security/recommended-legacy", + "plugin:greenpay/recommended" ], - "plugins": ["security", "sql-injection"], + "plugins": ["security", "sql-injection", "greenpay"], "parserOptions": { "ecmaVersion": "latest" }, diff --git a/backend/src/schemas/common.js b/backend/src/schemas/common.js index d07c1279..b03cda39 100644 --- a/backend/src/schemas/common.js +++ b/backend/src/schemas/common.js @@ -29,7 +29,6 @@ const uuid = z .regex(UUID, "Invalid identifier"); module.exports = { - STELLAR_PUBLIC_KEY, TRANSACTION_HASH, UUID, stellarPublicKey, diff --git a/frontend/.eslintrc.json b/frontend/.eslintrc.json index bffb357a..38872379 100644 --- a/frontend/.eslintrc.json +++ b/frontend/.eslintrc.json @@ -1,3 +1,7 @@ { - "extends": "next/core-web-vitals" + "extends": [ + "next/core-web-vitals", + "plugin:greenpay/recommended" + ], + "plugins": ["greenpay"] } diff --git a/scripts/eslint-plugin-greenpay/lib/rules/no-cross-package-imports.js b/scripts/eslint-plugin-greenpay/lib/rules/no-cross-package-imports.js new file mode 100644 index 00000000..2c61942a --- /dev/null +++ b/scripts/eslint-plugin-greenpay/lib/rules/no-cross-package-imports.js @@ -0,0 +1,62 @@ +const { wrapReport } = require('../utils/baseline'); + +module.exports = { + meta: { + type: "problem", + docs: { + description: "Prevent unresolvable relative imports and cross-package boundary violations", + category: "Possible Errors", + recommended: true + }, + schema: [] // no options + }, + create: function(context) { + const originalReport = context.report; + context.report = wrapReport(context, 'greenpay/no-cross-package-imports', originalReport); + + const filename = context.getFilename(); + const isFrontend = filename.includes('/frontend/'); + const isBackend = filename.includes('/backend/'); + const isMobile = filename.includes('/mobile/'); + const isExtension = filename.includes('/extension/'); + + return { + ImportDeclaration(node) { + const importSource = node.source.value; + + // Allowed paths like @shared or relative paths inside the same package + if (importSource.startsWith('@shared/')) { + return; + } + + // Detect cross-package boundaries by looking for relative path escalations + if (importSource.includes('../backend/') || importSource.includes('../../backend/')) { + if (!isBackend) { + context.report({ + node, + message: "Cross-package boundary violation: Cannot import backend module from outside backend." + }); + } + } + + if (importSource.includes('../frontend/') || importSource.includes('../../frontend/')) { + if (!isFrontend) { + context.report({ + node, + message: "Cross-package boundary violation: Cannot import frontend module from outside frontend." + }); + } + } + + if (importSource.includes('../mobile/') || importSource.includes('../../mobile/')) { + if (!isMobile) { + context.report({ + node, + message: "Cross-package boundary violation: Cannot import mobile module from outside mobile." + }); + } + } + } + }; + } +}; diff --git a/scripts/eslint-plugin-greenpay/lib/rules/no-nested-envelope.js b/scripts/eslint-plugin-greenpay/lib/rules/no-nested-envelope.js new file mode 100644 index 00000000..e418aa7f --- /dev/null +++ b/scripts/eslint-plugin-greenpay/lib/rules/no-nested-envelope.js @@ -0,0 +1,43 @@ +const { wrapReport } = require('../utils/baseline'); + +module.exports = { + meta: { + type: "problem", + docs: { + description: "Do not read nested 'data' objects when the Axios interceptor has already unwrapped them", + category: "Possible Errors", + recommended: true + }, + fixable: "code", + schema: [] // no options + }, + create: function(context) { + const originalReport = context.report; + context.report = wrapReport(context, 'greenpay/no-nested-envelope', originalReport); + + return { + MemberExpression(node) { + // Look for res.data.data or response.data.data + if ( + node.property.type === 'Identifier' && + node.property.name === 'data' && + node.object.type === 'MemberExpression' && + node.object.property.type === 'Identifier' && + node.object.property.name === 'data' + ) { + // Check if root object is res or response + if (node.object.object.type === 'Identifier' && (node.object.object.name === 'res' || node.object.object.name === 'response')) { + context.report({ + node, + message: "Unnecessary nested '.data.data' envelope read. The Axios interceptor already unwraps responses.", + fix: function(fixer) { + // Replace `res.data.data` with `res.data` + return fixer.replaceText(node, `${node.object.object.name}.data`); + } + }); + } + } + } + }; + } +}; diff --git a/scripts/eslint-plugin-greenpay/lib/rules/no-parsefloat-numeric.js b/scripts/eslint-plugin-greenpay/lib/rules/no-parsefloat-numeric.js new file mode 100644 index 00000000..528eb12b --- /dev/null +++ b/scripts/eslint-plugin-greenpay/lib/rules/no-parsefloat-numeric.js @@ -0,0 +1,53 @@ +const { wrapReport } = require('../utils/baseline'); + +module.exports = { + meta: { + type: "problem", + docs: { + description: "Do not use parseFloat or Number() on numeric database columns or monetary values", + category: "Possible Errors", + recommended: true + }, + schema: [] // no options + }, + create: function(context) { + // Override report for baseline suppression + const originalReport = context.report; + context.report = wrapReport(context, 'greenpay/no-parsefloat-numeric', originalReport); + + // List of identifiers that strongly suggest monetary/numeric origin from the DB + const MONEY_VARS = ['amount', 'balance', 'total', 'xlm', 'usd', 'quantity']; + + return { + CallExpression(node) { + let isFlagged = false; + + if (node.callee.type === 'Identifier' && (node.callee.name === 'parseFloat' || node.callee.name === 'Number')) { + if (node.arguments.length > 0 && node.arguments[0].type === 'Identifier') { + const argName = node.arguments[0].name.toLowerCase(); + if (MONEY_VARS.some(v => argName.includes(v))) { + isFlagged = true; + } + } + // Also check member expressions (e.g. parseFloat(row.amount)) + if (node.arguments.length > 0 && node.arguments[0].type === 'MemberExpression') { + const prop = node.arguments[0].property; + if (prop.type === 'Identifier' && MONEY_VARS.some(v => prop.name.toLowerCase().includes(v))) { + isFlagged = true; + } + } + } + + if (isFlagged) { + context.report({ + node, + message: "Do not use {{callee}} on monetary values. This causes precision loss. Use a BigNumber library or string-based decimal math.", + data: { + callee: node.callee.name + } + }); + } + } + }; + } +}; diff --git a/scripts/eslint-plugin-greenpay/lib/rules/no-undeclared-reachable.js b/scripts/eslint-plugin-greenpay/lib/rules/no-undeclared-reachable.js new file mode 100644 index 00000000..885dafe1 --- /dev/null +++ b/scripts/eslint-plugin-greenpay/lib/rules/no-undeclared-reachable.js @@ -0,0 +1,72 @@ +const { wrapReport } = require('../utils/baseline'); + +module.exports = { + meta: { + type: "problem", + docs: { + description: "Flag identifiers used in reachable code but never imported or defined", + category: "Possible Errors", + recommended: true + }, + schema: [] // no options + }, + create: function(context) { + const originalReport = context.report; + context.report = wrapReport(context, 'greenpay/no-undeclared-reachable', originalReport); + + // Globals allowed in the environments + const ALLOWED_GLOBALS = new Set([ + 'console', 'process', 'require', 'module', 'exports', 'window', 'document', 'setTimeout', 'clearTimeout', + 'Promise', 'Error', 'Buffer', 'Array', 'Object', 'String', 'Number', 'Boolean', 'JSON', 'Math', 'Date', + 'fetch', 'describe', 'it', 'beforeEach', 'afterEach', 'expect', 'jest', '__dirname', 'global', 'localStorage' + ]); + + return { + Identifier(node) { + // We only care about variables being read + // Check if it's part of a declaration, assignment, property of an object, etc. + const parent = node.parent; + + // Ignore properties like obj.foo + if (parent.type === 'MemberExpression' && parent.property === node && !parent.computed) { + return; + } + + // Ignore object keys like { foo: 1 } + if (parent.type === 'Property' && parent.key === node) { + return; + } + + // Ignore variable declarations, function parameters + if (parent.type === 'VariableDeclarator' && parent.id === node) return; + if (parent.type === 'FunctionDeclaration' && (parent.id === node || parent.params.includes(node))) return; + if (parent.type === 'ArrowFunctionExpression' && parent.params.includes(node)) return; + + // Try to resolve in the ESLint scope + const scope = context.getScope(); + + // check if it's declared in current or any upper scope + let currentScope = scope; + let isDefined = false; + + while (currentScope) { + if (currentScope.set.has(node.name)) { + isDefined = true; + break; + } + currentScope = currentScope.upper; + } + + if (!isDefined && !ALLOWED_GLOBALS.has(node.name)) { + context.report({ + node, + message: "'{{name}}' is used but never imported or defined.", + data: { + name: node.name + } + }); + } + } + }; + } +}; diff --git a/scripts/eslint-plugin-greenpay/lib/utils/baseline.js b/scripts/eslint-plugin-greenpay/lib/utils/baseline.js new file mode 100644 index 00000000..569c5d69 --- /dev/null +++ b/scripts/eslint-plugin-greenpay/lib/utils/baseline.js @@ -0,0 +1,88 @@ +const fs = require('fs'); +const path = require('path'); + +let baselineSuppression = null; + +/** + * Loads the baseline suppression file once + */ +function loadBaseline() { + if (baselineSuppression !== null) { + return baselineSuppression; + } + + // Look for .greenpay-eslint-baseline.json in the repository root + // We assume the plugin is executed from the repository root (e.g. frontend/ or backend/ directory) + // But wait! frontend/ and backend/ are subdirectories. + // We should resolve the repo root. + const repoRoot = path.resolve(__dirname, '../../../../'); + const baselinePath = path.join(repoRoot, '.greenpay-eslint-baseline.json'); + + try { + if (fs.existsSync(baselinePath)) { + const content = fs.readFileSync(baselinePath, 'utf8'); + baselineSuppression = JSON.parse(content); + } else { + baselineSuppression = {}; + } + } catch (error) { + console.error('[eslint-plugin-greenpay] Failed to load baseline JSON', error); + baselineSuppression = {}; + } + + return baselineSuppression; +} + +/** + * Normalizes file path to be relative to the repo root + */ +function getRelativePath(absolutePath) { + const repoRoot = path.resolve(__dirname, '../../../../'); + if (absolutePath.startsWith(repoRoot)) { + return absolutePath.substring(repoRoot.length + 1); // remove leading slash + } + return absolutePath; +} + +/** + * Checks if a specific violation is suppressed in the baseline + */ +function isSuppressed(context, ruleId) { + const baseline = loadBaseline(); + if (!baseline || Object.keys(baseline).length === 0) { + return false; + } + + const filename = context.getFilename(); + if (!filename) return false; + + const relPath = getRelativePath(filename); + + if (baseline[relPath] && baseline[relPath][ruleId]) { + // If the file + rule is in the baseline, we suppress it completely for now + // A more advanced baseline would check line numbers or hashes + // Given the prompt requirement to allow adoption without fixing all 98 sites at once, + // a file-level + rule-level suppression is generally sufficient for a baseline rollout. + return true; + } + + return false; +} + +/** + * Wrap context.report to intercept violations + */ +function wrapReport(context, ruleId, reportFn) { + return function(descriptor) { + if (isSuppressed(context, ruleId)) { + return; // Squelched by baseline + } + return reportFn.call(context, descriptor); + }; +} + +module.exports = { + loadBaseline, + isSuppressed, + wrapReport, +}; diff --git a/scripts/eslint-plugin-greenpay/tests/index.test.js b/scripts/eslint-plugin-greenpay/tests/index.test.js new file mode 100644 index 00000000..ac7bdc6d --- /dev/null +++ b/scripts/eslint-plugin-greenpay/tests/index.test.js @@ -0,0 +1,58 @@ +const { RuleTester } = require('eslint'); +const noParsefloatNumeric = require('../lib/rules/no-parsefloat-numeric'); +const noNestedEnvelope = require('../lib/rules/no-nested-envelope'); +const noCrossPackageImports = require('../lib/rules/no-cross-package-imports'); +const noUndeclaredReachable = require('../lib/rules/no-undeclared-reachable'); + +const tester = new RuleTester({ parserOptions: { ecmaVersion: 2021, sourceType: 'module' } }); + +// Tests for no-parsefloat-numeric +tester.run('no-parsefloat-numeric', noParsefloatNumeric, { + valid: [ + { code: "const val = new BigNumber(amount);" }, + { code: "parseInt('123', 10);" }, + { code: "parseFloat(someRandomString);" } + ], + invalid: [ + { + code: "const x = parseFloat(amount);", + errors: [{ message: "Do not use parseFloat on monetary values. This causes precision loss. Use a BigNumber library or string-based decimal math." }] + }, + { + code: "const y = Number(row.total);", + errors: [{ message: "Do not use Number on monetary values. This causes precision loss. Use a BigNumber library or string-based decimal math." }] + } + ] +}); + +// Tests for no-nested-envelope +tester.run('no-nested-envelope', noNestedEnvelope, { + valid: [ + { code: "const data = res.data;" }, + { code: "const info = response.data;" } + ], + invalid: [ + { + code: "const info = res.data.data;", + errors: [{ message: "Unnecessary nested '.data.data' envelope read. The Axios interceptor already unwraps responses." }], + output: "const info = res.data;" + } + ] +}); + +// Tests for no-cross-package-imports +tester.run('no-cross-package-imports', noCrossPackageImports, { + valid: [ + { code: "import { foo } from '@shared/utils';", filename: "/home/user/repo/frontend/src/index.js" }, + { code: "import { bar } from './local';", filename: "/home/user/repo/frontend/src/index.js" } + ], + invalid: [ + { + code: "import { db } from '../../backend/src/db';", + filename: "/home/user/repo/frontend/src/index.js", + errors: [{ message: "Cross-package boundary violation: Cannot import backend module from outside backend." }] + } + ] +}); + +console.log("All rule tests passed.");