Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .greenpay-eslint-baseline.json
Original file line number Diff line number Diff line change
@@ -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
}
}
5 changes: 3 additions & 2 deletions backend/.eslintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
1 change: 0 additions & 1 deletion backend/src/schemas/common.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,6 @@ const uuid = z
.regex(UUID, "Invalid identifier");

module.exports = {
STELLAR_PUBLIC_KEY,
TRANSACTION_HASH,
UUID,
stellarPublicKey,
Expand Down
6 changes: 5 additions & 1 deletion frontend/.eslintrc.json
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
{
"extends": "next/core-web-vitals"
"extends": [
"next/core-web-vitals",
"plugin:greenpay/recommended"
],
"plugins": ["greenpay"]
}
Original file line number Diff line number Diff line change
@@ -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."
});
}
}
}
};
}
};
43 changes: 43 additions & 0 deletions scripts/eslint-plugin-greenpay/lib/rules/no-nested-envelope.js
Original file line number Diff line number Diff line change
@@ -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`);
}
});
}
}
}
};
}
};
53 changes: 53 additions & 0 deletions scripts/eslint-plugin-greenpay/lib/rules/no-parsefloat-numeric.js
Original file line number Diff line number Diff line change
@@ -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
}
});
}
}
};
}
};
Original file line number Diff line number Diff line change
@@ -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
}
});
}
}
};
}
};
88 changes: 88 additions & 0 deletions scripts/eslint-plugin-greenpay/lib/utils/baseline.js
Original file line number Diff line number Diff line change
@@ -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,
};
Loading
Loading