commit 857c7cf90d30a3b0bcb8647d1eb442d895804514 Author: Sefinek Date: Thu Aug 15 10:31:55 2024 +0200 Main commit diff --git a/.env.default b/.env.default new file mode 100644 index 0000000..4150b69 --- /dev/null +++ b/.env.default @@ -0,0 +1,6 @@ +NODE_ENV=production + +CLOUDFLARE_ZONE_ID= +CLOUDFLARE_EMAIL= +CLOUDFLARE_API_KEY= +ABUSEIPDB_API_KEY= \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ec7dd75 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.idea +node_modules +.env +reported_ips.csv \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..bf6367f --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Sefinek + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/ecosystem.config.js b/ecosystem.config.js new file mode 100644 index 0000000..8907d3c --- /dev/null +++ b/ecosystem.config.js @@ -0,0 +1,33 @@ +module.exports = { + apps: [{ + name: 'waf-abuseipdb', + script: './index.js', + + // Configuration options + exec_mode: 'fork', + max_memory_restart: '500M', + + // Monitoring changes in files and restarting the application + watch: false, + ignore_watch: ['.git', 'node_modules', 'logs', 'eslint.config.mjs', 'ecosystem.config.js'], + + // Logging settings + log_date_format: 'HH:mm:ss.SSS DD.MM.YYYY', + merge_logs: true, + log_file: '/home/sefinek/logs/other/waf-abuseipdb/combined.log', + out_file: '/home/sefinek/logs/other/waf-abuseipdb/out.log', + error_file: '/home/sefinek/logs/other/waf-abuseipdb/error.log', + + // Application restart policy + wait_ready: true, + autorestart: true, + max_restarts: 4, + restart_delay: 4000, + min_uptime: 3000, + + // Environment variables + env: { + NODE_ENV: 'production' + } + }] +}; \ No newline at end of file diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 0000000..3061352 --- /dev/null +++ b/eslint.config.mjs @@ -0,0 +1,57 @@ +import js from '@eslint/js'; +import globals from 'globals'; + +// noinspection JSUnusedGlobalSymbols +export default [ + js.configs.recommended, + { + languageOptions: { + ecmaVersion: 2024, + globals: { + ...globals.node, + ...globals.es2024, + ...globals.browser, + } + }, + rules: { + 'arrow-spacing': ['warn', { before: true, after: true }], + 'comma-dangle': ['error'], + 'comma-spacing': 'error', + 'comma-style': 'error', + 'curly': ['error', 'multi-line', 'consistent'], + 'dot-location': ['error', 'property'], + 'handle-callback-err': 'off', + 'indent': ['warn', 'tab'], + 'keyword-spacing': 'warn', + 'max-nested-callbacks': ['error', { max: 4 }], + 'max-statements-per-line': ['error', { max: 2 }], + 'no-console': 'off', + 'no-empty': 'warn', + 'no-empty-function': 'error', + 'no-floating-decimal': 'error', + 'no-lonely-if': 'error', + 'no-multi-spaces': 'warn', + 'no-multiple-empty-lines': ['warn', { max: 4, maxEOF: 1, maxBOF: 0 }], + 'no-shadow': ['error', { allow: ['err', 'resolve', 'reject'] }], + 'no-trailing-spaces': ['warn'], + 'no-unreachable': 'warn', + 'no-unused-vars': 'warn', + 'no-use-before-define': ['error', { functions: false, classes: true }], + 'no-var': 'error', + 'object-curly-spacing': ['error', 'always'], + 'prefer-const': 'error', + 'quotes': ['warn', 'single'], + 'semi': ['warn', 'always'], + 'sort-vars': 'warn', + 'space-before-blocks': 'error', + 'space-before-function-paren': ['error', { anonymous: 'never', named: 'never', asyncArrow: 'always' }], + 'space-in-parens': 'error', + 'space-infix-ops': 'error', + 'space-unary-ops': 'error', + 'spaced-comment': 'warn', + 'wrap-regex': 'error', + 'yoda': 'error' + }, + ignores: ['node_modules', 'public/js/bootstrap.bundle.min.js'] + } +]; \ No newline at end of file diff --git a/index.js b/index.js new file mode 100644 index 0000000..433a428 --- /dev/null +++ b/index.js @@ -0,0 +1,232 @@ +require('dotenv').config(); +const axios = require('axios'); +const fs = require('fs'); +const path = require('path'); + +const { + CLOUDFLARE_ZONE_ID, + CLOUDFLARE_EMAIL, + CLOUDFLARE_API_KEY, + ABUSEIPDB_API_KEY, + NODE_ENV +} = process.env; + +const CSV_FILE_PATH = path.join(__dirname, 'reported_ips.csv'); +const TIME_WINDOW_MS = 20 * 60 * 1000; +const COOLDOWN_MS = 1000; +const BLOCK_TIME_MS = 5 * 60 * 60 * 1000; // 5h + +if (!fs.existsSync(CSV_FILE_PATH)) { + fs.writeFileSync(CSV_FILE_PATH, 'Timestamp,RayID,IP,Endpoint,Action,Country\n'); +} + +const PAYLOAD = { + query: `query ListFirewallEvents($zoneTag: string, $filter: FirewallEventsAdaptiveFilter_InputObject) { + viewer { + zones(filter: { zoneTag: $zoneTag }) { + firewallEventsAdaptive( + filter: $filter + limit: 1000 + orderBy: [datetime_DESC] + ) { + action + clientASNDescription + clientAsn + clientCountryName + clientIP + clientRequestHTTPHost + clientRequestHTTPMethodName + clientRequestHTTPProtocol + clientRequestPath + clientRequestQuery + datetime + rayName + ruleId + source + userAgent + } + } + } + }`, + variables: { + zoneTag: CLOUDFLARE_ZONE_ID, + filter: { + datetime_geq: new Date(Date.now() - (60 * 60 * 10.5 * 1000)).toISOString(), + datetime_leq: new Date(Date.now() - (60 * 60 * 8 * 1000)).toISOString(), + AND: [ + { action_neq: 'allow' }, + { action_neq: 'skip' }, + { action_neq: 'challenge_solved' }, + { action_neq: 'challenge_failed' }, + { action_neq: 'challenge_bypassed' }, + { action_neq: 'jschallenge_solved' }, + { action_neq: 'jschallenge_failed' }, + { action_neq: 'jschallenge_bypassed' }, + { action_neq: 'managed_challenge_skipped' }, + { action_neq: 'managed_challenge_non_interactive_solved' }, + { action_neq: 'managed_challenge_interactive_solved' }, + { action_neq: 'managed_challenge_bypassed' } + ] + } + } +}; + +const headers = { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${CLOUDFLARE_API_KEY}`, + 'X-Auth-Email': CLOUDFLARE_EMAIL +}; + +const imageExtensions = ['.png', '.jpg', '.jpeg', '.gif', '.bmp', '.svg', '.webp']; + +const isImageRequest = loc => imageExtensions.some(ext => loc.toLowerCase().endsWith(ext)); + +const logToCSV = (timestamp, rayid, ip, endpoint, action, country) => { + const logLine = `${timestamp.toISOString()},${rayid},${ip},${endpoint},${action},${country}\n`; + fs.appendFileSync(CSV_FILE_PATH, logLine); +}; + +const logMessage = (level, message) => { + const logLevels = { + info: '[INFO]', + warn: '[WARN]', + error: '[FAIL]' + }; + + const timestamp = NODE_ENV === 'development' ? `${new Date().toISOString()}: ` : ''; + console[level](`${logLevels[level]} ${timestamp}${message}`); +}; + +const getBlockedIP = async () => { + try { + const { data } = await axios.post('https://api.cloudflare.com/client/v4/graphql/', PAYLOAD, { headers }); + logMessage('info', `Fetched ${data.data.viewer.zones[0].firewallEventsAdaptive.length} events from Cloudflare`); + return data; + } catch (err) { + if (err.response) { + logMessage('error', `HTTP Error: ${err.response.status}. Data: ${JSON.stringify(err.response.data, null, 2)}`); + } else if (err.request) { + logMessage('error', 'No response received from Cloudflare.'); + } else { + logMessage('error', `Request setup error: ${err.message}`); + } + return null; + } +}; + +const getComment = (it) => ` + IP: ${it.clientIP} triggered Cloudflare WAF. + Action: ${it.action} + Source: ${it.source} + Client ASN: ${it.clientAsn} + Client ASN Description: ${it.clientASNDescription} + Client Country: ${it.clientCountryName} + HTTP Host: ${it.clientRequestHTTPHost} + HTTP Method: ${it.clientRequestHTTPMethodName} + HTTP Protocol: ${it.clientRequestHTTPProtocol} + HTTP Path: ${it.clientRequestPath} + HTTP Query: ${it.clientRequestQuery} + Datetime: ${it.datetime} + Ray ID: ${it.rayName} + Rule ID: ${it.ruleId} + User Agent: ${it.userAgent} + Report generated by Node-Cloudflare-WAF-To-AbuseIPDB (https://github.com/sefinek24/Node-Cloudflare-WAF-To-AbuseIPDB). +`; + +const reportBadIP = async (it, skippedRayIds, blockedIPs) => { + const endpoint = `${it.clientRequestHTTPHost}${it.clientRequestPath}`; + const country = it.clientCountryName; + + if (isImageRequest(it.clientRequestPath)) { + skippedRayIds.add(it.rayName); + logToCSV(new Date(), it.rayName, it.clientIP, endpoint, 'Skipped - Image Request', country); + logMessage('info', `Skipping IP: ${it.clientIP} for domain: ${it.clientRequestHTTPHost}, Endpoint: ${endpoint} (Image request detected)`); + return false; + } + + try { + const url = 'https://api.abuseipdb.com/api/v2/report'; + const params = { + ip: it.clientIP, + categories: '19', + comment: getComment(it) + }; + + await axios.post(url, params, { headers: { 'Accept': 'application/json', 'Key': ABUSEIPDB_API_KEY } }); + logToCSV(new Date(), it.rayName, it.clientIP, endpoint, 'Reported', country); + logMessage('info', `Successfully reported IP: ${it.clientIP} for domain: ${it.clientRequestHTTPHost}, Endpoint: ${endpoint}`); + return true; + } catch (err) { + if (err.response && err.response.status === 429) { + blockedIPs.set(it.clientIP, Date.now()); + logToCSV(new Date(), it.rayName, it.clientIP, endpoint, 'Blocked - 429 Too Many Requests', country); + logMessage('warn', `Rate limited (429) while reporting IP: ${it.clientIP}, Domain: ${it.clientRequestHTTPHost}, Endpoint: ${endpoint}. Will retry after 5 hours.`); + } else { + logMessage('error', `${err.message} - IP: ${it.clientIP}; Domain: ${it.clientRequestHTTPHost}; Endpoint: ${endpoint}`); + } + return false; + } +}; + +const exceptedRuleId = new Set(['fa01280809254f82978e827892db4e46']); + +const shouldReportDomain = (domain, reportedIPs) => { + const lastReport = reportedIPs.find(entry => entry.domain === domain); + if (!lastReport) return true; + const timeSinceLastReport = Date.now() - lastReport.timestamp.getTime(); + return timeSinceLastReport > TIME_WINDOW_MS; +}; + +const shouldSkipBlockedIP = (ip, blockedIPs) => { + const lastBlock = blockedIPs.get(ip); + if (!lastBlock) return false; + const timeSinceLastBlock = Date.now() - lastBlock; + return timeSinceLastBlock < BLOCK_TIME_MS; +}; + +const readReportedIPs = () => { + if (!fs.existsSync(CSV_FILE_PATH)) return []; + + const content = fs.readFileSync(CSV_FILE_PATH, 'utf8'); + return content.split('\n').slice(1).map(line => { + const [timestamp, rayid, ip, domain, action, country] = line.split(','); + return { timestamp: new Date(timestamp), rayid, ip, domain, action, country }; + }).filter(entry => entry.timestamp && entry.rayid && entry.ip); +}; + +(async () => { + logMessage('info', 'Starting IP reporting process'); + const reportedIPs = readReportedIPs(); + const skippedRayIds = new Set(reportedIPs.filter(ip => ip.action.startsWith('Skipped')).map(ip => ip.rayid)); + const blockedIPs = new Map(reportedIPs.filter(ip => ip.action.includes('429')).map(ip => [ip.ip, ip.timestamp.getTime()])); + + while (true) { + logMessage('info', '===================== New Reporting Cycle ====================='); + + const data = await getBlockedIP(); + + if (data && data.data) { + const ipBadList = data.data.viewer.zones[0].firewallEventsAdaptive; + + for (const i of ipBadList) { + const endpoint = `${i.clientRequestHTTPHost}${i.clientRequestPath}`; + if (skippedRayIds.has(i.rayName) || shouldSkipBlockedIP(i.clientIP, blockedIPs)) { + continue; + } + if (!exceptedRuleId.has(i.ruleId) && shouldReportDomain(i.clientRequestHTTPHost, reportedIPs)) { + const reported = await reportBadIP(i, skippedRayIds, blockedIPs); + if (reported) { + await new Promise(resolve => setTimeout(resolve, COOLDOWN_MS)); + } + } else if (!skippedRayIds.has(i.rayName)) { + skippedRayIds.add(i.rayName); + logToCSV(new Date(), i.rayName, i.clientIP, endpoint, 'Skipped - Already Reported', i.clientCountryName); + logMessage('info', `Skipping IP: ${i.clientIP} for domain: ${i.clientRequestHTTPHost}, Endpoint: ${endpoint} (Already reported recently)`); + } + } + } + + logMessage('info', '==================== End of Reporting Cycle ===================='); + await new Promise(resolve => setTimeout(resolve, NODE_ENV === 'production' ? 2 * 60 * 60 * 1000 : 10 * 1000)); + } +})(); \ No newline at end of file diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..1612330 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,155 @@ +{ + "name": "node-cf-waf-abuseipdb", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "node-cf-waf-abuseipdb", + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "axios": "^1.7.4", + "dotenv": "^16.4.5" + }, + "devDependencies": { + "@eslint/js": "^9.9.0", + "globals": "^15.9.0" + } + }, + "node_modules/@eslint/js": { + "version": "9.9.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.9.0.tgz", + "integrity": "sha512-hhetes6ZHP3BlXLxmd8K2SNgkhNSi+UcecbnwWKwpP7kyi/uC75DJ1lOOBO3xrC4jyojtGE3YxKZPHfk4yrgug==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.7.4", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.4.tgz", + "integrity": "sha512-DukmaFRnY6AzAALSH4J2M3k6PkaC+MfaAGdEERRWcC9q3/TWQwLpHR8ZRLKTdQ3aBDL64EdluRDjJqKw+BPZEw==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.0", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dotenv": { + "version": "16.4.5", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.5.tgz", + "integrity": "sha512-ZmdL2rui+eB2YwhsWzjInR8LldtZHGDoQ1ugH85ppHKwpUHL7j7rN0Ti9NCnGiQbhaZ11FpR+7ao1dNsmduNUg==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/follow-redirects": { + "version": "1.15.6", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.6.tgz", + "integrity": "sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", + "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/globals": { + "version": "15.9.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-15.9.0.tgz", + "integrity": "sha512-SmSKyLLKFbSr6rptvP8izbyxJL4ILwqO9Jg23UA0sDlGlu58V59D1//I3vlc0KJphVdUR7vMjHIplYnzBxorQA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..8e6786a --- /dev/null +++ b/package.json @@ -0,0 +1,28 @@ +{ + "name": "node-cf-waf-abuseipdb", + "version": "1.0.0", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1", + "up": "ncu -u && npm install && npm update && npm audit fix" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/sefinek24/Node-Cloudflare-WAF-AbuseIPDB.git" + }, + "author": "Sefinek (https://sefinek.net)", + "license": "MIT", + "bugs": { + "url": "https://github.com/sefinek24/Node-Cloudflare-WAF-AbuseIPDB/issues" + }, + "homepage": "https://github.com/sefinek24/Node-Cloudflare-WAF-AbuseIPDB#readme", + "description": "", + "devDependencies": { + "@eslint/js": "^9.9.0", + "globals": "^15.9.0" + }, + "dependencies": { + "axios": "^1.7.4", + "dotenv": "^16.4.5" + } +}